diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de0ea3b..6fff534 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,3 +28,5 @@ jobs: - run: bun run format - run: bun run test - run: bun run build + - run: bunx playwright install --with-deps chromium firefox + - run: bun run test:browser diff --git a/.gitignore b/.gitignore index c3930f9..5e9a0c4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ node_modules/ dist/ build/ coverage/ +test-results/ +playwright-report/ *.tsbuildinfo .DS_Store diff --git a/.prettierignore b/.prettierignore index 242d0c9..2390496 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,8 @@ dist/ build/ coverage/ +test-results/ +playwright-report/ node_modules/ bun.lock apps/*/public/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e38f956..a30b8f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,13 +11,13 @@ bun install bun run verify ``` -`bun run verify` is lint, typecheck, format check, tests, and a full site build. CI runs those same steps. The [README](./README.md) covers `bun run dev` and the Windows note about `TOOLS_BASE`. +`bun run verify` is lint, typecheck, format check, tests, and a full site build. CI runs those steps followed by the browser suite. The [README](./README.md) covers `bun run dev` and the Windows note about `TOOLS_BASE`. ## Where to change things - A new tool is `bun run new-app "Display Name"`. That creates `apps/` and an unlisted catalog entry. [`packages/catalog`](./packages/catalog/src/index.ts) is the only list of apps — the index, the build, and each page's title read it. - Shared browser behaviour goes in `packages/ui`. Code only one app uses stays in that app until a second one needs it. The catalog holds data, not app code. -- In the SVG app, start with [`model.ts`](./apps/svg/src/lib/model.ts) and [`io.ts`](./apps/svg/src/lib/io.ts). Those are the document. [`interaction.ts`](./apps/svg/src/lib/interaction.ts) and [`render.ts`](./apps/svg/src/lib/render.ts) draw that document; change them when the gesture or the picture is what is wrong. +- In the SVG app, [`model.ts`](./apps/svg/src/lib/model.ts) owns construction, transforms and editing. [`element-geometry.ts`](./apps/svg/src/lib/element-geometry.ts) owns bounds and rendering geometry; [`element-style.ts`](./apps/svg/src/lib/element-style.ts) owns paint and style attributes. [`project-file.ts`](./apps/svg/src/lib/project-file.ts) validates saved documents. Canvas gestures live in [`interaction.ts`](./apps/svg/src/lib/interaction.ts), ruler gestures in [`ruler-guides.ts`](./apps/svg/src/lib/ruler-guides.ts). ## Tests @@ -25,9 +25,20 @@ Put a test next to the pure logic it covers, the way `model.test.ts` sits beside [`io.test.ts`](./apps/svg/src/lib/io.test.ts) checks that export, then import, then export is byte-for-byte the same. The live SVG panel re-imports its own output, so an unstable serializer makes shapes drift while someone types. Leave that test as it is. +After a build, run real-browser checks with: + +```bash +bunx playwright install chromium firefox +bun run test:browser +``` + +The suite uses isolated profiles and a temporary production-file server on port 5171, shut down by Playwright. It does not use or stop the development server on 5170. Chromium and Firefox cover editing, drawing, source-panel loading, keyboard menus, responsive headers, and automated WCAG A/AA checks in both themes. Chromium also covers native touch drawing/pinch and offline service-worker replacement. Automated accessibility checks complement manual keyboard and screen-reader testing; they do not certify accessibility. + +Builds enforce gzip JavaScript budgets from the catalog's `jsBudget`: the initial entry and static module preloads, plus all chunks including lazy ones. The SVG source editor loads when visible; all chunks are still precached for offline use. Change a budget only after measuring and explaining the additional cost. + ## Saved documents -The SVG app is released. A drawing lives in the browser's IndexedDB (`ProjectFile`, currently version 1) and in exported SVG. A change to either keeps documents people already have opening: bump `PROJECT_VERSION` in `types.ts` and add the step up from the previous version in `readProject` (`io.ts`). Exported SVG carries no version marker, so an importer change must still read files written before it. +The SVG app is released. A drawing lives in the browser's IndexedDB (`ProjectFile`, currently version 1) and in exported SVG. A change to either keeps documents people already have opening: bump `PROJECT_VERSION` in `types.ts` and add the step up from the previous version in `readProject` (`project-file.ts`). Exported SVG carries no version marker, so an importer change must still read files written before it. An app that is not released yet has no saved documents to carry forward. When its stored format changes, fail and tell the person to clear that app's storage. diff --git a/README.md b/README.md index 244d672..9554848 100644 --- a/README.md +++ b/README.md @@ -63,14 +63,15 @@ editing code, reached only through `packages/editor` ## Commands -| Command | What it does | -| -------------------- | ------------------------------------ | -| `bun run dev [slug]` | Serve the whole site with hot reload | -| `bun run check` | Lint, typecheck and format check | -| `bun run test` | Run the test suites | -| `bun run build` | Build the whole site into `dist/` | -| `bun run verify` | `check` + `test` + `build` | -| `bun run new-app` | Scaffold a new app | +| Command | What it does | +| ---------------------- | -------------------------------------------------- | +| `bun run dev [slug]` | Serve the whole site with hot reload | +| `bun run check` | Lint, typecheck and format check | +| `bun run test` | Run the test suites | +| `bun run test:browser` | Check the production build in Chromium and Firefox | +| `bun run build` | Build the whole site into `dist/` | +| `bun run verify` | `check` + `test` + `build` | +| `bun run new-app` | Scaffold a new app | ## Contributing 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..312a931 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. */ @@ -582,6 +562,7 @@ function showLayout(text: string): void { } function rewrite(minify: boolean): void { + if (stale) validate(); if (!currentAnalysis.doc) return; replaceText( printJson(currentAnalysis.doc.root, { indent: minify ? 0 : indent(), sortKeys: isOn(sortKeys) }) @@ -590,6 +571,7 @@ function rewrite(minify: boolean): void { /** Makes almost-JSON strict in place, keeping the layout: only the offending bits change. */ function fix(): void { + if (stale) validate(); if (currentAnalysis.fault?.edits) replaceText(applyEdits(documentText(), currentAnalysis.fault.edits)); } @@ -647,6 +629,7 @@ function fitPath(): void { // ---------- Views ---------- function setMode(next: ViewMode): void { + if (stale) validate(); mode = next; codeEl.classList.toggle("hidden", mode !== "json"); for (const b of viewButtons) setPressed(b, b.dataset.view === mode); @@ -807,37 +790,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 +827,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/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)) + ); +} 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)); +} 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..d79c6b2 100644 --- a/apps/svg/src/lib/svg-source.ts +++ b/apps/svg/src/lib/svg-source.ts @@ -1,342 +1,63 @@ -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; +/** A failed load waits for the Retry button instead of trying again on every change. */ +let failed = false; +const host = byId("svg-editor"); + +function visible(): boolean { + return host.checkVisibility(); +} + +function load(): void { + if (loaded || loading || failed || !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(() => { + failed = true; + 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(); + failed = false; + 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/bun.lock b/bun.lock index 78f4e5c..5fde006 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,9 @@ "": { "name": "tools", "devDependencies": { + "@axe-core/playwright": "4.13.0", "@eslint/js": "^10.0.1", + "@playwright/test": "1.63.0", "@tools/ui": "workspace:*", "@types/bun": "^1.4.2", "eslint": "^10.11.0", @@ -97,6 +99,8 @@ }, }, "packages": { + "@axe-core/playwright": ["@axe-core/playwright@4.13.0", "", { "dependencies": { "axe-core": "~4.13.0" }, "peerDependencies": { "playwright-core": ">= 1.0.0" } }, "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg=="], + "@cacheable/memory": ["@cacheable/memory@2.2.0", "", { "dependencies": { "@cacheable/utils": "^2.5.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ=="], "@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="], @@ -163,6 +167,8 @@ "@oxc-project/types": ["@oxc-project/types@0.151.0", "", {}, "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA=="], + "@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.11", "", { "os": "android", "cpu": "arm" }, "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.11", "", { "os": "android", "cpu": "arm64" }, "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA=="], @@ -249,6 +255,8 @@ "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "axe-core": ["axe-core@4.13.0", "", {}, "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], @@ -389,6 +397,10 @@ "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], + + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], 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 `