1334 lines
42 KiB
TypeScript
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>
|
|
);
|
|
}
|