diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index f173e8a3..ec6a22c6 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -349,7 +349,7 @@ async function processBlocksToPages( Y.applyUpdate(doc, update); let nodes = doc.getXmlElement("prosemirror").toArray(); let stringValue = YJSFragmentToString(nodes[0]); - let facets = YJSFragmentToFacets(nodes[0]); + let { facets } = YJSFragmentToFacets(nodes[0]); return [stringValue, facets] as const; }; if (b.type === "card") { @@ -610,17 +610,67 @@ async function processBlocksToPages( function YJSFragmentToFacets( node: Y.XmlElement | Y.XmlText | Y.XmlHook, -): PubLeafletRichtextFacet.Main[] { + byteOffset: number = 0, +): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { if (node.constructor === Y.XmlElement) { - return node - .toArray() - .map((f) => YJSFragmentToFacets(f)) - .flat(); + // Handle inline mention nodes + if (node.nodeName === "didMention") { + const text = node.getAttribute("text") || ""; + const unicodestring = new UnicodeString(text); + const facet: PubLeafletRichtextFacet.Main = { + index: { + byteStart: byteOffset, + byteEnd: byteOffset + unicodestring.length, + }, + features: [ + { + $type: "pub.leaflet.richtext.facet#didMention", + did: node.getAttribute("did"), + }, + ], + }; + return { facets: [facet], byteLength: unicodestring.length }; + } + + if (node.nodeName === "atMention") { + const text = node.getAttribute("text") || ""; + const unicodestring = new UnicodeString(text); + const facet: PubLeafletRichtextFacet.Main = { + index: { + byteStart: byteOffset, + byteEnd: byteOffset + unicodestring.length, + }, + features: [ + { + $type: "pub.leaflet.richtext.facet#atMention", + atURI: node.getAttribute("atURI"), + }, + ], + }; + return { facets: [facet], byteLength: unicodestring.length }; + } + + if (node.nodeName === "hard_break") { + const unicodestring = new UnicodeString("\n"); + return { facets: [], byteLength: unicodestring.length }; + } + + // For other elements (like paragraph), process children + let allFacets: PubLeafletRichtextFacet.Main[] = []; + let currentOffset = byteOffset; + for (const child of node.toArray()) { + const result = YJSFragmentToFacets(child, currentOffset); + allFacets.push(...result.facets); + currentOffset += result.byteLength; + } + return { facets: allFacets, byteLength: currentOffset - byteOffset }; } + if (node.constructor === Y.XmlText) { let facets: PubLeafletRichtextFacet.Main[] = []; let delta = node.toDelta() as Delta[]; - let byteStart = 0; + let byteStart = byteOffset; + let totalLength = 0; for (let d of delta) { let unicodestring = new UnicodeString(d.insert); let facet: PubLeafletRichtextFacet.Main = { @@ -636,16 +686,6 @@ function YJSFragmentToFacets( $type: "pub.leaflet.richtext.facet#strikethrough", }); - if (d.attributes?.didMention) - facet.features.push({ - $type: "pub.leaflet.richtext.facet#didMention", - did: d.attributes.didMention.did, - }); - if (d.attributes?.atMention) - facet.features.push({ - $type: "pub.leaflet.richtext.facet#atMention", - atURI: d.attributes.atMention.atURI, - }); if (d.attributes?.code) facet.features.push({ $type: "pub.leaflet.richtext.facet#code" }); if (d.attributes?.highlight) @@ -663,10 +703,11 @@ function YJSFragmentToFacets( }); if (facet.features.length > 0) facets.push(facet); byteStart += unicodestring.length; + totalLength += unicodestring.length; } - return facets; + return { facets, byteLength: totalLength }; } - return []; + return { facets: [], byteLength: 0 }; } type ExcludeString = T extends string diff --git a/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx b/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx index e8c82569..41976ddb 100644 --- a/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx +++ b/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx @@ -384,27 +384,30 @@ export const addMentionToEditor = ( const tr = view.state.tr; if (mention.type == "did") { - // Delete the query text (keep the @) - tr.delete(from + 1, to); - tr.insertText(mention.handle, from + 1); - tr.addMark( - from, - from + 1 + mention.handle.length, - schema.marks.didMention.create({ did: mention.did }), - ); - tr.insertText(" ", from + 1 + mention.handle.length); + // Delete the @ and any query text + tr.delete(from, to); + // Insert didMention inline node + const mentionText = "@" + mention.handle; + const didMentionNode = schema.nodes.didMention.create({ + did: mention.did, + text: mentionText, + }); + tr.insert(from, didMentionNode); + // Add a space after the mention + tr.insertText(" ", from + 1); } if (mention.type === "publication" || mention.type === "post") { // Delete the @ and any query text tr.delete(from, to); let name = mention.type == "post" ? mention.title : mention.name; - tr.insertText(name, from); - tr.addMark( - from, - from + name.length, - schema.marks.atMention.create({ atURI: mention.uri }), - ); - tr.insertText(" ", from + name.length); + // Insert atMention inline node + const atMentionNode = schema.nodes.atMention.create({ + atURI: mention.uri, + text: name, + }); + tr.insert(from, atMentionNode); + // Add a space after the mention + tr.insertText(" ", from + 1); } view.dispatch(tr); diff --git a/components/Blocks/TextBlock/RenderYJSFragment.tsx b/components/Blocks/TextBlock/RenderYJSFragment.tsx index 127ac405..c68cff6c 100644 --- a/components/Blocks/TextBlock/RenderYJSFragment.tsx +++ b/components/Blocks/TextBlock/RenderYJSFragment.tsx @@ -48,30 +48,6 @@ export function RenderYJSFragment({ {d.insert} ); - if (d.attributes?.didMention) - return ( - - {d.insert} - - ); - if (d.attributes?.atMention) { - return ( - - {d.insert} - - ); - } return ( ; } + // Handle didMention inline nodes + if (node.constructor === XmlElement && node.nodeName === "didMention") { + const did = node.getAttribute("did") || ""; + const text = node.getAttribute("text") || ""; + return ( + + {text} + + ); + } + + // Handle atMention inline nodes + if (node.constructor === XmlElement && node.nodeName === "atMention") { + const atURI = node.getAttribute("atURI") || ""; + const text = node.getAttribute("text") || ""; + return ( + + {text} + + ); + } + return null; }) )} @@ -133,8 +137,6 @@ export type Delta = { strong?: {}; code?: {}; em?: {}; - didMention?: { did: string }; - atMention?: { atURI: string }; underline?: {}; strikethrough?: {}; highlight?: { color: string }; @@ -180,6 +182,10 @@ export function YJSFragmentToString( if (node.nodeName === "hard_break") { return "\n"; } + // Handle inline mention nodes + if (node.nodeName === "didMention" || node.nodeName === "atMention") { + return node.getAttribute("text") || ""; + } return node .toArray() .map((f) => YJSFragmentToString(f)) diff --git a/components/Blocks/TextBlock/mountProsemirror.ts b/components/Blocks/TextBlock/mountProsemirror.ts index 07139862..965f8a43 100644 --- a/components/Blocks/TextBlock/mountProsemirror.ts +++ b/components/Blocks/TextBlock/mountProsemirror.ts @@ -94,19 +94,24 @@ export function useMountProsemirror({ return; } - // Check for didMention marks - let didMentionMark = nodeAt1?.marks.find((f) => f.type === schema.marks.didMention) || - nodeAt2?.marks.find((f) => f.type === schema.marks.didMention); - if (didMentionMark) { - window.open(didToBlueskyUrl(didMentionMark.attrs.did), "_blank", "noopener,noreferrer"); + // Check for didMention inline nodes + if (nodeAt1?.type === schema.nodes.didMention) { + window.open(didToBlueskyUrl(nodeAt1.attrs.did), "_blank", "noopener,noreferrer"); + return; + } + if (nodeAt2?.type === schema.nodes.didMention) { + window.open(didToBlueskyUrl(nodeAt2.attrs.did), "_blank", "noopener,noreferrer"); return; } - // Check for atMention marks - let atMentionMark = nodeAt1?.marks.find((f) => f.type === schema.marks.atMention) || - nodeAt2?.marks.find((f) => f.type === schema.marks.atMention); - if (atMentionMark) { - const url = atUriToUrl(atMentionMark.attrs.atURI); + // Check for atMention inline nodes + if (nodeAt1?.type === schema.nodes.atMention) { + const url = atUriToUrl(nodeAt1.attrs.atURI); + window.open(url, "_blank", "noopener,noreferrer"); + return; + } + if (nodeAt2?.type === schema.nodes.atMention) { + const url = atUriToUrl(nodeAt2.attrs.atURI); window.open(url, "_blank", "noopener,noreferrer"); return; } diff --git a/components/Blocks/TextBlock/schema.ts b/components/Blocks/TextBlock/schema.ts index 3084dc80..a82aa7e3 100644 --- a/components/Blocks/TextBlock/schema.ts +++ b/components/Blocks/TextBlock/schema.ts @@ -1,5 +1,5 @@ import { AtUri } from "@atproto/api"; -import { Schema, Node, MarkSpec } from "prosemirror-model"; +import { Schema, Node, MarkSpec, NodeSpec } from "prosemirror-model"; import { marks } from "prosemirror-schema-basic"; import { theme } from "tailwind.config"; @@ -104,17 +104,42 @@ let baseSchema = { return ["a", { href, target: "_blank" }, 0]; }, } as MarkSpec, + }, + nodes: { + doc: { content: "block" }, + paragraph: { + content: "inline*", + group: "block", + parseDOM: [{ tag: "p" }], + toDOM: () => ["p", 0] as const, + }, + text: { + group: "inline", + }, + hard_break: { + group: "inline", + inline: true, + selectable: false, + parseDOM: [{ tag: "br" }], + toDOM: () => ["br"] as const, + }, atMention: { attrs: { atURI: {}, + text: { default: "" }, }, - inclusive: false, + group: "inline", + inline: true, + atom: true, + selectable: true, + draggable: true, parseDOM: [ { tag: "span.atMention", getAttrs(dom: HTMLElement) { return { atURI: dom.getAttribute("data-at-uri"), + text: dom.textContent || "", }; }, }, @@ -122,7 +147,6 @@ let baseSchema = { toDOM(node) { // NOTE: This rendering should match the AtMentionLink component in // components/AtMentionLink.tsx. If you update one, update the other. - // We can't use the React component here because ProseMirror expects DOM specs. let className = "atMention text-accent-contrast"; let aturi = new AtUri(node.attrs.atURI); if (aturi.collection === "pub.leaflet.publication") @@ -151,7 +175,7 @@ let baseSchema = { loading: "lazy", }, ], - ["span", 0], + node.attrs.text, ]; } @@ -161,21 +185,27 @@ let baseSchema = { class: className, "data-at-uri": node.attrs.atURI, }, - 0, + node.attrs.text, ]; }, - } as MarkSpec, + } as NodeSpec, didMention: { attrs: { did: {}, + text: { default: "" }, }, - inclusive: false, + group: "inline", + inline: true, + atom: true, + selectable: true, + draggable: true, parseDOM: [ { tag: "span.didMention", getAttrs(dom: HTMLElement) { return { did: dom.getAttribute("data-did"), + text: dom.textContent || "", }; }, }, @@ -187,29 +217,10 @@ let baseSchema = { class: "didMention text-accent-contrast", "data-did": node.attrs.did, }, - 0, + node.attrs.text, ]; }, - } as MarkSpec, - }, - nodes: { - doc: { content: "block" }, - paragraph: { - content: "inline*", - group: "block", - parseDOM: [{ tag: "p" }], - toDOM: () => ["p", 0] as const, - }, - text: { - group: "inline", - }, - hard_break: { - group: "inline", - inline: true, - selectable: false, - parseDOM: [{ tag: "br" }], - toDOM: () => ["br"] as const, - }, + } as NodeSpec, }, }; export const schema = new Schema(baseSchema);