Release SVG Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 04:48:12 +02:00
parent 902ff61e8d
commit 0c7736dbc3
18 changed files with 366 additions and 70 deletions
+114
View File
@@ -16,6 +16,7 @@ import { CommandHistory, createTransaction } from "../commands/history";
import { geometryDiagnostics } from "../diagnostics/geometry";
import type {
SemanticSvgDocument,
SemanticSvgNode,
SourcePatch,
SvgDiagnostic,
} from "../document/document.types";
@@ -49,6 +50,7 @@ import {
} 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";
@@ -608,6 +610,115 @@ export function Workbench() {
}
};
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 {
@@ -848,6 +959,7 @@ export function Workbench() {
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];
@@ -1104,6 +1216,8 @@ export function Workbench() {
onTabChange={setActiveTab}
onPathEditingChange={setPathEditing}
onSetAttribute={setAttribute}
onDuplicateNode={duplicateSelectedNode}
onDeleteNode={deleteSelectedNode}
onCommitPath={commitPath}
onTransformPreview={setTransformPreview}
onApplyTransform={applyTransform}