feat: introduce local-first SVG workbench

This commit is contained in:
2026-08-02 16:31:49 +02:00
commit 39d9802daa
97 changed files with 20702 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
export interface DiffLine {
kind: "same" | "add" | "remove";
text: string;
oldLine?: number;
newLine?: number;
}
export function lineDiff(
before: string,
after: string,
limit = 2_000,
): DiffLine[] {
const left = before.split(/\r?\n/u);
const right = after.split(/\r?\n/u);
if (left.length * right.length > limit * limit) {
return [
{
kind: "remove",
text: `${left.length} lines (${before.length} characters)`,
oldLine: 1,
},
{
kind: "add",
text: `${right.length} lines (${after.length} characters)`,
newLine: 1,
},
];
}
const lengths = Array.from(
{ length: left.length + 1 },
() => new Uint32Array(right.length + 1),
);
for (let i = left.length - 1; i >= 0; i -= 1) {
for (let j = right.length - 1; j >= 0; j -= 1) {
lengths[i]![j] =
left[i] === right[j]
? lengths[i + 1]![j + 1]! + 1
: Math.max(lengths[i + 1]![j]!, lengths[i]![j + 1]!);
}
}
const output: DiffLine[] = [];
let i = 0;
let j = 0;
while (i < left.length || j < right.length) {
if (i < left.length && j < right.length && left[i] === right[j]) {
output.push({
kind: "same",
text: left[i]!,
oldLine: i + 1,
newLine: j + 1,
});
i += 1;
j += 1;
} else if (
j < right.length &&
(i === left.length || lengths[i]![j + 1]! >= lengths[i + 1]![j]!)
) {
output.push({ kind: "add", text: right[j]!, newLine: j + 1 });
j += 1;
} else {
output.push({ kind: "remove", text: left[i]!, oldLine: i + 1 });
i += 1;
}
}
return output;
}
+78
View File
@@ -0,0 +1,78 @@
const XML_NAMESPACE = "http://www.w3.org/2000/xmlns/";
function escapeText(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function escapeAttribute(value: string): string {
return escapeText(value).replaceAll('"', "&quot;");
}
function serializeNode(
node: Node,
depth: number,
indentation: string,
newline: string,
): string {
const prefix = indentation.repeat(depth);
if (node.nodeType === Node.COMMENT_NODE)
return `${prefix}<!--${node.nodeValue ?? ""}-->`;
if (node.nodeType === Node.CDATA_SECTION_NODE)
return `${prefix}<![CDATA[${node.nodeValue ?? ""}]]>`;
if (node.nodeType === Node.PROCESSING_INSTRUCTION_NODE) {
const instruction = node as ProcessingInstruction;
return `${prefix}<?${instruction.target} ${instruction.data}?>`;
}
if (node.nodeType === Node.TEXT_NODE) {
const value = node.nodeValue ?? "";
return value.trim() ? `${prefix}${escapeText(value.trim())}` : "";
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const element = node as Element;
const attributes = Array.from(element.attributes)
.sort((left, right) => {
const leftNamespace = left.namespaceURI === XML_NAMESPACE ? 0 : 1;
const rightNamespace = right.namespaceURI === XML_NAMESPACE ? 0 : 1;
return (
leftNamespace - rightNamespace || left.name.localeCompare(right.name)
);
})
.map(
(attribute) => ` ${attribute.name}="${escapeAttribute(attribute.value)}"`,
)
.join("");
const children = Array.from(element.childNodes)
.map((child) => serializeNode(child, depth + 1, indentation, newline))
.filter(Boolean);
if (children.length === 0)
return `${prefix}<${element.tagName}${attributes}/>`;
const inlineText =
element.childNodes.length === 1 &&
element.firstChild?.nodeType === Node.TEXT_NODE;
if (inlineText) {
return `${prefix}<${element.tagName}${attributes}>${escapeText(element.textContent ?? "")}</${element.tagName}>`;
}
return `${prefix}<${element.tagName}${attributes}>${newline}${children.join(newline)}${newline}${prefix}</${element.tagName}>`;
}
export function formatSvgSource(source: string, indentation = " "): string {
const document = new DOMParser().parseFromString(source, "image/svg+xml");
if (
document.documentElement.localName === "parsererror" ||
document.querySelector("parsererror")
) {
throw new Error("Only well-formed SVG can be formatted");
}
const newline = source.includes("\r\n") ? "\r\n" : "\n";
const declaration = /^\s*<\?xml[^?]*\?>/iu.exec(source)?.[0].trim();
const doctype = /<!DOCTYPE\b[^>]*>/iu.exec(source)?.[0];
const parts = [
declaration,
doctype,
serializeNode(document.documentElement, 0, indentation, newline),
].filter(Boolean);
return `${parts.join(newline)}${newline}`;
}