From e0ccade8fa048eb2536b31340b5b0eda12bf4ab7 Mon Sep 17 00:00:00 2001 From: Peter Hewat Date: Thu, 1 Oct 2026 18:39:08 +0200 Subject: [PATCH 1/6] Validate SVG project geometry and document collections --- apps/svg/src/lib/project-file.test.ts | 80 +++++++++++++++++++++ apps/svg/src/lib/project-file.ts | 57 +++++++++++---- apps/svg/src/lib/project-validation.ts | 98 ++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 12 deletions(-) create mode 100644 apps/svg/src/lib/project-file.test.ts create mode 100644 apps/svg/src/lib/project-validation.ts diff --git a/apps/svg/src/lib/project-file.test.ts b/apps/svg/src/lib/project-file.test.ts new file mode 100644 index 0000000..2024cb5 --- /dev/null +++ b/apps/svg/src/lib/project-file.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test"; +import { createDocument, readProject, serializeProject, cleanElement } from "./project-file.js"; +import { createPath, createPoint, createRect, createImage, createText } from "./model.js"; +import { formatExportSvg } from "./svg-export.js"; + +const project = () => + serializeProject({ ...createDocument(), elements: [createRect(0, 0, 20, 10)] }); + +describe("project structural validation", () => { + test("missing path geometry is refused even beside a usable shape", () => { + const raw = { ...project(), elements: [...project().elements, { id: "path-a", type: "path" }] }; + expect(() => readProject(raw)).toThrow(/damaged/); + expect(cleanElement(raw.elements[1])).toBeNull(); + }); + + test("valid version-1 geometry survives reading and export", () => { + const raw = project(); + raw.elements.push( + createPath([createPoint(0, 0), createPoint(5, 5)], false), + createText(1, 2, "hi") + ); + const opened = readProject(raw); + expect(opened.elements).toEqual(raw.elements); + expect(formatExportSvg(opened)).toBe(formatExportSvg(raw)); + }); + + test.each([ + { x: "1" }, + { width: -1 }, + { y: Infinity }, + { rotation: NaN }, + { dash: [1, -2] }, + { name: {} }, + { hidden: "false" }, + ])("refuses invalid shape fields %j", (patch) => { + const raw = project(); + Object.assign(raw.elements[0], patch); + expect(() => readProject(raw)).toThrow(/damaged/); + }); + + test.each([ + { points: [null] }, + { points: [{ x: 0, y: 1 }] }, + { points: [{ ...createPoint(0, 0), hIn: { x: NaN, y: 0 } }] }, + { subpaths: [0] }, + { subpaths: [1, 1] }, + { subpaths: [0.5] }, + { subpaths: [2] }, + { closed: "false" }, + ])("refuses invalid anchors and subpaths %j", (patch) => { + const raw = project(); + raw.elements = [Object.assign(createPath([createPoint(0, 0), createPoint(2, 2)]), patch)]; + expect(() => readProject(raw)).toThrow(/damaged/); + }); + + test.each([ + { grid: { step: 0, visible: true, snap: false } }, + { grid: { step: 1, visible: "true", snap: false } }, + { guides: { x: [NaN], y: [] } }, + { guides: { x: [], y: {} } }, + { groupNames: [] }, + { groupNames: { "group-a": 123 } }, + { groupHues: { "group-a": Infinity } }, + { version: 1.5 }, + ])("refuses invalid document fields %j", (patch) => { + expect(() => readProject({ ...project(), ...patch })).toThrow(); + }); + + test("duplicate identities are refused", () => { + const raw = project(); + raw.elements.push(structuredClone(raw.elements[0])); + expect(() => readProject(raw)).toThrow(/damaged/); + }); + + test("invalid image transforms are refused", () => { + const raw = project(); + raw.images = [{ ...createImage("data:image/png;base64,AAAA", "a.png"), scaleX: NaN }]; + expect(() => readProject(raw)).toThrow(/damaged/); + }); +}); diff --git a/apps/svg/src/lib/project-file.ts b/apps/svg/src/lib/project-file.ts index 09ee2a8..f08afc0 100644 --- a/apps/svg/src/lib/project-file.ts +++ b/apps/svg/src/lib/project-file.ts @@ -3,6 +3,13 @@ import { cleanColor, cleanUnit, isElementType } from "./utils.js"; import { ofGroupsInUse } from "./groups.js"; import { MAX_ARTBOARD, PROJECT_VERSION } from "./types.js"; import type { DocumentState, ProjectFile, SceneElement } from "./types.js"; +import { + plainId, + record, + validCollections, + validGeometry, + validImage, +} from "./project-validation.js"; /** Fresh document data, independent of the editor and its input device. */ export function createDocument(snap = false): DocumentState { @@ -62,7 +69,6 @@ export function isInert(value: unknown, key = ""): boolean { /** What a reference image may point at: pixels carried in the document, never the network. */ const IMAGE_URL = /^data:image\/[\w.+-]+[;,]/; /** Element, group and image ids as the app writes them. */ -const PLAIN_ID = /^[A-Za-z][\w-]*$/; /** * An element from outside, made safe to draw: its colours are `#rrggbb` and its opacities run @@ -71,13 +77,12 @@ const PLAIN_ID = /^[A-Za-z][\w-]*$/; * app writes. */ export function cleanElement(raw: unknown): SceneElement | null { - const el = raw as SceneElement | null; - if (!el || !PLAIN_ID.test(el.id) || !isElementType(el.type)) return null; + if (!record(raw) || !plainId(raw.id) || !isElementType(raw.type as string) || !validGeometry(raw)) + return null; + const el = raw as unknown as SceneElement; const out = { ...structuredClone(el), ...completeStyle(el) }; - if (el.groups) - out.groups = Array.isArray(el.groups) - ? el.groups.filter((g) => typeof g === "string" && PLAIN_ID.test(g)) - : []; + out.name ??= ""; + if (el.groups) out.groups = Array.isArray(el.groups) ? el.groups.filter(plainId) : []; return out; } @@ -92,12 +97,13 @@ export function readProject(raw: unknown): Required { const positive = (n: unknown) => typeof n === "number" && n > 0 && Number.isFinite(n); if ( !json || + !Number.isInteger(version) || typeof version !== "number" || version < 1 || !positive(json.artboard?.width) || !positive(json.artboard?.height) || - !json.grid || - !json.background || + !record(json.grid) || + !record(json.background) || !Array.isArray(json.elements) || !Array.isArray(json.images) ) { @@ -109,18 +115,45 @@ export function readProject(raw: unknown): Required { ); } const damaged = () => new Error("This document is damaged and cannot be opened."); - if (!isInert(json)) throw damaged(); + if (!isInert(json) || !validCollections(json as unknown as Record)) + throw damaged(); const doc = json as ProjectFile; + // A recognized shape with broken geometry must not silently disappear beside valid shapes. + for (const el of doc.elements) { + if (record(el) && isElementType(el.type) && !validGeometry(el)) throw damaged(); + } const elements = doc.elements.map(cleanElement).filter((e): e is SceneElement => !!e); + const ids = new Set(); + for (const el of elements) { + if (ids.has(el.id)) throw damaged(); + ids.add(el.id); + } + for (const img of doc.images) { + if ( + record(img) && + plainId(img.id) && + typeof img.dataUrl === "string" && + IMAGE_URL.test(img.dataUrl) + ) { + if (!validImage(img) || ids.has(img.id)) throw damaged(); + ids.add(img.id); + } + } const images = doc.images - .filter((img) => PLAIN_ID.test(img?.id) && IMAGE_URL.test(img?.dataUrl)) + .filter( + (img) => + record(img) && + plainId(img.id) && + typeof img.dataUrl === "string" && + IMAGE_URL.test(img.dataUrl) + ) .map((img) => ({ ...img, opacity: cleanUnit(img.opacity) })); if ((doc.elements.length || doc.images.length) && !elements.length && !images.length) { throw damaged(); } return { version: PROJECT_VERSION, - grid: doc.grid, + grid: { step: doc.grid.step, visible: doc.grid.visible, snap: doc.grid.snap }, artboard: { width: Math.min(doc.artboard.width, MAX_ARTBOARD), height: Math.min(doc.artboard.height, MAX_ARTBOARD), diff --git a/apps/svg/src/lib/project-validation.ts b/apps/svg/src/lib/project-validation.ts new file mode 100644 index 0000000..40bfa97 --- /dev/null +++ b/apps/svg/src/lib/project-validation.ts @@ -0,0 +1,98 @@ +/** Structural checks at file boundaries, before data reaches geometry or DOM code. */ +export const plainId = (value: unknown): value is string => + typeof value === "string" && /^[A-Za-z][\w-]*$/.test(value); + +export const finite = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value); + +export const record = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); + +const nonnegative = (value: unknown) => finite(value) && value >= 0; +const positive = (value: unknown) => finite(value) && value > 0; +const optional = (value: unknown, check: (value: unknown) => boolean) => + value === undefined || check(value); +const numbers = (value: unknown): value is number[] => Array.isArray(value) && value.every(finite); +const point = (value: unknown) => record(value) && finite(value.x) && finite(value.y); +const anchor = (value: unknown) => + record(value) && + point(value) && + typeof value.smooth === "boolean" && + (value.hIn === null || point(value.hIn)) && + (value.hOut === null || point(value.hOut)); + +export function validGeometry(el: Record): boolean { + if ( + !optional(el.name, (v) => typeof v === "string") || + !optional(el.rotation, finite) || + !optional(el.hidden, (v) => typeof v === "boolean") || + !optional(el.locked, (v) => typeof v === "boolean") || + !optional(el.fillRule, (v) => v === "evenodd") || + !optional(el.dash, (v) => Array.isArray(v) && v.every(nonnegative)) + ) + return false; + switch (el.type) { + case "path": { + if (!Array.isArray(el.points) || !el.points.every(anchor) || typeof el.closed !== "boolean") + return false; + const length = el.points.length; + return optional( + el.subpaths, + (v) => + numbers(v) && + v.every((n, i) => Number.isInteger(n) && n > 0 && n < length && (i === 0 || n > v[i - 1])) + ); + } + case "polyline": + case "polygon": + return Array.isArray(el.points) && el.points.every(point); + case "line": + return [el.x1, el.y1, el.x2, el.y2].every(finite); + case "rect": + return ( + [el.x, el.y].every(finite) && + [el.width, el.height, el.rx].every(nonnegative) && + optional(el.ry, nonnegative) + ); + case "ellipse": + return [el.cx, el.cy].every(finite) && [el.rx, el.ry].every(nonnegative); + case "text": + return ( + [el.x, el.y].every(finite) && + positive(el.fontSize) && + typeof el.text === "string" && + typeof el.fontFamily === "string" && + ["start", "middle", "end"].includes(String(el.anchor)) + ); + default: + return false; + } +} + +export function validImage(img: Record): boolean { + return ( + [img.x, img.y, img.scaleX, img.scaleY, img.rotation].every(finite) && + typeof img.name === "string" && + typeof img.fileName === "string" && + typeof img.visible === "boolean" && + optional(img.naturalWidth, positive) && + optional(img.naturalHeight, positive) + ); +} + +export function validCollections(doc: Record): boolean { + const dictionary = (value: unknown, check: (v: unknown) => boolean) => + optional( + value, + (v) => record(v) && Object.entries(v).every(([id, item]) => plainId(id) && check(item)) + ); + return ( + record(doc.grid) && + positive(doc.grid.step) && + typeof doc.grid.visible === "boolean" && + typeof doc.grid.snap === "boolean" && + dictionary(doc.groupNames, (v) => typeof v === "string") && + dictionary(doc.groupHues, finite) && + optional(doc.guides, (v) => record(v) && numbers(v.x) && numbers(v.y)) + ); +} From ce675a63ca0e8da0c9e72954657fc2ccce2be623 Mon Sep 17 00:00:00 2001 From: Peter Hewat Date: Thu, 1 Oct 2026 18:44:03 +0200 Subject: [PATCH 2/6] Separate SVG geometry and styles from editor orchestration --- apps/json/src/lib/header.ts | 36 ++ apps/json/src/lib/preferences.test.ts | 19 + apps/json/src/lib/preferences.ts | 43 ++ apps/json/src/main.ts | 101 ++--- apps/svg/src/lib/element-geometry.ts | 376 ++++++++++++++++ apps/svg/src/lib/element-style.ts | 214 +++++++++ apps/svg/src/lib/interaction.ts | 83 +--- apps/svg/src/lib/model.ts | 619 ++------------------------ apps/svg/src/lib/ruler-guides.ts | 87 ++++ 9 files changed, 850 insertions(+), 728 deletions(-) create mode 100644 apps/json/src/lib/header.ts create mode 100644 apps/json/src/lib/preferences.test.ts create mode 100644 apps/json/src/lib/preferences.ts create mode 100644 apps/svg/src/lib/element-geometry.ts create mode 100644 apps/svg/src/lib/element-style.ts create mode 100644 apps/svg/src/lib/ruler-guides.ts diff --git a/apps/json/src/lib/header.ts b/apps/json/src/lib/header.ts new file mode 100644 index 0000000..067351f --- /dev/null +++ b/apps/json/src/lib/header.ts @@ -0,0 +1,36 @@ +import { bindDock, bindMenu, bindThemeToggle, byId } from "@tools/ui"; + +/** Header menus and docks own their layout behavior; document actions stay in the controller. */ +export function bindHeader(): void { + const more = byId("more"); + const moreBtn = byId("more-btn"); + const fileButtons = ["open", "download", "copy", "clear"].map((id) => + byId(id) + ); + const findButton = byId("find-open"); + const menu = bindMenu(moreBtn, more, { + onOpen: () => + more.replaceChildren( + ...[ + ...fileButtons, + ...(getComputedStyle(findButton).display === "none" ? [findButton] : []), + ].map((button) => { + const item = document.createElement("button"); + item.type = "button"; + item.className = "ui-menu-item"; + item.disabled = button.disabled; + item.append( + button.querySelector("svg")!.cloneNode(true), + button.title.replace(/ —.*/, "") + ); + item.addEventListener("click", () => { + menu.close(); + button.click(); + }); + return item; + }) + ), + }); + bindDock(byId("help"), byId("help-btn"), { key: "tools.json.help" }).restore(); + bindThemeToggle(byId("theme-toggle")); +} diff --git a/apps/json/src/lib/preferences.test.ts b/apps/json/src/lib/preferences.test.ts new file mode 100644 index 0000000..58dde5f --- /dev/null +++ b/apps/json/src/lib/preferences.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test"; +import { parsePrefs } from "./preferences.js"; + +test("older settings keep recognized fields and default anything missing or unknown", () => { + expect(parsePrefs({ indent: "tab", sortKeys: true, view: "csv" })).toEqual({ + indent: "tab", + sortKeys: true, + view: "csv", + pathStyle: "js", + colours: true, + }); + expect( + parsePrefs({ indent: 4, sortKeys: "true", view: "future", pathStyle: "unknown", colours: 0 }) + ).toEqual(parsePrefs(null)); + expect(parsePrefs({ colours: false, pathStyle: "pointer" })).toMatchObject({ + colours: false, + pathStyle: "pointer", + }); +}); diff --git a/apps/json/src/lib/preferences.ts b/apps/json/src/lib/preferences.ts new file mode 100644 index 0000000..5a9f9ee --- /dev/null +++ b/apps/json/src/lib/preferences.ts @@ -0,0 +1,43 @@ +import { readStored, writeStored } from "@tools/ui"; +import type { ExportKind } from "./exports.js"; + +export type ViewMode = "json" | ExportKind; +export type PathStyle = "js" | "pointer"; +export type Indent = "2" | "4" | "tab"; +export interface Prefs { + indent: Indent; + sortKeys: boolean; + view: ViewMode; + pathStyle: PathStyle; + colours: boolean; +} + +const PREFS_KEY = "tools.json.prefs"; +const DRAFT_KEY = "tools.json.draft"; +const EXACT_KEY = "tools.json.exact"; +const MAX_SAVED = 2_000_000; +const VIEWS: readonly ViewMode[] = ["json", "yaml", "csv", "ts", "schema"]; + +/** Each released setting is read independently, retaining older and partial preferences. */ +export function parsePrefs(value: unknown): Prefs { + const raw = value && typeof value === "object" ? (value as Partial) : {}; + return { + indent: raw.indent === "4" || raw.indent === "tab" ? raw.indent : "2", + sortKeys: raw.sortKeys === true, + view: raw.view && VIEWS.includes(raw.view) ? raw.view : "json", + pathStyle: raw.pathStyle === "pointer" ? "pointer" : "js", + colours: raw.colours !== false, + }; +} + +export const readPrefs = () => parsePrefs(readStored(PREFS_KEY)); +export const writePrefs = (prefs: Prefs) => writeStored(PREFS_KEY, prefs); +export const readDraft = () => readStored(DRAFT_KEY, "session"); +export const writeDraft = (text: string, dropped: boolean) => + writeStored(DRAFT_KEY, text.length > MAX_SAVED || dropped ? false : text, "session"); +export function readExactChoices(): string[] { + const raw = readStored(EXACT_KEY, "session"); + return Array.isArray(raw) ? raw.filter((v): v is string => typeof v === "string") : []; +} +export const writeExactChoices = (places: ReadonlySet) => + writeStored(EXACT_KEY, [...places], "session"); diff --git a/apps/json/src/main.ts b/apps/json/src/main.ts index 82dbaf0..1b549fb 100644 --- a/apps/json/src/main.ts +++ b/apps/json/src/main.ts @@ -1,17 +1,26 @@ +import { bindHeader } from "./lib/header.js"; +import { + readPrefs, + writePrefs, + readDraft, + writeDraft, + readExactChoices, + writeExactChoices, + type Prefs, + type ViewMode, + type PathStyle, + type Indent, +} from "./lib/preferences.js"; import { - bindDock, bindMenu, - bindThemeToggle, byId, copyText, downloadText, formatBytes, onFileDrop, pickFiles, - readStored, registerServiceWorker, setPressed, - writeStored, } from "@tools/ui"; import { createEditor, type LineLexer } from "@tools/editor"; import { applyEdits, REPAIRS, type Edit, type JsonNode, type RepairKind } from "./lib/ast.js"; @@ -110,9 +119,6 @@ const exportView = createEditor(exportEl, { // ---------- State ---------- /** JSON edits the document; the others show it converted, read-only. */ -type ViewMode = "json" | ExportKind; -type PathStyle = "js" | "pointer"; -type Indent = "2" | "4" | "tab"; let fileName = "data.json"; const analysis = createAnalysis(); @@ -137,50 +143,24 @@ const find = bindFind([editor, exportView], () => ({ // ---------- Storage ---------- -/** - * Settings persist (localStorage, shared by every tab). The draft is the person's data, so - * it lives only as long as the tab (sessionStorage): a reload keeps it, closing the tab ends it. - */ -const PREFS_KEY = "tools.json.prefs"; -const DRAFT_KEY = "tools.json.draft"; -/** The Exact values turned on, kept beside the draft they were chosen for. */ -const EXACT_KEY = "tools.json.exact"; -/** Past this, a draft is not worth the storage quota it would eat: `false` is kept instead. */ -const MAX_SAVED = 2_000_000; /** The tab reloaded after dropping a draft too large to keep: said until the next change. */ let droppedDraft = false; /** Only text changes write the draft; settings changes leave it alone. */ let draftChanged = true; -interface Prefs { - indent: Indent; - sortKeys: boolean; - view: ViewMode; - pathStyle: PathStyle; - colours: boolean; -} - -const VIEWS: readonly ViewMode[] = ["json", "yaml", "csv", "ts", "schema"]; - function restore(): void { - // Each setting is checked: storage holds whatever was last put there. - const prefs = (readStored(PREFS_KEY) ?? {}) as Partial; - if (prefs.indent === "2" || prefs.indent === "4" || prefs.indent === "tab") { - indentChoice = prefs.indent; - } - setOn(sortKeys, prefs.sortKeys === true); - if (prefs.view && VIEWS.includes(prefs.view)) mode = prefs.view; - if (prefs.pathStyle === "pointer") pathStyle = "pointer"; - if (prefs.colours === false) colours = false; + const prefs = readPrefs(); + indentChoice = prefs.indent; + setOn(sortKeys, prefs.sortKeys); + mode = prefs.view; + pathStyle = prefs.pathStyle; + colours = prefs.colours; // A tab with no draft yet opens on the sample. Cleared, the draft is "" and stays empty. - const draft = readStored(DRAFT_KEY, "session"); + const draft = readDraft(); showDocument(typeof draft === "string" ? draft : draft === false ? "" : SAMPLE); // After the document is in: putting it there was a change, and a change ends the warning. droppedDraft = draft === false; - const exact = readStored(EXACT_KEY, "session"); - if (Array.isArray(exact)) { - for (const place of exact) if (typeof place === "string") exactPlaces.add(place); - } + for (const place of readExactChoices()) exactPlaces.add(place); showOptions(); } @@ -192,7 +172,7 @@ function savePrefs(): void { pathStyle, colours, }; - writeStored(PREFS_KEY, prefs); + writePrefs(prefs); } function saveDraft(): void { @@ -200,11 +180,11 @@ function saveDraft(): void { draftChanged = false; const text = documentText(); // False while the empty editor still stands for a draft too large to keep: every reload says so. - writeStored(DRAFT_KEY, text.length > MAX_SAVED || droppedDraft ? false : text, "session"); + writeDraft(text, droppedDraft); } function saveExactChoices(): void { - writeStored(EXACT_KEY, [...exactPlaces], "session"); + writeExactChoices(exactPlaces); } /** Switches (settings that are on or off) keep theirs in aria-checked, as role="switch" has it. */ @@ -807,37 +787,7 @@ wrapBtn.addEventListener("click", () => { runNested("wrap"); }); -// On a phone the file actions fold into the "⋯" menu; Find joins them when the header is full. Its -// items are made from the bar's buttons each time it opens, so they carry the same names and -// the same disabled state, and pressing one presses that button. -const more = byId("more"); -const moreBtn = byId("more-btn"); -const fileButtons = [byId("open"), downloadBtn, copyBtn, clearBtn] as HTMLButtonElement[]; -const findButton = byId("find-open"); -const moreMenu = bindMenu(moreBtn, more, { - onOpen: () => - more.replaceChildren( - ...[ - ...fileButtons, - ...(getComputedStyle(findButton).display === "none" ? [findButton] : []), - ].map((b) => { - const item = document.createElement("button"); - item.type = "button"; - item.className = "ui-menu-item"; - item.disabled = b.disabled; - item.append(b.querySelector("svg")!.cloneNode(true), b.title.replace(/ —.*/, "")); - item.addEventListener("click", () => { - moreMenu.close(); - b.click(); - }); - return item; - }) - ), -}); - -// Help docks under the header and stays open until its button closes it; a reload of the tab -// keeps it open. -bindDock(byId("help"), byId("help-btn"), { key: "tools.json.help" }).restore(); +bindHeader(); pathBtn.addEventListener("click", () => void copyText(pathBtn.textContent ?? "", pathBtn)); // A phone's status bar: folded away until this opens it over the editor (see styles.css). @@ -874,7 +824,6 @@ for (const view of [editor, exportView]) { }); } -bindThemeToggle(byId("theme-toggle")); restore(); editor.setIndent(indent()); showColours(); diff --git a/apps/svg/src/lib/element-geometry.ts b/apps/svg/src/lib/element-geometry.ts new file mode 100644 index 0000000..81ec441 --- /dev/null +++ b/apps/svg/src/lib/element-geometry.ts @@ -0,0 +1,376 @@ +import type { + Anchor, + BBox, + PathElement, + Point, + RectElement, + SceneElement, + TextElement, +} from "./types.js"; +import type { AttrMap } from "./element-style.js"; + +export interface Geometry { + tag: string; + attrs: AttrMap; + text?: string; +} + +/** + * The shapes whose rotation is stored rather than baked in: their SVG element cannot express + * one in its own coordinates, and baking it in would turn a rect into a polygon and an ellipse + * into a path. Keeping the angle keeps the shape editable as what it is. + */ +export function keepsRotation(el: SceneElement): boolean { + return el.type === "rect" || el.type === "ellipse" || el.type === "text"; +} + +/** + * The point a stored rotation turns about. A box or ellipse turns about its own centre; text + * turns about its anchor, which is where the SVG `rotate()` on a `` has always put it. + */ +export function rotationCentre(el: SceneElement): Point { + if (el.type === "rect") return { x: el.x + el.width / 2, y: el.y + el.height / 2 }; + if (el.type === "ellipse") return { x: el.cx, y: el.cy }; + if (el.type === "text") return { x: el.x, y: el.y }; + const box = localBBox(el); + return box ? { x: box.x + box.width / 2, y: box.y + box.height / 2 } : { x: 0, y: 0 }; +} + +/** Turns `p` about (cx, cy) by `deg` degrees. */ +export function rotatePoint(p: Point, cx: number, cy: number, deg: number): Point { + if (!deg) return { x: p.x, y: p.y }; + const a = (deg * Math.PI) / 180; + const cos = Math.cos(a); + const sin = Math.sin(a); + const dx = p.x - cx; + const dy = p.y - cy; + return { x: cx + dx * cos - dy * sin, y: cy + dx * sin + dy * cos }; +} + +/** A world point in the element's own unrotated frame, which is where its geometry lives. */ +export function toLocalPoint(el: SceneElement, p: Point): Point { + const c = rotationCentre(el); + return rotatePoint(p, c.x, c.y, -(el.rotation ?? 0)); +} + +/** The reverse of `toLocalPoint`: an unrotated coordinate back to where it is drawn. */ +export function toWorldPoint(el: SceneElement, p: Point): Point { + const c = rotationCentre(el); + return rotatePoint(p, c.x, c.y, el.rotation ?? 0); +} + +/** Corner radius clamped so it can never exceed half the rect's shorter side. */ +export function cornerRadius(el: RectElement): number { + const limit = el.ry === undefined ? Math.min(el.width, el.height) / 2 : el.width / 2; + return Math.max(0, Math.min(el.rx, limit)); +} + +/** Vertical corner radius; follows the horizontal one until it is set separately. */ +export function cornerRadiusY(el: RectElement): number { + if (el.ry === undefined) return cornerRadius(el); + return Math.max(0, Math.min(el.ry, el.height / 2)); +} + +/* ---------- Segments and path geometry ---------- */ + +interface SegmentInfo { + curved: boolean; + c1: Point; + c2: Point; +} + +/** The segment from `a` to `b`: whether it curves, and the two cubic control points. */ +export function segmentInfo(a: Anchor, b: Anchor): SegmentInfo { + return { + curved: + !!(a.hOut && (a.hOut.x !== a.x || a.hOut.y !== a.y)) || + !!(b.hIn && (b.hIn.x !== b.x || b.hIn.y !== b.y)), + c1: a.hOut ?? a, + c2: b.hIn ?? b, + }; +} + +/** + * Whether a path draws a segment from its last anchor back to its first. Two anchors are + * enough: two curves between the same two points is how a heart or a lens is drawn. + */ +export function closesBack(path: PathElement): boolean { + return path.closed && path.points.length >= 2; +} + +/** A run of `points`, `start` to `end` exclusive: one outline of a path. */ +export interface Contour { + start: number; + end: number; +} + +/** The outlines of a path, in order: one for an ordinary path, more for a shape with holes. */ +export function contours(path: PathElement): Contour[] { + const n = path.points.length; + const starts = [0, ...(path.subpaths ?? []).filter((i) => i > 0 && i < n)]; + return starts.map((start, k) => ({ start, end: starts[k + 1] ?? n })); +} + +/** Whether a path is made of more than one outline. */ +export function isCompound(el: SceneElement): boolean { + return el.type === "path" && !!el.subpaths?.length; +} + +/** The outline holding point `i`. */ +function contourOf(path: PathElement, i: number): Contour { + return contours(path).find((c) => i >= c.start && i < c.end) ?? { start: 0, end: 0 }; +} + +/** The points before and after `i` along its own outline, wrapping round a closed one. */ +export function neighbours( + path: PathElement, + i: number +): { prev?: Anchor; next?: Anchor; count: number } { + const { start, end } = contourOf(path, i); + const count = end - start; + const wrap = path.closed && count >= 2; + const prev = i > start ? path.points[i - 1] : wrap ? path.points[end - 1] : undefined; + const next = i < end - 1 ? path.points[i + 1] : wrap ? path.points[start] : undefined; + return { prev, next, count }; +} + +/** + * Every drawn segment, including each outline's closing one on a closed path. `from` is the + * index of the segment's first anchor. + */ +export function indexedSegments(path: PathElement): { from: number; a: Anchor; b: Anchor }[] { + const pts = path.points; + const segs: { from: number; a: Anchor; b: Anchor }[] = []; + for (const { start, end } of contours(path)) { + for (let i = start; i < end - 1; i++) segs.push({ from: i, a: pts[i]!, b: pts[i + 1]! }); + if (path.closed && end - start >= 2) { + segs.push({ from: end - 1, a: pts[end - 1]!, b: pts[start]! }); + } + } + return segs; +} + +export type Formatter = (n: number) => number; + +/** `fmt` formats each coordinate (identity for live rendering, Math.round for export). */ +function pathToD(path: PathElement, fmt: Formatter = (n) => n): string { + const pts = path.points; + if (!pts.length) return ""; + const draw = (a: Anchor, b: Anchor) => { + const { curved, c1, c2 } = segmentInfo(a, b); + return curved + ? ` C ${fmt(c1.x)} ${fmt(c1.y)} ${fmt(c2.x)} ${fmt(c2.y)} ${fmt(b.x)} ${fmt(b.y)}` + : ` L ${fmt(b.x)} ${fmt(b.y)}`; + }; + // Each outline is a subpath of its own: a move to its first point, and a Z when closed. + return contours(path) + .filter(({ start, end }) => end > start) + .map(({ start, end }) => { + let d = `M ${fmt(pts[start]!.x)} ${fmt(pts[start]!.y)}`; + for (let i = start; i < end - 1; i++) d += draw(pts[i]!, pts[i + 1]!); + if (path.closed && end - start >= 2) { + const last = pts[end - 1]!; + // A straight closing segment is implied by Z; only a curved one needs its own command. + if (segmentInfo(last, pts[start]!).curved) d += draw(last, pts[start]!); + d += " Z"; + } + return d; + }) + .join(" "); +} + +/** True if every segment of `path` is a straight line (no Bezier curvature). */ +export function pathIsStraight(path: PathElement): boolean { + return indexedSegments(path).every(({ a, b }) => !segmentInfo(a, b).curved); +} + +/** + * Every point that defines a shape's geometry: anchors, plus Bezier handles for paths. + * For paths/polylines/polygons the returned objects are the live ones (so they can be moved); + * `skipIndex` drops one anchor and its handles. + */ +export function geometryPoints(el: SceneElement, skipIndex = -1): Point[] { + switch (el.type) { + case "path": { + const out: Point[] = []; + el.points.forEach((p, i) => { + if (i === skipIndex) return; + out.push(p); + if (p.hIn) out.push(p.hIn); + if (p.hOut) out.push(p.hOut); + }); + return out; + } + case "polyline": + case "polygon": + return el.points.filter((_, i) => i !== skipIndex); + case "line": + return [ + { x: el.x1, y: el.y1 }, + { x: el.x2, y: el.y2 }, + ]; + case "rect": { + // Perimeter order (TL, TR, BR, BL) so tracing these as a path gives the rectangle back. + const corners = [ + { x: el.x, y: el.y }, + { x: el.x + el.width, y: el.y }, + { x: el.x + el.width, y: el.y + el.height }, + { x: el.x, y: el.y + el.height }, + ]; + return el.rotation ? corners.map((p) => toWorldPoint(el, p)) : corners; + } + case "ellipse": + return [{ x: el.cx, y: el.cy }]; + case "text": + return [{ x: el.x, y: el.y }]; + } +} + +function boundsOf(points: readonly Point[]): BBox | null { + if (!points.length) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const p of points) { + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +} + +/* ---------- Rendering / export geometry ---------- */ + +let measureCtx: CanvasRenderingContext2D | null = null; + +function textWidth(el: TextElement): number { + if (!measureCtx && typeof document !== "undefined") { + measureCtx = document.createElement("canvas").getContext("2d"); + } + const fontSize = el.fontSize; + if (!measureCtx) return el.text.length * fontSize * 0.6; + measureCtx.font = `${fontSize}px ${el.fontFamily}`; + return measureCtx.measureText(el.text).width; +} + +/** A shape's box before any rotation: where its handles and its geometry actually live. */ +export function localBBox(el: SceneElement): BBox | null { + switch (el.type) { + case "rect": + return { x: el.x, y: el.y, width: el.width, height: el.height }; + case "ellipse": + return { x: el.cx - el.rx, y: el.cy - el.ry, width: el.rx * 2, height: el.ry * 2 }; + case "text": { + const w = textWidth(el); + const h = el.fontSize * 1.1; + const x = el.anchor === "middle" ? el.x - w / 2 : el.anchor === "end" ? el.x - w : el.x; + return { x, y: el.y - el.fontSize * 0.85, width: w, height: h }; + } + default: + return boundsOf(geometryPoints(el)); + } +} + +/** The corners of the local box, turned into where they are actually drawn. */ +export function cornersOf(el: SceneElement): Point[] { + const box = localBBox(el); + if (!box) return []; + return [ + { x: box.x, y: box.y }, + { x: box.x + box.width, y: box.y }, + { x: box.x + box.width, y: box.y + box.height }, + { x: box.x, y: box.y + box.height }, + ].map((p) => toWorldPoint(el, p)); +} + +/** The axis-aligned box the shape occupies on the artboard, rotation included. */ +export function elementBBox(el: SceneElement): BBox | null { + const box = localBBox(el); + if (!box || !el.rotation) return box; + // An ellipse's rotated extent is not its rotated corner box, so it gets the exact formula. + if (el.type === "ellipse") { + const { rx, ry } = el; + const a = (el.rotation * Math.PI) / 180; + const hw = Math.hypot(rx * Math.cos(a), ry * Math.sin(a)); + const hh = Math.hypot(rx * Math.sin(a), ry * Math.cos(a)); + return { x: el.cx - hw, y: el.cy - hh, width: hw * 2, height: hh * 2 }; + } + return boundsOf(cornersOf(el)); +} + +/** `rotate(a cx cy)` for a shape that carries an angle, or null when it does not. */ +function rotateAttr(el: SceneElement, fmt: Formatter): string | null { + const angle = el.rotation ?? 0; + if (!angle) return null; + const c = rotationCentre(el); + return `rotate(${Math.round(angle * 10) / 10} ${fmt(c.x)} ${fmt(c.y)})`; +} + +/** + * Tag and geometry attributes for an element, shared by the canvas renderer and the SVG + * exporter so the two can never drift. `fmt` formats coordinates (identity on canvas, + * Math.round for export); `text` is present only for ``. Null attributes are dropped. + */ +export function geometryOf(el: SceneElement, fmt: Formatter = (n) => n): Geometry | null { + switch (el.type) { + case "path": + return { tag: "path", attrs: { d: pathToD(el, fmt) } }; + case "line": + return { + tag: "line", + attrs: { x1: fmt(el.x1), y1: fmt(el.y1), x2: fmt(el.x2), y2: fmt(el.y2) }, + }; + case "rect": { + const rx = fmt(cornerRadius(el)); + const ry = fmt(cornerRadiusY(el)); + const rounded = rx > 0 || ry > 0; + return { + tag: "rect", + attrs: { + x: fmt(el.x), + y: fmt(el.y), + width: fmt(el.width), + height: fmt(el.height), + rx: rounded ? rx : null, + // `rx` alone already implies an equal `ry`. + ry: rounded && ry !== rx ? ry : null, + transform: rotateAttr(el, fmt), + }, + }; + } + case "ellipse": { + const rx = fmt(el.rx); + const ry = fmt(el.ry); + // An ellipse whose radii have come out equal is a circle, and says so in the markup. + // There is no circle tool; the diagonal handle on an ellipse is how you draw one. + const transform = rotateAttr(el, fmt); + if (rx === ry) { + return { tag: "circle", attrs: { cx: fmt(el.cx), cy: fmt(el.cy), r: rx, transform } }; + } + return { tag: "ellipse", attrs: { cx: fmt(el.cx), cy: fmt(el.cy), rx, ry, transform } }; + } + case "polyline": + case "polygon": + return { + tag: el.type, + attrs: { points: el.points.map((p) => `${fmt(p.x)},${fmt(p.y)}`).join(" ") }, + }; + case "text": + return { + tag: "text", + attrs: { + x: fmt(el.x), + y: fmt(el.y), + "font-family": el.fontFamily, + "font-size": fmt(el.fontSize), + "text-anchor": el.anchor !== "start" ? el.anchor : null, + transform: rotateAttr(el, fmt), + }, + text: el.text, + }; + default: + return null; + } +} diff --git a/apps/svg/src/lib/element-style.ts b/apps/svg/src/lib/element-style.ts new file mode 100644 index 0000000..6ceef26 --- /dev/null +++ b/apps/svg/src/lib/element-style.ts @@ -0,0 +1,214 @@ +import { cleanColor, cleanUnit } from "./utils.js"; +import type { ElementType, GradientStop, SceneElement, StyleCarrier, StyleProps } from "./types.js"; + +export const DEFAULT_STROKE: StyleProps = { + stroke: "#000000", + strokeOpacity: 1, + strokeWidth: 2, + strokeType: "solid", + strokeStops: [ + { offset: 0, color: "#000000", opacity: 1 }, + { offset: 1, color: "#ffffff", opacity: 1 }, + ], + strokeFrom: { x: 0, y: 0.5 }, + strokeTo: { x: 1, y: 0.5 }, + linecap: "round", + linejoin: "round", + fillEnabled: false, + fillType: "solid", + fill: "#000000", + fillOpacity: 1, + fillStops: [ + { offset: 0, color: "#000000", opacity: 1 }, + { offset: 1, color: "#ffffff", opacity: 1 }, + ], + fillFrom: { x: 0, y: 0.5 }, + fillTo: { x: 1, y: 0.5 }, + markerStart: "none", + markerEnd: "none", +}; + +/** Complete, owned style data at construction and file boundaries; readers need no defaults. */ +export function completeStyle(style: Partial): StyleProps { + const input = style as Record; + const out = structuredClone( + Object.fromEntries( + Object.entries(DEFAULT_STROKE).map(([key, fallback]) => [key, input[key] ?? fallback]) + ) + ) as unknown as StyleProps; + for (const kind of PAINT_KINDS) { + const k = PAINT_KEYS[kind]; + out[k.color] = cleanColor(out[k.color]); + out[k.opacity] = cleanUnit(out[k.opacity]); + if (!["solid", "linear", "radial"].includes(out[k.type])) out[k.type] = "solid"; + const stops = Array.isArray(out[k.stops]) + ? out[k.stops] + .filter((s) => s && Number.isFinite(s.offset)) + .map((s) => ({ + offset: cleanUnit(s.offset, 0), + color: cleanColor(s.color), + opacity: cleanUnit(s.opacity), + })) + : []; + out[k.stops] = + stops.length >= 2 + ? stops.sort((a, b) => a.offset - b.offset) + : [ + { offset: 0, color: out[k.color], opacity: out[k.opacity] }, + { offset: 1, color: "#ffffff", opacity: 1 }, + ]; + for (const key of [k.from, k.to]) { + const p = out[key]; + if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) + out[key] = { ...DEFAULT_STROKE[key] }; + } + } + for (const key of ["markerStart", "markerEnd"] as const) { + if (!MARKER_SHAPES.includes(out[key])) out[key] = "none"; + } + if (!["round", "butt", "square"].includes(out.linecap)) out.linecap = DEFAULT_STROKE.linecap; + if (!["round", "miter", "bevel"].includes(out.linejoin)) out.linejoin = DEFAULT_STROKE.linejoin; + out.strokeWidth = Number.isFinite(out.strokeWidth) + ? Math.max(0, out.strokeWidth) + : DEFAULT_STROKE.strokeWidth; + out.fillEnabled = out.fillEnabled === true; + return out; +} + +/** Keys copied when an element is converted from one type to another: identity plus every style. */ +const STYLE_KEYS: readonly string[] = [ + "name", + "groups", + "hidden", + "fillRule", + "dash", + "locked", + ...Object.keys(DEFAULT_STROKE), +]; + +export function styleOf(el: SceneElement): StyleCarrier { + const src = el as unknown as Record; + const out: Record = {}; + for (const k of STYLE_KEYS) if (src[k] !== undefined) out[k] = structuredClone(src[k]); + return out as StyleCarrier; +} + +export const MARKER_TYPES: readonly ElementType[] = ["line", "polyline", "path"]; +export const MARKER_SHAPES = ["none", "arrow", "dot", "square", "diamond"] as const; + +export type AttrMap = Record; + +/* ---------- Paints: the fill and the stroke ---------- */ + +/** Which of a shape's two paints. */ +export type PaintKind = "fill" | "stroke"; + +/** The fields that hold each paint, so code that handles a paint handles either. */ +export const PAINT_KEYS = { + fill: { + type: "fillType", + color: "fill", + opacity: "fillOpacity", + stops: "fillStops", + from: "fillFrom", + to: "fillTo", + }, + stroke: { + type: "strokeType", + color: "stroke", + opacity: "strokeOpacity", + stops: "strokeStops", + from: "strokeFrom", + to: "strokeTo", + }, +} as const satisfies Record>; + +export const PAINT_KINDS: readonly PaintKind[] = ["fill", "stroke"]; + +/** Whether the paint is drawn at all: a fill switched on, a stroke with a width. */ +function paintShown(el: SceneElement, kind: PaintKind): boolean { + return kind === "fill" ? !!el.fillEnabled : el.strokeWidth > 0; +} + +/** A paint's stops, in offset order, always at least two so a gradient is well formed. */ +export function gradientStops(el: SceneElement, kind: PaintKind = "fill"): GradientStop[] { + const k = PAINT_KEYS[kind]; + return [...el[k.stops]].sort((a, b) => a.offset - b.offset); +} + +/** Whether a paint is drawn as a gradient. */ +export function isGradient(el: SceneElement, kind: PaintKind = "fill"): boolean { + const type = el[PAINT_KEYS[kind].type]; + return paintShown(el, kind) && (type === "linear" || type === "radial"); +} + +/** The id of a paint's gradient in the markup: the fill's is `grad-`, the stroke's too, with `-stroke`. */ +export function gradientId(el: SceneElement, kind: PaintKind): string { + return kind === "fill" ? `grad-${el.id}` : `grad-${el.id}-stroke`; +} + +/** Value for the SVG `fill` or `stroke` attribute: none, a solid color, or a gradient reference. */ +function paintValue(el: SceneElement, kind: PaintKind): string { + if (!paintShown(el, kind)) return "none"; + if (isGradient(el, kind)) return `url(#${gradientId(el, kind)})`; + return el[PAINT_KEYS[kind].color]; +} + +/** + * Whether line ends are drawn. A marker is sized in stroke widths, and with no stroke the SVG + * falls back to a width of 1 - so without this, a line of width 0 would still show its arrows. + */ +export function hasMarkers(el: SceneElement): boolean { + return MARKER_TYPES.includes(el.type) && el.strokeWidth > 0; +} + +/** + * Presentation attributes, shared by the canvas renderer and the SVG exporter. Opacity is + * emitted only when not fully opaque, and a zero-width stroke exports as `stroke="none"`, + * so the markup stays minimal and the canvas matches the file. + */ +export function styleAttrs(el: SceneElement): AttrMap { + const attrs: AttrMap = {}; + // The canvas renders from these same attributes, so this hides it there and in the file alike. + if (el.hidden) attrs.display = "none"; + attrs.stroke = paintValue(el, "stroke"); + if (paintShown(el, "stroke")) { + attrs["stroke-width"] = el.strokeWidth; + attrs["stroke-linecap"] = el.linecap; + attrs["stroke-linejoin"] = el.linejoin; + // A gradient carries its opacity in its stops. + if (!isGradient(el, "stroke") && el.strokeOpacity !== 1) { + attrs["stroke-opacity"] = el.strokeOpacity; + } + if (el.dash?.length) attrs["stroke-dasharray"] = el.dash.join(" "); + } + attrs.fill = paintValue(el, "fill"); + if (el.fillRule === "evenodd") attrs["fill-rule"] = "evenodd"; + if (el.fillEnabled && !isGradient(el) && el.fillOpacity !== 1) { + attrs["fill-opacity"] = el.fillOpacity; + } + if (hasMarkers(el)) { + if (el.markerStart !== "none") { + attrs["marker-start"] = `url(#mk-${el.id}-start)`; + } + if (el.markerEnd !== "none") { + attrs["marker-end"] = `url(#mk-${el.id}-end)`; + } + } + return attrs; +} + +/** + * A dash pattern from what someone typed or a file said: lengths separated by spaces or commas. + * Absent for a solid line - nothing to read, "none", a negative length, or nothing but zeros, + * which SVG draws solid as well. + */ +export function parseDash(raw: string | null | undefined): number[] | undefined { + const text = (raw ?? "").trim(); + if (!text || text === "none") return undefined; + const parts = text.split(/[\s,]+/).map(Number); + if (parts.some((n) => !Number.isFinite(n) || n < 0) || parts.every((n) => n === 0)) { + return undefined; + } + return parts; +} diff --git a/apps/svg/src/lib/interaction.ts b/apps/svg/src/lib/interaction.ts index 2478475..03e1c50 100644 --- a/apps/svg/src/lib/interaction.ts +++ b/apps/svg/src/lib/interaction.ts @@ -1,3 +1,5 @@ +import { overRuler, guideAt } from "./ruler-guides.js"; +export { bindRulerGuides } from "./ruler-guides.js"; import { getState, setState, @@ -47,7 +49,7 @@ import { applyResize, pointIndexForRole } from "./resize.js"; import { boxCorners, rotateAll, scaleAllByCorner, unionBox } from "./selection-transform.js"; import { SELECTION_HANDLE_ID } from "./render.js"; import { snapFeatures } from "./snap.js"; -import { boxToGuides, movedGuide, nearestGuide, withGuide, type GuideAxis } from "./guides.js"; +import { boxToGuides, movedGuide, nearestGuide, type GuideAxis } from "./guides.js"; import { byShape, isPicked, @@ -204,85 +206,6 @@ function showPenCloseTarget(path: PathElement, world: Point): void { setState({ dropTarget: next }); } -/** Whether a press or release is over the ruler a guide on `axis` comes out of. */ -function overRuler(axis: GuideAxis, e: PointerEvent): boolean { - const ruler = document.getElementById(axis === "y" ? "ruler-top" : "ruler-left"); - const r = ruler?.getBoundingClientRect(); - if (!r || !r.width || !r.height) return false; - return axis === "y" ? e.clientY <= r.bottom : e.clientX <= r.right; -} - -/** - * Where a guide dragged to `raw` settles. Snapping to shapes (the switch, or Alt) puts it in line - * with the nearest point of a shape - corners, centres, midpoints, crossings - within reach; grid - * snap puts it on the grid. `points` is asked only when shapes are snapped to, as working out - * crossings is not free. - */ -function guideAt( - axis: GuideAxis, - raw: number, - e: PointerEvent, - points: (s: EditorState) => readonly Point[] -): number { - const s = getState(); - if (getState().alignSnap || e.altKey) { - let at = raw; - let best = ALIGN_TOL_PX / s.viewport.zoom; - for (const p of points(s)) { - const d = Math.abs(p[axis] - raw); - if (d < best) { - best = d; - at = p[axis]; - } - } - return at; - } - if (!s.grid.snap) return raw; - const step = Math.max(1, s.grid.step); - return Math.round(raw / step) * step; -} - -/** Every point of the document a guide can line up with. */ -function guideTargets(s: EditorState): Point[] { - return [...collectAlignPoints(s.elements, {}), ...snapFeatures(s.elements, new Set())]; -} - -/** - * Dragging a new guide out of a ruler: the top ruler gives a horizontal one, the left a vertical - * one. It follows the pointer as a dashed line and is placed where it is let go - unless that is - * back on the ruler, which is how a guide pulled out by mistake goes away again. - */ -export function bindRulerGuides(top: HTMLCanvasElement, left: HTMLCanvasElement): void { - const start = (axis: GuideAxis, canvas: HTMLCanvasElement) => (e: PointerEvent) => { - const st = getState(); - if (e.button !== 0 || st.drawing?.activePathId) return; - e.preventDefault(); - canvas.setPointerCapture(e.pointerId); - // The shapes stay put while a guide is dragged, so their points are worked out once. - let targets: Point[] | null = null; - const place = (ev: PointerEvent) => { - const raw = screenToWorld(ev.clientX, ev.clientY)[axis]; - const at = guideAt(axis, raw, ev, (s) => (targets ??= guideTargets(s))); - setState({ drawing: { guide: { axis, at } } }); - }; - const up = (ev: PointerEvent) => { - canvas.removeEventListener("pointermove", place); - canvas.removeEventListener("pointerup", up); - canvas.removeEventListener("pointercancel", up); - const draft = getState().drawing?.guide; - clearDrawing(); - if (!draft || ev.type === "pointercancel" || overRuler(axis, ev)) return; - pushUndo(); - setState((s) => ({ ...s, guides: withGuide(s.guides, axis, draft.at) })); - }; - canvas.addEventListener("pointermove", place); - canvas.addEventListener("pointerup", up); - canvas.addEventListener("pointercancel", up); - }; - top.addEventListener("pointerdown", start("y", top)); - left.addEventListener("pointerdown", start("x", left)); -} - /** The canvas and its wrapper, set once by `bindInteraction`. */ let svg: SVGSVGElement; let wrap: HTMLElement; diff --git a/apps/svg/src/lib/model.ts b/apps/svg/src/lib/model.ts index 9b272b4..21d1b37 100644 --- a/apps/svg/src/lib/model.ts +++ b/apps/svg/src/lib/model.ts @@ -1,11 +1,55 @@ +import { completeStyle, styleOf } from "./element-style.js"; +export { + DEFAULT_STROKE, + completeStyle, + styleOf, + MARKER_TYPES, + MARKER_SHAPES, + PAINT_KEYS, + PAINT_KINDS, + gradientStops, + isGradient, + gradientId, + hasMarkers, + styleAttrs, + parseDash, +} from "./element-style.js"; +export type { AttrMap, PaintKind } from "./element-style.js"; +import { + closesBack, + contours, + cornerRadius, + cornerRadiusY, + geometryPoints, + indexedSegments, + isCompound, + keepsRotation, + neighbours, + pathIsStraight, + rotatePoint, + rotationCentre, + segmentInfo, +} from "./element-geometry.js"; +export { + keepsRotation, + rotationCentre, + toLocalPoint, + toWorldPoint, + cornerRadius, + cornerRadiusY, + contours, + localBBox, + cornersOf, + elementBBox, + geometryOf, +} from "./element-geometry.js"; +export type { Geometry, Contour, Formatter } from "./element-geometry.js"; import { cubicAt, splitCubic } from "./cubic.js"; -import { cleanColor, cleanUnit, isDefaultName, uid } from "./utils.js"; +import { isDefaultName, uid } from "./utils.js"; import type { Anchor, - BBox, ElementType, EllipseElement, - GradientStop, LineElement, PathElement, Point, @@ -20,109 +64,6 @@ import type { TextElement, } from "./types.js"; -export const DEFAULT_STROKE: StyleProps = { - stroke: "#000000", - strokeOpacity: 1, - strokeWidth: 2, - strokeType: "solid", - strokeStops: [ - { offset: 0, color: "#000000", opacity: 1 }, - { offset: 1, color: "#ffffff", opacity: 1 }, - ], - strokeFrom: { x: 0, y: 0.5 }, - strokeTo: { x: 1, y: 0.5 }, - linecap: "round", - linejoin: "round", - fillEnabled: false, - fillType: "solid", - fill: "#000000", - fillOpacity: 1, - fillStops: [ - { offset: 0, color: "#000000", opacity: 1 }, - { offset: 1, color: "#ffffff", opacity: 1 }, - ], - fillFrom: { x: 0, y: 0.5 }, - fillTo: { x: 1, y: 0.5 }, - markerStart: "none", - markerEnd: "none", -}; - -/** Complete, owned style data at construction and file boundaries; readers need no defaults. */ -export function completeStyle(style: Partial): StyleProps { - const input = style as Record; - const out = structuredClone( - Object.fromEntries( - Object.entries(DEFAULT_STROKE).map(([key, fallback]) => [key, input[key] ?? fallback]) - ) - ) as unknown as StyleProps; - for (const kind of PAINT_KINDS) { - const k = PAINT_KEYS[kind]; - out[k.color] = cleanColor(out[k.color]); - out[k.opacity] = cleanUnit(out[k.opacity]); - if (!["solid", "linear", "radial"].includes(out[k.type])) out[k.type] = "solid"; - const stops = Array.isArray(out[k.stops]) - ? out[k.stops] - .filter((s) => s && Number.isFinite(s.offset)) - .map((s) => ({ - offset: cleanUnit(s.offset, 0), - color: cleanColor(s.color), - opacity: cleanUnit(s.opacity), - })) - : []; - out[k.stops] = - stops.length >= 2 - ? stops.sort((a, b) => a.offset - b.offset) - : [ - { offset: 0, color: out[k.color], opacity: out[k.opacity] }, - { offset: 1, color: "#ffffff", opacity: 1 }, - ]; - for (const key of [k.from, k.to]) { - const p = out[key]; - if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) - out[key] = { ...DEFAULT_STROKE[key] }; - } - } - for (const key of ["markerStart", "markerEnd"] as const) { - if (!MARKER_SHAPES.includes(out[key])) out[key] = "none"; - } - if (!["round", "butt", "square"].includes(out.linecap)) out.linecap = DEFAULT_STROKE.linecap; - if (!["round", "miter", "bevel"].includes(out.linejoin)) out.linejoin = DEFAULT_STROKE.linejoin; - out.strokeWidth = Number.isFinite(out.strokeWidth) - ? Math.max(0, out.strokeWidth) - : DEFAULT_STROKE.strokeWidth; - out.fillEnabled = out.fillEnabled === true; - return out; -} - -/** Keys copied when an element is converted from one type to another: identity plus every style. */ -const STYLE_KEYS: readonly string[] = [ - "name", - "groups", - "hidden", - "fillRule", - "dash", - "locked", - ...Object.keys(DEFAULT_STROKE), -]; - -export function styleOf(el: SceneElement): StyleCarrier { - const src = el as unknown as Record; - const out: Record = {}; - for (const k of STYLE_KEYS) if (src[k] !== undefined) out[k] = structuredClone(src[k]); - return out as StyleCarrier; -} - -export const MARKER_TYPES: readonly ElementType[] = ["line", "polyline", "path"]; -export const MARKER_SHAPES = ["none", "arrow", "dot", "square", "diamond"] as const; - -export type AttrMap = Record; - -export interface Geometry { - tag: string; - attrs: AttrMap; - text?: string; -} - /** Identity plus a full style set, shared by every element constructor. */ function base( type: T, @@ -138,118 +79,6 @@ function base( } as { id: string; type: T; name: string; groups?: string[] } & StyleProps; } -/** - * The shapes whose rotation is stored rather than baked in: their SVG element cannot express - * one in its own coordinates, and baking it in would turn a rect into a polygon and an ellipse - * into a path. Keeping the angle keeps the shape editable as what it is. - */ -export function keepsRotation(el: SceneElement): boolean { - return el.type === "rect" || el.type === "ellipse" || el.type === "text"; -} - -/** - * The point a stored rotation turns about. A box or ellipse turns about its own centre; text - * turns about its anchor, which is where the SVG `rotate()` on a `` has always put it. - */ -export function rotationCentre(el: SceneElement): Point { - if (el.type === "rect") return { x: el.x + el.width / 2, y: el.y + el.height / 2 }; - if (el.type === "ellipse") return { x: el.cx, y: el.cy }; - if (el.type === "text") return { x: el.x, y: el.y }; - const box = localBBox(el); - return box ? { x: box.x + box.width / 2, y: box.y + box.height / 2 } : { x: 0, y: 0 }; -} - -/** Turns `p` about (cx, cy) by `deg` degrees. */ -function rotatePoint(p: Point, cx: number, cy: number, deg: number): Point { - if (!deg) return { x: p.x, y: p.y }; - const a = (deg * Math.PI) / 180; - const cos = Math.cos(a); - const sin = Math.sin(a); - const dx = p.x - cx; - const dy = p.y - cy; - return { x: cx + dx * cos - dy * sin, y: cy + dx * sin + dy * cos }; -} - -/** A world point in the element's own unrotated frame, which is where its geometry lives. */ -export function toLocalPoint(el: SceneElement, p: Point): Point { - const c = rotationCentre(el); - return rotatePoint(p, c.x, c.y, -(el.rotation ?? 0)); -} - -/** The reverse of `toLocalPoint`: an unrotated coordinate back to where it is drawn. */ -export function toWorldPoint(el: SceneElement, p: Point): Point { - const c = rotationCentre(el); - return rotatePoint(p, c.x, c.y, el.rotation ?? 0); -} - -/** Corner radius clamped so it can never exceed half the rect's shorter side. */ -export function cornerRadius(el: RectElement): number { - const limit = el.ry === undefined ? Math.min(el.width, el.height) / 2 : el.width / 2; - return Math.max(0, Math.min(el.rx, limit)); -} - -/** Vertical corner radius; follows the horizontal one until it is set separately. */ -export function cornerRadiusY(el: RectElement): number { - if (el.ry === undefined) return cornerRadius(el); - return Math.max(0, Math.min(el.ry, el.height / 2)); -} - -/* ---------- Paints: the fill and the stroke ---------- */ - -/** Which of a shape's two paints. */ -export type PaintKind = "fill" | "stroke"; - -/** The fields that hold each paint, so code that handles a paint handles either. */ -export const PAINT_KEYS = { - fill: { - type: "fillType", - color: "fill", - opacity: "fillOpacity", - stops: "fillStops", - from: "fillFrom", - to: "fillTo", - }, - stroke: { - type: "strokeType", - color: "stroke", - opacity: "strokeOpacity", - stops: "strokeStops", - from: "strokeFrom", - to: "strokeTo", - }, -} as const satisfies Record>; - -export const PAINT_KINDS: readonly PaintKind[] = ["fill", "stroke"]; - -/** Whether the paint is drawn at all: a fill switched on, a stroke with a width. */ -function paintShown(el: SceneElement, kind: PaintKind): boolean { - return kind === "fill" ? !!el.fillEnabled : el.strokeWidth > 0; -} - -/** A paint's stops, in offset order, always at least two so a gradient is well formed. */ -export function gradientStops(el: SceneElement, kind: PaintKind = "fill"): GradientStop[] { - const k = PAINT_KEYS[kind]; - return [...el[k.stops]].sort((a, b) => a.offset - b.offset); -} - -/** Whether a paint is drawn as a gradient. */ -export function isGradient(el: SceneElement, kind: PaintKind = "fill"): boolean { - const type = el[PAINT_KEYS[kind].type]; - return paintShown(el, kind) && (type === "linear" || type === "radial"); -} - -/** The id of a paint's gradient in the markup: the fill's is `grad-`, the stroke's too, with `-stroke`. */ -export function gradientId(el: SceneElement, kind: PaintKind): string { - return kind === "fill" ? `grad-${el.id}` : `grad-${el.id}-stroke`; -} - -/** Value for the SVG `fill` or `stroke` attribute: none, a solid color, or a gradient reference. */ -function paintValue(el: SceneElement, kind: PaintKind): string { - if (!paintShown(el, kind)) return "none"; - if (isGradient(el, kind)) return `url(#${gradientId(el, kind)})`; - return el[PAINT_KEYS[kind].color]; -} - export function createPath( points: Anchor[] = [], closed = false, @@ -391,360 +220,6 @@ export function createImage(dataUrl: string, name: string): ReferenceImage { }; } -/* ---------- Segments and path geometry ---------- */ - -interface SegmentInfo { - curved: boolean; - c1: Point; - c2: Point; -} - -/** The segment from `a` to `b`: whether it curves, and the two cubic control points. */ -function segmentInfo(a: Anchor, b: Anchor): SegmentInfo { - return { curved: standsOff(a.hOut, a) || standsOff(b.hIn, b), c1: a.hOut ?? a, c2: b.hIn ?? b }; -} - -/** - * Whether a path draws a segment from its last anchor back to its first. Two anchors are - * enough: two curves between the same two points is how a heart or a lens is drawn. - */ -function closesBack(path: PathElement): boolean { - return path.closed && path.points.length >= 2; -} - -/** A run of `points`, `start` to `end` exclusive: one outline of a path. */ -export interface Contour { - start: number; - end: number; -} - -/** The outlines of a path, in order: one for an ordinary path, more for a shape with holes. */ -export function contours(path: PathElement): Contour[] { - const n = path.points.length; - const starts = [0, ...(path.subpaths ?? []).filter((i) => i > 0 && i < n)]; - return starts.map((start, k) => ({ start, end: starts[k + 1] ?? n })); -} - -/** Whether a path is made of more than one outline. */ -function isCompound(el: SceneElement): boolean { - return el.type === "path" && !!el.subpaths?.length; -} - -/** The outline holding point `i`. */ -function contourOf(path: PathElement, i: number): Contour { - return contours(path).find((c) => i >= c.start && i < c.end) ?? { start: 0, end: 0 }; -} - -/** The points before and after `i` along its own outline, wrapping round a closed one. */ -function neighbours(path: PathElement, i: number): { prev?: Anchor; next?: Anchor; count: number } { - const { start, end } = contourOf(path, i); - const count = end - start; - const wrap = path.closed && count >= 2; - const prev = i > start ? path.points[i - 1] : wrap ? path.points[end - 1] : undefined; - const next = i < end - 1 ? path.points[i + 1] : wrap ? path.points[start] : undefined; - return { prev, next, count }; -} - -/** - * Every drawn segment, including each outline's closing one on a closed path. `from` is the - * index of the segment's first anchor. - */ -function indexedSegments(path: PathElement): { from: number; a: Anchor; b: Anchor }[] { - const pts = path.points; - const segs: { from: number; a: Anchor; b: Anchor }[] = []; - for (const { start, end } of contours(path)) { - for (let i = start; i < end - 1; i++) segs.push({ from: i, a: pts[i]!, b: pts[i + 1]! }); - if (path.closed && end - start >= 2) { - segs.push({ from: end - 1, a: pts[end - 1]!, b: pts[start]! }); - } - } - return segs; -} - -export type Formatter = (n: number) => number; - -/** `fmt` formats each coordinate (identity for live rendering, Math.round for export). */ -function pathToD(path: PathElement, fmt: Formatter = (n) => n): string { - const pts = path.points; - if (!pts.length) return ""; - const draw = (a: Anchor, b: Anchor) => { - const { curved, c1, c2 } = segmentInfo(a, b); - return curved - ? ` C ${fmt(c1.x)} ${fmt(c1.y)} ${fmt(c2.x)} ${fmt(c2.y)} ${fmt(b.x)} ${fmt(b.y)}` - : ` L ${fmt(b.x)} ${fmt(b.y)}`; - }; - // Each outline is a subpath of its own: a move to its first point, and a Z when closed. - return contours(path) - .filter(({ start, end }) => end > start) - .map(({ start, end }) => { - let d = `M ${fmt(pts[start]!.x)} ${fmt(pts[start]!.y)}`; - for (let i = start; i < end - 1; i++) d += draw(pts[i]!, pts[i + 1]!); - if (path.closed && end - start >= 2) { - const last = pts[end - 1]!; - // A straight closing segment is implied by Z; only a curved one needs its own command. - if (segmentInfo(last, pts[start]!).curved) d += draw(last, pts[start]!); - d += " Z"; - } - return d; - }) - .join(" "); -} - -/** True if every segment of `path` is a straight line (no Bezier curvature). */ -function pathIsStraight(path: PathElement): boolean { - return indexedSegments(path).every(({ a, b }) => !segmentInfo(a, b).curved); -} - -/** - * Every point that defines a shape's geometry: anchors, plus Bezier handles for paths. - * For paths/polylines/polygons the returned objects are the live ones (so they can be moved); - * `skipIndex` drops one anchor and its handles. - */ -function geometryPoints(el: SceneElement, skipIndex = -1): Point[] { - switch (el.type) { - case "path": { - const out: Point[] = []; - el.points.forEach((p, i) => { - if (i === skipIndex) return; - out.push(p); - if (p.hIn) out.push(p.hIn); - if (p.hOut) out.push(p.hOut); - }); - return out; - } - case "polyline": - case "polygon": - return el.points.filter((_, i) => i !== skipIndex); - case "line": - return [ - { x: el.x1, y: el.y1 }, - { x: el.x2, y: el.y2 }, - ]; - case "rect": { - // Perimeter order (TL, TR, BR, BL) so tracing these as a path gives the rectangle back. - const corners = [ - { x: el.x, y: el.y }, - { x: el.x + el.width, y: el.y }, - { x: el.x + el.width, y: el.y + el.height }, - { x: el.x, y: el.y + el.height }, - ]; - return el.rotation ? corners.map((p) => toWorldPoint(el, p)) : corners; - } - case "ellipse": - return [{ x: el.cx, y: el.cy }]; - case "text": - return [{ x: el.x, y: el.y }]; - } -} - -function boundsOf(points: readonly Point[]): BBox | null { - if (!points.length) return null; - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (const p of points) { - minX = Math.min(minX, p.x); - minY = Math.min(minY, p.y); - maxX = Math.max(maxX, p.x); - maxY = Math.max(maxY, p.y); - } - return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; -} - -/* ---------- Rendering / export geometry ---------- */ - -let measureCtx: CanvasRenderingContext2D | null = null; - -function textWidth(el: TextElement): number { - if (!measureCtx && typeof document !== "undefined") { - measureCtx = document.createElement("canvas").getContext("2d"); - } - const fontSize = el.fontSize; - if (!measureCtx) return el.text.length * fontSize * 0.6; - measureCtx.font = `${fontSize}px ${el.fontFamily}`; - return measureCtx.measureText(el.text).width; -} - -/** A shape's box before any rotation: where its handles and its geometry actually live. */ -export function localBBox(el: SceneElement): BBox | null { - switch (el.type) { - case "rect": - return { x: el.x, y: el.y, width: el.width, height: el.height }; - case "ellipse": - return { x: el.cx - el.rx, y: el.cy - el.ry, width: el.rx * 2, height: el.ry * 2 }; - case "text": { - const w = textWidth(el); - const h = el.fontSize * 1.1; - const x = el.anchor === "middle" ? el.x - w / 2 : el.anchor === "end" ? el.x - w : el.x; - return { x, y: el.y - el.fontSize * 0.85, width: w, height: h }; - } - default: - return boundsOf(geometryPoints(el)); - } -} - -/** The corners of the local box, turned into where they are actually drawn. */ -export function cornersOf(el: SceneElement): Point[] { - const box = localBBox(el); - if (!box) return []; - return [ - { x: box.x, y: box.y }, - { x: box.x + box.width, y: box.y }, - { x: box.x + box.width, y: box.y + box.height }, - { x: box.x, y: box.y + box.height }, - ].map((p) => toWorldPoint(el, p)); -} - -/** The axis-aligned box the shape occupies on the artboard, rotation included. */ -export function elementBBox(el: SceneElement): BBox | null { - const box = localBBox(el); - if (!box || !el.rotation) return box; - // An ellipse's rotated extent is not its rotated corner box, so it gets the exact formula. - if (el.type === "ellipse") { - const { rx, ry } = el; - const a = (el.rotation * Math.PI) / 180; - const hw = Math.hypot(rx * Math.cos(a), ry * Math.sin(a)); - const hh = Math.hypot(rx * Math.sin(a), ry * Math.cos(a)); - return { x: el.cx - hw, y: el.cy - hh, width: hw * 2, height: hh * 2 }; - } - return boundsOf(cornersOf(el)); -} - -/** `rotate(a cx cy)` for a shape that carries an angle, or null when it does not. */ -function rotateAttr(el: SceneElement, fmt: Formatter): string | null { - const angle = el.rotation ?? 0; - if (!angle) return null; - const c = rotationCentre(el); - return `rotate(${Math.round(angle * 10) / 10} ${fmt(c.x)} ${fmt(c.y)})`; -} - -/** - * Tag and geometry attributes for an element, shared by the canvas renderer and the SVG - * exporter so the two can never drift. `fmt` formats coordinates (identity on canvas, - * Math.round for export); `text` is present only for ``. Null attributes are dropped. - */ -export function geometryOf(el: SceneElement, fmt: Formatter = (n) => n): Geometry | null { - switch (el.type) { - case "path": - return { tag: "path", attrs: { d: pathToD(el, fmt) } }; - case "line": - return { - tag: "line", - attrs: { x1: fmt(el.x1), y1: fmt(el.y1), x2: fmt(el.x2), y2: fmt(el.y2) }, - }; - case "rect": { - const rx = fmt(cornerRadius(el)); - const ry = fmt(cornerRadiusY(el)); - const rounded = rx > 0 || ry > 0; - return { - tag: "rect", - attrs: { - x: fmt(el.x), - y: fmt(el.y), - width: fmt(el.width), - height: fmt(el.height), - rx: rounded ? rx : null, - // `rx` alone already implies an equal `ry`. - ry: rounded && ry !== rx ? ry : null, - transform: rotateAttr(el, fmt), - }, - }; - } - case "ellipse": { - const rx = fmt(el.rx); - const ry = fmt(el.ry); - // An ellipse whose radii have come out equal is a circle, and says so in the markup. - // There is no circle tool; the diagonal handle on an ellipse is how you draw one. - const transform = rotateAttr(el, fmt); - if (rx === ry) { - return { tag: "circle", attrs: { cx: fmt(el.cx), cy: fmt(el.cy), r: rx, transform } }; - } - return { tag: "ellipse", attrs: { cx: fmt(el.cx), cy: fmt(el.cy), rx, ry, transform } }; - } - case "polyline": - case "polygon": - return { - tag: el.type, - attrs: { points: el.points.map((p) => `${fmt(p.x)},${fmt(p.y)}`).join(" ") }, - }; - case "text": - return { - tag: "text", - attrs: { - x: fmt(el.x), - y: fmt(el.y), - "font-family": el.fontFamily, - "font-size": fmt(el.fontSize), - "text-anchor": el.anchor !== "start" ? el.anchor : null, - transform: rotateAttr(el, fmt), - }, - text: el.text, - }; - default: - return null; - } -} - -/** - * Whether line ends are drawn. A marker is sized in stroke widths, and with no stroke the SVG - * falls back to a width of 1 - so without this, a line of width 0 would still show its arrows. - */ -export function hasMarkers(el: SceneElement): boolean { - return MARKER_TYPES.includes(el.type) && el.strokeWidth > 0; -} - -/** - * Presentation attributes, shared by the canvas renderer and the SVG exporter. Opacity is - * emitted only when not fully opaque, and a zero-width stroke exports as `stroke="none"`, - * so the markup stays minimal and the canvas matches the file. - */ -export function styleAttrs(el: SceneElement): AttrMap { - const attrs: AttrMap = {}; - // The canvas renders from these same attributes, so this hides it there and in the file alike. - if (el.hidden) attrs.display = "none"; - attrs.stroke = paintValue(el, "stroke"); - if (paintShown(el, "stroke")) { - attrs["stroke-width"] = el.strokeWidth; - attrs["stroke-linecap"] = el.linecap; - attrs["stroke-linejoin"] = el.linejoin; - // A gradient carries its opacity in its stops. - if (!isGradient(el, "stroke") && el.strokeOpacity !== 1) { - attrs["stroke-opacity"] = el.strokeOpacity; - } - if (el.dash?.length) attrs["stroke-dasharray"] = el.dash.join(" "); - } - attrs.fill = paintValue(el, "fill"); - if (el.fillRule === "evenodd") attrs["fill-rule"] = "evenodd"; - if (el.fillEnabled && !isGradient(el) && el.fillOpacity !== 1) { - attrs["fill-opacity"] = el.fillOpacity; - } - if (hasMarkers(el)) { - if (el.markerStart !== "none") { - attrs["marker-start"] = `url(#mk-${el.id}-start)`; - } - if (el.markerEnd !== "none") { - attrs["marker-end"] = `url(#mk-${el.id}-end)`; - } - } - return attrs; -} - -/** - * A dash pattern from what someone typed or a file said: lengths separated by spaces or commas. - * Absent for a solid line - nothing to read, "none", a negative length, or nothing but zeros, - * which SVG draws solid as well. - */ -export function parseDash(raw: string | null | undefined): number[] | undefined { - const text = (raw ?? "").trim(); - if (!text || text === "none") return undefined; - const parts = text.split(/[\s,]+/).map(Number); - if (parts.some((n) => !Number.isFinite(n) || n < 0) || parts.every((n) => n === 0)) { - return undefined; - } - return parts; -} - /* ---------- Transforms ---------- */ export function translateElement(el: SceneElement, dx: number, dy: number): void { diff --git a/apps/svg/src/lib/ruler-guides.ts b/apps/svg/src/lib/ruler-guides.ts new file mode 100644 index 0000000..398b16e --- /dev/null +++ b/apps/svg/src/lib/ruler-guides.ts @@ -0,0 +1,87 @@ +import { getState, setState, clearDrawing } from "./state.js"; +import { collectAlignPoints } from "./model.js"; +import { snapFeatures } from "./snap.js"; +import { screenToWorld } from "./viewport.js"; +import { pushUndo } from "./undo.js"; +import { withGuide, type GuideAxis } from "./guides.js"; +import type { EditorState, Point } from "./types.js"; +const ALIGN_TOL_PX = 6; + +/** Whether a press or release is over the ruler a guide on `axis` comes out of. */ +export function overRuler(axis: GuideAxis, e: PointerEvent): boolean { + const ruler = document.getElementById(axis === "y" ? "ruler-top" : "ruler-left"); + const r = ruler?.getBoundingClientRect(); + if (!r || !r.width || !r.height) return false; + return axis === "y" ? e.clientY <= r.bottom : e.clientX <= r.right; +} + +/** + * Where a guide dragged to `raw` settles. Snapping to shapes (the switch, or Alt) puts it in line + * with the nearest point of a shape - corners, centres, midpoints, crossings - within reach; grid + * snap puts it on the grid. `points` is asked only when shapes are snapped to, as working out + * crossings is not free. + */ +export function guideAt( + axis: GuideAxis, + raw: number, + e: PointerEvent, + points: (s: EditorState) => readonly Point[] +): number { + const s = getState(); + if (getState().alignSnap || e.altKey) { + let at = raw; + let best = ALIGN_TOL_PX / s.viewport.zoom; + for (const p of points(s)) { + const d = Math.abs(p[axis] - raw); + if (d < best) { + best = d; + at = p[axis]; + } + } + return at; + } + if (!s.grid.snap) return raw; + const step = Math.max(1, s.grid.step); + return Math.round(raw / step) * step; +} + +/** Every point of the document a guide can line up with. */ +function guideTargets(s: EditorState): Point[] { + return [...collectAlignPoints(s.elements, {}), ...snapFeatures(s.elements, new Set())]; +} + +/** + * Dragging a new guide out of a ruler: the top ruler gives a horizontal one, the left a vertical + * one. It follows the pointer as a dashed line and is placed where it is let go - unless that is + * back on the ruler, which is how a guide pulled out by mistake goes away again. + */ +export function bindRulerGuides(top: HTMLCanvasElement, left: HTMLCanvasElement): void { + const start = (axis: GuideAxis, canvas: HTMLCanvasElement) => (e: PointerEvent) => { + const st = getState(); + if (e.button !== 0 || st.drawing?.activePathId) return; + e.preventDefault(); + canvas.setPointerCapture(e.pointerId); + // The shapes stay put while a guide is dragged, so their points are worked out once. + let targets: Point[] | null = null; + const place = (ev: PointerEvent) => { + const raw = screenToWorld(ev.clientX, ev.clientY)[axis]; + const at = guideAt(axis, raw, ev, (s) => (targets ??= guideTargets(s))); + setState({ drawing: { guide: { axis, at } } }); + }; + const up = (ev: PointerEvent) => { + canvas.removeEventListener("pointermove", place); + canvas.removeEventListener("pointerup", up); + canvas.removeEventListener("pointercancel", up); + const draft = getState().drawing?.guide; + clearDrawing(); + if (!draft || ev.type === "pointercancel" || overRuler(axis, ev)) return; + pushUndo(); + setState((s) => ({ ...s, guides: withGuide(s.guides, axis, draft.at) })); + }; + canvas.addEventListener("pointermove", place); + canvas.addEventListener("pointerup", up); + canvas.addEventListener("pointercancel", up); + }; + top.addEventListener("pointerdown", start("y", top)); + left.addEventListener("pointerdown", start("x", left)); +} From 52d277180136b89542959e5f7d8bec258a9cefde Mon Sep 17 00:00:00 2001 From: Peter Hewat Date: Thu, 1 Oct 2026 18:58:01 +0200 Subject: [PATCH 3/6] Load SVG source editor on demand and enforce bundle budgets --- apps/svg/src/lib/svg-source-editor.ts | 342 +++++++++++++++++ apps/svg/src/lib/svg-source.ts | 385 +++----------------- docs/adr/003-codemirror-for-code-editing.md | 9 +- packages/catalog/src/index.ts | 4 + scripts/build-site.ts | 6 + scripts/bundle-budget.test.ts | 30 ++ scripts/bundle-budget.ts | 38 ++ 7 files changed, 476 insertions(+), 338 deletions(-) create mode 100644 apps/svg/src/lib/svg-source-editor.ts create mode 100644 scripts/bundle-budget.test.ts create mode 100644 scripts/bundle-budget.ts diff --git a/apps/svg/src/lib/svg-source-editor.ts b/apps/svg/src/lib/svg-source-editor.ts new file mode 100644 index 0000000..3ac5b12 --- /dev/null +++ b/apps/svg/src/lib/svg-source-editor.ts @@ -0,0 +1,342 @@ +import { getState, setState, findElement, selectOnly } from "./state.js"; +import { undoStepper } from "./undo.js"; +import { elementToSvgMarkup, buildDefsMarkup, formatExportSvg } from "./svg-export.js"; +import { sanitizeName, elementIdFromSvgId, groupIdFromSvgId } from "./svg-names.js"; +import { importSvgFile } from "./svg-import.js"; +import { groupsOf, selectedGroups } from "./groups.js"; +import { type EditorState, type SceneElement } from "./types.js"; +import { byId } from "@tools/ui"; +import { createEditor, type Highlight } from "@tools/editor"; + +/* ---------- SVG source: editable, highlighted, synced with the selection ---------- */ +const svgError = byId("svg-error"); +const primitiveListEl = byId("primitive-list"); +let editStep = undoStepper(); +let svgApplyTimer: ReturnType | null = null; +let lastSelectionKey = ""; +/** What the highlights were last built from; see `refreshSvgHighlight`. */ +let highlightKey = ""; + +// Primitive-row field that has keyboard focus -> the SVG attribute(s) it edits. +const FIELD_ATTRS: Record = { + stroke: ["stroke", "stroke-opacity"], + strokeWidth: ["stroke-width", "stroke"], + linecap: ["stroke-linecap"], + linejoin: ["stroke-linejoin"], + dash: ["stroke-dasharray"], + fill: ["fill", "fill-opacity"], + fillEnabled: ["fill", "fill-opacity"], + fillType: ["fill"], + strokeType: ["stroke"], + fillRule: ["fill-rule"], + fillStopOffset: [], + strokeStopOffset: [], + rx: ["rx", "ry"], + ry: ["rx", "ry"], + fontSize: ["font-size"], + fontFamily: ["font-family"], + anchor: ["text-anchor"], + rotation: ["transform"], + markerStart: ["marker-start"], + markerEnd: ["marker-end"], + name: ["id"], + closed: ["d", "points"], +}; +// Fields whose values live in a block: id prefix (and suffix) of that block. +const FIELD_BLOCKS: Record = { + fillType: ["grad-"], + fill: ["grad-"], + fillStopOffset: ["grad-"], + strokeType: ["grad-", "-stroke"], + stroke: ["grad-", "-stroke"], + strokeStopOffset: ["grad-", "-stroke"], + markerStart: ["mk-", "-start"], + markerEnd: ["mk-", "-end"], +}; +let svgFocus: { id: string; field: string } | null = null; + +const editor = createEditor(byId("svg-editor"), { + language: "xml", + colours: "syntax", + lineWrapping: true, + label: "SVG source (editable)", + onChange(user) { + if (!user) return; + refreshSvgHighlight(getState().selection.elementIds); + if (svgApplyTimer) clearTimeout(svgApplyTimer); + svgApplyTimer = setTimeout(applySvgText, 500); + }, + onSelection: selectFromCaret, + onFocus(focused) { + if (focused) { + editStep = undoStepper(); + } else if (svgApplyTimer) { + clearTimeout(svgApplyTimer); + applySvgText(); + } + }, +}); + +/** Stretches of a line (starting at offset `at`) holding what a primitive field edits. */ +function attrRanges(line: string, at: number, field: string): Highlight[] { + const out: Highlight[] = []; + const add = (index: number, length: number) => + out.push({ from: at + index, to: at + index + length, class: "svg-attr-focus" }); + for (const attr of FIELD_ATTRS[field] ?? []) { + for (const m of line.matchAll(new RegExp(`(?<=\\s)${attr}="[^"]*"`, "g"))) { + add(m.index, m[0].length); + } + } + if (field === "text") { + const m = /(?<=>)[^<]*(?=<\/text)/.exec(line); + if (m && m[0]) add(m.index, m[0].length); + } + return out; +} + +/** Where the selected shapes, and the focused attribute, first show: scrolled to. */ +let firstSelected = -1; +let firstFocused = -1; + +/** Highlights the selected shapes' lines and the attribute the focused primitive field edits. */ +function refreshSvgHighlight(selectedIds: readonly string[]): void { + // Rebuilt only when what it shows changed: on a drag, the text changes every frame, but on a + // pointer move or a click elsewhere it rarely does. + const text = editor.text; + const key = `${text}\0${selectedIds.join(",")}\0${svgFocus?.id}:${svgFocus?.field}`; + if (key === highlightKey) return; + highlightKey = key; + const sel = new Set(selectedIds); + // A group counts as selected when all of it is; its and its are highlighted then. + const groups = selectedGroups(getState().elements, sel); + // One entry per still open, saying whether its should be highlighted too. + const open: boolean[] = []; + const focus = svgFocus; + const block = focus ? FIELD_BLOCKS[focus.field] : undefined; + let inBlock = false; + const highlights: Highlight[] = []; + firstSelected = firstFocused = -1; + let at = 0; + for (const line of text.split("\n")) { + const m = line.match(/\bid="([^"]+)"/); + const svgId = m ? m[1]! : ""; + const elId = m ? elementIdFromSvgId(svgId) : null; + if (focus && elId === focus.id) highlights.push(...attrRanges(line, at, focus.field)); + let selected = !!elId && sel.has(elId); + if (/^\s*]/.test(line) && !/\/>\s*$/.test(line)) { + const gid = groupIdFromSvgId(svgId); + selected = !!gid && groups.has(gid); + open.push(selected); + } else if (/^\s*<\/g>/.test(line)) { + selected = open.pop() ?? false; + } + if (selected) { + highlights.push({ from: at, to: at, class: "svg-line--selected", line: true }); + if (firstSelected < 0) firstSelected = at; + } + if (block && focus) { + if (svgId === block[0] + focus.id + (block[1] ?? "")) { + inBlock = true; + } + if (inBlock && line) { + highlights.push({ from: at, to: at + line.length, class: "svg-attr-focus" }); + } + if (inBlock && /<\/(linearGradient|radialGradient|marker)>/.test(line)) inBlock = false; + } + at += line.length + 1; + } + for (const h of highlights) { + if (h.class === "svg-attr-focus" && (firstFocused < 0 || h.from < firstFocused)) { + firstFocused = h.from; + } + } + editor.setHighlights(highlights); +} + +export function setSvgFocus(next: { id: string; field: string } | null): void { + const same = svgFocus?.id === next?.id && svgFocus?.field === next?.field; + svgFocus = next; + if (same) return; + refreshSvgHighlight(getState().selection.elementIds); + if (next && !editor.focused && firstFocused >= 0) editor.scrollTo(firstFocused, "top"); +} + +function fieldOf(target: EventTarget | null): { id: string; field: string } | null { + const el = target as HTMLElement | null; + const li = el?.closest("[data-element-id]"); + if (!li || !primitiveListEl.contains(li)) return null; + const field = el?.closest("[data-picker]")?.dataset.picker ?? el?.dataset.field; + const id = li.dataset.elementId; + return field && id ? { id, field } : null; +} + +primitiveListEl.addEventListener("focusin", (e) => setSvgFocus(fieldOf(e.target))); +let pickerHold = false; // the color popover steals focus but its swatch stays "in use" + +/** Called while the colour popover is open, which takes focus without the swatch leaving use. */ +export function holdSvgFocus(hold: boolean): void { + pickerHold = hold; +} +primitiveListEl.addEventListener("focusout", () => { + if (!pickerHold) setSvgFocus(null); +}); + +/** + * How often, at most, the panel follows a stream of changes. Keeping it current means exporting + * the whole document, which on a large one is most of the cost of each frame of a drag; a few + * updates a second read as live, and the last change always lands. + */ +const SYNC_INTERVAL_MS = 120; +let lastSyncAt = -Infinity; +let syncTimer: ReturnType | null = null; +/** Changes were skipped while the panel was not on screen. */ +let stale = false; + +function onScreen(): boolean { + const dom = editor.dom; + return typeof dom.checkVisibility === "function" ? dom.checkVisibility() : true; +} + +// Opening the section or the panel lays the field out again, and that is when it catches up. +new ResizeObserver(() => { + if (stale && onScreen()) syncSvgEditorNow(getState()); +}).observe(editor.dom); + +/** Keeps the panel in step with the document: at once when it can, soon after when it cannot. */ +export function syncSvgEditor(state: EditorState): void { + if (!onScreen()) { + stale = true; + return; + } + const wait = lastSyncAt + SYNC_INTERVAL_MS - performance.now(); + if (wait <= 0) { + syncSvgEditorNow(state); + return; + } + syncTimer ??= setTimeout(() => { + syncTimer = null; + syncSvgEditorNow(getState()); + }, wait); +} + +function syncSvgEditorNow(state: EditorState): void { + if (syncTimer) { + clearTimeout(syncTimer); + syncTimer = null; + } + lastSyncAt = performance.now(); + stale = false; + const focused = editor.focused; + if (!focused) { + const text = formatExportSvg(state, true); + // Only what changed is replaced, so the scroll stays put. The text follows the drawing, so + // it is not a step of the editor's undo: the app's own undo covers the drawing. + if (editor.text !== text) { + editor.setText(text, "sync"); + hideSvgError(); + } + } + refreshSvgHighlight(state.selection.elementIds); + const key = state.selection.elementIds.join(","); + if (!focused && key !== lastSelectionKey && firstSelected >= 0) { + editor.scrollTo(firstSelected, "top"); + } + lastSelectionKey = key; +} + +function showSvgError(message: string): void { + svgError.textContent = `Invalid SVG — not applied: ${message}`; + svgError.classList.remove("hidden"); + editor.dom.classList.add("invalid"); +} + +function hideSvgError(): void { + svgError.classList.add("hidden"); + editor.dom.classList.remove("invalid"); +} + +function sameElement(a: SceneElement, b: SceneElement): boolean { + return ( + a.type === b.type && + elementToSvgMarkup(a) === elementToSvgMarkup(b) && + buildDefsMarkup([a]) === buildDefsMarkup([b]) && + sanitizeName(a.name) === sanitizeName(b.name) && + groupsOf(a).join("/") === groupsOf(b).join("/") + ); +} + +/** + * Group names from the markup. A name typed with spaces comes back underscored from its id, so + * where the markup still says the same thing the name as typed is kept, as shapes' names are. + */ +function mergeGroupNames( + parsed: Record, + current: Readonly> +): Record { + const out: Record = {}; + for (const [gid, name] of Object.entries(parsed)) { + const typed = current[gid]; + out[gid] = typed && sanitizeName(typed) === sanitizeName(name) ? typed : name; + } + return out; +} + +const sameNames = (a: Record, b: Record) => + Object.keys(a).length === Object.keys(b).length && Object.keys(a).every((k) => a[k] === b[k]); + +/** Applies the edited markup. Untouched shapes keep their exact float geometry. */ +function applySvgText(): void { + svgApplyTimer = null; + let parsed; + try { + parsed = importSvgFile(editor.text, { keepIds: true }); + } catch (err) { + showSvgError(err instanceof Error ? err.message : String(err)); + return; + } + hideSvgError(); + const st = getState(); + const old = new Map(st.elements.map((e) => [e.id, e])); + const next = parsed.elements.map((n) => { + const o = old.get(n.id); + if (o && sameElement(o, n)) return o; + // The markup does not carry a lock: an edited shape keeps the one it had. + return o?.locked ? { ...n, locked: true } : n; + }); + const artboard = parsed.artboard ? { ...st.artboard, ...parsed.artboard } : st.artboard; + // No background rect in the markup means a transparent document; the colour is kept so that + // deleting the rect and typing it back does not lose what was chosen. + const background = parsed.background ?? { color: st.background.color, opacity: 0 }; + const groupNames = mergeGroupNames(parsed.groupNames, st.groupNames); + const same = + sameNames(groupNames, st.groupNames) && + next.length === st.elements.length && + next.every((e, i) => e === st.elements[i]) && + artboard.width === st.artboard.width && + artboard.height === st.artboard.height && + background.color === st.background.color && + background.opacity === st.background.opacity; + if (same) return; + editStep(); + const ids = new Set(next.map((e) => e.id)); + setState((s) => ({ + ...s, + elements: next, + groupNames, + artboard, + background, + selection: selectOnly(s.selection.elementIds.filter((id) => ids.has(id))), + })); +} + +/** The caret inside a shape's line selects that shape (like picking it in Primitives). */ +function selectFromCaret(): void { + if (!editor.focused) return; + const line = editor.line(editor.position(editor.selection.head).line); + const m = line.match(/\bid="([^"]+)"/); + const elId = m ? elementIdFromSvgId(m[1]) : null; + if (!elId || !findElement(elId)) return; + const cur = getState().selection.elementIds; + if (cur.length === 1 && cur[0] === elId) return; + setState({ tool: "select", selection: selectOnly([elId]) }); +} diff --git a/apps/svg/src/lib/svg-source.ts b/apps/svg/src/lib/svg-source.ts index 3ac5b12..31152b2 100644 --- a/apps/svg/src/lib/svg-source.ts +++ b/apps/svg/src/lib/svg-source.ts @@ -1,342 +1,59 @@ -import { getState, setState, findElement, selectOnly } from "./state.js"; -import { undoStepper } from "./undo.js"; -import { elementToSvgMarkup, buildDefsMarkup, formatExportSvg } from "./svg-export.js"; -import { sanitizeName, elementIdFromSvgId, groupIdFromSvgId } from "./svg-names.js"; -import { importSvgFile } from "./svg-import.js"; -import { groupsOf, selectedGroups } from "./groups.js"; -import { type EditorState, type SceneElement } from "./types.js"; import { byId } from "@tools/ui"; -import { createEditor, type Highlight } from "@tools/editor"; +import { getState } from "./state.js"; +import type { EditorState } from "./types.js"; + +/** The canvas needs no code editor until its source panel is shown. */ +type SourceEditor = typeof import("./svg-source-editor.js"); +let loaded: SourceEditor | null = null; +let loading: Promise | null = null; +let focus: { id: string; field: string } | null = null; +let held = false; +const host = byId("svg-editor"); + +function visible(): boolean { + return host.checkVisibility(); +} + +function load(): void { + if (loaded || loading || !visible()) return; + host.setAttribute("aria-busy", "true"); + loading = import("./svg-source-editor.js") + .then((source) => { + loaded = source; + source.syncSvgEditor(getState()); + source.holdSvgFocus(held); + source.setSvgFocus(focus); + }) + .catch(() => { + const retry = document.createElement("button"); + retry.type = "button"; + retry.className = "ui-btn"; + retry.textContent = "Could not load SVG source. Retry"; + retry.addEventListener("click", () => { + retry.remove(); + load(); + }); + host.replaceChildren(retry); + }) + .finally(() => { + loading = null; + host.removeAttribute("aria-busy"); + }); +} + +new ResizeObserver(load).observe(host); -/* ---------- SVG source: editable, highlighted, synced with the selection ---------- */ -const svgError = byId("svg-error"); -const primitiveListEl = byId("primitive-list"); -let editStep = undoStepper(); -let svgApplyTimer: ReturnType | null = null; -let lastSelectionKey = ""; -/** What the highlights were last built from; see `refreshSvgHighlight`. */ -let highlightKey = ""; - -// Primitive-row field that has keyboard focus -> the SVG attribute(s) it edits. -const FIELD_ATTRS: Record = { - stroke: ["stroke", "stroke-opacity"], - strokeWidth: ["stroke-width", "stroke"], - linecap: ["stroke-linecap"], - linejoin: ["stroke-linejoin"], - dash: ["stroke-dasharray"], - fill: ["fill", "fill-opacity"], - fillEnabled: ["fill", "fill-opacity"], - fillType: ["fill"], - strokeType: ["stroke"], - fillRule: ["fill-rule"], - fillStopOffset: [], - strokeStopOffset: [], - rx: ["rx", "ry"], - ry: ["rx", "ry"], - fontSize: ["font-size"], - fontFamily: ["font-family"], - anchor: ["text-anchor"], - rotation: ["transform"], - markerStart: ["marker-start"], - markerEnd: ["marker-end"], - name: ["id"], - closed: ["d", "points"], -}; -// Fields whose values live in a block: id prefix (and suffix) of that block. -const FIELD_BLOCKS: Record = { - fillType: ["grad-"], - fill: ["grad-"], - fillStopOffset: ["grad-"], - strokeType: ["grad-", "-stroke"], - stroke: ["grad-", "-stroke"], - strokeStopOffset: ["grad-", "-stroke"], - markerStart: ["mk-", "-start"], - markerEnd: ["mk-", "-end"], -}; -let svgFocus: { id: string; field: string } | null = null; - -const editor = createEditor(byId("svg-editor"), { - language: "xml", - colours: "syntax", - lineWrapping: true, - label: "SVG source (editable)", - onChange(user) { - if (!user) return; - refreshSvgHighlight(getState().selection.elementIds); - if (svgApplyTimer) clearTimeout(svgApplyTimer); - svgApplyTimer = setTimeout(applySvgText, 500); - }, - onSelection: selectFromCaret, - onFocus(focused) { - if (focused) { - editStep = undoStepper(); - } else if (svgApplyTimer) { - clearTimeout(svgApplyTimer); - applySvgText(); - } - }, -}); - -/** Stretches of a line (starting at offset `at`) holding what a primitive field edits. */ -function attrRanges(line: string, at: number, field: string): Highlight[] { - const out: Highlight[] = []; - const add = (index: number, length: number) => - out.push({ from: at + index, to: at + index + length, class: "svg-attr-focus" }); - for (const attr of FIELD_ATTRS[field] ?? []) { - for (const m of line.matchAll(new RegExp(`(?<=\\s)${attr}="[^"]*"`, "g"))) { - add(m.index, m[0].length); - } - } - if (field === "text") { - const m = /(?<=>)[^<]*(?=<\/text)/.exec(line); - if (m && m[0]) add(m.index, m[0].length); - } - return out; -} - -/** Where the selected shapes, and the focused attribute, first show: scrolled to. */ -let firstSelected = -1; -let firstFocused = -1; - -/** Highlights the selected shapes' lines and the attribute the focused primitive field edits. */ -function refreshSvgHighlight(selectedIds: readonly string[]): void { - // Rebuilt only when what it shows changed: on a drag, the text changes every frame, but on a - // pointer move or a click elsewhere it rarely does. - const text = editor.text; - const key = `${text}\0${selectedIds.join(",")}\0${svgFocus?.id}:${svgFocus?.field}`; - if (key === highlightKey) return; - highlightKey = key; - const sel = new Set(selectedIds); - // A group counts as selected when all of it is; its and its are highlighted then. - const groups = selectedGroups(getState().elements, sel); - // One entry per still open, saying whether its should be highlighted too. - const open: boolean[] = []; - const focus = svgFocus; - const block = focus ? FIELD_BLOCKS[focus.field] : undefined; - let inBlock = false; - const highlights: Highlight[] = []; - firstSelected = firstFocused = -1; - let at = 0; - for (const line of text.split("\n")) { - const m = line.match(/\bid="([^"]+)"/); - const svgId = m ? m[1]! : ""; - const elId = m ? elementIdFromSvgId(svgId) : null; - if (focus && elId === focus.id) highlights.push(...attrRanges(line, at, focus.field)); - let selected = !!elId && sel.has(elId); - if (/^\s*]/.test(line) && !/\/>\s*$/.test(line)) { - const gid = groupIdFromSvgId(svgId); - selected = !!gid && groups.has(gid); - open.push(selected); - } else if (/^\s*<\/g>/.test(line)) { - selected = open.pop() ?? false; - } - if (selected) { - highlights.push({ from: at, to: at, class: "svg-line--selected", line: true }); - if (firstSelected < 0) firstSelected = at; - } - if (block && focus) { - if (svgId === block[0] + focus.id + (block[1] ?? "")) { - inBlock = true; - } - if (inBlock && line) { - highlights.push({ from: at, to: at + line.length, class: "svg-attr-focus" }); - } - if (inBlock && /<\/(linearGradient|radialGradient|marker)>/.test(line)) inBlock = false; - } - at += line.length + 1; - } - for (const h of highlights) { - if (h.class === "svg-attr-focus" && (firstFocused < 0 || h.from < firstFocused)) { - firstFocused = h.from; - } - } - editor.setHighlights(highlights); -} - -export function setSvgFocus(next: { id: string; field: string } | null): void { - const same = svgFocus?.id === next?.id && svgFocus?.field === next?.field; - svgFocus = next; - if (same) return; - refreshSvgHighlight(getState().selection.elementIds); - if (next && !editor.focused && firstFocused >= 0) editor.scrollTo(firstFocused, "top"); -} - -function fieldOf(target: EventTarget | null): { id: string; field: string } | null { - const el = target as HTMLElement | null; - const li = el?.closest("[data-element-id]"); - if (!li || !primitiveListEl.contains(li)) return null; - const field = el?.closest("[data-picker]")?.dataset.picker ?? el?.dataset.field; - const id = li.dataset.elementId; - return field && id ? { id, field } : null; -} - -primitiveListEl.addEventListener("focusin", (e) => setSvgFocus(fieldOf(e.target))); -let pickerHold = false; // the color popover steals focus but its swatch stays "in use" - -/** Called while the colour popover is open, which takes focus without the swatch leaving use. */ -export function holdSvgFocus(hold: boolean): void { - pickerHold = hold; -} -primitiveListEl.addEventListener("focusout", () => { - if (!pickerHold) setSvgFocus(null); -}); - -/** - * How often, at most, the panel follows a stream of changes. Keeping it current means exporting - * the whole document, which on a large one is most of the cost of each frame of a drag; a few - * updates a second read as live, and the last change always lands. - */ -const SYNC_INTERVAL_MS = 120; -let lastSyncAt = -Infinity; -let syncTimer: ReturnType | null = null; -/** Changes were skipped while the panel was not on screen. */ -let stale = false; - -function onScreen(): boolean { - const dom = editor.dom; - return typeof dom.checkVisibility === "function" ? dom.checkVisibility() : true; -} - -// Opening the section or the panel lays the field out again, and that is when it catches up. -new ResizeObserver(() => { - if (stale && onScreen()) syncSvgEditorNow(getState()); -}).observe(editor.dom); - -/** Keeps the panel in step with the document: at once when it can, soon after when it cannot. */ export function syncSvgEditor(state: EditorState): void { - if (!onScreen()) { - stale = true; - return; - } - const wait = lastSyncAt + SYNC_INTERVAL_MS - performance.now(); - if (wait <= 0) { - syncSvgEditorNow(state); - return; - } - syncTimer ??= setTimeout(() => { - syncTimer = null; - syncSvgEditorNow(getState()); - }, wait); -} - -function syncSvgEditorNow(state: EditorState): void { - if (syncTimer) { - clearTimeout(syncTimer); - syncTimer = null; - } - lastSyncAt = performance.now(); - stale = false; - const focused = editor.focused; - if (!focused) { - const text = formatExportSvg(state, true); - // Only what changed is replaced, so the scroll stays put. The text follows the drawing, so - // it is not a step of the editor's undo: the app's own undo covers the drawing. - if (editor.text !== text) { - editor.setText(text, "sync"); - hideSvgError(); - } - } - refreshSvgHighlight(state.selection.elementIds); - const key = state.selection.elementIds.join(","); - if (!focused && key !== lastSelectionKey && firstSelected >= 0) { - editor.scrollTo(firstSelected, "top"); - } - lastSelectionKey = key; -} - -function showSvgError(message: string): void { - svgError.textContent = `Invalid SVG — not applied: ${message}`; - svgError.classList.remove("hidden"); - editor.dom.classList.add("invalid"); + if (loaded) loaded.syncSvgEditor(state); + else load(); } -function hideSvgError(): void { - svgError.classList.add("hidden"); - editor.dom.classList.remove("invalid"); -} - -function sameElement(a: SceneElement, b: SceneElement): boolean { - return ( - a.type === b.type && - elementToSvgMarkup(a) === elementToSvgMarkup(b) && - buildDefsMarkup([a]) === buildDefsMarkup([b]) && - sanitizeName(a.name) === sanitizeName(b.name) && - groupsOf(a).join("/") === groupsOf(b).join("/") - ); -} - -/** - * Group names from the markup. A name typed with spaces comes back underscored from its id, so - * where the markup still says the same thing the name as typed is kept, as shapes' names are. - */ -function mergeGroupNames( - parsed: Record, - current: Readonly> -): Record { - const out: Record = {}; - for (const [gid, name] of Object.entries(parsed)) { - const typed = current[gid]; - out[gid] = typed && sanitizeName(typed) === sanitizeName(name) ? typed : name; - } - return out; -} - -const sameNames = (a: Record, b: Record) => - Object.keys(a).length === Object.keys(b).length && Object.keys(a).every((k) => a[k] === b[k]); - -/** Applies the edited markup. Untouched shapes keep their exact float geometry. */ -function applySvgText(): void { - svgApplyTimer = null; - let parsed; - try { - parsed = importSvgFile(editor.text, { keepIds: true }); - } catch (err) { - showSvgError(err instanceof Error ? err.message : String(err)); - return; - } - hideSvgError(); - const st = getState(); - const old = new Map(st.elements.map((e) => [e.id, e])); - const next = parsed.elements.map((n) => { - const o = old.get(n.id); - if (o && sameElement(o, n)) return o; - // The markup does not carry a lock: an edited shape keeps the one it had. - return o?.locked ? { ...n, locked: true } : n; - }); - const artboard = parsed.artboard ? { ...st.artboard, ...parsed.artboard } : st.artboard; - // No background rect in the markup means a transparent document; the colour is kept so that - // deleting the rect and typing it back does not lose what was chosen. - const background = parsed.background ?? { color: st.background.color, opacity: 0 }; - const groupNames = mergeGroupNames(parsed.groupNames, st.groupNames); - const same = - sameNames(groupNames, st.groupNames) && - next.length === st.elements.length && - next.every((e, i) => e === st.elements[i]) && - artboard.width === st.artboard.width && - artboard.height === st.artboard.height && - background.color === st.background.color && - background.opacity === st.background.opacity; - if (same) return; - editStep(); - const ids = new Set(next.map((e) => e.id)); - setState((s) => ({ - ...s, - elements: next, - groupNames, - artboard, - background, - selection: selectOnly(s.selection.elementIds.filter((id) => ids.has(id))), - })); +export function setSvgFocus(next: { id: string; field: string } | null): void { + focus = next; + loaded?.setSvgFocus(next); } -/** The caret inside a shape's line selects that shape (like picking it in Primitives). */ -function selectFromCaret(): void { - if (!editor.focused) return; - const line = editor.line(editor.position(editor.selection.head).line); - const m = line.match(/\bid="([^"]+)"/); - const elId = m ? elementIdFromSvgId(m[1]) : null; - if (!elId || !findElement(elId)) return; - const cur = getState().selection.elementIds; - if (cur.length === 1 && cur[0] === elId) return; - setState({ tool: "select", selection: selectOnly([elId]) }); +export function holdSvgFocus(next: boolean): void { + held = next; + loaded?.holdSvgFocus(next); } diff --git a/docs/adr/003-codemirror-for-code-editing.md b/docs/adr/003-codemirror-for-code-editing.md index 5033506..4c87428 100644 --- a/docs/adr/003-codemirror-for-code-editing.md +++ b/docs/adr/003-codemirror-for-code-editing.md @@ -36,10 +36,11 @@ Code editing uses **CodeMirror 6**, bundled into each app's static files at pinn - The first runtime dependency in the repo. ADR 001's goal still holds: the output is static files that run from any server and need nothing at runtime that is not in them. - Each app that edits code grows by about 120 KB gzipped (CodeMirror's view and state alone are - about 65 KB): the JSON app is 141 KB with it, 20 KB of that its own code; the SVG app 189 KB, - 65 KB its own. Apps are - built one by one, so each carries its own copy, and the site's service worker precaches them - all. That is paid once per deploy, for an editor that would otherwise be months of work. + about 65 KB). JSON loads its editor immediately, about 140 KB of JavaScript in total. SVG loads + about 68 KB initially and a further 126 KB when its source panel is shown. Apps are built one + by one, so each carries its own copy, and the site's service worker precaches every chunk, + including lazy ones. That is paid once per deploy. Catalog budgets bound both initial and + total JavaScript cost; splitting a bundle does not remove its offline download cost. - Upgrades are deliberate: versions are pinned, and `bun run outdated` shows what moved. - An app with a plain text field (a token, a key) keeps an `` or `