import * as base64 from "base64-js"; import { Doc, XmlText, applyUpdate, encodeStateAsUpdate } from "yjs"; import { generateKeyBetween } from "fractional-indexing"; import { v7 } from "uuid"; import { createYjsText } from "src/utils/createYjsText"; import { scanIndexLocal } from "src/replicache/utils"; import type { Fact } from "src/replicache"; import type { Attribute } from "src/replicache/attributes"; import { getPageReadingOrder } from "src/replicache/getBlocks"; import { canvasBlockOrder } from "src/utils/canvasBlockOrder"; const COPY_SUFFIX = " (Copy)"; const COPY_BLOCK_TEXT = "Copy"; // Canvas positions are pixels; enough room for a one-line text block. const CANVAS_MARKER_HEIGHT = 60; /** * Mark a leaflet's facts as a copy by writing the marker into its title — the * first block of its first page, which is what the editor syncs up as the * leaflet's title. When that block can't hold text (an image, an embed, an * empty canvas) a new text block carrying the marker is inserted ahead of it * instead. * * Returns the facts unchanged if there's no page to write into. */ export function markTitleBlockAsCopy( facts: Fact[], rootEntity: string, ): Fact[] { const scan = scanIndexLocal(facts); const page = scan .eav(rootEntity, "root/page") .sort((a, b) => (a.data.position > b.data.position ? 1 : -1))[0] ?.data.value; if (!page) return facts; const isCanvas = scan.eav(page, "page/type")[0]?.data.value === "canvas"; const firstBlock = getPageReadingOrder(scan, page)[0]; if (firstBlock?.type === "text" || firstBlock?.type === "heading") { const text = scan.eav(firstBlock.entityID, "block/text")[0]; // An empty title block reads as the marker on its own rather than as a // stray leading space. if (!text) return [...facts, blockTextFact(firstBlock.entityID, COPY_BLOCK_TEXT)]; return facts.map((f) => f.id === text.id ? { ...f, data: { ...text.data, value: append(text.data.value) } } : f, ); } const newBlock = v7(); const markerBlock: Fact[] = [ { id: v7(), entity: newBlock, attribute: "block/type", data: { type: "block-type-union", value: "text" }, }, blockTextFact(newBlock, COPY_BLOCK_TEXT), ]; if (!isCanvas) return [ ...facts, ...markerBlock, { id: v7(), entity: page, attribute: "card/block", data: { type: "ordered-reference", value: newBlock, position: generateKeyBetween(null, firstBlock?.position), }, }, ]; // Nothing on a canvas is "first", so the marker takes the topmost block's // spot and everything else slides down to make room for it. const canvasBlocks = scan .eav(page, "canvas/block") .sort((a, b) => canvasBlockOrder(a.data.position, b.data.position)); const topmost = canvasBlocks[0]?.data.position ?? { x: 8, y: 12 }; const topStackOrder = canvasBlocks .map( (b) => scan.eav(b.data.value, "canvas/block/stack-order")[0]?.data.value, ) .filter((o): o is string => !!o) .sort() .pop() ?? null; return [ ...facts.map((f) => f.entity === page && f.data.type === "spatial-reference" ? { ...f, data: { ...f.data, position: { x: f.data.position.x, y: f.data.position.y + CANVAS_MARKER_HEIGHT, }, }, } : f, ), ...markerBlock, { id: v7(), entity: page, attribute: "canvas/block", data: { type: "spatial-reference", value: newBlock, position: topmost }, }, { id: v7(), entity: newBlock, attribute: "canvas/block/stack-order", data: { type: "string", value: generateKeyBetween(topStackOrder, null) }, }, ]; } function blockTextFact(entity: string, content: string): Fact { return { id: v7(), entity, attribute: "block/text", data: { type: "text", value: createYjsText(content) }, }; } // Append the marker to the end of a block's first line, preserving the // existing yjs document so its marks and inline nodes survive the copy. function append(value: string) { const doc = new Doc(); applyUpdate(doc, base64.toByteArray(value)); const first = doc.getXmlFragment("prosemirror").toArray()[0]; if (!first) return createYjsText(COPY_BLOCK_TEXT); if (first.constructor === XmlText) { first.insert(first.length, COPY_SUFFIX); } else if ("toArray" in first) { const children = first.toArray(); const last = children[children.length - 1]; if (last?.constructor === XmlText) { (last as XmlText).insert((last as XmlText).length, COPY_SUFFIX); } else { const text = new XmlText(); text.insert(0, COPY_SUFFIX); first.insert(children.length, [text]); } } return base64.fromByteArray(encodeStateAsUpdate(doc)); }