Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159import * 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<Attribute>[], rootEntity: string,): Fact<Attribute>[] { 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<Attribute>[] = [ { 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<Attribute> { 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));}