Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
44 kB · 1455 lines
TypeScript
at perf/paste
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456import { MutableRefObject, useCallback } from "react";import { Fact, ReplicacheMutators, useReplicache } from "src/replicache";import { EditorView } from "prosemirror-view";import { setEditorState, useEditorStates } from "src/state/useEditorState";import { DOMParser as ProsemirrorDOMParser, Node as ProsemirrorNode,} from "prosemirror-model";import { EditorState } from "prosemirror-state";import { schema } from "./schema";import { generateKeyBetween, generateNKeysBetween } from "fractional-indexing";import { addImage, prepareImage } from "src/utils/addImage";import { BlockProps } from "../Block";import { focusBlock } from "src/utils/focusBlock";import { useEntitySetContext } from "components/EntitySetProvider";import { v7 } from "uuid";import { Replicache } from "replicache";import { markdownToHtml } from "src/htmlMarkdownParsers";import { betterIsUrl } from "src/utils/isURL";import { TextSelection } from "prosemirror-state";import type { FilterAttributes } from "src/replicache/attributes";import { addLinkBlock } from "src/utils/addLinkBlock";import { UndoManager } from "src/undoManager";import { prosemirrorToYDoc } from "y-prosemirror";import * as Y from "yjs";import * as base64 from "base64-js";import type { FactInput } from "src/replicache/mutations";
const parser = ProsemirrorDOMParser.fromSchema(schema);
type BlockType = Fact<"block/type">["data"]["value"];
type BuiltBlock = { entityID: string; parent: string; type: BlockType; facts: FactInput[]; parsedContent?: ProsemirrorNode;};
type ImageRefetchTask = { url: string; entityID: string; attribute: keyof FilterAttributes<{ type: "image" }>;};
type LinkBlockTask = { url: string; entityID: string;};
type BuildResult = { blocks: BuiltBlock[]; extraEntities: Array<{ entityID: string; permission_set: string }>; imageTasks: ImageRefetchTask[]; linkTasks: LinkBlockTask[];};
const emptyBuildResult = (): BuildResult => ({ blocks: [], extraEntities: [], imageTasks: [], linkTasks: [],});
export const useHandlePaste = ( entityID: string, propsRef: MutableRefObject<BlockProps>,) => { let { rep, undoManager } = useReplicache(); let entity_set = useEntitySetContext(); return useCallback( (view: EditorView, e: ClipboardEvent) => { if (!rep) return; if (!e.clipboardData) return; let textHTML = e.clipboardData.getData("text/html"); let text = e.clipboardData.getData("text"); let editorState = useEditorStates.getState().editorStates[entityID]; if (!editorState) return; if (text && betterIsUrl(text)) { let selection = view.state.selection as TextSelection; let tr = view.state.tr; let { from, to } = selection; if (selection.empty) { tr.insertText(text, selection.from); tr.addMark( from, from + text.length, schema.marks.link.create({ href: text }), ); } else { tr.addMark(from, to, schema.marks.link.create({ href: text })); } let oldState = view.state; let newState = view.state.apply(tr); undoManager.add({ undo: () => { if (!view?.hasFocus()) view?.focus(); setEditorState(entityID, { editor: oldState, }); }, redo: () => { if (!view?.hasFocus()) view?.focus(); setEditorState(entityID, { editor: newState, }); }, }); setEditorState(entityID, { editor: newState, }); return true; } // if there is no html, but there is text, convert the text to markdown let xml = new DOMParser().parseFromString(textHTML, "text/html"); if ((!textHTML || !xml.children.length) && text) { textHTML = markdownToHtml(text); } if (textHTML) { let parsedXml = new DOMParser().parseFromString(textHTML, "text/html"); // see stripCommentMarks — this path parses clipboard HTML itself, // so the comment-anchor markup is stripped here instead for (let anchor of parsedXml.querySelectorAll("span.comment-anchor")) { anchor.classList.remove("comment-anchor"); anchor.removeAttribute("data-comment-id"); } let children = flattenHTMLToTextBlocks(parsedXml.body); let hasImage = false; for (let item of e.clipboardData.items) { if (item.type.includes("image")) hasImage = true; } if ( !(children.length === 1 && children[0].tagName === "IMG" && hasImage) ) { const pasteParent = propsRef.current.listData ? propsRef.current.listData.parent : propsRef.current.parent; const useBulkPath = propsRef.current.pageType === "doc" && !isLegacyPasteEnabled(); if (useBulkPath) { bulkPaste({ children, rep, undoManager, entity_set, propsRef, pasteParent, }); } else { let currentPosition = propsRef.current.position; children.forEach((child, index) => { createBlockFromHTMLLegacy(child, { undoManager, parentType: propsRef.current.pageType, first: index === 0, activeBlockProps: propsRef, entity_set, rep, parent: pasteParent, getPosition: () => { currentPosition = generateKeyBetween( currentPosition || null, propsRef.current.nextPosition, ); return currentPosition; }, last: index === children.length - 1, }); }); } } }
for (let item of e.clipboardData.items) { if (item?.type.includes("image")) { let file = item.getAsFile(); if (file) { // Decode the image FIRST, then commit the block's structural facts // (type / card/block) together with the optimistic block/image fact // in a single transaction. Committing them atomically means the block // never paints as an image-type block with no image — i.e. no empty // "Upload An Image" placeholder flashes before the picture appears. // The whole structural commit is one undo group (one Cmd-Z removes // the pasted image); finishUpload writes the post-upload fact with // ignoreUndo so it doesn't add a stray step on top of the group. const intoEmptyBlock = editorState.editor.doc.textContent.length === 0; const entity = intoEmptyBlock ? propsRef.current.entityID : v7(); const parent = propsRef.current.parent; const position = generateKeyBetween( propsRef.current.position, propsRef.current.nextPosition, ); prepareImage(file, rep, { attribute: "block/image", entityID: entity, ignoreUndo: true, }) .then(async ({ imageFact, finishUpload }) => { // finally → finishUpload guarantees the concurrency slot is // released even if a commit throws. try { if (intoEmptyBlock) { await undoManager.withUndoGroup(async () => { await rep.mutate.assertFact([ { entity, attribute: "block/type", data: { type: "block-type-union", value: "image" }, }, imageFact, ]); await rep.mutate.retractAttribute({ entity, attribute: "block/text", }); }); } else { // createEntity first (it renders nothing until referenced), // then commit the card/block reference, type, and image fact // in one transaction so the new block appears with its image. await rep.mutate.createEntity([ { entityID: entity, permission_set: entity_set.set }, ]); await undoManager.withUndoGroup(() => rep.mutate.assertFact([ { entity: parent, id: v7(), attribute: "card/block", data: { type: "ordered-reference", value: entity, position, }, }, { entity, attribute: "block/type", data: { type: "block-type-union", value: "image" }, }, imageFact, ]), ); } } finally { await finishUpload(); } }) .catch(() => {}); } return; } } e.preventDefault(); e.stopPropagation(); return true; }, [rep, entity_set, entityID, propsRef, undoManager], );};
function isLegacyPasteEnabled(): boolean { try { return ( typeof window !== "undefined" && window.localStorage?.getItem("legacyPaste") === "1" ); } catch { return false; }}
async function bulkPaste({ children, rep, undoManager, entity_set, propsRef, pasteParent,}: { children: HTMLElement[]; rep: Replicache<ReplicacheMutators>; undoManager: UndoManager; entity_set: { set: string }; propsRef: MutableRefObject<BlockProps>; pasteParent: string;}) { let result = emptyBuildResult(); for (const child of children) { const r = buildBlockFromHTML(child, { parent: pasteParent, permission_set: entity_set.set, }); result.blocks.push(...r.blocks); result.extraEntities.push(...r.extraEntities); result.imageTasks.push(...r.imageTasks); result.linkTasks.push(...r.linkTasks); } if (result.blocks.length === 0) return;
// Determine if the first built block should reuse the active block. const firstBuilt = result.blocks[0]; const activeType = propsRef.current.type; let activeReuse: { content: ProsemirrorNode; type: BlockType } | null = null; if ( firstBuilt && firstBuilt.parent === pasteParent && firstBuilt.parsedContent && (firstBuilt.type === "text" || firstBuilt.type === "heading" || firstBuilt.type === "blockquote") && (activeType === "heading" || activeType === "blockquote" || activeType === firstBuilt.type) ) { activeReuse = { content: firstBuilt.parsedContent, type: firstBuilt.type, }; }
// When active reuse drops the first built block, fan its non-content facts // out to the active block: alignment, text-size, list metadata, and any // card/block references that connect to nested children. Nested blocks that // pointed at firstBuilt as their parent get re-pointed at the active block. let bulkBlocks: BuiltBlock[]; const reuseFacts: FactInput[] = []; if (activeReuse) { const droppedID = firstBuilt.entityID; const activeID = propsRef.current.entityID; bulkBlocks = result.blocks .slice(1) .map((b) => (b.parent === droppedID ? { ...b, parent: activeID } : b)); for (const f of firstBuilt.facts) { if (f.attribute === "block/type" || f.attribute === "block/text") continue; const remapped: FactInput = { ...f, entity: activeID }; reuseFacts.push(remapped); } } else { bulkBlocks = result.blocks; } const topLevel = bulkBlocks.filter((b) => b.parent === pasteParent); const positions = topLevel.length > 0 ? generateNKeysBetween( propsRef.current.position || null, propsRef.current.nextPosition || null, topLevel.length, ) : []; const positionByEntity = new Map<string, string>(); topLevel.forEach((b, i) => positionByEntity.set(b.entityID, positions[i]));
const entities: Array<{ entityID: string; permission_set: string }> & { ignoreUndo?: true; } = [ ...bulkBlocks.map((b) => ({ entityID: b.entityID, permission_set: entity_set.set, })), ...result.extraEntities, ];
const allFacts: FactInput[] & { ignoreUndo?: true } = []; for (const b of bulkBlocks) { allFacts.push(...b.facts); const position = positionByEntity.get(b.entityID); if (position !== undefined) { allFacts.push({ entity: b.parent, attribute: "card/block", data: { type: "ordered-reference", value: b.entityID, position, }, }); } } allFacts.push(...reuseFacts);
// Apply active-block reuse via the live editor BEFORE committing the bulk // payload so the user sees the absorbed content immediately. The Replicache // commit follows. let activeReuseUndo: { oldEditorState: EditorState; newEditorState: EditorState; } | null = null; if (activeReuse) { const activeID = propsRef.current.entityID; const liveState = useEditorStates.getState().editorStates[activeID]; if (liveState) { const oldEditorState = liveState.editor; const tr = liveState.editor.tr; const sel = liveState.editor.selection; if (!sel.empty) { tr.delete(sel.from, sel.to); } tr.replaceSelectionWith(activeReuse.content); const newEditorState = liveState.editor.apply(tr); setEditorState(activeID, { editor: newEditorState }); activeReuseUndo = { oldEditorState, newEditorState }; } }
// Single transaction for entity creation; second for all facts. We split // these to fit the existing generic mutators — both honor ignoreUndo. entities.ignoreUndo = true; allFacts.ignoreUndo = true; if (entities.length > 0) await rep.mutate.createEntity(entities); if (allFacts.length > 0) await rep.mutate.assertFact(allFacts);
const activeID = propsRef.current.entityID; const newEntityIDs = entities.map((e) => e.entityID);
// Group the live-editor undo with the bulk undo so a single Cmd-Z reverses // the whole paste. undoManager.withUndoGroup(() => { if (activeReuseUndo) { const { oldEditorState, newEditorState } = activeReuseUndo; undoManager.add({ undo: () => { const view = useEditorStates.getState().editorStates[activeID]?.view; if (view && !view.hasFocus()) view.focus(); setEditorState(activeID, { editor: oldEditorState }); }, redo: () => { const view = useEditorStates.getState().editorStates[activeID]?.view; if (view && !view.hasFocus()) view.focus(); setEditorState(activeID, { editor: newEditorState }); }, }); } if (newEntityIDs.length > 0) { const facts = allFacts.slice(); undoManager.add({ undo: async () => { for (const id of newEntityIDs) { await rep.mutate.deleteEntity({ entity: id, ignoreUndo: true }); } }, redo: async () => { const e = entities.slice() as Array<{ entityID: string; permission_set: string; }> & { ignoreUndo?: true }; const f = facts.slice() as FactInput[] & { ignoreUndo?: true }; e.ignoreUndo = true; f.ignoreUndo = true; await rep.mutate.createEntity(e); await rep.mutate.assertFact(f); }, }); } });
// Kick off async work after the bulk commit so the new blocks are already // present locally before image/link metadata arrives. for (const task of result.linkTasks) { addLinkBlock(task.url, task.entityID, rep); } for (const task of result.imageTasks) { fetch(task.url) .then((res) => res.blob()) .then((blob) => { const file = new File([blob], "image.png", { type: blob.type }); return addImage(file, rep, { attribute: task.attribute, entityID: task.entityID, // The bulk undo group already reverts these entities; skip recording // the image facts so they don't add a stray undo step on top. ignoreUndo: true, }); }) .catch(() => { // Image refetch failed; leave the placeholder block. The renderer // already handles missing image data. }); }
// Cursor placement: focus the end of the last bulk-added block, or the // active block if reuse absorbed everything. const lastBulk = bulkBlocks[bulkBlocks.length - 1]; if (lastBulk) { focusBlock( { value: lastBulk.entityID, type: lastBulk.type, parent: lastBulk.parent, }, { type: "end" }, ); } else if (activeReuse) { focusBlock( { value: propsRef.current.entityID, type: activeReuse.type, parent: propsRef.current.parent, }, { type: "end" }, ); }}
function buildBlockFromHTML( child: Element, opts: { parent: string; permission_set: string; listStyle?: "ordered" | "unordered"; },): BuildResult { const { parent, permission_set, listStyle } = opts; const result = emptyBuildResult();
if (child.tagName === "UL" || child.tagName === "OL") { const childListStyle = child.tagName === "OL" ? "ordered" : "unordered"; for (const c of Array.from(child.children)) { const r = buildBlockFromHTML(c, { parent, permission_set, listStyle: childListStyle, }); result.blocks.push(...r.blocks); result.extraEntities.push(...r.extraEntities); result.imageTasks.push(...r.imageTasks); result.linkTasks.push(...r.linkTasks); } return result; }
// Initial type from tagName; may be overridden below. let baseType: BlockType | null; let headingLevel: number | null = null; switch (child.tagName) { case "BLOCKQUOTE": baseType = "blockquote"; break; case "LI": case "SPAN": baseType = "text"; break; case "PRE": baseType = "code"; break; case "P": baseType = "text"; break; case "H1": baseType = "heading"; headingLevel = 1; break; case "H2": baseType = "heading"; headingLevel = 2; break; case "H3": baseType = "heading"; headingLevel = 3; break; case "H4": baseType = "heading"; headingLevel = 4; break; case "DIV": baseType = "card"; break; case "IMG": baseType = "image"; break; case "A": baseType = "link"; break; case "HR": baseType = "horizontal-rule"; break; default: baseType = null; } if (!baseType) return result;
// Special DIV variants determine final type. const isMath = child.tagName === "DIV" && !!child.getAttribute("data-tex"); const isBlueskyPost = child.tagName === "DIV" && !!child.getAttribute("data-bluesky-post"); const isCardPaste = child.tagName === "DIV" && !!child.getAttribute("data-entityid"); const aHref = child.tagName === "A" ? child.getAttribute("href") : null; const aIsButton = child.tagName === "A" && child.getAttribute("data-type") === "button";
// A with no href is invalid as a link block; skip it. if (child.tagName === "A" && !aHref) return result;
let finalType: BlockType = baseType; if (isMath) finalType = "math"; else if (isBlueskyPost) finalType = "bluesky-post"; else if (aIsButton && aHref) finalType = "button";
const entityID = v7(); const facts: FactInput[] = []; let parsedContent: ProsemirrorNode | undefined;
// block/type is emitted for everything except A-without-buttondata (link // blocks) — addLinkBlock writes the type asynchronously based on metadata. const isLink = child.tagName === "A" && !aIsButton; if (!isLink) { facts.push({ entity: entityID, attribute: "block/type", data: { type: "block-type-union", value: finalType }, }); }
if (finalType === "heading" && headingLevel) { facts.push({ entity: entityID, attribute: "block/heading-level", data: { type: "number", value: headingLevel }, }); }
const alignment = child.getAttribute("data-alignment"); if (alignment && ["right", "left", "center"].includes(alignment)) { facts.push({ entity: entityID, attribute: "block/text-alignment", data: { type: "text-alignment-type-union", value: alignment as "right" | "left" | "center", }, }); }
const textSize = child.getAttribute("data-text-size"); if (textSize && ["default", "small", "large"].includes(textSize)) { facts.push({ entity: entityID, attribute: "block/text-size", data: { type: "text-size-union", value: textSize as "default" | "small" | "large", }, }); }
// block/text — Yjs-encode parsed content for text-ish blocks only. if ( (finalType === "text" || finalType === "heading" || finalType === "blockquote") && child.tagName !== "PRE" ) { const doc = parser.parse(child); parsedContent = doc; const ydoc = prosemirrorToYDoc(doc, "prosemirror"); const update = Y.encodeStateAsUpdate(ydoc); facts.push({ entity: entityID, attribute: "block/text", data: { type: "text", value: base64.fromByteArray(update) }, }); ydoc.destroy(); }
// PRE → code body. data-lang is Leaflet's own exact language; fall back to a // language-* class (markdown fenced code) and then data-language. if (child.tagName === "PRE" && child.textContent) { let lang = child.getAttribute("data-lang"); if (!lang && child.firstElementChild?.className) { const match = child.firstElementChild.className.match(/language-([\w.+-]+)/); if (match) lang = match[1]; } if (!lang) lang = child.getAttribute("data-language") || "plaintext"; facts.push({ entity: entityID, attribute: "block/code-language", data: { type: "string", value: lang }, }); facts.push({ entity: entityID, attribute: "block/code", // markdown→HTML wraps the body in extra blank lines; strip them so pasted // code doesn't gain leading/trailing empty lines. data: { type: "string", value: child.textContent.replace(/^\n+|\n+$/g, ""), }, }); }
// Button variant of A if (aIsButton && aHref) { facts.push({ entity: entityID, attribute: "button/text", data: { type: "string", value: child.textContent || "" }, }); facts.push({ entity: entityID, attribute: "button/url", data: { type: "string", value: aHref }, }); }
// Plain link → queue addLinkBlock (metadata + type written async) if (isLink && aHref) { result.linkTasks.push({ url: aHref, entityID }); }
// IMG → placeholder + async fetch+upload if (child.tagName === "IMG") { const src = child.getAttribute("src"); if (src) { result.imageTasks.push({ url: src, entityID, attribute: "block/image", }); } }
// DIV with data-tex → math (markdown "$$…$$" wraps the tex in newlines) if (isMath) { const tex = child.getAttribute("data-tex"); facts.push({ entity: entityID, attribute: "block/math", data: { type: "string", value: (tex || "").trim() }, }); }
// DIV with data-bluesky-post if (isBlueskyPost) { const postData = child.getAttribute("data-bluesky-post"); if (postData) { try { facts.push({ entity: entityID, attribute: "block/bluesky-post", data: { type: "bluesky-post", value: JSON.parse(postData) }, }); } catch { // Malformed bluesky-post payload — leave as a bare bluesky-post block. } } }
// Card paste — recreate nested entities and remap references. if (isCardPaste) { const oldRootID = child.getAttribute("data-entityid") as string; const factsData = child.getAttribute("data-facts"); if (factsData) { let cardFacts: Fact<any>[] = []; try { cardFacts = JSON.parse(factsData) as Fact<any>[]; } catch { cardFacts = []; } const oldToNew: { [k: string]: string } = {}; const seen = new Set<string>(); for (const f of cardFacts) { if (!seen.has(f.entity)) { seen.add(f.entity); const newID = v7(); oldToNew[f.entity] = newID; result.extraEntities.push({ entityID: newID, permission_set }); } } for (const fact of cardFacts) { const newEntity = oldToNew[fact.entity]; if (!newEntity) continue; const data = { ...fact.data }; if ( data.type === "ordered-reference" || data.type === "spatial-reference" || data.type === "reference" ) { data.value = oldToNew[data.value] ?? data.value; } if (data.type === "image") { result.imageTasks.push({ url: data.src, entityID: newEntity, attribute: fact.attribute, }); } else { facts.push({ entity: newEntity, attribute: fact.attribute, data, } as FactInput); } } const newRootID = oldToNew[oldRootID]; if (newRootID) { facts.push({ entity: entityID, attribute: "block/card", data: { type: "reference", value: newRootID }, }); } } }
// LI metadata + nested-list children if (child.tagName === "LI") { const nestedList = Array.from(child.children) .flatMap((f) => flattenHTMLToTextBlocks(f as HTMLElement)) .find((f) => f.tagName === "UL" || f.tagName === "OL"); const checked = child.getAttribute("data-checked"); if (checked !== null) { facts.push({ entity: entityID, attribute: "block/check-list", data: { type: "boolean", value: checked === "true" }, }); } facts.push({ entity: entityID, attribute: "block/is-list", data: { type: "boolean", value: true }, }); if (listStyle) { facts.push({ entity: entityID, attribute: "block/list-style", data: { type: "list-style-union", value: listStyle }, }); } if (nestedList) { const nested = buildBlockFromHTML(nestedList, { parent: entityID, permission_set, }); const nestedTopLevel = nested.blocks.filter((b) => b.parent === entityID); if (nestedTopLevel.length > 0) { const nestedPositions = generateNKeysBetween( null, null, nestedTopLevel.length, ); nestedTopLevel.forEach((nb, i) => { facts.push({ entity: entityID, attribute: "card/block", data: { type: "ordered-reference", value: nb.entityID, position: nestedPositions[i], }, }); }); } result.blocks.push(...nested.blocks); result.extraEntities.push(...nested.extraEntities); result.imageTasks.push(...nested.imageTasks); result.linkTasks.push(...nested.linkTasks); } }
result.blocks.unshift({ entityID, parent, type: finalType, facts, parsedContent, }); return result;}
// Block-level tags that become their own block when flattening pasted HTML.// NOTE: inline/phrasing tags (A, SPAN, I, SUP, …) are deliberately NOT here —// they're coalesced into the surrounding text block (see INLINE_FLATTEN_TAGS)// so a hyperlink in flowing prose parses as an inline link mark instead of// splitting the paragraph and becoming its own (async) link block.const BLOCK_FLATTEN_TAGS = new Set([ "BLOCKQUOTE", "P", "PRE", "H1", "H2", "H3", "H4", "H5", "H6", "LI", "UL", "OL", "IMG", "HR",]);
// Phrasing/inline elements that stay *within* a text block rather than breaking// the paragraph. A run of these (plus text nodes) is gathered into one <p>.const INLINE_FLATTEN_TAGS = new Set([ "A", "SPAN", "I", "B", "EM", "STRONG", "U", "S", "STRIKE", "DEL", "INS", "SUP", "SUB", "CODE", "MARK", "SMALL", "ABBR", "CITE", "Q", "TIME", "FONT", "KBD", "VAR", "SAMP", "BDI", "BDO", "DFN", "BR", "WBR",]);
function flattenHTMLToTextBlocks(element: HTMLElement): HTMLElement[] { const htmlBlocks: HTMLElement[] = [];
const isSpecialBlock = (el: HTMLElement) => !!el.getAttribute("data-entityid") || !!el.getAttribute("data-tex") || !!el.getAttribute("data-bluesky-post");
// A <p> that only wraps a special block (e.g. inline "$$…$$" math, which // markdown nests inside a paragraph) should yield the inner block rather than // a text block, so we recurse into it instead of pushing the <p>. const wrapsOnlySpecialBlock = (el: HTMLElement) => el.tagName === "P" && !isSpecialBlock(el) && !!el.querySelector("[data-tex], [data-entityid], [data-bluesky-post]") && !Array.from(el.childNodes).some( (n) => n.nodeType === Node.TEXT_NODE && n.textContent?.trim() !== "", );
// Walk a container's children, coalescing consecutive inline content (text // nodes + phrasing elements) into a single <p>. This keeps inline links and // other marks inside one text block instead of fragmenting the paragraph. function walk(container: Node): void { let inlineBuffer: Node[] = []; const flushInline = () => { if (inlineBuffer.length === 0) return; const p = document.createElement("p"); for (const n of inlineBuffer) p.appendChild(n.cloneNode(true)); inlineBuffer = []; if (p.textContent && p.textContent.trim() !== "") htmlBlocks.push(p); };
for (const child of Array.from(container.childNodes)) { if (child.nodeType === Node.TEXT_NODE) { inlineBuffer.push(child); continue; } if (child.nodeType !== Node.ELEMENT_NODE) continue; const el = child as HTMLElement; // Explicit buttons keep their own block; plain links stay inline. const isButtonLink = el.tagName === "A" && el.getAttribute("data-type") === "button";
if ( (isSpecialBlock(el) || BLOCK_FLATTEN_TAGS.has(el.tagName) || isButtonLink) && !wrapsOnlySpecialBlock(el) ) { flushInline(); htmlBlocks.push(el); } else if (INLINE_FLATTEN_TAGS.has(el.tagName)) { inlineBuffer.push(el); } else { // Non-phrasing container (DIV, SECTION, a special-wrapping <p>, …): // descend so its own inline content coalesces at that level. flushInline(); walk(el); } } flushInline(); }
// A block/special element passed directly (e.g. the nested-list lookup in the // LI handler hands us a <ul>/<ol>) is returned as-is. if ( element.nodeType === Node.ELEMENT_NODE && (BLOCK_FLATTEN_TAGS.has(element.tagName) || isSpecialBlock(element)) ) { return [element]; } walk(element); return htmlBlocks;}
// Legacy per-block paste path. Kept for canvas paste (parentType === "canvas")// and as a localStorage("legacyPaste") = "1" kill-switch escape hatch for doc// paste. Delete in a follow-up release.const createBlockFromHTMLLegacy = ( child: Element, { first, last, activeBlockProps, rep, undoManager, entity_set, getPosition, parent, parentType, listStyle, depth = 1, }: { parentType: "canvas" | "doc"; parent: string; first: boolean; last: boolean; activeBlockProps?: MutableRefObject<BlockProps>; rep: Replicache<ReplicacheMutators>; undoManager: UndoManager; entity_set: { set: string }; getPosition: () => string; listStyle?: "ordered" | "unordered"; depth?: number; },) => { let type: Fact<"block/type">["data"]["value"] | null; let headingLevel: number | null = null; let hasChildren = false;
if (child.tagName === "UL" || child.tagName === "OL") { let children = Array.from(child.children); if (children.length > 0) hasChildren = true; const childListStyle = child.tagName === "OL" ? "ordered" : "unordered"; for (let c of children) { createBlockFromHTMLLegacy(c, { first: first && c === children[0], last: last && c === children[children.length - 1], activeBlockProps, rep, undoManager, entity_set, getPosition, parent, parentType, listStyle: childListStyle, depth, }); } } switch (child.tagName) { case "BLOCKQUOTE": { type = "blockquote"; break; } case "LI": case "SPAN": { type = "text"; break; } case "PRE": { type = "code"; break; } case "P": { type = "text"; break; } case "H1": { headingLevel = 1; type = "heading"; break; } case "H2": { headingLevel = 2; type = "heading"; break; } case "H3": { headingLevel = 3; type = "heading"; break; } case "H4": { headingLevel = 4; type = "heading"; break; } case "DIV": { type = "card"; break; } case "IMG": { type = "image"; break; } case "A": { // Only explicit buttons get their own block; plain links are // autolinked inline as a link mark within a text block. type = child.getAttribute("data-type") === "button" ? "link" : "text"; break; } case "HR": { type = "horizontal-rule"; break; } default: type = null; } let content = parser.parse(child); if (!type) return;
let entityID: string; let position: string; if ( (parentType === "canvas" && activeBlockProps?.current) || (first && (activeBlockProps?.current.type === "heading" || activeBlockProps?.current.type === "blockquote" || type === activeBlockProps?.current.type)) ) entityID = activeBlockProps.current.entityID; else { entityID = v7(); if (parentType === "doc") { position = getPosition(); rep.mutate.addBlock({ permission_set: entity_set.set, factID: v7(), newEntityID: entityID, parent: parent, type: type, position, }); } if (type === "heading" && headingLevel) { rep.mutate.assertFact({ entity: entityID, attribute: "block/heading-level", data: { type: "number", value: headingLevel }, }); } } let alignment = child.getAttribute("data-alignment"); if (alignment && ["right", "left", "center"].includes(alignment)) { rep.mutate.assertFact({ entity: entityID, attribute: "block/text-alignment", data: { type: "text-alignment-type-union", value: alignment as "right" | "left" | "center", }, }); } let textSize = child.getAttribute("data-text-size"); if (textSize && ["default", "small", "large"].includes(textSize)) { rep.mutate.assertFact({ entity: entityID, attribute: "block/text-size", data: { type: "text-size-union", value: textSize as "default" | "small" | "large", }, }); } if (child.tagName === "A") { let href = child.getAttribute("href"); let dataType = child.getAttribute("data-type"); if (href && dataType === "button") { rep.mutate.assertFact([ { entity: entityID, attribute: "block/type", data: { type: "block-type-union", value: "button" }, }, { entity: entityID, attribute: "button/text", data: { type: "string", value: child.textContent || "" }, }, { entity: entityID, attribute: "button/url", data: { type: "string", value: href }, }, ]); } // Non-button links fall through and are parsed inline as a link mark // (see the parser.parse(child) handling below) rather than becoming a // standalone link block. } if (child.tagName === "PRE") { let lang = child.getAttribute("data-lang"); if (!lang && child.firstElementChild?.className) { let match = child.firstElementChild.className.match(/language-([\w.+-]+)/); if (match) lang = match[1]; } if (!lang) lang = child.getAttribute("data-language") || "plaintext"; if (child.textContent) { rep.mutate.assertFact([ { entity: entityID, attribute: "block/type", data: { type: "block-type-union", value: "code" }, }, { entity: entityID, attribute: "block/code-language", data: { type: "string", value: lang }, }, { entity: entityID, attribute: "block/code", data: { type: "string", value: child.textContent.replace(/^\n+|\n+$/g, ""), }, }, ]); } } if (child.tagName === "IMG") { let src = child.getAttribute("src"); if (src) { fetch(src) .then((res) => res.blob()) .then((Blob) => { const file = new File([Blob], "image.png", { type: Blob.type }); addImage(file, rep, { attribute: "block/image", entityID: entityID, }); }) .catch(() => {}); } } if (child.tagName === "DIV" && child.getAttribute("data-tex")) { let tex = child.getAttribute("data-tex"); rep.mutate.assertFact([ { entity: entityID, attribute: "block/type", data: { type: "block-type-union", value: "math" }, }, { entity: entityID, attribute: "block/math", data: { type: "string", value: (tex || "").trim() }, }, ]); }
if (child.tagName === "DIV" && child.getAttribute("data-bluesky-post")) { let postData = child.getAttribute("data-bluesky-post"); if (postData) { rep.mutate.assertFact([ { entity: entityID, attribute: "block/type", data: { type: "block-type-union", value: "bluesky-post" }, }, { entity: entityID, attribute: "block/bluesky-post", data: { type: "bluesky-post", value: JSON.parse(postData) }, }, ]); } }
if (child.tagName === "DIV" && child.getAttribute("data-entityid")) { let oldEntityID = child.getAttribute("data-entityid") as string; let factsData = child.getAttribute("data-facts"); if (factsData) { let facts = JSON.parse(factsData) as Fact<any>[];
let oldEntityIDToNewID = {} as { [k: string]: string }; let oldEntities = facts.reduce((acc, f) => { if (!acc.includes(f.entity)) acc.push(f.entity); return acc; }, [] as string[]); let newEntities = [] as string[]; for (let oldEntity of oldEntities) { let newEntity = v7(); oldEntityIDToNewID[oldEntity] = newEntity; newEntities.push(newEntity); }
let newFacts = [] as Array< Pick<Fact<any>, "entity" | "attribute" | "data"> >; for (let fact of facts) { let entity = oldEntityIDToNewID[fact.entity]; let data = fact.data; if ( data.type === "ordered-reference" || data.type == "spatial-reference" || data.type === "reference" ) { data.value = oldEntityIDToNewID[data.value]; } if (data.type === "image") { //idk get it from the clipboard maybe? } newFacts.push({ entity, attribute: fact.attribute, data }); } rep.mutate.createEntity( newEntities.map((e) => ({ entityID: e, permission_set: entity_set.set, })), ); rep.mutate.assertFact(newFacts.filter((f) => f.data.type !== "image")); let newCardEntity = oldEntityIDToNewID[oldEntityID]; rep.mutate.assertFact({ entity: entityID, attribute: "block/card", data: { type: "reference", value: newCardEntity }, }); let images: Pick< Fact<keyof FilterAttributes<{ type: "image" }>>, "entity" | "data" | "attribute" >[] = newFacts.filter((f) => f.data.type === "image"); for (let image of images) { fetch(image.data.src) .then((res) => res.blob()) .then((Blob) => { const file = new File([Blob], "image.png", { type: Blob.type }); addImage(file, rep, { attribute: image.attribute, entityID: image.entity, }); }) .catch(() => {}); } } }
if (child.tagName === "LI") { let nestedList = Array.from(child.children) .flatMap((f) => flattenHTMLToTextBlocks(f as HTMLElement)) .find((f) => f.tagName === "UL" || f.tagName === "OL"); let checked = child.getAttribute("data-checked"); if (checked !== null) { rep.mutate.assertFact({ entity: entityID, attribute: "block/check-list", data: { type: "boolean", value: checked === "true" ? true : false }, }); } rep.mutate.assertFact({ entity: entityID, attribute: "block/is-list", data: { type: "boolean", value: true }, }); if (listStyle) { rep.mutate.assertFact({ entity: entityID, attribute: "block/list-style", data: { type: "list-style-union", value: listStyle }, }); } if (nestedList) { hasChildren = true; let currentPosition: string | null = null; createBlockFromHTMLLegacy(nestedList, { parentType, first: false, last: last, activeBlockProps, rep, undoManager, entity_set, getPosition: () => { currentPosition = generateKeyBetween(currentPosition, null); return currentPosition; }, parent: entityID, depth: depth + 1, }); } }
setTimeout(() => { let block = useEditorStates.getState().editorStates[entityID]; if (block) { let tr = block.editor.tr; if ( block.editor.selection.from !== undefined && block.editor.selection.to !== undefined ) tr.delete(block.editor.selection.from, block.editor.selection.to); tr.replaceSelectionWith(content); let newState = block.editor.apply(tr); setEditorState(entityID, { editor: newState, });
undoManager.add({ redo: () => { useEditorStates.setState((oldState) => { let view = oldState.editorStates[entityID]?.view; if (!view?.hasFocus()) view?.focus(); return { editorStates: { ...oldState.editorStates, [entityID]: { ...oldState.editorStates[entityID]!, editor: newState, }, }, }; }); }, undo: () => { useEditorStates.setState((oldState) => { let view = oldState.editorStates[entityID]?.view; if (!view?.hasFocus()) view?.focus(); return { editorStates: { ...oldState.editorStates, [entityID]: { ...oldState.editorStates[entityID]!, editor: block.editor, }, }, }; }); }, }); } if (last && !hasChildren && !first) { focusBlock( { value: entityID, type: type, parent: parent, }, { type: "end" }, ); } }, 10);};