Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894import * as Y from "yjs";import * as base64 from "base64-js";import { $Typed, UnicodeString } from "@atproto/api";import { BlobRef } from "@atproto/lexicon";
import { PubLeafletBlocksBlockquote, PubLeafletBlocksBskyPost, PubLeafletBlocksStandardSitePost, PubLeafletBlocksStandardSitePublication, PubLeafletBlocksButton, PubLeafletBlocksCode, PubLeafletBlocksHeader, PubLeafletBlocksHorizontalRule, PubLeafletBlocksHtml, PubLeafletBlocksIframe, PubLeafletBlocksImage, PubLeafletBlocksImageGallery, PubLeafletBlocksMath, PubLeafletBlocksMembersOnlyDelimiter, PubLeafletBlocksOrderedList, PubLeafletBlocksPage, PubLeafletBlocksPoll, PubLeafletBlocksPostsList, PubLeafletBlocksSignup, PubLeafletBlocksText, PubLeafletBlocksUnorderedList, PubLeafletBlocksWebsite, PubLeafletPagesCanvas, PubLeafletPagesLinearDocument, PubLeafletPollDefinition, PubLeafletRichtextFacet,} from "lexicons/api";import { ids } from "lexicons/api/lexicons";
import { Block } from "components/Blocks/Block";import type { Fact } from "src/replicache";import type { Attribute } from "src/replicache/attributes";import { scanIndexLocal } from "src/replicache/utils";import { getBlocksWithTypeLocal } from "src/replicache/getBlocks";import { List, parseBlocksToList } from "src/utils/parseBlocksToList";import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString";import { ColorToRGB } from "components/ThemeManager/colorToLexicons";import { ThemeDefaults } from "components/ThemeManager/themeUtils";import { parseColor } from "@react-stately/color";
type ExcludeString<T> = T extends string ? (string extends T ? never : T) : T;
export type ProcessBlocksToPagesHooks = { /** * Resolve an image URL to a BlobRef. For publish, this uploads the blob to * the user's PDS. For preview, this synthesizes a BlobRef that carries the * URL through to the email template (no PDS side-effects). * `membersOnly` is true for images past the members-only delimiter * (including subpages linked from there) — content that gated publishes * strip from the PDS copy of the record. * Returning undefined causes the image block to be dropped. */ uploadImage: ( src: string, opts: { membersOnly: boolean }, ) => Promise<BlobRef | undefined>;
/** * Persist a poll definition record and return its at-uri / CID. For * publish, this writes to the PDS and (optionally) Supabase. For preview, * pass null to skip poll blocks entirely — they'll be rendered as * "unsupported" in the email. */ uploadPoll: | (( entityId: string, record: PubLeafletPollDefinition.Record, ) => Promise<{ uri: string; cid: string } | undefined>) | null;};
type ProcessBlocksToPagesResult = { pages: { id: string; blocks: | PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[]; type: "doc" | "canvas"; }[];};
function resolveHighlightColors( scan: ReturnType<typeof scanIndexLocal>, root_entity: string,): HighlightColors { const result: HighlightColors = {}; const slots: Array<{ slot: "2" | "3"; attribute: "theme/highlight-2" | "theme/highlight-3"; }> = [ { slot: "2", attribute: "theme/highlight-2" }, { slot: "3", attribute: "theme/highlight-3" }, ]; for (const { slot, attribute } of slots) { const stored = scan.eav(root_entity, attribute)?.[0]?.data.value; // Mirror useColorAttribute's fallback: theme/highlight-2 and -3 facts are // only written when the user opens the color picker, so an uncustomized // leaflet has no fact and the editor shows the JS default. Without this // fallback, slot-2 and slot-3 highlights would publish without a color // and collapse to slot-1 styling on render. const colorString = stored ? `hsba(${stored})` : ThemeDefaults[attribute]; try { result[slot] = ColorToRGB(parseColor(colorString)); } catch { // ignore unparseable color values } } return result;}
export async function processBlocksToPages(opts: { facts: Fact<Attribute>[]; root_entity: string; // The page entity to serialize as the record's first page. Defaults to the // leaflet's first root/page; publication drafts pass each nav page here // (theme facts still resolve against root_entity). start_page?: string; hooks: ProcessBlocksToPagesHooks;}): Promise<ProcessBlocksToPagesResult> { const { facts, root_entity, hooks } = opts; const scan = scanIndexLocal(facts); const pages: ProcessBlocksToPagesResult["pages"] = [];
const highlightColors = resolveHighlightColors(scan, root_entity);
const startPage = opts.start_page ?? scan.eav(root_entity, "root/page")?.[0]?.data.value; if (!startPage) throw new Error("No root page");
const [pageType] = scan.eav(startPage, "page/type");
if (pageType?.data.value === "canvas") { const canvasBlocks = await canvasBlocksToRecord(startPage, false); pages.unshift({ id: startPage, blocks: canvasBlocks, type: "canvas", }); } else { const blocks = getBlocksWithTypeLocal(facts, startPage); const b = await blocksToRecord(blocks, false); pages.unshift({ id: startPage, blocks: b, type: "doc", }); }
return { pages };
async function blocksToRecord( blocks: Block[], membersOnly: boolean, ): Promise<PubLeafletPagesLinearDocument.Block[]> { const parsedBlocks = parseBlocksToList(blocks); // Blocks past the delimiter are members-only; the delimiter itself stays // public as the paywall anchor. It can only be a top-level block, never // inside a list. const delimiterIndex = parsedBlocks.findIndex( (b) => b.type === "block" && b.block.type === "members-only-delimiter", ); return ( await Promise.all( parsedBlocks.map(async (blockOrList, blockIndex) => { const blockMembersOnly = membersOnly || (delimiterIndex !== -1 && blockIndex > delimiterIndex); if (blockOrList.type === "block") { const alignmentValue = scan.eav( blockOrList.block.entityID, "block/text-alignment", )[0]?.data.value; const alignment: ExcludeString< PubLeafletPagesLinearDocument.Block["alignment"] > = alignmentValue === "center" ? "lex:pub.leaflet.pages.linearDocument#textAlignCenter" : alignmentValue === "right" ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" : alignmentValue === "justify" ? "lex:pub.leaflet.pages.linearDocument#textAlignJustify" : alignmentValue === "left" ? "lex:pub.leaflet.pages.linearDocument#textAlignLeft" : undefined; const b = await blockToRecord(blockOrList.block, blockMembersOnly); if (!b) return []; const block: PubLeafletPagesLinearDocument.Block = { $type: "pub.leaflet.pages.linearDocument#block", block: b, }; if (alignment) block.alignment = alignment; return [block]; } else { const runs = splitListByStyle(blockOrList.children); const out = await Promise.all( runs.map(async (run) => { if (run.style === "ordered") { const block: PubLeafletPagesLinearDocument.Block = { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.orderedList", startIndex: run.children[0].block.listData?.listStart || 1, children: await orderedChildrenToRecord( run.children, blockMembersOnly, ), }, }; return block; } else { const block: PubLeafletPagesLinearDocument.Block = { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.unorderedList", children: await unorderedChildrenToRecord( run.children, blockMembersOnly, ), }, }; return block; } }), ); return out; } }), ) ).flat(); }
function splitListByStyle(children: List[]) { const runs: { style: "ordered" | "unordered"; children: List[] }[] = []; for (const child of children) { const style: "ordered" | "unordered" = child.block.listData?.listStyle === "ordered" ? "ordered" : "unordered"; const last = runs[runs.length - 1]; if (last && last.style === style) { last.children.push(child); } else { runs.push({ style, children: [child] }); } } return runs; }
async function unorderedChildrenToRecord( children: List[], membersOnly: boolean, ): Promise<PubLeafletBlocksUnorderedList.ListItem[]> { return ( await Promise.all( children.map(async (child) => { const content = await blockToRecord(child.block, membersOnly); if (!content) return []; const record: PubLeafletBlocksUnorderedList.ListItem = { $type: "pub.leaflet.blocks.unorderedList#listItem", content, ...(child.block.listData?.checklist && { checked: child.block.listData.checked ?? false, }), }; const sameStyle = child.children.filter( (c) => c.block.listData?.listStyle !== "ordered", ); const diffStyle = child.children.filter( (c) => c.block.listData?.listStyle === "ordered", ); if (sameStyle.length > 0) { record.children = await unorderedChildrenToRecord( sameStyle, membersOnly, ); } if (diffStyle.length > 0) { record.orderedListChildren = { $type: "pub.leaflet.blocks.orderedList", children: await orderedChildrenToRecord(diffStyle, membersOnly), }; } return record; }), ) ).flat(); }
async function orderedChildrenToRecord( children: List[], membersOnly: boolean, ): Promise<PubLeafletBlocksOrderedList.ListItem[]> { return ( await Promise.all( children.map(async (child) => { const content = await blockToRecord(child.block, membersOnly); if (!content) return []; const record: PubLeafletBlocksOrderedList.ListItem = { $type: "pub.leaflet.blocks.orderedList#listItem", content, ...(child.block.listData?.checklist && { checked: child.block.listData.checked ?? false, }), }; const sameStyle = child.children.filter( (c) => c.block.listData?.listStyle === "ordered", ); const diffStyle = child.children.filter( (c) => c.block.listData?.listStyle !== "ordered", ); if (sameStyle.length > 0) { record.children = await orderedChildrenToRecord( sameStyle, membersOnly, ); } if (diffStyle.length > 0) { record.unorderedListChildren = { $type: "pub.leaflet.blocks.unorderedList", children: await unorderedChildrenToRecord(diffStyle, membersOnly), }; } return record; }), ) ).flat(); }
async function blockToRecord(b: Block, membersOnly: boolean) { const footnoteContentResolver = (footnoteEntityID: string) => { const [content] = scan.eav(footnoteEntityID, "block/text"); if (!content) return { plaintext: "", facets: [] as PubLeafletRichtextFacet.Main[], }; const doc = new Y.Doc(); const update = base64.toByteArray(content.data.value); Y.applyUpdate(doc, update); const nodes = doc.getXmlElement("prosemirror").toArray(); const plaintext = YJSFragmentToString(nodes[0]); const { facets } = YJSFragmentToFacets( nodes[0], 0, undefined, highlightColors, ); return { plaintext, facets }; }; const getBlockContent = (b: string) => { const [content] = scan.eav(b, "block/text"); if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const; const doc = new Y.Doc(); const update = base64.toByteArray(content.data.value); Y.applyUpdate(doc, update); const nodes = doc.getXmlElement("prosemirror").toArray(); const stringValue = YJSFragmentToString(nodes[0]); const { facets } = YJSFragmentToFacets( nodes[0], 0, footnoteContentResolver, highlightColors, ); return [stringValue, facets] as const; }; if (b.type === "card") { const [page] = scan.eav(b.entityID, "block/card"); if (!page) return; const [pageType] = scan.eav(page.data.value, "page/type");
if (pageType?.data.value === "canvas") { const canvasBlocks = await canvasBlocksToRecord( page.data.value, membersOnly, ); pages.push({ id: page.data.value, blocks: canvasBlocks, type: "canvas", }); } else { const blocks = getBlocksWithTypeLocal(facts, page.data.value); pages.push({ id: page.data.value, blocks: await blocksToRecord(blocks, membersOnly), type: "doc", }); }
const block: $Typed<PubLeafletBlocksPage.Main> = { $type: "pub.leaflet.blocks.page", id: page.data.value, }; return block; }
if (b.type === "bluesky-post") { const [post] = scan.eav(b.entityID, "block/bluesky-post"); if (!post || !post.data.value.post) return; const [hostFact] = scan.eav(b.entityID, "bluesky-post/host"); const block: $Typed<PubLeafletBlocksBskyPost.Main> = { $type: ids.PubLeafletBlocksBskyPost, postRef: { uri: post.data.value.post.uri, cid: post.data.value.post.cid, }, clientHost: hostFact?.data.value, }; return block; } if (b.type === "standard-site-post") { const [uri] = scan.eav(b.entityID, "block/standard-site-post"); if (!uri) return; const [sizeFact] = scan.eav(b.entityID, "standard-site-post/size"); const [showPubThemeFact] = scan.eav( b.entityID, "standard-site-post/show-publication-theme", ); const block: $Typed<PubLeafletBlocksStandardSitePost.Main> = { $type: ids.PubLeafletBlocksStandardSitePost, uri: uri.data.value, ...(sizeFact && { size: sizeFact.data.value }), ...(showPubThemeFact?.data.value === false && { showPublicationTheme: false, }), }; return block; } if (b.type === "standard-site-publication") { const [uri] = scan.eav(b.entityID, "block/standard-site-publication"); if (!uri) return; const [showPubThemeFact] = scan.eav( b.entityID, "standard-site-publication/show-publication-theme", ); const block: $Typed<PubLeafletBlocksStandardSitePublication.Main> = { $type: ids.PubLeafletBlocksStandardSitePublication, uri: uri.data.value, ...(showPubThemeFact?.data.value === false && { showPublicationTheme: false, }), }; return block; } if (b.type === "horizontal-rule") { const block: $Typed<PubLeafletBlocksHorizontalRule.Main> = { $type: ids.PubLeafletBlocksHorizontalRule, }; return block; } if (b.type === "members-only-delimiter") { const block: $Typed<PubLeafletBlocksMembersOnlyDelimiter.Main> = { $type: ids.PubLeafletBlocksMembersOnlyDelimiter, }; return block; }
if (b.type === "heading") { const [headingLevel] = scan.eav(b.entityID, "block/heading-level");
const [stringValue, facets] = getBlockContent(b.entityID); const block: $Typed<PubLeafletBlocksHeader.Main> = { $type: "pub.leaflet.blocks.header", level: Math.floor(headingLevel?.data.value || 1), plaintext: stringValue, ...(facets.length > 0 && { facets }), }; return block; }
if (b.type === "blockquote") { const [stringValue, facets] = getBlockContent(b.entityID); const block: $Typed<PubLeafletBlocksBlockquote.Main> = { $type: ids.PubLeafletBlocksBlockquote, plaintext: stringValue, ...(facets.length > 0 && { facets }), }; return block; }
if (b.type == "text") { const [stringValue, facets] = getBlockContent(b.entityID); const [textSize] = scan.eav(b.entityID, "block/text-size"); const block: $Typed<PubLeafletBlocksText.Main> = { $type: ids.PubLeafletBlocksText, plaintext: stringValue, ...(facets.length > 0 && { facets }), ...(textSize && { textSize: textSize.data.value }), }; return block; } if (b.type === "embed" || b.type === "html") { const [url] = scan.eav(b.entityID, "embed/url"); // embed-typed blocks may carry an embed/html fact from before html // embeds were split into their own block type const [html] = scan.eav(b.entityID, "embed/html"); const [height] = scan.eav(b.entityID, "embed/height"); const [aspectRatio] = scan.eav(b.entityID, "embed/aspect-ratio"); if (!url && !html) return; const blockHeight = Math.floor(height?.data.value || 600); const block: $Typed< PubLeafletBlocksHtml.Main | PubLeafletBlocksIframe.Main > = html ? { $type: "pub.leaflet.blocks.html", html: html.data.value, height: blockHeight, } : { $type: "pub.leaflet.blocks.iframe", url: url!.data.value, height: blockHeight, }; if (aspectRatio) { const [w, h] = aspectRatio.data.value.split("/").map(Number); if (w && h) { block.aspectRatio = { width: w, height: h }; } } return block; } if (b.type == "image") { const [image] = scan.eav(b.entityID, "block/image"); if (!image) return; const [altText] = scan.eav(b.entityID, "image/alt"); const [fullBleed] = scan.eav(b.entityID, "image/full-bleed"); const [maxWidth] = scan.eav(b.entityID, "image/max-width"); const blobref = await hooks.uploadImage(image.data.src, { membersOnly }); if (!blobref) return; const block: $Typed<PubLeafletBlocksImage.Main> = { $type: "pub.leaflet.blocks.image", image: blobref, aspectRatio: { height: Math.floor(image.data.height), width: Math.floor(image.data.width), }, alt: altText ? altText.data.value : undefined, fullBleed: fullBleed?.data.value || undefined, ...(maxWidth !== undefined && { width: Math.floor(maxWidth.data.value) }), }; return block; } if (b.type === "image-gallery") { const imageFacts = scan .eav(b.entityID, "gallery/image") .toSorted((a, c) => (a.data.position > c.data.position ? 1 : -1)); const images = ( await Promise.all( imageFacts.map(async (f) => { const imageEntity = f.data.value; const [image] = scan.eav(imageEntity, "block/image"); if (!image) return null; const [alt] = scan.eav(imageEntity, "image/alt"); const blobref = await hooks.uploadImage(image.data.src, { membersOnly, }); if (!blobref) return null; const galleryImage: PubLeafletBlocksImageGallery.Image = { $type: "pub.leaflet.blocks.imageGallery#image", image: blobref, aspectRatio: { width: Math.floor(image.data.width), height: Math.floor(image.data.height), }, ...(alt ? { alt: alt.data.value } : {}), }; return galleryImage; }), ) ).filter((i): i is PubLeafletBlocksImageGallery.Image => i !== null); if (images.length === 0) return;
const [format] = scan.eav(b.entityID, "gallery/format"); const [gap] = scan.eav(b.entityID, "gallery/gap"); const [maxWidth] = scan.eav(b.entityID, "gallery/max-width"); const block: $Typed<PubLeafletBlocksImageGallery.Main> = { $type: "pub.leaflet.blocks.imageGallery", images, ...(format && { format: format.data.value }), ...(gap !== undefined && { gap: Math.floor(gap.data.value) }), ...(maxWidth !== undefined && { maxWidth: Math.floor(maxWidth.data.value), }), }; return block; } if (b.type === "link") { const [previewImage] = scan.eav(b.entityID, "link/preview"); const [description] = scan.eav(b.entityID, "link/description"); const [src] = scan.eav(b.entityID, "link/url"); if (!src) return; const blobref = previewImage ? await hooks.uploadImage(previewImage.data.src, { membersOnly }) : undefined; const [title] = scan.eav(b.entityID, "link/title"); const block: $Typed<PubLeafletBlocksWebsite.Main> = { $type: "pub.leaflet.blocks.website", previewImage: blobref, src: src.data.value, description: description?.data.value, title: title?.data.value, }; return block; } if (b.type === "code") { const [language] = scan.eav(b.entityID, "block/code-language"); const [code] = scan.eav(b.entityID, "block/code"); const [theme] = scan.eav(root_entity, "theme/code-theme"); const block: $Typed<PubLeafletBlocksCode.Main> = { $type: "pub.leaflet.blocks.code", language: language?.data.value, plaintext: code?.data.value || "", syntaxHighlightingTheme: theme?.data.value, }; return block; } if (b.type === "math") { const [math] = scan.eav(b.entityID, "block/math"); const block: $Typed<PubLeafletBlocksMath.Main> = { $type: "pub.leaflet.blocks.math", tex: math?.data.value || "", }; return block; } if (b.type === "poll") { if (!hooks.uploadPoll) return;
const pollOptions = scan.eav(b.entityID, "poll/options"); const options: PubLeafletPollDefinition.Option[] = pollOptions.map( (opt) => { const optionName = scan.eav(opt.data.value, "poll-option/name")?.[0]; return { $type: "pub.leaflet.poll.definition#option", text: optionName?.data.value || "", }; }, );
const pollRecord: PubLeafletPollDefinition.Record = { $type: "pub.leaflet.poll.definition", name: "Poll", options, };
const result = await hooks.uploadPoll(b.entityID, pollRecord); if (!result) return;
const block: $Typed<PubLeafletBlocksPoll.Main> = { $type: "pub.leaflet.blocks.poll", pollRef: { uri: result.uri, cid: result.cid, }, }; return block; } if (b.type === "button") { const [text] = scan.eav(b.entityID, "button/text"); const [url] = scan.eav(b.entityID, "button/url"); if (!text || !url) return; const block: $Typed<PubLeafletBlocksButton.Main> = { $type: "pub.leaflet.blocks.button", text: text.data.value, url: url.data.value, }; return block; } if (b.type === "posts-list") { const [viewFact] = scan.eav(b.entityID, "posts-list/view"); const [highlightFact] = scan.eav( b.entityID, "posts-list/highlight-first-post", ); const filterTagFacts = scan.eav(b.entityID, "posts-list/filter-tag"); const filterByTags = filterTagFacts.map((f) => f.data.value); const [limitFact] = scan.eav(b.entityID, "posts-list/limit"); const limit = limitFact?.data.value; const block: $Typed<PubLeafletBlocksPostsList.Main> = { $type: "pub.leaflet.blocks.postsList", ...(viewFact && { view: viewFact.data.value }), ...(highlightFact && { highlightFirstPost: highlightFact.data.value }), ...(filterByTags.length > 0 && { filterByTags }), ...(limit && limit > 0 && { limit }), }; return block; } if (b.type === "signup") { const block: $Typed<PubLeafletBlocksSignup.Main> = { $type: ids.PubLeafletBlocksSignup, }; return block; } return; }
async function canvasBlocksToRecord( pageID: string, membersOnly: boolean, ): Promise<PubLeafletPagesCanvas.Block[]> { const canvasBlocks = scan.eav(pageID, "canvas/block"); return ( await Promise.all( canvasBlocks.map(async (canvasBlock) => { const blockEntity = canvasBlock.data.value; const position = canvasBlock.data.position;
const blockType = scan.eav(blockEntity, "block/type")?.[0]; if (!blockType) return null;
const block: Block = { type: blockType.data.value, entityID: blockEntity, parent: pageID, position: "", factID: canvasBlock.id, };
const content = await blockToRecord(block, membersOnly); if (!content) return null;
const width = scan.eav(blockEntity, "canvas/block/width")?.[0]?.data.value || 360; const rotation = scan.eav(blockEntity, "canvas/block/rotation")?.[0] ?.data.value;
const canvasBlockRecord: PubLeafletPagesCanvas.Block = { $type: "pub.leaflet.pages.canvas#block", block: content, x: Math.floor(position.x), y: Math.floor(position.y), width: Math.floor(width), ...(rotation !== undefined && { rotation: Math.floor(rotation) }), };
return canvasBlockRecord; }), ) ).filter((b): b is PubLeafletPagesCanvas.Block => b !== null); }}
type HighlightColors = Partial< Record<"1" | "2" | "3", PubLeafletRichtextFacet.Highlight["color"]>>;
function YJSFragmentToFacets( node: Y.XmlElement | Y.XmlText | Y.XmlHook, byteOffset: number = 0, footnoteContentResolver?: (footnoteEntityID: string) => { plaintext: string; facets: PubLeafletRichtextFacet.Main[]; }, highlightColors?: HighlightColors,): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { if (node.constructor === Y.XmlElement) { if (node.nodeName === "footnote") { const footnoteEntityID = node.getAttribute("footnoteEntityID") || ""; const placeholder = "*"; const unicodestring = new UnicodeString(placeholder); const footnoteContent = footnoteContentResolver?.(footnoteEntityID); const facet: PubLeafletRichtextFacet.Main = { index: { byteStart: byteOffset, byteEnd: byteOffset + unicodestring.length, }, features: [ { $type: "pub.leaflet.richtext.facet#footnote", footnoteId: footnoteEntityID, contentPlaintext: footnoteContent?.plaintext || "", ...(footnoteContent?.facets?.length ? { contentFacets: footnoteContent.facets } : {}), }, ], }; return { facets: [facet], byteLength: unicodestring.length }; }
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"), ...(node.getAttribute("href") ? { href: node.getAttribute("href") } : {}), }, ], }; return { facets: [facet], byteLength: unicodestring.length }; }
if (node.nodeName === "hard_break") { const unicodestring = new UnicodeString("\n"); return { facets: [], byteLength: unicodestring.length }; }
const allFacets: PubLeafletRichtextFacet.Main[] = []; let currentOffset = byteOffset; for (const child of node.toArray()) { const result = YJSFragmentToFacets( child, currentOffset, footnoteContentResolver, highlightColors, ); allFacets.push(...result.facets); currentOffset += result.byteLength; } return { facets: allFacets, byteLength: currentOffset - byteOffset }; }
if (node.constructor === Y.XmlText) { const facets: PubLeafletRichtextFacet.Main[] = []; const delta = node.toDelta() as Delta[]; let byteStart = byteOffset; let totalLength = 0; for (const d of delta) { const unicodestring = new UnicodeString(d.insert); const facet: PubLeafletRichtextFacet.Main = { index: { byteStart, byteEnd: byteStart + unicodestring.length, }, features: [], };
if (d.attributes?.strikethrough) facet.features.push({ $type: "pub.leaflet.richtext.facet#strikethrough", });
if (d.attributes?.code) facet.features.push({ $type: "pub.leaflet.richtext.facet#code" }); if (d.attributes?.highlight) { const slot = d.attributes.highlight.color as "1" | "2" | "3"; const resolvedColor = slot && slot !== "1" ? highlightColors?.[slot] : undefined; facet.features.push({ $type: "pub.leaflet.richtext.facet#highlight", ...(resolvedColor ? { color: resolvedColor } : {}), }); } if (d.attributes?.underline) facet.features.push({ $type: "pub.leaflet.richtext.facet#underline" }); if (d.attributes?.strong) facet.features.push({ $type: "pub.leaflet.richtext.facet#bold" }); if (d.attributes?.em) facet.features.push({ $type: "pub.leaflet.richtext.facet#italic" }); if (d.attributes?.link) facet.features.push({ $type: "pub.leaflet.richtext.facet#link", uri: d.attributes.link.href, }); if (facet.features.length > 0) facets.push(facet); byteStart += unicodestring.length; totalLength += unicodestring.length; } return { facets, byteLength: totalLength }; } return { facets: [], byteLength: 0 };}