a tool for shared writing and social publishing

create static textblock component for rss

+205 -144
+171
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
··· 1 + "use client"; 2 + import { UnicodeString } from "@atproto/api"; 3 + import { PubLeafletRichtextFacet } from "lexicons/api"; 4 + 5 + type Facet = PubLeafletRichtextFacet.Main; 6 + export function BaseTextBlock(props: { 7 + plaintext: string; 8 + facets?: Facet[]; 9 + index: number[]; 10 + preview?: boolean; 11 + }) { 12 + let children = []; 13 + let richText = new RichText({ 14 + text: props.plaintext, 15 + facets: props.facets || [], 16 + }); 17 + let counter = 0; 18 + for (const segment of richText.segments()) { 19 + let id = segment.facet?.find(PubLeafletRichtextFacet.isId); 20 + let link = segment.facet?.find(PubLeafletRichtextFacet.isLink); 21 + let isBold = segment.facet?.find(PubLeafletRichtextFacet.isBold); 22 + let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode); 23 + let isStrikethrough = segment.facet?.find( 24 + PubLeafletRichtextFacet.isStrikethrough, 25 + ); 26 + let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 27 + let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); 28 + let isHighlighted = segment.facet?.find( 29 + PubLeafletRichtextFacet.isHighlight, 30 + ); 31 + let className = ` 32 + ${isCode ? "inline-code" : ""} 33 + ${id ? "scroll-mt-12 scroll-mb-10" : ""} 34 + ${isBold ? "font-bold" : ""} 35 + ${isItalic ? "italic" : ""} 36 + ${isUnderline ? "underline" : ""} 37 + ${isStrikethrough ? "line-through decoration-tertiary" : ""} 38 + ${isHighlighted ? "highlight bg-highlight-1" : ""}`; 39 + 40 + if (isCode) { 41 + children.push( 42 + <code key={counter} className={className} id={id?.id}> 43 + {segment.text} 44 + </code>, 45 + ); 46 + } else if (link) { 47 + children.push( 48 + <a 49 + key={counter} 50 + href={link.uri} 51 + className={`text-accent-contrast hover:underline ${className}`} 52 + target="_blank" 53 + > 54 + {segment.text} 55 + </a>, 56 + ); 57 + } else { 58 + children.push( 59 + <span key={counter} className={className} id={id?.id}> 60 + {segment.text} 61 + </span>, 62 + ); 63 + } 64 + 65 + counter++; 66 + } 67 + return <>{children}</>; 68 + } 69 + 70 + type RichTextSegment = { 71 + text: string; 72 + facet?: Exclude<Facet["features"], { $type: string }>; 73 + }; 74 + 75 + export class RichText { 76 + unicodeText: UnicodeString; 77 + facets?: Facet[]; 78 + 79 + constructor(props: { text: string; facets: Facet[] }) { 80 + this.unicodeText = new UnicodeString(props.text); 81 + this.facets = props.facets; 82 + if (this.facets) { 83 + this.facets = this.facets 84 + .filter((facet) => facet.index.byteStart <= facet.index.byteEnd) 85 + .sort((a, b) => a.index.byteStart - b.index.byteStart); 86 + } 87 + } 88 + 89 + *segments(): Generator<RichTextSegment, void, void> { 90 + const facets = this.facets || []; 91 + if (!facets.length) { 92 + yield { text: this.unicodeText.utf16 }; 93 + return; 94 + } 95 + 96 + let textCursor = 0; 97 + let facetCursor = 0; 98 + do { 99 + const currFacet = facets[facetCursor]; 100 + if (textCursor < currFacet.index.byteStart) { 101 + yield { 102 + text: this.unicodeText.slice(textCursor, currFacet.index.byteStart), 103 + }; 104 + } else if (textCursor > currFacet.index.byteStart) { 105 + facetCursor++; 106 + continue; 107 + } 108 + if (currFacet.index.byteStart < currFacet.index.byteEnd) { 109 + const subtext = this.unicodeText.slice( 110 + currFacet.index.byteStart, 111 + currFacet.index.byteEnd, 112 + ); 113 + if (!subtext.trim()) { 114 + // dont empty string entities 115 + yield { text: subtext }; 116 + } else { 117 + yield { text: subtext, facet: currFacet.features }; 118 + } 119 + } 120 + textCursor = currFacet.index.byteEnd; 121 + facetCursor++; 122 + } while (facetCursor < facets.length); 123 + if (textCursor < this.unicodeText.length) { 124 + yield { 125 + text: this.unicodeText.slice(textCursor, this.unicodeText.length), 126 + }; 127 + } 128 + } 129 + } 130 + function addFacet(facets: Facet[], newFacet: Facet, length: number) { 131 + if (facets.length === 0) { 132 + return [newFacet]; 133 + } 134 + 135 + const allFacets = [...facets, newFacet]; 136 + 137 + // Collect all boundary positions 138 + const boundaries = new Set<number>(); 139 + boundaries.add(0); 140 + boundaries.add(length); 141 + 142 + for (const facet of allFacets) { 143 + boundaries.add(facet.index.byteStart); 144 + boundaries.add(facet.index.byteEnd); 145 + } 146 + 147 + const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b); 148 + const result: Facet[] = []; 149 + 150 + // Process segments between consecutive boundaries 151 + for (let i = 0; i < sortedBoundaries.length - 1; i++) { 152 + const start = sortedBoundaries[i]; 153 + const end = sortedBoundaries[i + 1]; 154 + 155 + // Find facets that are active at the start position 156 + const activeFacets = allFacets.filter( 157 + (facet) => facet.index.byteStart <= start && facet.index.byteEnd > start, 158 + ); 159 + 160 + // Only create facet if there are active facets (features present) 161 + if (activeFacets.length > 0) { 162 + const features = activeFacets.flatMap((f) => f.features); 163 + result.push({ 164 + index: { byteStart: start, byteEnd: end }, 165 + features, 166 + }); 167 + } 168 + } 169 + 170 + return result; 171 + }
+6 -6
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
··· 10 10 PubLeafletPagesLinearDocument, 11 11 } from "lexicons/api"; 12 12 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 13 - import { TextBlock } from "./TextBlock"; 13 + import { BaseTextBlock } from "./BaseTextBlock"; 14 14 import { StaticMathBlock } from "./StaticMathBlock"; 15 15 import { codeToHtml } from "shiki"; 16 16 ··· 96 96 case PubLeafletBlocksText.isMain(b.block): 97 97 return ( 98 98 <p> 99 - <TextBlock 99 + <BaseTextBlock 100 100 facets={b.block.facets} 101 101 plaintext={b.block.plaintext} 102 102 index={[]} ··· 107 107 if (b.block.level === 1) 108 108 return ( 109 109 <h1> 110 - <TextBlock {...b.block} index={[]} /> 110 + <BaseTextBlock {...b.block} index={[]} /> 111 111 </h1> 112 112 ); 113 113 if (b.block.level === 2) 114 114 return ( 115 115 <h2> 116 - <TextBlock {...b.block} index={[]} /> 116 + <BaseTextBlock {...b.block} index={[]} /> 117 117 </h2> 118 118 ); 119 119 if (b.block.level === 3) 120 120 return ( 121 121 <h3> 122 - <TextBlock {...b.block} index={[]} /> 122 + <BaseTextBlock {...b.block} index={[]} /> 123 123 </h3> 124 124 ); 125 125 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 126 126 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 127 127 return ( 128 128 <h6> 129 - <TextBlock {...b.block} index={[]} /> 129 + <BaseTextBlock {...b.block} index={[]} /> 130 130 </h6> 131 131 ); 132 132 }
+28 -138
app/lish/[did]/[publication]/[rkey]/TextBlock.tsx
··· 3 3 import { PubLeafletRichtextFacet } from "lexicons/api"; 4 4 import { useMemo } from "react"; 5 5 import { useHighlight } from "./useHighlight"; 6 + import { BaseTextBlock } from "./BaseTextBlock"; 6 7 7 8 type Facet = PubLeafletRichtextFacet.Main; 8 9 export function TextBlock(props: { ··· 13 14 }) { 14 15 let children = []; 15 16 let highlights = useHighlight(props.index); 16 - let richText = useMemo(() => { 17 + let facets = useMemo(() => { 18 + if (props.preview) return props.facets; 17 19 let facets = [...(props.facets || [])]; 18 - if (!props.preview) { 19 - for (let highlight of highlights) { 20 - facets = addFacet( 21 - facets, 22 - { 23 - index: { 24 - byteStart: highlight.startOffset 25 - ? new UnicodeString( 26 - props.plaintext.slice(0, highlight.startOffset), 27 - ).length 28 - : 0, 29 - byteEnd: new UnicodeString( 30 - props.plaintext.slice(0, highlight.endOffset ?? undefined), 31 - ).length, 32 - }, 33 - features: [ 34 - { $type: "pub.leaflet.richtext.facet#highlight" }, 35 - { 36 - $type: "pub.leaflet.richtext.facet#id", 37 - id: `${props.index.join(".")}_${highlight.startOffset || 0}`, 38 - }, 39 - ], 20 + for (let highlight of highlights) { 21 + facets = addFacet( 22 + facets, 23 + { 24 + index: { 25 + byteStart: highlight.startOffset 26 + ? new UnicodeString( 27 + props.plaintext.slice(0, highlight.startOffset), 28 + ).length 29 + : 0, 30 + byteEnd: new UnicodeString( 31 + props.plaintext.slice(0, highlight.endOffset ?? undefined), 32 + ).length, 40 33 }, 41 - new UnicodeString(props.plaintext).length, 42 - ); 43 - } 44 - } 45 - return new RichText({ text: props.plaintext, facets }); 46 - }, [props.plaintext, props.facets, highlights, props.preview]); 47 - let counter = 0; 48 - for (const segment of richText.segments()) { 49 - let id = segment.facet?.find(PubLeafletRichtextFacet.isId); 50 - let link = segment.facet?.find(PubLeafletRichtextFacet.isLink); 51 - let isBold = segment.facet?.find(PubLeafletRichtextFacet.isBold); 52 - let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode); 53 - let isStrikethrough = segment.facet?.find( 54 - PubLeafletRichtextFacet.isStrikethrough, 55 - ); 56 - let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 57 - let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); 58 - let isHighlighted = segment.facet?.find( 59 - PubLeafletRichtextFacet.isHighlight, 60 - ); 61 - let className = ` 62 - ${isCode ? "inline-code" : ""} 63 - ${id ? "scroll-mt-12 scroll-mb-10" : ""} 64 - ${isBold ? "font-bold" : ""} 65 - ${isItalic ? "italic" : ""} 66 - ${isUnderline ? "underline" : ""} 67 - ${isStrikethrough ? "line-through decoration-tertiary" : ""} 68 - ${isHighlighted ? "highlight bg-highlight-1" : ""}`; 69 - 70 - if (isCode) { 71 - children.push( 72 - <code key={counter} className={className} id={id?.id}> 73 - {segment.text} 74 - </code>, 75 - ); 76 - } else if (link) { 77 - children.push( 78 - <a 79 - key={counter} 80 - href={link.uri} 81 - className={`text-accent-contrast hover:underline ${className}`} 82 - target="_blank" 83 - > 84 - {segment.text} 85 - </a>, 86 - ); 87 - } else { 88 - children.push( 89 - <span key={counter} className={className} id={id?.id}> 90 - {segment.text} 91 - </span>, 34 + features: [ 35 + { $type: "pub.leaflet.richtext.facet#highlight" }, 36 + { 37 + $type: "pub.leaflet.richtext.facet#id", 38 + id: `${props.index.join(".")}_${highlight.startOffset || 0}`, 39 + }, 40 + ], 41 + }, 42 + new UnicodeString(props.plaintext).length, 92 43 ); 93 44 } 94 - 95 - counter++; 96 - } 97 - return <>{children}</>; 45 + return facets; 46 + }, [props.plaintext, props.facets, highlights, props.preview]); 47 + return <BaseTextBlock {...props} facets={facets} />; 98 48 } 99 49 100 - type RichTextSegment = { 101 - text: string; 102 - facet?: Exclude<Facet["features"], { $type: string }>; 103 - }; 104 - 105 - export class RichText { 106 - unicodeText: UnicodeString; 107 - facets?: Facet[]; 108 - 109 - constructor(props: { text: string; facets: Facet[] }) { 110 - this.unicodeText = new UnicodeString(props.text); 111 - this.facets = props.facets; 112 - if (this.facets) { 113 - this.facets = this.facets 114 - .filter((facet) => facet.index.byteStart <= facet.index.byteEnd) 115 - .sort((a, b) => a.index.byteStart - b.index.byteStart); 116 - } 117 - } 118 - 119 - *segments(): Generator<RichTextSegment, void, void> { 120 - const facets = this.facets || []; 121 - if (!facets.length) { 122 - yield { text: this.unicodeText.utf16 }; 123 - return; 124 - } 125 - 126 - let textCursor = 0; 127 - let facetCursor = 0; 128 - do { 129 - const currFacet = facets[facetCursor]; 130 - if (textCursor < currFacet.index.byteStart) { 131 - yield { 132 - text: this.unicodeText.slice(textCursor, currFacet.index.byteStart), 133 - }; 134 - } else if (textCursor > currFacet.index.byteStart) { 135 - facetCursor++; 136 - continue; 137 - } 138 - if (currFacet.index.byteStart < currFacet.index.byteEnd) { 139 - const subtext = this.unicodeText.slice( 140 - currFacet.index.byteStart, 141 - currFacet.index.byteEnd, 142 - ); 143 - if (!subtext.trim()) { 144 - // dont empty string entities 145 - yield { text: subtext }; 146 - } else { 147 - yield { text: subtext, facet: currFacet.features }; 148 - } 149 - } 150 - textCursor = currFacet.index.byteEnd; 151 - facetCursor++; 152 - } while (facetCursor < facets.length); 153 - if (textCursor < this.unicodeText.length) { 154 - yield { 155 - text: this.unicodeText.slice(textCursor, this.unicodeText.length), 156 - }; 157 - } 158 - } 159 - } 160 50 function addFacet(facets: Facet[], newFacet: Facet, length: number) { 161 51 if (facets.length === 0) { 162 52 return [newFacet];