a tool for shared writing and social publishing

make mentions inline nodes

+165 -99
+60 -19
actions/publishToPublication.ts
··· 349 349 Y.applyUpdate(doc, update); 350 350 let nodes = doc.getXmlElement("prosemirror").toArray(); 351 351 let stringValue = YJSFragmentToString(nodes[0]); 352 - let facets = YJSFragmentToFacets(nodes[0]); 352 + let { facets } = YJSFragmentToFacets(nodes[0]); 353 353 return [stringValue, facets] as const; 354 354 }; 355 355 if (b.type === "card") { ··· 610 610 611 611 function YJSFragmentToFacets( 612 612 node: Y.XmlElement | Y.XmlText | Y.XmlHook, 613 - ): PubLeafletRichtextFacet.Main[] { 613 + byteOffset: number = 0, 614 + ): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { 614 615 if (node.constructor === Y.XmlElement) { 615 - return node 616 - .toArray() 617 - .map((f) => YJSFragmentToFacets(f)) 618 - .flat(); 616 + // Handle inline mention nodes 617 + if (node.nodeName === "didMention") { 618 + const text = node.getAttribute("text") || ""; 619 + const unicodestring = new UnicodeString(text); 620 + const facet: PubLeafletRichtextFacet.Main = { 621 + index: { 622 + byteStart: byteOffset, 623 + byteEnd: byteOffset + unicodestring.length, 624 + }, 625 + features: [ 626 + { 627 + $type: "pub.leaflet.richtext.facet#didMention", 628 + did: node.getAttribute("did"), 629 + }, 630 + ], 631 + }; 632 + return { facets: [facet], byteLength: unicodestring.length }; 633 + } 634 + 635 + if (node.nodeName === "atMention") { 636 + const text = node.getAttribute("text") || ""; 637 + const unicodestring = new UnicodeString(text); 638 + const facet: PubLeafletRichtextFacet.Main = { 639 + index: { 640 + byteStart: byteOffset, 641 + byteEnd: byteOffset + unicodestring.length, 642 + }, 643 + features: [ 644 + { 645 + $type: "pub.leaflet.richtext.facet#atMention", 646 + atURI: node.getAttribute("atURI"), 647 + }, 648 + ], 649 + }; 650 + return { facets: [facet], byteLength: unicodestring.length }; 651 + } 652 + 653 + if (node.nodeName === "hard_break") { 654 + const unicodestring = new UnicodeString("\n"); 655 + return { facets: [], byteLength: unicodestring.length }; 656 + } 657 + 658 + // For other elements (like paragraph), process children 659 + let allFacets: PubLeafletRichtextFacet.Main[] = []; 660 + let currentOffset = byteOffset; 661 + for (const child of node.toArray()) { 662 + const result = YJSFragmentToFacets(child, currentOffset); 663 + allFacets.push(...result.facets); 664 + currentOffset += result.byteLength; 665 + } 666 + return { facets: allFacets, byteLength: currentOffset - byteOffset }; 619 667 } 668 + 620 669 if (node.constructor === Y.XmlText) { 621 670 let facets: PubLeafletRichtextFacet.Main[] = []; 622 671 let delta = node.toDelta() as Delta[]; 623 - let byteStart = 0; 672 + let byteStart = byteOffset; 673 + let totalLength = 0; 624 674 for (let d of delta) { 625 675 let unicodestring = new UnicodeString(d.insert); 626 676 let facet: PubLeafletRichtextFacet.Main = { ··· 636 686 $type: "pub.leaflet.richtext.facet#strikethrough", 637 687 }); 638 688 639 - if (d.attributes?.didMention) 640 - facet.features.push({ 641 - $type: "pub.leaflet.richtext.facet#didMention", 642 - did: d.attributes.didMention.did, 643 - }); 644 - if (d.attributes?.atMention) 645 - facet.features.push({ 646 - $type: "pub.leaflet.richtext.facet#atMention", 647 - atURI: d.attributes.atMention.atURI, 648 - }); 649 689 if (d.attributes?.code) 650 690 facet.features.push({ $type: "pub.leaflet.richtext.facet#code" }); 651 691 if (d.attributes?.highlight) ··· 663 703 }); 664 704 if (facet.features.length > 0) facets.push(facet); 665 705 byteStart += unicodestring.length; 706 + totalLength += unicodestring.length; 666 707 } 667 - return facets; 708 + return { facets, byteLength: totalLength }; 668 709 } 669 - return []; 710 + return { facets: [], byteLength: 0 }; 670 711 } 671 712 672 713 type ExcludeString<T> = T extends string
+19 -16
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
··· 384 384 const tr = view.state.tr; 385 385 386 386 if (mention.type == "did") { 387 - // Delete the query text (keep the @) 388 - tr.delete(from + 1, to); 389 - tr.insertText(mention.handle, from + 1); 390 - tr.addMark( 391 - from, 392 - from + 1 + mention.handle.length, 393 - schema.marks.didMention.create({ did: mention.did }), 394 - ); 395 - tr.insertText(" ", from + 1 + mention.handle.length); 387 + // Delete the @ and any query text 388 + tr.delete(from, to); 389 + // Insert didMention inline node 390 + const mentionText = "@" + mention.handle; 391 + const didMentionNode = schema.nodes.didMention.create({ 392 + did: mention.did, 393 + text: mentionText, 394 + }); 395 + tr.insert(from, didMentionNode); 396 + // Add a space after the mention 397 + tr.insertText(" ", from + 1); 396 398 } 397 399 if (mention.type === "publication" || mention.type === "post") { 398 400 // Delete the @ and any query text 399 401 tr.delete(from, to); 400 402 let name = mention.type == "post" ? mention.title : mention.name; 401 - tr.insertText(name, from); 402 - tr.addMark( 403 - from, 404 - from + name.length, 405 - schema.marks.atMention.create({ atURI: mention.uri }), 406 - ); 407 - tr.insertText(" ", from + name.length); 403 + // Insert atMention inline node 404 + const atMentionNode = schema.nodes.atMention.create({ 405 + atURI: mention.uri, 406 + text: name, 407 + }); 408 + tr.insert(from, atMentionNode); 409 + // Add a space after the mention 410 + tr.insertText(" ", from + 1); 408 411 } 409 412 410 413 view.dispatch(tr);
+32 -26
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 48 48 {d.insert} 49 49 </a> 50 50 ); 51 - if (d.attributes?.didMention) 52 - return ( 53 - <a 54 - href={didToBlueskyUrl(d.attributes.didMention.did)} 55 - target="_blank" 56 - rel="noopener noreferrer" 57 - key={index} 58 - {...attributesToStyle(d)} 59 - className={`${attributesToStyle(d).className} text-accent-contrast hover:underline cursor-pointer`} 60 - > 61 - {d.insert} 62 - </a> 63 - ); 64 - if (d.attributes?.atMention) { 65 - return ( 66 - <AtMentionLink 67 - key={index} 68 - atURI={d.attributes.atMention.atURI} 69 - className={attributesToStyle(d).className} 70 - > 71 - {d.insert} 72 - </AtMentionLink> 73 - ); 74 - } 75 51 return ( 76 52 <span 77 53 key={index} ··· 90 66 return <br key={index} />; 91 67 } 92 68 69 + // Handle didMention inline nodes 70 + if (node.constructor === XmlElement && node.nodeName === "didMention") { 71 + const did = node.getAttribute("did") || ""; 72 + const text = node.getAttribute("text") || ""; 73 + return ( 74 + <a 75 + href={didToBlueskyUrl(did)} 76 + target="_blank" 77 + rel="noopener noreferrer" 78 + key={index} 79 + className="text-accent-contrast hover:underline cursor-pointer" 80 + > 81 + {text} 82 + </a> 83 + ); 84 + } 85 + 86 + // Handle atMention inline nodes 87 + if (node.constructor === XmlElement && node.nodeName === "atMention") { 88 + const atURI = node.getAttribute("atURI") || ""; 89 + const text = node.getAttribute("text") || ""; 90 + return ( 91 + <AtMentionLink key={index} atURI={atURI}> 92 + {text} 93 + </AtMentionLink> 94 + ); 95 + } 96 + 93 97 return null; 94 98 }) 95 99 )} ··· 133 137 strong?: {}; 134 138 code?: {}; 135 139 em?: {}; 136 - didMention?: { did: string }; 137 - atMention?: { atURI: string }; 138 140 underline?: {}; 139 141 strikethrough?: {}; 140 142 highlight?: { color: string }; ··· 179 181 // Handle hard_break nodes specially 180 182 if (node.nodeName === "hard_break") { 181 183 return "\n"; 184 + } 185 + // Handle inline mention nodes 186 + if (node.nodeName === "didMention" || node.nodeName === "atMention") { 187 + return node.getAttribute("text") || ""; 182 188 } 183 189 return node 184 190 .toArray()
+15 -10
components/Blocks/TextBlock/mountProsemirror.ts
··· 94 94 return; 95 95 } 96 96 97 - // Check for didMention marks 98 - let didMentionMark = nodeAt1?.marks.find((f) => f.type === schema.marks.didMention) || 99 - nodeAt2?.marks.find((f) => f.type === schema.marks.didMention); 100 - if (didMentionMark) { 101 - window.open(didToBlueskyUrl(didMentionMark.attrs.did), "_blank", "noopener,noreferrer"); 97 + // Check for didMention inline nodes 98 + if (nodeAt1?.type === schema.nodes.didMention) { 99 + window.open(didToBlueskyUrl(nodeAt1.attrs.did), "_blank", "noopener,noreferrer"); 100 + return; 101 + } 102 + if (nodeAt2?.type === schema.nodes.didMention) { 103 + window.open(didToBlueskyUrl(nodeAt2.attrs.did), "_blank", "noopener,noreferrer"); 102 104 return; 103 105 } 104 106 105 - // Check for atMention marks 106 - let atMentionMark = nodeAt1?.marks.find((f) => f.type === schema.marks.atMention) || 107 - nodeAt2?.marks.find((f) => f.type === schema.marks.atMention); 108 - if (atMentionMark) { 109 - const url = atUriToUrl(atMentionMark.attrs.atURI); 107 + // Check for atMention inline nodes 108 + if (nodeAt1?.type === schema.nodes.atMention) { 109 + const url = atUriToUrl(nodeAt1.attrs.atURI); 110 + window.open(url, "_blank", "noopener,noreferrer"); 111 + return; 112 + } 113 + if (nodeAt2?.type === schema.nodes.atMention) { 114 + const url = atUriToUrl(nodeAt2.attrs.atURI); 110 115 window.open(url, "_blank", "noopener,noreferrer"); 111 116 return; 112 117 }
+39 -28
components/Blocks/TextBlock/schema.ts
··· 1 1 import { AtUri } from "@atproto/api"; 2 - import { Schema, Node, MarkSpec } from "prosemirror-model"; 2 + import { Schema, Node, MarkSpec, NodeSpec } from "prosemirror-model"; 3 3 import { marks } from "prosemirror-schema-basic"; 4 4 import { theme } from "tailwind.config"; 5 5 ··· 104 104 return ["a", { href, target: "_blank" }, 0]; 105 105 }, 106 106 } as MarkSpec, 107 + }, 108 + nodes: { 109 + doc: { content: "block" }, 110 + paragraph: { 111 + content: "inline*", 112 + group: "block", 113 + parseDOM: [{ tag: "p" }], 114 + toDOM: () => ["p", 0] as const, 115 + }, 116 + text: { 117 + group: "inline", 118 + }, 119 + hard_break: { 120 + group: "inline", 121 + inline: true, 122 + selectable: false, 123 + parseDOM: [{ tag: "br" }], 124 + toDOM: () => ["br"] as const, 125 + }, 107 126 atMention: { 108 127 attrs: { 109 128 atURI: {}, 129 + text: { default: "" }, 110 130 }, 111 - inclusive: false, 131 + group: "inline", 132 + inline: true, 133 + atom: true, 134 + selectable: true, 135 + draggable: true, 112 136 parseDOM: [ 113 137 { 114 138 tag: "span.atMention", 115 139 getAttrs(dom: HTMLElement) { 116 140 return { 117 141 atURI: dom.getAttribute("data-at-uri"), 142 + text: dom.textContent || "", 118 143 }; 119 144 }, 120 145 }, ··· 122 147 toDOM(node) { 123 148 // NOTE: This rendering should match the AtMentionLink component in 124 149 // components/AtMentionLink.tsx. If you update one, update the other. 125 - // We can't use the React component here because ProseMirror expects DOM specs. 126 150 let className = "atMention text-accent-contrast"; 127 151 let aturi = new AtUri(node.attrs.atURI); 128 152 if (aturi.collection === "pub.leaflet.publication") ··· 151 175 loading: "lazy", 152 176 }, 153 177 ], 154 - ["span", 0], 178 + node.attrs.text, 155 179 ]; 156 180 } 157 181 ··· 161 185 class: className, 162 186 "data-at-uri": node.attrs.atURI, 163 187 }, 164 - 0, 188 + node.attrs.text, 165 189 ]; 166 190 }, 167 - } as MarkSpec, 191 + } as NodeSpec, 168 192 didMention: { 169 193 attrs: { 170 194 did: {}, 195 + text: { default: "" }, 171 196 }, 172 - inclusive: false, 197 + group: "inline", 198 + inline: true, 199 + atom: true, 200 + selectable: true, 201 + draggable: true, 173 202 parseDOM: [ 174 203 { 175 204 tag: "span.didMention", 176 205 getAttrs(dom: HTMLElement) { 177 206 return { 178 207 did: dom.getAttribute("data-did"), 208 + text: dom.textContent || "", 179 209 }; 180 210 }, 181 211 }, ··· 187 217 class: "didMention text-accent-contrast", 188 218 "data-did": node.attrs.did, 189 219 }, 190 - 0, 220 + node.attrs.text, 191 221 ]; 192 222 }, 193 - } as MarkSpec, 194 - }, 195 - nodes: { 196 - doc: { content: "block" }, 197 - paragraph: { 198 - content: "inline*", 199 - group: "block", 200 - parseDOM: [{ tag: "p" }], 201 - toDOM: () => ["p", 0] as const, 202 - }, 203 - text: { 204 - group: "inline", 205 - }, 206 - hard_break: { 207 - group: "inline", 208 - inline: true, 209 - selectable: false, 210 - parseDOM: [{ tag: "br" }], 211 - toDOM: () => ["br"] as const, 212 - }, 223 + } as NodeSpec, 213 224 }, 214 225 }; 215 226 export const schema = new Schema(baseSchema);