feat: introduce local-first SVG workbench
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
const XML_NAMESPACE = "http://www.w3.org/2000/xmlns/";
|
||||
|
||||
function escapeText(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return escapeText(value).replaceAll('"', """);
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user