@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user