Files
svg-tools/src/components/Workbench.tsx
T
zemion 0c7736dbc3
Verify / verify (push) Canceled after 0s
Release SVG Tools 0.2.0
2026-09-02 04:48:12 +02:00

1334 lines
42 KiB
TypeScript

import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type DragEvent,
} from "react";
import {
accessibilityFixPatch,
auditAccessibility,
} from "../accessibility/audit";
import type { AnimationDefinition } from "../animation/animation.types";
import { buildAnimationCss } from "../animation/validation";
import { CommandHistory, createTransaction } from "../commands/history";
import { geometryDiagnostics } from "../diagnostics/geometry";
import type {
SemanticSvgDocument,
SemanticSvgNode,
SourcePatch,
SvgDiagnostic,
} from "../document/document.types";
import { parseSvgSource } from "../document/source-parser";
import { applySourcePatches, patchAttribute } from "../document/source-patcher";
import {
composeTransformList,
matrixToTransform,
multiply,
parseTransformList,
type Matrix,
} from "../domain/affine";
import { bakeElementTransform } from "../domain/bake-transform";
import { parsePathData } from "../domain/path";
import { downloadBlob, exportFileName } from "../export/file-name";
import { createSvgExport, readSvgFile } from "../export/svg-export";
import { formatSvgSource } from "../format/formatter";
import {
createProject,
PROJECT_MIME,
readProject,
serializeProject,
} from "../project/project-format";
import {
createEditingProjection,
createSanitizedCandidate,
} from "../security/sanitize-svg";
import {
buildReferenceIndex,
previewIdRename,
} from "../structure/reference-index";
import { APPLICATION_VERSION } from "../version";
import { STARTER_SVG } from "../app/sample";
import { defaultSvgLimits, utf8ByteLength } from "../app/limits";
import { CanvasPane, type CanvasView } from "./CanvasPane";
import { Inspector, type InspectorTab } from "./Inspector";
import { SourceEditor } from "./SourceEditor";
import { StructureTree } from "./StructureTree";
import {
ChangePreviewDialog,
ExportDialog,
NewDocumentDialog,
OptimizeDialog,
} from "./WorkflowDialogs";
interface ValidSnapshot {
revision: number;
semantic: SemanticSvgDocument;
projection: string;
diagnostics: SvgDiagnostic[];
}
interface ParseState {
phase: "scheduled" | "parsing" | "ready" | "invalid";
revision: number;
diagnostics: SvgDiagnostic[];
}
interface ChangePreview {
title: string;
description: string;
before: string;
after: string;
label: string;
expectedRevision: number;
}
function snapshotFor(source: string, revision: number): ValidSnapshot {
const parsed = parseSvgSource(source, revision);
if (!parsed.valid || !parsed.semantic) {
throw new Error(
parsed.diagnostics[0]?.message ?? "The SVG source is invalid",
);
}
const projection = createEditingProjection(parsed.semantic);
const references = buildReferenceIndex(parsed.semantic);
const securityDiagnostics: SvgDiagnostic[] = projection.findings.map(
(finding) => ({
severity: finding.severity,
code: `security-${finding.code}`,
message: finding.description,
nodeKey: finding.nodeKey,
range: finding.sourceRange,
}),
);
return {
revision,
semantic: parsed.semantic,
projection: projection.source,
diagnostics: [
...parsed.diagnostics,
...geometryDiagnostics(parsed.semantic),
...references.diagnostics,
...securityDiagnostics,
],
};
}
function selectionState(key: string | null) {
return { nodeKeys: key ? [key] : [], primaryNodeKey: key };
}
function isEditingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return Boolean(
target.closest(
"input, textarea, select, [contenteditable='true'], .cm-editor",
),
);
}
function sourceAnimationCss(
definitions: readonly AnimationDefinition[],
semantic: SemanticSvgDocument,
assignedIds: ReadonlyMap<string, string>,
): string {
return buildAnimationCss(definitions, {
keyframeNamePrefix: "svg-tools",
selectorFor: (definition) => {
const node = semantic.nodes.get(definition.targetNodeKey);
if (!node) throw new Error("An animation target no longer exists");
const id = node.id ?? assignedIds.get(node.key);
if (!id) throw new Error("Animation target needs a stable ID");
return `#${CSS.escape(id)}`;
},
});
}
export function Workbench() {
const initial = useMemo(() => snapshotFor(STARTER_SVG, 0), []);
const [source, setSource] = useState(STARTER_SVG);
const [revision, setRevision] = useState(0);
const [snapshot, setSnapshot] = useState(initial);
const [parseState, setParseState] = useState<ParseState>({
phase: "ready",
revision: 0,
diagnostics: initial.diagnostics,
});
const [selectedKey, setSelectedKey] = useState<string | null>(
initial.semantic.rootKey,
);
const [expanded, setExpanded] = useState<Set<string>>(
() =>
new Set([
initial.semantic.rootKey,
...initial.semantic.nodes
.get(initial.semantic.rootKey)!
.childKeys.filter(
(key) => initial.semantic.nodes.get(key)?.localName === "defs",
),
]),
);
const [fileName, setFileName] = useState("untitled.svg");
const [dirty, setDirty] = useState(false);
const [activeTab, setActiveTab] = useState<InspectorTab>("document");
const [pathEditing, setPathEditing] = useState(false);
const [transformPreview, setTransformPreview] = useState<Matrix | null>(null);
const [animations, setAnimations] = useState<AnimationDefinition[]>([]);
const [animationPreview, setAnimationPreview] = useState(false);
const [showGrid, setShowGrid] = useState(true);
const [canvasView, setCanvasView] = useState<CanvasView>({
zoom: 1,
pan: { x: 0, y: 0 },
});
const [status, setStatus] = useState(
"Starter SVG ready. All processing stays in this browser.",
);
const [revealRange, setRevealRange] = useState<{
from: number;
to: number;
}>();
const [dialog, setDialog] = useState<
"new" | "optimize" | "export" | "change" | null
>(null);
const [changePreview, setChangePreview] = useState<ChangePreview | null>(
null,
);
const [historyAvailability, setHistoryAvailability] = useState({
canUndo: false,
canRedo: false,
});
const historyRef = useRef(new CommandHistory());
const sourceRef = useRef(source);
const revisionRef = useRef(revision);
const selectedRef = useRef(selectedKey);
const fileInputRef = useRef<HTMLInputElement>(null);
const projectCreatedAt = useRef(new Date().toISOString());
useEffect(() => {
sourceRef.current = source;
revisionRef.current = revision;
selectedRef.current = selectedKey;
}, [revision, selectedKey, source]);
const refreshHistory = useCallback(() => {
setHistoryAvailability({
canUndo: historyRef.current.canUndo,
canRedo: historyRef.current.canRedo,
});
}, []);
useEffect(() => {
const expectedRevision = revision;
const delay = source.length > 2_000_000 ? 450 : 90;
const timeout = globalThis.setTimeout(() => {
if (revisionRef.current !== expectedRevision) return;
setParseState((current) => ({
...current,
phase: "parsing",
revision: expectedRevision,
}));
const parsed = parseSvgSource(source, expectedRevision);
if (revisionRef.current !== expectedRevision) return;
if (!parsed.valid || !parsed.semantic) {
setParseState({
phase: "invalid",
revision: expectedRevision,
diagnostics: [...parsed.diagnostics],
});
setStatus(
`Source has ${parsed.diagnostics.filter((item) => item.severity === "error").length} error(s). Canvas and structure retain the last valid revision.`,
);
return;
}
try {
const next = snapshotFor(source, expectedRevision);
if (revisionRef.current !== expectedRevision) return;
setSnapshot(next);
setParseState({
phase: "ready",
revision: expectedRevision,
diagnostics: next.diagnostics,
});
setSelectedKey((key) =>
key && next.semantic.nodes.has(key) ? key : next.semantic.rootKey,
);
setStatus(
`Revision ${expectedRevision} parsed: ${next.semantic.metrics.elementCount.toLocaleString()} elements.`,
);
} catch (error) {
setParseState({
phase: "invalid",
revision: expectedRevision,
diagnostics: [
...parsed.diagnostics,
{
severity: "error",
code: "projection-error",
message:
error instanceof Error ? error.message : "Projection failed",
},
],
});
}
}, delay);
return () => globalThis.clearTimeout(timeout);
}, [revision, source]);
const visualDisabled =
parseState.phase !== "ready" || snapshot.revision !== revision;
const stale = snapshot.revision !== revision;
const accessibility = useMemo(
() => auditAccessibility(snapshot.semantic),
[snapshot.semantic],
);
const selectedNode = selectedKey
? snapshot.semantic.nodes.get(selectedKey)
: undefined;
const commitWholeSource = useCallback(
(
next: string,
label: string,
mergeKey?: string,
expectedRevision = revisionRef.current,
) => {
if (expectedRevision !== revisionRef.current) {
throw new Error("Change rejected because the source revision is stale");
}
const before = sourceRef.current;
if (next === before) return;
const nextRevision = revisionRef.current + 1;
const transaction = createTransaction({
label,
baseRevision: revisionRef.current,
sourceBefore: before,
sourceAfter: next,
patches: [{ from: 0, to: before.length, insert: next, label }],
selectionBefore: selectionState(selectedRef.current),
selectionAfter: selectionState(selectedRef.current),
...(mergeKey ? { mergeKey } : {}),
});
historyRef.current.commit(transaction);
sourceRef.current = next;
revisionRef.current = nextRevision;
setSource(next);
setRevision(nextRevision);
setParseState((current) => ({
...current,
phase: "scheduled",
revision: nextRevision,
}));
setDirty(true);
setTransformPreview(null);
setPathEditing(false);
refreshHistory();
},
[refreshHistory],
);
const commitPatches = useCallback(
(
patches: readonly SourcePatch[],
label: string,
mergeKey?: string,
expectedRevision = revisionRef.current,
) => {
if (
expectedRevision !== revisionRef.current ||
snapshot.revision !== expectedRevision
) {
throw new Error(
"Visual edit rejected because the source revision is stale",
);
}
const before = sourceRef.current;
const next = applySourcePatches(before, patches);
if (next === before) return;
const nextRevision = revisionRef.current + 1;
historyRef.current.commit(
createTransaction({
label,
baseRevision: revisionRef.current,
sourceBefore: before,
sourceAfter: next,
patches: [...patches],
affectedNodeKeys: selectedRef.current ? [selectedRef.current] : [],
selectionBefore: selectionState(selectedRef.current),
selectionAfter: selectionState(selectedRef.current),
...(mergeKey ? { mergeKey } : {}),
}),
);
sourceRef.current = next;
revisionRef.current = nextRevision;
setSource(next);
setRevision(nextRevision);
setParseState((current) => ({
...current,
phase: "scheduled",
revision: nextRevision,
}));
setDirty(true);
refreshHistory();
},
[refreshHistory, snapshot.revision],
);
const resetDocument = useCallback(
(
nextSource: string,
nextFileName: string,
projectState?: {
selectedNodeKey: string | null;
expandedNodeKeys: string[];
activePanel: string;
zoom: number;
pan: { x: number; y: number };
showGrid: boolean;
sourceSelection?: { anchor: number; head: number };
},
nextAnimations: AnimationDefinition[] = [],
) => {
const nextRevision = revisionRef.current + 1;
sourceRef.current = nextSource;
revisionRef.current = nextRevision;
setSource(nextSource);
setRevision(nextRevision);
setFileName(nextFileName);
setDirty(false);
historyRef.current.clear();
refreshHistory();
setAnimations(nextAnimations);
setAnimationPreview(false);
setPathEditing(false);
setTransformPreview(null);
setCanvasView(
projectState
? { zoom: projectState.zoom, pan: { ...projectState.pan } }
: { zoom: 1, pan: { x: 0, y: 0 } },
);
if (projectState) {
setSelectedKey(projectState.selectedNodeKey);
setExpanded(new Set(projectState.expandedNodeKeys));
const tab = projectState.activePanel as InspectorTab;
if (
[
"document",
"element",
"path",
"transform",
"animation",
"accessibility",
].includes(tab)
)
setActiveTab(tab);
setShowGrid(projectState.showGrid);
if (projectState.sourceSelection) {
setRevealRange({
from: projectState.sourceSelection.anchor,
to: projectState.sourceSelection.head,
});
}
}
setDialog(null);
setStatus(`${nextFileName} loaded locally.`);
},
[refreshHistory],
);
const undo = useCallback(() => {
try {
const transaction = historyRef.current.undo(sourceRef.current);
if (!transaction) return;
const nextRevision = revisionRef.current + 1;
sourceRef.current = transaction.sourceBefore;
revisionRef.current = nextRevision;
setSource(transaction.sourceBefore);
setRevision(nextRevision);
setSelectedKey(transaction.selectionBefore.primaryNodeKey);
refreshHistory();
setDirty(true);
setStatus(`Undid “${transaction.label}”.`);
} catch (error) {
setStatus(error instanceof Error ? error.message : "Undo failed");
}
}, [refreshHistory]);
const redo = useCallback(() => {
try {
const transaction = historyRef.current.redo(sourceRef.current);
if (!transaction) return;
const nextRevision = revisionRef.current + 1;
sourceRef.current = transaction.sourceAfter;
revisionRef.current = nextRevision;
setSource(transaction.sourceAfter);
setRevision(nextRevision);
setSelectedKey(transaction.selectionAfter.primaryNodeKey);
refreshHistory();
setDirty(true);
setStatus(`Redid “${transaction.label}”.`);
} catch (error) {
setStatus(error instanceof Error ? error.message : "Redo failed");
}
}, [refreshHistory]);
const downloadSvg = useCallback(() => {
try {
const artifact = createSvgExport(sourceRef.current, "svg", fileName);
downloadBlob(artifact.blob, artifact.fileName);
setStatus(`${artifact.fileName} downloaded locally.`);
setDirty(false);
} catch (error) {
setStatus(error instanceof Error ? error.message : "SVG export failed");
}
}, [fileName]);
useEffect(() => {
const listener = (event: KeyboardEvent) => {
if (!(event.ctrlKey || event.metaKey) || isEditingTarget(event.target))
return;
if (event.key.toLowerCase() === "o") {
event.preventDefault();
fileInputRef.current?.click();
} else if (event.key.toLowerCase() === "s") {
event.preventDefault();
downloadSvg();
} else if (event.key.toLowerCase() === "z") {
event.preventDefault();
if (event.shiftKey) redo();
else undo();
} else if (event.key.toLowerCase() === "y") {
event.preventDefault();
redo();
}
};
globalThis.addEventListener("keydown", listener);
return () => globalThis.removeEventListener("keydown", listener);
}, [downloadSvg, redo, undo]);
const openFile = useCallback(
async (file: File) => {
try {
if (/\.svgtools\.json$/iu.test(file.name)) {
const project = await readProject(file);
projectCreatedAt.current = project.metadata.createdAt;
resetDocument(
project.document.source,
project.metadata.originalFileName ?? "project.svg",
project.ui,
project.animations,
);
} else {
const document = await readSvgFile(file);
resetDocument(document.source, document.fileName);
}
} catch (error) {
setStatus(
error instanceof Error
? error.message
: "The selected file could not be opened",
);
}
},
[resetDocument],
);
const handleDrop = (event: DragEvent) => {
event.preventDefault();
const file = event.dataTransfer.files[0];
if (file) void openFile(file);
};
const setAttribute = (
name: string,
value: string | null,
mergeKey?: string,
) => {
if (visualDisabled) return;
const targetKey =
activeTab === "document" ? snapshot.semantic.rootKey : selectedKey;
const node = targetKey ? snapshot.semantic.nodes.get(targetKey) : undefined;
if (!node) return;
try {
if (name === "id" && node.id && value !== node.id) {
const references = buildReferenceIndex(snapshot.semantic);
if (value === null) {
const incoming = references.incomingById.get(node.id) ?? [];
if (incoming.length) {
throw new Error(
`ID “${node.id}” has ${incoming.length} incoming reference(s). Rename it atomically or remove those references first.`,
);
}
} else {
const rename = previewIdRename(snapshot.semantic, node.key, value);
const after = applySourcePatches(source, rename.patches);
setChangePreview({
title: `Rename #${rename.oldId} to #${rename.newId}`,
description: [
`${rename.affectedNodeKeys.length} element(s) are updated atomically, including local href, URL and ARIA references.`,
...rename.warnings,
].join(" "),
before: source,
after,
label: "Rename ID and references",
expectedRevision: snapshot.revision,
});
setDialog("change");
return;
}
}
const patch = patchAttribute(
source,
node,
name,
value,
snapshot.semantic.preferences,
);
if (patch)
commitPatches([patch], patch.label, mergeKey, snapshot.revision);
} catch (error) {
setStatus(
error instanceof Error ? error.message : "Attribute update failed",
);
}
};
const commitPath = (data: string, label = "Edit path", mergeKey?: string) => {
if (visualDisabled || selectedNode?.localName !== "path") return;
try {
parsePathData(data);
const patch = patchAttribute(
source,
selectedNode,
"d",
data,
snapshot.semantic.preferences,
);
if (patch) commitPatches([patch], label, mergeKey, snapshot.revision);
} catch (error) {
setStatus(error instanceof Error ? error.message : "Path update failed");
}
};
const selectedSubtree = (): SemanticSvgNode[] => {
if (!selectedNode) return [];
const result: SemanticSvgNode[] = [];
const visit = (node: SemanticSvgNode) => {
result.push(node);
for (const childKey of node.childKeys) {
const child = snapshot.semantic.nodes.get(childKey);
if (child) visit(child);
}
};
visit(selectedNode);
return result;
};
const duplicateSelectedNode = () => {
if (visualDisabled || !selectedNode?.parentKey) return;
try {
const subtree = selectedSubtree();
const ids = subtree.flatMap((node) => (node.id ? [node.id] : []));
if (ids.length)
throw new Error(
`Duplicate is refused because the subtree owns ${ids.length} ID(s). Rename or remove them first so references cannot become ambiguous.`,
);
const fragment = source.slice(
selectedNode.sourceRange.full.from,
selectedNode.sourceRange.full.to,
);
const lineStart = Math.max(
source.lastIndexOf("\n", selectedNode.sourceRange.full.from - 1) + 1,
source.lastIndexOf("\r", selectedNode.sourceRange.full.from - 1) + 1,
);
const indentation =
/^\s*/u.exec(
source.slice(lineStart, selectedNode.sourceRange.full.from),
)?.[0] ?? "";
const insert = `${snapshot.semantic.preferences.newline}${indentation}${fragment}`;
if (
utf8ByteLength(source) + utf8ByteLength(insert) >
defaultSvgLimits.sourceHardBytes
)
throw new RangeError(
"Duplicate would exceed the 20 MiB SVG source limit.",
);
commitPatches(
[
{
from: selectedNode.sourceRange.full.to,
to: selectedNode.sourceRange.full.to,
insert,
label: `Duplicate <${selectedNode.name}>`,
},
],
`Duplicate <${selectedNode.name}>`,
undefined,
snapshot.revision,
);
setStatus(
`Duplicated <${selectedNode.name}> without introducing editor metadata.`,
);
} catch (error) {
setStatus(error instanceof Error ? error.message : "Duplicate failed");
}
};
const deleteSelectedNode = () => {
if (visualDisabled || !selectedNode?.parentKey) return;
try {
const subtree = selectedSubtree();
const subtreeKeys = new Set(subtree.map((node) => node.key));
const references = buildReferenceIndex(snapshot.semantic);
for (const node of subtree) {
if (!node.id) continue;
const externalIncoming = (
references.incomingById.get(node.id) ?? []
).filter((edge) => !subtreeKeys.has(edge.sourceKey));
if (externalIncoming.length)
throw new Error(
`Delete is refused because #${node.id} has ${externalIncoming.length} reference(s) from outside the selected subtree.`,
);
}
const parentKey = selectedNode.parentKey;
commitPatches(
[
{
from: selectedNode.sourceRange.full.from,
to: selectedNode.sourceRange.full.to,
insert: "",
label: `Delete <${selectedNode.name}>`,
},
],
`Delete <${selectedNode.name}>`,
undefined,
snapshot.revision,
);
setAnimations((current) =>
current.filter(
(animation) => !subtreeKeys.has(animation.targetNodeKey),
),
);
setSelectedKey(parentKey);
selectedRef.current = parentKey;
setStatus(
`Deleted <${selectedNode.name}> and its ${subtree.length - 1} descendant(s).`,
);
} catch (error) {
setStatus(error instanceof Error ? error.message : "Delete failed");
}
};
const applyTransform = (matrix: Matrix, mode: "attribute" | "bake") => {
if (visualDisabled || !selectedNode) return;
try {
let patches: SourcePatch[];
let description: string;
let label: string;
if (mode === "bake") {
const existing = composeTransformList(
parseTransformList(selectedNode.attributes.transform ?? ""),
);
const baked = bakeElementTransform(
source,
selectedNode,
multiply(existing, matrix),
snapshot.semantic.preferences,
);
patches = baked.patches;
label = "Bake transform into geometry";
description = [
`The local transform is baked into <${baked.outputElement}> geometry and removed in one transaction.`,
...baked.warnings,
].join(" ");
} else {
const current = selectedNode.attributes.transform ?? "";
const value = `${current}${current ? " " : ""}${matrixToTransform(matrix)}`;
const patch = patchAttribute(
source,
selectedNode,
"transform",
value,
snapshot.semantic.preferences,
);
if (!patch) return;
patches = [patch];
label = "Apply transform attribute";
description =
"The matrix is appended to the existing SVG transform list. No geometry is rewritten until you apply this preview.";
}
setChangePreview({
title: label,
description,
before: source,
after: applySourcePatches(source, patches),
label,
expectedRevision: snapshot.revision,
});
setDialog("change");
} catch (error) {
setStatus(error instanceof Error ? error.message : "Transform failed");
}
};
const applyAnimations = () => {
if (visualDisabled || !animations.length) return;
try {
const assigned = new Map<string, string>();
const patches: SourcePatch[] = [];
animations.forEach((definition, index) => {
const node = snapshot.semantic.nodes.get(definition.targetNodeKey);
if (!node) throw new Error("An animation target no longer exists");
if (!node.id) {
const id = `svg-tools-target-${index + 1}`;
assigned.set(node.key, id);
const patch = patchAttribute(
source,
node,
"id",
id,
snapshot.semantic.preferences,
);
if (patch) patches.push(patch);
}
});
const css = sourceAnimationCss(animations, snapshot.semantic, assigned);
const root = snapshot.semantic.nodes.get(snapshot.semantic.rootKey)!;
patches.push({
from: root.sourceRange.openTag.to,
to: root.sourceRange.openTag.to,
insert: `${snapshot.semantic.preferences.newline}${snapshot.semantic.preferences.indentation}<style id="svg-tools-animations">${snapshot.semantic.preferences.newline}${css}${snapshot.semantic.preferences.newline}${snapshot.semantic.preferences.indentation}</style>`,
label: "Add animation stylesheet",
});
commitPatches(
patches,
"Apply animations to SVG",
undefined,
snapshot.revision,
);
setAnimationPreview(false);
} catch (error) {
setStatus(
error instanceof Error ? error.message : "Animation apply failed",
);
}
};
const saveProject = () => {
try {
const now = new Date().toISOString();
const project = createProject({
appVersion: APPLICATION_VERSION,
document: { source },
ui: {
selectedNodeKey: selectedKey,
expandedNodeKeys: [...expanded],
activePanel: activeTab,
zoom: canvasView.zoom,
pan: { ...canvasView.pan },
showGrid,
...(revealRange
? {
sourceSelection: {
anchor: revealRange.from,
head: revealRange.to,
},
}
: {}),
},
animations,
metadata: {
title: fileName.replace(/\.svg$/iu, ""),
originalFileName: fileName,
createdAt: projectCreatedAt.current,
updatedAt: now,
},
});
const blob = new Blob([serializeProject(project)], {
type: `${PROJECT_MIME};charset=utf-8`,
});
const name = exportFileName(fileName, "project");
downloadBlob(blob, name);
setStatus(`${name} downloaded locally.`);
} catch (error) {
setStatus(
error instanceof Error ? error.message : "Project export failed",
);
}
};
const previewFormat = () => {
try {
setChangePreview({
title: "Prettify source",
description:
"Formatting is explicit because it rewrites the whole XML document, including attribute order and whitespace.",
before: source,
after: formatSvgSource(
source,
snapshot.semantic.preferences.indentation,
),
label: "Prettify source",
expectedRevision: revision,
});
setDialog("change");
} catch (error) {
setStatus(
error instanceof Error ? error.message : "Formatting preview failed",
);
}
};
const previewSanitize = () => {
if (visualDisabled) return;
try {
const candidate = createSanitizedCandidate(snapshot.semantic).source;
setChangePreview({
title: "Sanitize source",
description:
"This explicit operation replaces canonical source with the current safe projection. Review removed scripts, event attributes, external URLs and editor-only mapping metadata.",
before: source,
after: candidate,
label: "Sanitize source",
expectedRevision: revision,
});
setDialog("change");
} catch (error) {
setStatus(
error instanceof Error ? error.message : "Sanitization preview failed",
);
}
};
const pasteSvg = async () => {
try {
const text = await navigator.clipboard.readText();
if (!text.trim()) throw new Error("Clipboard contains no text");
resetDocument(text, "pasted.svg");
} catch (error) {
setStatus(
error instanceof Error ? error.message : "Clipboard access failed",
);
}
};
const selectNode = (key: string) => {
if (visualDisabled) return;
setSelectedKey(key);
const node = snapshot.semantic.nodes.get(key);
if (node) setRevealRange(node.sourceRange.openTag);
if (node?.localName !== "path") setPathEditing(false);
};
const selectFromSource = (offset: number) => {
if (visualDisabled) return;
const candidates = [...snapshot.semantic.nodes.values()]
.filter(
(node) =>
node.sourceRange.full.from <= offset &&
offset <= node.sourceRange.full.to,
)
.sort(
(left, right) =>
left.sourceRange.full.to -
left.sourceRange.full.from -
(right.sourceRange.full.to - right.sourceRange.full.from),
);
const node = candidates[0];
if (!node) return;
setSelectedKey(node.key);
setExpanded((current) => {
const next = new Set(current);
let parentKey = node.parentKey;
while (parentKey) {
next.add(parentKey);
parentKey = snapshot.semantic.nodes.get(parentKey)?.parentKey ?? null;
}
return next;
});
if (node.localName !== "path") setPathEditing(false);
};
return (
<main
className="svg-tools-page"
onDragOver={(event) => event.preventDefault()}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
className="sr-only"
type="file"
aria-label="Open SVG or SVG Tools project"
accept=".svg,.svgz,.svgtools.json,image/svg+xml,application/gzip,application/json"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (file) void openFile(file);
event.currentTarget.value = "";
}}
/>
<div
className="command-bar"
role="toolbar"
aria-label="Document commands"
>
<div className="command-group">
<button
type="button"
className="primary-button"
onClick={() => setDialog("new")}
>
New
</button>
<button
type="button"
className="secondary-button"
onClick={() => fileInputRef.current?.click()}
>
Open
</button>
<button
type="button"
className="secondary-button"
onClick={() => void pasteSvg()}
>
Paste
</button>
</div>
<span className="command-divider" />
<div className="command-group">
<button
type="button"
className="icon-button"
aria-label="Undo"
disabled={!historyAvailability.canUndo}
onClick={undo}
>
</button>
<button
type="button"
className="icon-button"
aria-label="Redo"
disabled={!historyAvailability.canRedo}
onClick={redo}
>
</button>
</div>
<span className="command-divider" />
<div className="command-group">
<button
type="button"
className="secondary-button"
disabled={parseState.phase !== "ready"}
onClick={previewFormat}
>
Prettify
</button>
<button
type="button"
className="secondary-button"
disabled={visualDisabled}
onClick={() => setDialog("optimize")}
>
Optimize
</button>
<button
type="button"
className="secondary-button"
disabled={visualDisabled}
onClick={previewSanitize}
>
Sanitize
</button>
</div>
<div className="command-spacer" />
<div className="document-identity" title={fileName}>
<strong>{fileName}</strong>
<span>
{dirty ? "Unsaved changes" : "Saved locally"} · rev {revision}
</span>
</div>
<button
type="button"
className="secondary-button"
disabled={parseState.phase !== "ready"}
onClick={downloadSvg}
>
Download SVG
</button>
<button
type="button"
className="primary-button"
onClick={() => setDialog("export")}
>
Export
</button>
</div>
<div
className={`document-status is-${parseState.phase}`}
role={parseState.phase === "invalid" ? "alert" : "status"}
>
<span className="status-dot" />
<strong>
{parseState.phase === "ready"
? "Source synchronized"
: parseState.phase === "invalid"
? "Source invalid"
: "Updating projection"}
</strong>
<span>
{parseState.phase === "invalid"
? "Canvas and structure show the last valid revision; visual editing is paused."
: `${snapshot.semantic.metrics.elementCount.toLocaleString()} elements · ${snapshot.semantic.metrics.pathCommandCount.toLocaleString()} path commands · ${snapshot.diagnostics.length} diagnostics`}
</span>
{parseState.phase === "invalid" && parseState.diagnostics[0]?.range ? (
<button
type="button"
className="link-button"
onClick={() => setRevealRange(parseState.diagnostics[0]!.range)}
>
Jump to first error
</button>
) : null}
</div>
<nav className="mobile-view-tabs" aria-label="Workspace panels">
{[
["tree", "Structure"],
["canvas", "Canvas"],
["source", "Source"],
["inspector", "Inspect"],
].map(([target, label]) => (
<a href={`#svg-${target}`} key={target}>
{label}
</a>
))}
</nav>
<div className="svg-workspace">
<div id="svg-tree" className="workspace-tree">
<StructureTree
semantic={snapshot.semantic}
selectedKey={selectedKey}
expanded={expanded}
diagnostics={snapshot.diagnostics}
disabled={visualDisabled}
onSelect={selectNode}
onExpandedChange={setExpanded}
/>
</div>
<div id="svg-canvas" className="workspace-canvas">
<CanvasPane
projection={snapshot.projection}
semantic={snapshot.semantic}
selectedKey={selectedKey}
disabled={visualDisabled}
stale={stale}
pathEditing={pathEditing}
showGrid={showGrid}
transformPreview={transformPreview}
animations={animations}
animationPreview={animationPreview}
view={canvasView}
onSelect={selectNode}
onCommitPath={(data, mergeKey) =>
commitPath(data, "Move path handle", mergeKey)
}
onPathError={setStatus}
onShowGridChange={setShowGrid}
onViewChange={setCanvasView}
/>
</div>
<section
id="svg-source"
className="panel source-panel workspace-source"
aria-labelledby="source-heading"
>
<div className="panel-heading compact">
<div>
<p className="eyebrow">Canonical document</p>
<h2 id="source-heading">Source</h2>
</div>
<span className="count-badge">
{source.length.toLocaleString()} chars
</span>
</div>
<div className="source-editor-wrap">
<SourceEditor
value={source}
revealRange={revealRange}
onChange={(next) =>
commitWholeSource(next, "Edit source", "source-editor")
}
onSelectionChange={selectFromSource}
onUndo={undo}
onRedo={redo}
/>
</div>
<div className="diagnostics-list" aria-label="Source diagnostics">
{(parseState.phase === "invalid"
? parseState.diagnostics
: snapshot.diagnostics
)
.slice(0, 100)
.map((diagnostic, index) => (
<button
type="button"
className={`diagnostic-row is-${diagnostic.severity}`}
key={`${diagnostic.code}-${diagnostic.range?.from ?? index}-${index}`}
onClick={() =>
diagnostic.range && setRevealRange(diagnostic.range)
}
>
<span>
{diagnostic.severity === "error"
? "!"
: diagnostic.severity === "warning"
? "△"
: "i"}
</span>
<strong>{diagnostic.code}</strong>
<span>{diagnostic.message}</span>
</button>
))}
{(parseState.phase === "invalid"
? parseState.diagnostics
: snapshot.diagnostics
).length > 100 ? (
<p className="hint">Only the first 100 diagnostics are shown.</p>
) : null}
</div>
</section>
<div id="svg-inspector" className="workspace-inspector">
<Inspector
semantic={snapshot.semantic}
selectedKey={selectedKey}
disabled={visualDisabled}
activeTab={activeTab}
pathEditing={pathEditing}
diagnostics={snapshot.diagnostics}
accessibility={accessibility}
animations={animations}
animationPreview={animationPreview}
onTabChange={setActiveTab}
onPathEditingChange={setPathEditing}
onSetAttribute={setAttribute}
onDuplicateNode={duplicateSelectedNode}
onDeleteNode={deleteSelectedNode}
onCommitPath={commitPath}
onTransformPreview={setTransformPreview}
onApplyTransform={applyTransform}
onAccessibilityFix={(fix, text) => {
try {
commitPatches(
[accessibilityFixPatch(snapshot.semantic, fix, text)],
fix === "add-title"
? "Add accessible title"
: "Add description",
undefined,
snapshot.revision,
);
} catch (error) {
setStatus(
error instanceof Error
? error.message
: "Accessibility fix failed",
);
}
}}
onAnimationsChange={(next) => {
setAnimations(next);
setDirty(true);
}}
onAnimationPreviewChange={setAnimationPreview}
onApplyAnimations={applyAnimations}
/>
</div>
</div>
<footer className="workbench-footer">
<span aria-live="polite">{status}</span>
<span>Editing projection policy v1 · no network processing</span>
</footer>
<NewDocumentDialog
open={dialog === "new"}
onClose={() => setDialog(null)}
onCreate={(next, name) => resetDocument(next, name)}
/>
<OptimizeDialog
open={dialog === "optimize"}
source={source}
revision={revision}
fileName={fileName}
onClose={() => setDialog(null)}
onStatus={setStatus}
onApply={(next, expectedRevision) => {
try {
commitWholeSource(
next,
"Optimize source",
undefined,
expectedRevision,
);
setDialog(null);
} catch (error) {
setStatus(
error instanceof Error
? error.message
: "Optimization apply failed",
);
}
}}
/>
<ExportDialog
open={dialog === "export"}
source={source}
projection={snapshot.projection}
fileName={fileName}
valid={parseState.phase === "ready"}
semantic={snapshot.semantic}
selectedKeys={selectedKey ? [selectedKey] : []}
onSaveProject={saveProject}
onStatus={setStatus}
onClose={() => setDialog(null)}
/>
{changePreview ? (
<ChangePreviewDialog
open={dialog === "change"}
title={changePreview.title}
description={changePreview.description}
before={changePreview.before}
after={changePreview.after}
applyLabel={changePreview.label}
onClose={() => {
setDialog(null);
setChangePreview(null);
setTransformPreview(null);
}}
onApply={() => {
try {
commitWholeSource(
changePreview.after,
changePreview.label,
undefined,
changePreview.expectedRevision,
);
setDialog(null);
setChangePreview(null);
setTransformPreview(null);
} catch (error) {
setStatus(
error instanceof Error ? error.message : "Change apply failed",
);
}
}}
/>
) : null}
</main>
);
}