Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
12 kB · 377 lines
TypeScript
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378import type { SpatialCanvasDocument, SpatialElement, SpatialEntity,} from "./model";import type { AppState, BinaryFiles,} from "@excalidraw/excalidraw/types";import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types";import { getCommonBounds } from "@excalidraw/excalidraw";import { exportTransparentDrawingSvg } from "./SpatialDrawingSurface";
const SVG_NS = "http://www.w3.org/2000/svg";
export async function exportSpatialDocumentSvg( document: SpatialCanvasDocument,) { if (document.scene?.elements.length) { const svg = await exportTransparentDrawingSvg({ elements: document.scene.elements as ExcalidrawElement[], appState: document.scene.appState as Partial<AppState>, files: document.scene.files as unknown as BinaryFiles, }); // Excalidraw cannot export a React renderEmbeddable. Replace its generic // embed placeholder with the actual Forge content at the same scene bounds. const visible = (document.scene.elements as ExcalidrawElement[]).filter((element) => !element.isDeleted); const [left, top] = getCommonBounds(visible); for (const element of visible) { if (element.type !== "embeddable") continue; const entityId = element.customData?.forgeEntityId; const entity = typeof entityId === "string" ? document.entities[entityId] : undefined; if (!entity) continue; for (const link of svg.querySelectorAll("a")) { if (link.getAttribute("href") === element.link) link.remove(); } const group = documentNode("g"); group.setAttribute("transform", `translate(${8 - left} ${8 - top})`); appendEntity(group, { id: element.id, type: "entity", entityId: entity.id, x: element.x, y: element.y, width: element.width, height: element.height, rotation: element.angle }, entity); svg.append(group); } return svg; } const visible = document.elements; if (!visible.length) { throw new Error("Add at least one object before inserting the canvas."); }
const padding = 24; const bounds = documentBounds(visible); const svg = documentNode("svg"); svg.setAttribute("xmlns", SVG_NS); svg.setAttribute( "viewBox", `${bounds.left - padding} ${bounds.top - padding} ${bounds.width + padding * 2} ${bounds.height + padding * 2}`, ); svg.setAttribute("width", String(Math.ceil(bounds.width + padding * 2))); svg.setAttribute("height", String(Math.ceil(bounds.height + padding * 2))); svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); svg.setAttribute("role", "img"); svg.setAttribute("aria-label", "Spatial canvas"); svg.style.background = "transparent";
const defs = documentNode("defs"); const marker = documentNode("marker"); marker.setAttribute("id", "spatial-arrow"); marker.setAttribute("viewBox", "0 0 10 10"); marker.setAttribute("refX", "9"); marker.setAttribute("refY", "5"); marker.setAttribute("markerWidth", "7"); marker.setAttribute("markerHeight", "7"); marker.setAttribute("orient", "auto-start-reverse"); const markerPath = documentNode("path"); markerPath.setAttribute("d", "M 0 0 L 10 5 L 0 10 z"); markerPath.setAttribute("fill", "#647a6e"); marker.append(markerPath); defs.append(marker); svg.append(defs);
const elementById = new Map( visible.map((element) => [element.id, element]), ); for (const connector of document.connectors) { const from = elementById.get(connector.fromElementId); const to = elementById.get(connector.toElementId); if (!from || !to) continue; const path = documentNode("path"); path.setAttribute("d", connectorPath(from, to)); path.setAttribute("fill", "none"); path.setAttribute("stroke", connector.color); path.setAttribute("stroke-width", "2"); path.setAttribute("marker-end", "url(#spatial-arrow)"); if (connector.style === "dashed") { path.setAttribute("stroke-dasharray", "7 7"); } svg.append(path); }
for (const element of visible) { if (element.type === "shape") { const shape = element.shape === "ellipse" ? documentNode("ellipse") : documentNode("rect"); if (element.shape === "ellipse") { shape.setAttribute("cx", String(element.x + element.width / 2)); shape.setAttribute("cy", String(element.y + element.height / 2)); shape.setAttribute("rx", String(element.width / 2)); shape.setAttribute("ry", String(element.height / 2)); } else { shape.setAttribute("x", String(element.x)); shape.setAttribute("y", String(element.y)); shape.setAttribute("width", String(element.width)); shape.setAttribute("height", String(element.height)); shape.setAttribute("rx", "4"); } shape.setAttribute("fill", element.fill); shape.setAttribute("stroke", element.stroke); shape.setAttribute("stroke-width", "2"); applyRotation(shape, element); svg.append(shape); continue; }
if (element.type === "stroke") { const polyline = documentNode("polyline"); polyline.setAttribute( "points", element.points .map( (point) => `${element.x + point.x},${element.y + point.y}`, ) .join(" "), ); polyline.setAttribute("fill", "none"); polyline.setAttribute("stroke", element.color); polyline.setAttribute("stroke-width", String(element.strokeWidth)); polyline.setAttribute("stroke-linecap", "round"); polyline.setAttribute("stroke-linejoin", "round"); svg.append(polyline); continue; }
if (element.type === "text") { appendWrappedText( svg, element.text, element.x, element.y + element.fontSize, element.width, element.fontSize, element.color, 600, ); continue; }
if (element.type === "image") { const image = documentNode("image"); image.setAttribute("x", String(element.x)); image.setAttribute("y", String(element.y)); image.setAttribute("width", String(element.width)); image.setAttribute("height", String(element.height)); image.setAttribute("preserveAspectRatio", "xMidYMid meet"); image.setAttribute("href", await inlineImage(element.src)); applyRotation(image, element); svg.append(image); continue; }
if (element.type === "frame") { const frame = documentNode("rect"); frame.setAttribute("x", String(element.x)); frame.setAttribute("y", String(element.y)); frame.setAttribute("width", String(element.width)); frame.setAttribute("height", String(element.height)); frame.setAttribute("fill", "none"); frame.setAttribute("stroke", "#718078"); frame.setAttribute("stroke-width", "1.5"); frame.setAttribute("stroke-dasharray", "8 7"); svg.append(frame); appendWrappedText( svg, element.title, element.x, element.y - 7, element.width, 12, "#66736c", 700, ); continue; }
const entity = document.entities[element.entityId]; if (entity) appendEntity(svg, element, entity); }
return svg;}
function appendEntity( svg: SVGElement, element: Extract<SpatialElement, { type: "entity" }>, entity: SpatialEntity,) { const palette = entity.type === "card" ? { sage: ["#e7eee5", "#a6b9a7"], sand: ["#f3ead8", "#cdb995"], lavender: ["#ebe8f3", "#b9afcc"], blue: ["#e6eef1", "#a6bec7"], }[entity.color] : entity.kind === "post" ? ["#faf2e5", "#ccb18c"] : ["#fffefa", "#aab9b0"]; const card = documentNode("rect"); card.setAttribute("x", String(element.x)); card.setAttribute("y", String(element.y)); card.setAttribute("width", String(element.width)); card.setAttribute("height", String(element.height)); card.setAttribute("rx", "10"); card.setAttribute("fill", palette[0]); card.setAttribute("stroke", palette[1]); card.setAttribute("stroke-width", "1"); applyRotation(card, element); svg.append(card);
appendWrappedText( svg, entity.title || "Untitled thought", element.x + 18, element.y + 31, element.width - 36, 22, "#26312c", 600, 3, ); const description = entity.type === "card" ? entity.body : entity.excerpt; if (description) { appendWrappedText( svg, description, element.x + 18, element.y + 77, element.width - 36, 13, "#58665e", 400, 4, ); }}
function appendWrappedText( svg: SVGElement, text: string, x: number, y: number, maxWidth: number, fontSize: number, color: string, weight: number, maxLines = 2,) { const node = documentNode("text"); node.setAttribute("x", String(x)); node.setAttribute("y", String(y)); node.setAttribute("fill", color); node.setAttribute("font-size", String(fontSize)); node.setAttribute("font-weight", String(weight)); node.setAttribute( "font-family", "DM Sans, ui-sans-serif, system-ui, sans-serif", ); const lines = wrapText(text, Math.max(4, Math.floor(maxWidth / (fontSize * .56)))); lines.slice(0, maxLines).forEach((line, index) => { const tspan = documentNode("tspan"); tspan.setAttribute("x", String(x)); tspan.setAttribute("dy", index === 0 ? "0" : String(fontSize * 1.35)); const truncated = index === maxLines - 1 && lines.length > maxLines ? `${line.replace(/[.,;:!?]$/, "")}…` : line; tspan.textContent = truncated; node.append(tspan); }); svg.append(node);}
function wrapText(text: string, maximum: number) { const words = text.trim().split(/\s+/).filter(Boolean); if (!words.length) return [""]; const lines: string[] = []; let line = ""; for (const word of words) { const candidate = line ? `${line} ${word}` : word; if (candidate.length <= maximum || !line) { line = candidate; } else { lines.push(line); line = word; } } if (line) lines.push(line); return lines;}
function documentBounds(elements: SpatialElement[]) { const left = Math.min(...elements.map((element) => element.x)); const top = Math.min(...elements.map((element) => element.y)); const right = Math.max( ...elements.map((element) => element.x + element.width), ); const bottom = Math.max( ...elements.map((element) => element.y + element.height), ); return { left, top, width: Math.max(1, right - left), height: Math.max(1, bottom - top), };}
function connectorPath(from: SpatialElement, to: SpatialElement) { const startX = from.x + from.width / 2; const startY = from.y + from.height / 2; const endX = to.x + to.width / 2; const endY = to.y + to.height / 2; const curve = Math.max(70, Math.abs(endX - startX) * .38); const direction = endX >= startX ? 1 : -1; return `M ${startX} ${startY} C ${startX + curve * direction} ${startY}, ${endX - curve * direction} ${endY}, ${endX} ${endY}`;}
function applyRotation( node: SVGElement, element: SpatialElement,) { if (!element.rotation) return; const centerX = element.x + element.width / 2; const centerY = element.y + element.height / 2; node.setAttribute( "transform", `rotate(${(element.rotation * 180) / Math.PI} ${centerX} ${centerY})`, );}
async function inlineImage(src: string) { if (src.startsWith("data:")) return src; try { const response = await fetch(src); if (!response.ok) return src; const blob = await response.blob(); return await blobToDataUrl(blob); } catch { return src; }}
function blobToDataUrl(blob: Blob) { return new Promise<string>((resolve, reject) => { const reader = new FileReader(); reader.onload = () => typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("Unable to encode image")); reader.onerror = () => reject(reader.error); reader.readAsDataURL(blob); });}
function documentNode<K extends keyof SVGElementTagNameMap>(name: K) { return document.createElementNS(SVG_NS, name);}