Skip to content

Commit 0cf0ec7

Browse files
committed
prototype of new svg transformer
1 parent 943102d commit 0cf0ec7

File tree

3 files changed

+255
-36
lines changed

3 files changed

+255
-36
lines changed

frontend/webEditor/package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/webEditor/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,14 @@
2020
"monaco-editor": "^0.52.2",
2121
"prettier": "^3.8.1",
2222
"reflect-metadata": "^0.2.2",
23+
"snabbdom": "^3.6.3",
24+
"snabbdom-to-html": "^7.1.0",
2325
"sprotty": "^1.4.0",
2426
"sprotty-elk": "^1.4.0",
2527
"sprotty-protocol": "^1.4.0",
2628
"typescript": "^5.8.3",
2729
"typescript-eslint": "^8.54.0",
28-
"vite": "^7.3.1",
29-
"snabbdom-to-html": "^7.1.0"
30+
"vite": "^7.3.1"
3031
},
3132
"scripts": {
3233
"dev": "vite",
Lines changed: 241 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,28 @@
1-
import { Command, CommandExecutionContext, CommandReturn, IVNodePostprocessor, ModelRenderer, TYPES, ViewRegistration, ViewRegistry } from "sprotty";
1+
import {
2+
Command,
3+
CommandExecutionContext,
4+
CommandReturn,
5+
IVNodePostprocessor,
6+
ModelRenderer,
7+
TYPES,
8+
ViewRegistry,
9+
} from "sprotty";
210
import themeCss from "../assets/theme.css?raw";
311
import elementCss from "../diagram/style.css?raw";
4-
import toHTML from "snabbdom-to-html"
12+
import toHTML from "snabbdom-to-html";
13+
import { classModule, eventListenersModule, h, init, propsModule, styleModule, VNode, VNodeStyle } from "snabbdom";
514
import { Action } from "sprotty-protocol";
615
import { inject, multiInject } from "inversify";
716
import { FileName } from "../fileName/fileName";
817

18+
const patch = init([
19+
// Init patch function with chosen modules
20+
classModule, // makes it easy to toggle classes
21+
propsModule, // for setting properties on DOM elements
22+
styleModule, // handles styling on elements with support for animations
23+
eventListenersModule, // attaches event listeners
24+
]);
25+
926
export namespace SaveImageAction {
1027
export const KIND = "save-image";
1128

@@ -18,41 +35,30 @@ export namespace SaveImageAction {
1835

1936
export class SaveImageCommand extends Command {
2037
static readonly KIND = SaveImageAction.KIND;
38+
private static readonly PADDING = 5;
2139

2240
constructor(
2341
@inject(TYPES.Action) _: Action,
2442
@inject(FileName) private readonly fileName: FileName,
2543
@inject(TYPES.ViewRegistry) private readonly viewRegistry: ViewRegistry,
26-
@multiInject(TYPES.IVNodePostprocessor) private readonly postProcessors: IVNodePostprocessor[]
44+
@multiInject(TYPES.IVNodePostprocessor) private readonly postProcessors: IVNodePostprocessor[],
2745
) {
2846
super();
2947
}
3048

3149
execute(context: CommandExecutionContext): CommandReturn {
32-
const renderer = new ModelRenderer(this.viewRegistry, 'main', this.postProcessors )
33-
const svg = renderer.renderElement(context.root)
34-
if (!svg) return context.root
35-
console.debug(toHTML(svg))
36-
37-
38-
/* The result svg will render (0,0) as the top left corner of the svg.
39-
* We calculate the minimum translation of all children.
40-
* We then offset the whole svg by this opposite of this amount.
41-
*/
42-
/*const minTranslate = { x: Infinity, y: Infinity };
43-
for (const child of firstChild.children) {
44-
const childTranslate = this.getMinTranslate(child as HTMLElement);
45-
minTranslate.x = Math.min(minTranslate.x, childTranslate.x);
46-
minTranslate.y = Math.min(minTranslate.y, childTranslate.y);
47-
}
48-
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><defs><style type="text/css">${themeCss}\n${elementCss}</style></defs><g transform="translate(${-minTranslate.x}, ${-minTranslate.y})">${innerSvg}</g></svg>`;
50+
const dummyRoot = document.createElement("div");
51+
dummyRoot.style.position = "absolute";
52+
dummyRoot.style.left = "-100000px";
53+
dummyRoot.style.top = "-100000px";
54+
dummyRoot.style.visibility = "hidden";
4955

50-
const blob = new Blob([svg], { type: "image/svg+xml" });
51-
const url = URL.createObjectURL(blob);
52-
const link = document.createElement("a");
53-
link.href = url;
54-
link.download = this.fileName.getName() + ".svg";*/
55-
//link.click();
56+
document.body.appendChild(dummyRoot);
57+
try {
58+
this.makeImage(context, dummyRoot);
59+
} finally {
60+
document.body.removeChild(dummyRoot);
61+
}
5662

5763
return context.root;
5864
}
@@ -63,6 +69,67 @@ export class SaveImageCommand extends Command {
6369
return context.root;
6470
}
6571

72+
makeImage(context: CommandExecutionContext, dom: HTMLElement) {
73+
// render diagram virtually
74+
const renderer = new ModelRenderer(this.viewRegistry, "hidden", this.postProcessors);
75+
const svg = renderer.renderElement(context.root);
76+
if (!svg) return;
77+
78+
// add stylesheets
79+
const styleHolder = document.createElement("style");
80+
styleHolder.innerHTML = `${themeCss}\n${elementCss}`;
81+
dom.appendChild(styleHolder);
82+
83+
// render svg into dom
84+
const dummyDom = h("div", {}, [svg]);
85+
patch(dom, dummyDom);
86+
// apply style and clean attributes
87+
transformStyleToAttributes(dummyDom);
88+
removeUnusedAttributes(dummyDom);
89+
90+
// compute diagram offset and size
91+
const holderG = svg.children?.[0];
92+
if (!holderG || typeof holderG == "string") return;
93+
const actualElements = holderG.children ?? [];
94+
const minTranslate = { x: Infinity, y: Infinity };
95+
const maxSize = { x: 0, y: 0 };
96+
for (const child of actualElements) {
97+
if (typeof child == "string") continue;
98+
const childTranslate = this.getMinTranslate(child);
99+
minTranslate.x = Math.min(minTranslate.x, childTranslate.x);
100+
minTranslate.y = Math.min(minTranslate.y, childTranslate.y);
101+
102+
const childSize = this.getMaxRequieredCanvasSize(child);
103+
maxSize.x = Math.max(maxSize.x, childSize.x);
104+
maxSize.y = Math.max(maxSize.y, childSize.y);
105+
}
106+
107+
// correct offset and set size
108+
if (!holderG.data) holderG.data = {};
109+
if (!holderG.data.attrs) holderG.data.attrs = {};
110+
holderG.data.attrs["transform"] =
111+
`translate(${-minTranslate.x + SaveImageCommand.PADDING},${-minTranslate.y + SaveImageCommand.PADDING})`;
112+
if (!svg.data) svg.data = {};
113+
if (!svg.data.attrs) svg.data.attrs = {};
114+
const width = maxSize.x - minTranslate.x + 2 * SaveImageCommand.PADDING;
115+
const height = maxSize.y - minTranslate.y + 2 * SaveImageCommand.PADDING;
116+
svg.data.attrs.width = width;
117+
svg.data.attrs.height = height;
118+
svg.data.attrs.viewBox = `0 0 ${width} ${height}`;
119+
120+
// make sure element is seen as svg by all users
121+
svg.data.attrs.version = "1.0";
122+
svg.data.attrs.xmlns = "http://www.w3.org/2000/svg";
123+
124+
// download file
125+
const blob = new Blob([toHTML(svg)], { type: "image/svg+xml" });
126+
const url = URL.createObjectURL(blob);
127+
const link = document.createElement("a");
128+
link.href = url;
129+
link.download = this.fileName.getName() + ".svg";
130+
link.click();
131+
}
132+
66133
/**
67134
* Gets the minimum translation of an element relative to the svg.
68135
* This is done by recursively getting the translation of all child elements
@@ -71,15 +138,16 @@ export class SaveImageCommand extends Command {
71138
* @returns Minimum absolute offset of any child element relative to the svg
72139
*/
73140
private getMinTranslate(
74-
e: HTMLElement,
141+
e: VNode,
75142
parentOffset: { x: number; y: number } = { x: 0, y: 0 },
76143
): { x: number; y: number } {
77144
const myTranslate = this.getTranslate(e, parentOffset);
78-
const minTranslate = myTranslate;
145+
const minTranslate = myTranslate ?? { x: Infinity, y: Infinity };
79146

80-
const children = e.children;
147+
const children = e.children ?? [];
81148
for (const child of children) {
82-
const childTranslate = this.getMinTranslate(child as HTMLElement, myTranslate);
149+
if (typeof child == "string") continue;
150+
const childTranslate = this.getMinTranslate(child, myTranslate);
83151
minTranslate.x = Math.min(minTranslate.x, childTranslate.x);
84152
minTranslate.y = Math.min(minTranslate.y, childTranslate.y);
85153
}
@@ -94,11 +162,11 @@ export class SaveImageCommand extends Command {
94162
* @returns Offset of the child relative to the svg
95163
*/
96164
private getTranslate(
97-
e: HTMLElement,
165+
e: VNode,
98166
parentOffset: { x: number; y: number } = { x: 0, y: 0 },
99-
): { x: number; y: number } {
100-
const transform = e.getAttribute("transform");
101-
if (!transform) return parentOffset;
167+
): { x: number; y: number } | undefined {
168+
const transform = e.data?.attrs?.["transform"] as string | undefined;
169+
if (!transform) return undefined;
102170
const translateMatch = transform.match(/translate\(([^)]+)\)/);
103171
if (!translateMatch) return parentOffset;
104172
const translate = translateMatch[1].match(/(-?[0-9.]+)(?:, | |,)(-?[0-9.]+)/);
@@ -109,4 +177,143 @@ export class SaveImageCommand extends Command {
109177
const newY = y + parentOffset.y;
110178
return { x: newX, y: newY };
111179
}
180+
181+
private getMaxRequieredCanvasSize(
182+
e: VNode,
183+
parentOffset: { x: number; y: number } = { x: 0, y: 0 },
184+
): { x: number; y: number } {
185+
const myTranslate = this.getTranslate(e, parentOffset);
186+
const maxSize = this.getRequieredCanvasSize(e, parentOffset);
187+
188+
const children = e.children ?? [];
189+
for (const child of children) {
190+
if (typeof child == "string") continue;
191+
const childTranslate = this.getMaxRequieredCanvasSize(child, myTranslate);
192+
maxSize.x = Math.max(maxSize.x, childTranslate.x);
193+
maxSize.y = Math.max(maxSize.y, childTranslate.y);
194+
}
195+
return maxSize;
196+
}
197+
198+
private getRequieredCanvasSize(
199+
e: VNode,
200+
parentOffset: { x: number; y: number } = { x: 0, y: 0 },
201+
): { x: number; y: number } {
202+
const width = (e.data?.attrs?.["width"] as number | undefined) ?? 0;
203+
const height = (e.data?.attrs?.["height"] as number | undefined) ?? 0;
204+
const translate = this.getTranslate(e, parentOffset) ?? parentOffset;
205+
206+
const x = translate.x + width;
207+
const y = translate.y + height;
208+
return { x: x, y: y };
209+
}
210+
}
211+
212+
function transformStyleToAttributes(v: VNode) {
213+
if (!v.elm) return;
214+
215+
if (!v.data) v.data = {};
216+
if (!v.data.style) v.data.style = {};
217+
if (!v.data.attrs) v.data.attrs = {};
218+
219+
const computedStyle = getComputedStyle(v.elm as Element) as VNodeStyle;
220+
for (const key of getRelevantStyleProps(v)) {
221+
let value = v.data.style[key] ?? computedStyle[key];
222+
if (key == "fill" && value.startsWith("color(srgb")) {
223+
const srgb = /color\(srgb ([^ ]+) ([^ ]+) ([^ ]+)(?: ?\/ ?([^ ]+))?\)/.exec(value);
224+
if (srgb) {
225+
const r = Math.round(Number(srgb[1]) * 255);
226+
const g = Math.round(Number(srgb[2]) * 255);
227+
const b = Math.round(Number(srgb[3]) * 255);
228+
const a = srgb[4] ? Number(srgb[4]) : 1;
229+
value = `rgb(${r},${g},${b})`;
230+
231+
v.data.attrs["fill-opacity"] = a;
232+
}
233+
}
234+
if (key == "font-family") {
235+
value = "sans-serif";
236+
}
237+
238+
if (value.endsWith("px")) {
239+
value = value.substring(0, value.length - 2);
240+
}
241+
if (value != getDefaultValues(key)) {
242+
v.data.attrs[key] = value;
243+
}
244+
}
245+
246+
if (getVNodeSVGType(v) == "text") {
247+
const oldY = (v.data.attrs.y as number | undefined) ?? 0;
248+
const fontSize = computedStyle.fontSize
249+
? Number(computedStyle.fontSize.substring(0, computedStyle.fontSize.length - 2))
250+
: 12;
251+
const newY = oldY + 0.35 * fontSize;
252+
v.data.attrs.y = newY;
253+
}
254+
255+
if (!v.children) return;
256+
for (const child of v.children) {
257+
if (typeof child === "string") continue;
258+
transformStyleToAttributes(child);
259+
}
260+
}
261+
262+
function removeUnusedAttributes(v: VNode) {
263+
if (!v.data) v.data = {};
264+
if (v.data.attrs) {
265+
delete v.data.attrs["id"];
266+
delete v.data.attrs["tabindex"];
267+
}
268+
if (v.data.class) {
269+
for (const clas in v.data.class) {
270+
v.data.class[clas] = false;
271+
}
272+
}
273+
274+
if (!v.children) return;
275+
for (const child of v.children) {
276+
if (typeof child === "string") continue;
277+
removeUnusedAttributes(child);
278+
}
279+
}
280+
281+
function getVNodeSVGType(v: VNode): string | undefined {
282+
return v.sel?.split(/#|\./)[0];
283+
}
284+
285+
function getRelevantStyleProps(v: VNode): string[] {
286+
const type = getVNodeSVGType(v);
287+
switch (type) {
288+
case "g":
289+
case "svg":
290+
return [];
291+
case "text":
292+
return ["font-size", "font-family", "font-weight", "text-anchor", "opacity"];
293+
default:
294+
return [
295+
"fill",
296+
"stroke",
297+
"stroke-width",
298+
"stroke-dasharray",
299+
"stroke-linecap",
300+
"stroke-linejoin",
301+
"opacity",
302+
];
303+
}
304+
}
305+
306+
function getDefaultValues(key: string) {
307+
switch (key) {
308+
case "stroke-dasharray":
309+
return "none";
310+
case "stroke-linecap":
311+
return "butt";
312+
case "stroke-linejoin":
313+
return "miter";
314+
case "opacity":
315+
return 1;
316+
default:
317+
return undefined;
318+
}
112319
}

0 commit comments

Comments
 (0)