From a07b8fa949ccf0be3ba17fa681a7cbfdd8c4d455 Mon Sep 17 00:00:00 2001 From: Alex | Kronox Date: Wed, 4 Feb 2026 12:52:20 +0100 Subject: [PATCH 1/7] readd image export --- .../commandPalette/commandPaletteProvider.ts | 3 +- frontend/webEditor/src/serialize/di.config.ts | 2 + frontend/webEditor/src/serialize/image.ts | 108 ++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 frontend/webEditor/src/serialize/image.ts diff --git a/frontend/webEditor/src/commandPalette/commandPaletteProvider.ts b/frontend/webEditor/src/commandPalette/commandPaletteProvider.ts index ad0e53e2..bfb31452 100644 --- a/frontend/webEditor/src/commandPalette/commandPaletteProvider.ts +++ b/frontend/webEditor/src/commandPalette/commandPaletteProvider.ts @@ -10,6 +10,7 @@ import { LayoutMethod } from "../layout/layoutMethod"; import { LayoutModelAction } from "../layout/command"; import { SaveJsonFileAction } from "../serialize/saveJsonFile"; import { SaveDfdAndDdFileAction } from "../serialize/saveDfdAndDdFile"; +import { SaveImageAction } from "../serialize/image"; /** * Provides possible actions for the command palette. @@ -39,7 +40,7 @@ export class WebEditorCommandPaletteActionProvider implements ICommandPaletteAct [ new LabeledAction("Save diagram as JSON", [SaveJsonFileAction.create()], "json"), new LabeledAction("Save diagram as DFD and DD", [SaveDfdAndDdFileAction.create()], "coffee"), - //new LabeledAction("Save viewport as image", [SaveImageAction.create()], "device-camera"), + new LabeledAction("Save viewport as image", [SaveImageAction.create()], "device-camera"), ], "save", ), diff --git a/frontend/webEditor/src/serialize/di.config.ts b/frontend/webEditor/src/serialize/di.config.ts index 4c1c0f4a..e6a5e6c3 100644 --- a/frontend/webEditor/src/serialize/di.config.ts +++ b/frontend/webEditor/src/serialize/di.config.ts @@ -9,6 +9,7 @@ import { SaveJsonFileCommand } from "./saveJsonFile"; import { SaveDfdAndDdFileCommand } from "./saveDfdAndDdFile"; import { AnalyzeCommand } from "./analyze"; import { LoadFromUrlCommand } from "./LoadUrl"; +import { SaveImageCommand } from "./image"; export const serializeModule = new ContainerModule((bind, unbind, isBound, rebind) => { const context = { bind, unbind, isBound, rebind }; @@ -20,6 +21,7 @@ export const serializeModule = new ContainerModule((bind, unbind, isBound, rebin configureCommand(context, SaveJsonFileCommand); configureCommand(context, SaveDfdAndDdFileCommand); configureCommand(context, AnalyzeCommand); + configureCommand(context, SaveImageCommand); rebind(TYPES.IModelFactory).to(DfdModelFactory); }); diff --git a/frontend/webEditor/src/serialize/image.ts b/frontend/webEditor/src/serialize/image.ts new file mode 100644 index 00000000..b6bf57ee --- /dev/null +++ b/frontend/webEditor/src/serialize/image.ts @@ -0,0 +1,108 @@ +import { Command, CommandExecutionContext, CommandReturn, TYPES } from "sprotty"; +import themeCss from "../assets/theme.css?raw"; +import elementCss from "../diagram/style.css?raw"; +import { Action } from "sprotty-protocol"; +import { inject } from "inversify"; +import { FileName } from "../fileName/fileName"; + +export namespace SaveImageAction { + export const KIND = "save-image"; + + export function create(): Action { + return { + kind: KIND, + }; + } +} + +export class SaveImageCommand extends Command { + static readonly KIND = SaveImageAction.KIND; + + constructor( + @inject(TYPES.Action) _: Action, + @inject(FileName) private readonly fileName: FileName, + ) { + super(); + } + + execute(context: CommandExecutionContext): CommandReturn { + const root = document.getElementById("sprotty_root"); + if (!root) return context.root; + const firstChild = root.children[0]; + if (!firstChild) return context.root; + const innerSvg = firstChild.innerHTML; + /* The result svg will render (0,0) as the top left corner of the svg. + * We calculate the minimum translation of all children. + * We then offset the whole svg by this opposite of this amount. + */ + const minTranslate = { x: Infinity, y: Infinity }; + for (const child of firstChild.children) { + const childTranslate = this.getMinTranslate(child as HTMLElement); + minTranslate.x = Math.min(minTranslate.x, childTranslate.x); + minTranslate.y = Math.min(minTranslate.y, childTranslate.y); + } + const svg = `${innerSvg}`; + + const blob = new Blob([svg], { type: "image/svg+xml" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = this.fileName.getName() + ".svg"; + link.click(); + + return context.root; + } + undo(context: CommandExecutionContext): CommandReturn { + return context.root; + } + redo(context: CommandExecutionContext): CommandReturn { + return context.root; + } + + /** + * Gets the minimum translation of an element relative to the svg. + * This is done by recursively getting the translation of all child elements + * @param e the element to get the translation from + * @param parentOffset Offset of the containing element + * @returns Minimum absolute offset of any child element relative to the svg + */ + private getMinTranslate( + e: HTMLElement, + parentOffset: { x: number; y: number } = { x: 0, y: 0 }, + ): { x: number; y: number } { + const myTranslate = this.getTranslate(e, parentOffset); + const minTranslate = myTranslate; + + const children = e.children; + for (const child of children) { + const childTranslate = this.getMinTranslate(child as HTMLElement, myTranslate); + minTranslate.x = Math.min(minTranslate.x, childTranslate.x); + minTranslate.y = Math.min(minTranslate.y, childTranslate.y); + } + return minTranslate; + } + + /** + * Calculates the absolute translation of an element relative to the svg. + * If the element has no translation, the offset of the parent is returned. + * @param e the element to get the translation from + * @param parentOffset Offset of the containing element + * @returns Offset of the child relative to the svg + */ + private getTranslate( + e: HTMLElement, + parentOffset: { x: number; y: number } = { x: 0, y: 0 }, + ): { x: number; y: number } { + const transform = e.getAttribute("transform"); + if (!transform) return parentOffset; + const translateMatch = transform.match(/translate\(([^)]+)\)/); + if (!translateMatch) return parentOffset; + const translate = translateMatch[1].match(/(-?[0-9.]+)(?:, | |,)(-?[0-9.]+)/); + if (!translate) return parentOffset; + const x = parseFloat(translate[1]); + const y = parseFloat(translate[2]); + const newX = x + parentOffset.x; + const newY = y + parentOffset.y; + return { x: newX, y: newY }; + } +} From 943102d9a45da00c19dd5a8c69e1016a454dc534 Mon Sep 17 00:00:00 2001 From: Alex | Kronox Date: Wed, 4 Feb 2026 20:56:19 +0100 Subject: [PATCH 2/7] init on better image generation --- frontend/webEditor/package-lock.json | 91 +++++++++++++++++++++-- frontend/webEditor/package.json | 3 +- frontend/webEditor/src/serialize/image.ts | 24 +++--- 3 files changed, 101 insertions(+), 17 deletions(-) diff --git a/frontend/webEditor/package-lock.json b/frontend/webEditor/package-lock.json index 06d89d2a..ba4ea942 100644 --- a/frontend/webEditor/package-lock.json +++ b/frontend/webEditor/package-lock.json @@ -20,6 +20,7 @@ "monaco-editor": "^0.52.2", "prettier": "^3.8.1", "reflect-metadata": "^0.2.2", + "snabbdom-to-html": "^7.1.0", "sprotty": "^1.4.0", "sprotty-elk": "^1.4.0", "sprotty-protocol": "^1.4.0", @@ -1419,6 +1420,13 @@ "node": ">=8" } }, + "node_modules/browser-split": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/browser-split/-/browser-split-0.0.1.tgz", + "integrity": "sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow==", + "dev": true, + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2218,6 +2226,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.escape": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz", + "integrity": "sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.forown": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.forown/-/lodash.forown-4.4.0.tgz", + "integrity": "sha512-xcpca6BCshoe5SFSrQOoV8FBEbNzcBa6QQYmtv48eEFNzdwQLkHkcWSaBlecHhyHb1BUk1xqFdXoiSLJkt/w5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.kebabcase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", + "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -2225,6 +2254,20 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.remove": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.remove/-/lodash.remove-4.7.0.tgz", + "integrity": "sha512-GnwkSsEXGXirSxh3YI+jc/qvptE2DV8ZjA4liK0NT1MJ3mNDMFhX3bY+4Wr8onlNItYuPp7/4u19Fi55mvzkTw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true, + "license": "MIT" + }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -2338,6 +2381,16 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -2417,6 +2470,16 @@ "node": ">=6" } }, + "node_modules/parse-sel": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-sel/-/parse-sel-1.0.0.tgz", + "integrity": "sha512-GLAtaUf/vkdsPQwFKKBR1LHsw4Sm5YVDSbzcPQqmq3N/qlI8gsxUMhSV8NRHj677eeJUV1YpI8OeALaUL5yxUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "browser-split": "0.0.1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2697,14 +2760,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/snabbdom": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/snabbdom/-/snabbdom-3.5.1.tgz", - "integrity": "sha512-wHMNIOjkm/YNE5EM3RCbr/+DVgPg6AqQAX1eOxO46zYNvCXjKP5Y865tqQj3EXnaMBjkxmQA5jFuDpDK/dbfiA==", + "node_modules/snabbdom-to-html": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/snabbdom-to-html/-/snabbdom-to-html-7.1.0.tgz", + "integrity": "sha512-R/Rc/5duxWvV4w/B3JI/lt1jXb96pIVV9Xxwqvne259cNuDRXYppuBMtoUGdda0JGhMjNmvc1z+G9ch/PQx+kg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8.3.0" + "dependencies": { + "lodash.escape": "^4.0.1", + "lodash.forown": "^4.4.0", + "lodash.kebabcase": "^4.1.1", + "lodash.remove": "^4.7.0", + "lodash.uniq": "^4.5.0", + "object-assign": "^4.1.0", + "parse-sel": "^1.0.0" } }, "node_modules/source-map-js": { @@ -2753,6 +2822,16 @@ "dev": true, "license": "(EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0)" }, + "node_modules/sprotty/node_modules/snabbdom": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/snabbdom/-/snabbdom-3.5.1.tgz", + "integrity": "sha512-wHMNIOjkm/YNE5EM3RCbr/+DVgPg6AqQAX1eOxO46zYNvCXjKP5Y865tqQj3EXnaMBjkxmQA5jFuDpDK/dbfiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", diff --git a/frontend/webEditor/package.json b/frontend/webEditor/package.json index 1a1fc37f..fdc93fd4 100644 --- a/frontend/webEditor/package.json +++ b/frontend/webEditor/package.json @@ -25,7 +25,8 @@ "sprotty-protocol": "^1.4.0", "typescript": "^5.8.3", "typescript-eslint": "^8.54.0", - "vite": "^7.3.1" + "vite": "^7.3.1", + "snabbdom-to-html": "^7.1.0" }, "scripts": { "dev": "vite", diff --git a/frontend/webEditor/src/serialize/image.ts b/frontend/webEditor/src/serialize/image.ts index b6bf57ee..4765bb9f 100644 --- a/frontend/webEditor/src/serialize/image.ts +++ b/frontend/webEditor/src/serialize/image.ts @@ -1,8 +1,9 @@ -import { Command, CommandExecutionContext, CommandReturn, TYPES } from "sprotty"; +import { Command, CommandExecutionContext, CommandReturn, IVNodePostprocessor, ModelRenderer, TYPES, ViewRegistration, ViewRegistry } from "sprotty"; import themeCss from "../assets/theme.css?raw"; import elementCss from "../diagram/style.css?raw"; +import toHTML from "snabbdom-to-html" import { Action } from "sprotty-protocol"; -import { inject } from "inversify"; +import { inject, multiInject } from "inversify"; import { FileName } from "../fileName/fileName"; export namespace SaveImageAction { @@ -21,21 +22,24 @@ export class SaveImageCommand extends Command { constructor( @inject(TYPES.Action) _: Action, @inject(FileName) private readonly fileName: FileName, + @inject(TYPES.ViewRegistry) private readonly viewRegistry: ViewRegistry, + @multiInject(TYPES.IVNodePostprocessor) private readonly postProcessors: IVNodePostprocessor[] ) { super(); } execute(context: CommandExecutionContext): CommandReturn { - const root = document.getElementById("sprotty_root"); - if (!root) return context.root; - const firstChild = root.children[0]; - if (!firstChild) return context.root; - const innerSvg = firstChild.innerHTML; + const renderer = new ModelRenderer(this.viewRegistry, 'main', this.postProcessors ) + const svg = renderer.renderElement(context.root) + if (!svg) return context.root + console.debug(toHTML(svg)) + + /* The result svg will render (0,0) as the top left corner of the svg. * We calculate the minimum translation of all children. * We then offset the whole svg by this opposite of this amount. */ - const minTranslate = { x: Infinity, y: Infinity }; + /*const minTranslate = { x: Infinity, y: Infinity }; for (const child of firstChild.children) { const childTranslate = this.getMinTranslate(child as HTMLElement); minTranslate.x = Math.min(minTranslate.x, childTranslate.x); @@ -47,8 +51,8 @@ export class SaveImageCommand extends Command { const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.download = this.fileName.getName() + ".svg"; - link.click(); + link.download = this.fileName.getName() + ".svg";*/ + //link.click(); return context.root; } From 0cf0ec74a3a54cb29aebc7adfa2e8a58b3c9c4c2 Mon Sep 17 00:00:00 2001 From: Alexander Vogt Date: Thu, 5 Feb 2026 12:51:05 +0100 Subject: [PATCH 3/7] prototype of new svg transformer --- frontend/webEditor/package-lock.json | 11 + frontend/webEditor/package.json | 5 +- frontend/webEditor/src/serialize/image.ts | 275 +++++++++++++++++++--- 3 files changed, 255 insertions(+), 36 deletions(-) diff --git a/frontend/webEditor/package-lock.json b/frontend/webEditor/package-lock.json index ba4ea942..d2df2c1e 100644 --- a/frontend/webEditor/package-lock.json +++ b/frontend/webEditor/package-lock.json @@ -20,6 +20,7 @@ "monaco-editor": "^0.52.2", "prettier": "^3.8.1", "reflect-metadata": "^0.2.2", + "snabbdom": "^3.6.3", "snabbdom-to-html": "^7.1.0", "sprotty": "^1.4.0", "sprotty-elk": "^1.4.0", @@ -2760,6 +2761,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/snabbdom": { + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/snabbdom/-/snabbdom-3.6.3.tgz", + "integrity": "sha512-W2lHLLw2qR2Vv0DcMmcxXqcfdBaIcoN+y/86SmHv8fn4DazEQSH6KN3TjZcWvwujW56OHiiirsbHWZb4vx/0fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/snabbdom-to-html": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/snabbdom-to-html/-/snabbdom-to-html-7.1.0.tgz", diff --git a/frontend/webEditor/package.json b/frontend/webEditor/package.json index fdc93fd4..5c9aa7a6 100644 --- a/frontend/webEditor/package.json +++ b/frontend/webEditor/package.json @@ -20,13 +20,14 @@ "monaco-editor": "^0.52.2", "prettier": "^3.8.1", "reflect-metadata": "^0.2.2", + "snabbdom": "^3.6.3", + "snabbdom-to-html": "^7.1.0", "sprotty": "^1.4.0", "sprotty-elk": "^1.4.0", "sprotty-protocol": "^1.4.0", "typescript": "^5.8.3", "typescript-eslint": "^8.54.0", - "vite": "^7.3.1", - "snabbdom-to-html": "^7.1.0" + "vite": "^7.3.1" }, "scripts": { "dev": "vite", diff --git a/frontend/webEditor/src/serialize/image.ts b/frontend/webEditor/src/serialize/image.ts index 4765bb9f..80f0b880 100644 --- a/frontend/webEditor/src/serialize/image.ts +++ b/frontend/webEditor/src/serialize/image.ts @@ -1,11 +1,28 @@ -import { Command, CommandExecutionContext, CommandReturn, IVNodePostprocessor, ModelRenderer, TYPES, ViewRegistration, ViewRegistry } from "sprotty"; +import { + Command, + CommandExecutionContext, + CommandReturn, + IVNodePostprocessor, + ModelRenderer, + TYPES, + ViewRegistry, +} from "sprotty"; import themeCss from "../assets/theme.css?raw"; import elementCss from "../diagram/style.css?raw"; -import toHTML from "snabbdom-to-html" +import toHTML from "snabbdom-to-html"; +import { classModule, eventListenersModule, h, init, propsModule, styleModule, VNode, VNodeStyle } from "snabbdom"; import { Action } from "sprotty-protocol"; import { inject, multiInject } from "inversify"; import { FileName } from "../fileName/fileName"; +const patch = init([ + // Init patch function with chosen modules + classModule, // makes it easy to toggle classes + propsModule, // for setting properties on DOM elements + styleModule, // handles styling on elements with support for animations + eventListenersModule, // attaches event listeners +]); + export namespace SaveImageAction { export const KIND = "save-image"; @@ -18,41 +35,30 @@ export namespace SaveImageAction { export class SaveImageCommand extends Command { static readonly KIND = SaveImageAction.KIND; + private static readonly PADDING = 5; constructor( @inject(TYPES.Action) _: Action, @inject(FileName) private readonly fileName: FileName, @inject(TYPES.ViewRegistry) private readonly viewRegistry: ViewRegistry, - @multiInject(TYPES.IVNodePostprocessor) private readonly postProcessors: IVNodePostprocessor[] + @multiInject(TYPES.IVNodePostprocessor) private readonly postProcessors: IVNodePostprocessor[], ) { super(); } execute(context: CommandExecutionContext): CommandReturn { - const renderer = new ModelRenderer(this.viewRegistry, 'main', this.postProcessors ) - const svg = renderer.renderElement(context.root) - if (!svg) return context.root - console.debug(toHTML(svg)) - - - /* The result svg will render (0,0) as the top left corner of the svg. - * We calculate the minimum translation of all children. - * We then offset the whole svg by this opposite of this amount. - */ - /*const minTranslate = { x: Infinity, y: Infinity }; - for (const child of firstChild.children) { - const childTranslate = this.getMinTranslate(child as HTMLElement); - minTranslate.x = Math.min(minTranslate.x, childTranslate.x); - minTranslate.y = Math.min(minTranslate.y, childTranslate.y); - } - const svg = `${innerSvg}`; + const dummyRoot = document.createElement("div"); + dummyRoot.style.position = "absolute"; + dummyRoot.style.left = "-100000px"; + dummyRoot.style.top = "-100000px"; + dummyRoot.style.visibility = "hidden"; - const blob = new Blob([svg], { type: "image/svg+xml" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = this.fileName.getName() + ".svg";*/ - //link.click(); + document.body.appendChild(dummyRoot); + try { + this.makeImage(context, dummyRoot); + } finally { + document.body.removeChild(dummyRoot); + } return context.root; } @@ -63,6 +69,67 @@ export class SaveImageCommand extends Command { return context.root; } + makeImage(context: CommandExecutionContext, dom: HTMLElement) { + // render diagram virtually + const renderer = new ModelRenderer(this.viewRegistry, "hidden", this.postProcessors); + const svg = renderer.renderElement(context.root); + if (!svg) return; + + // add stylesheets + const styleHolder = document.createElement("style"); + styleHolder.innerHTML = `${themeCss}\n${elementCss}`; + dom.appendChild(styleHolder); + + // render svg into dom + const dummyDom = h("div", {}, [svg]); + patch(dom, dummyDom); + // apply style and clean attributes + transformStyleToAttributes(dummyDom); + removeUnusedAttributes(dummyDom); + + // compute diagram offset and size + const holderG = svg.children?.[0]; + if (!holderG || typeof holderG == "string") return; + const actualElements = holderG.children ?? []; + const minTranslate = { x: Infinity, y: Infinity }; + const maxSize = { x: 0, y: 0 }; + for (const child of actualElements) { + if (typeof child == "string") continue; + const childTranslate = this.getMinTranslate(child); + minTranslate.x = Math.min(minTranslate.x, childTranslate.x); + minTranslate.y = Math.min(minTranslate.y, childTranslate.y); + + const childSize = this.getMaxRequieredCanvasSize(child); + maxSize.x = Math.max(maxSize.x, childSize.x); + maxSize.y = Math.max(maxSize.y, childSize.y); + } + + // correct offset and set size + if (!holderG.data) holderG.data = {}; + if (!holderG.data.attrs) holderG.data.attrs = {}; + holderG.data.attrs["transform"] = + `translate(${-minTranslate.x + SaveImageCommand.PADDING},${-minTranslate.y + SaveImageCommand.PADDING})`; + if (!svg.data) svg.data = {}; + if (!svg.data.attrs) svg.data.attrs = {}; + const width = maxSize.x - minTranslate.x + 2 * SaveImageCommand.PADDING; + const height = maxSize.y - minTranslate.y + 2 * SaveImageCommand.PADDING; + svg.data.attrs.width = width; + svg.data.attrs.height = height; + svg.data.attrs.viewBox = `0 0 ${width} ${height}`; + + // make sure element is seen as svg by all users + svg.data.attrs.version = "1.0"; + svg.data.attrs.xmlns = "http://www.w3.org/2000/svg"; + + // download file + const blob = new Blob([toHTML(svg)], { type: "image/svg+xml" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = this.fileName.getName() + ".svg"; + link.click(); + } + /** * Gets the minimum translation of an element relative to the svg. * This is done by recursively getting the translation of all child elements @@ -71,15 +138,16 @@ export class SaveImageCommand extends Command { * @returns Minimum absolute offset of any child element relative to the svg */ private getMinTranslate( - e: HTMLElement, + e: VNode, parentOffset: { x: number; y: number } = { x: 0, y: 0 }, ): { x: number; y: number } { const myTranslate = this.getTranslate(e, parentOffset); - const minTranslate = myTranslate; + const minTranslate = myTranslate ?? { x: Infinity, y: Infinity }; - const children = e.children; + const children = e.children ?? []; for (const child of children) { - const childTranslate = this.getMinTranslate(child as HTMLElement, myTranslate); + if (typeof child == "string") continue; + const childTranslate = this.getMinTranslate(child, myTranslate); minTranslate.x = Math.min(minTranslate.x, childTranslate.x); minTranslate.y = Math.min(minTranslate.y, childTranslate.y); } @@ -94,11 +162,11 @@ export class SaveImageCommand extends Command { * @returns Offset of the child relative to the svg */ private getTranslate( - e: HTMLElement, + e: VNode, parentOffset: { x: number; y: number } = { x: 0, y: 0 }, - ): { x: number; y: number } { - const transform = e.getAttribute("transform"); - if (!transform) return parentOffset; + ): { x: number; y: number } | undefined { + const transform = e.data?.attrs?.["transform"] as string | undefined; + if (!transform) return undefined; const translateMatch = transform.match(/translate\(([^)]+)\)/); if (!translateMatch) return parentOffset; const translate = translateMatch[1].match(/(-?[0-9.]+)(?:, | |,)(-?[0-9.]+)/); @@ -109,4 +177,143 @@ export class SaveImageCommand extends Command { const newY = y + parentOffset.y; return { x: newX, y: newY }; } + + private getMaxRequieredCanvasSize( + e: VNode, + parentOffset: { x: number; y: number } = { x: 0, y: 0 }, + ): { x: number; y: number } { + const myTranslate = this.getTranslate(e, parentOffset); + const maxSize = this.getRequieredCanvasSize(e, parentOffset); + + const children = e.children ?? []; + for (const child of children) { + if (typeof child == "string") continue; + const childTranslate = this.getMaxRequieredCanvasSize(child, myTranslate); + maxSize.x = Math.max(maxSize.x, childTranslate.x); + maxSize.y = Math.max(maxSize.y, childTranslate.y); + } + return maxSize; + } + + private getRequieredCanvasSize( + e: VNode, + parentOffset: { x: number; y: number } = { x: 0, y: 0 }, + ): { x: number; y: number } { + const width = (e.data?.attrs?.["width"] as number | undefined) ?? 0; + const height = (e.data?.attrs?.["height"] as number | undefined) ?? 0; + const translate = this.getTranslate(e, parentOffset) ?? parentOffset; + + const x = translate.x + width; + const y = translate.y + height; + return { x: x, y: y }; + } +} + +function transformStyleToAttributes(v: VNode) { + if (!v.elm) return; + + if (!v.data) v.data = {}; + if (!v.data.style) v.data.style = {}; + if (!v.data.attrs) v.data.attrs = {}; + + const computedStyle = getComputedStyle(v.elm as Element) as VNodeStyle; + for (const key of getRelevantStyleProps(v)) { + let value = v.data.style[key] ?? computedStyle[key]; + if (key == "fill" && value.startsWith("color(srgb")) { + const srgb = /color\(srgb ([^ ]+) ([^ ]+) ([^ ]+)(?: ?\/ ?([^ ]+))?\)/.exec(value); + if (srgb) { + const r = Math.round(Number(srgb[1]) * 255); + const g = Math.round(Number(srgb[2]) * 255); + const b = Math.round(Number(srgb[3]) * 255); + const a = srgb[4] ? Number(srgb[4]) : 1; + value = `rgb(${r},${g},${b})`; + + v.data.attrs["fill-opacity"] = a; + } + } + if (key == "font-family") { + value = "sans-serif"; + } + + if (value.endsWith("px")) { + value = value.substring(0, value.length - 2); + } + if (value != getDefaultValues(key)) { + v.data.attrs[key] = value; + } + } + + if (getVNodeSVGType(v) == "text") { + const oldY = (v.data.attrs.y as number | undefined) ?? 0; + const fontSize = computedStyle.fontSize + ? Number(computedStyle.fontSize.substring(0, computedStyle.fontSize.length - 2)) + : 12; + const newY = oldY + 0.35 * fontSize; + v.data.attrs.y = newY; + } + + if (!v.children) return; + for (const child of v.children) { + if (typeof child === "string") continue; + transformStyleToAttributes(child); + } +} + +function removeUnusedAttributes(v: VNode) { + if (!v.data) v.data = {}; + if (v.data.attrs) { + delete v.data.attrs["id"]; + delete v.data.attrs["tabindex"]; + } + if (v.data.class) { + for (const clas in v.data.class) { + v.data.class[clas] = false; + } + } + + if (!v.children) return; + for (const child of v.children) { + if (typeof child === "string") continue; + removeUnusedAttributes(child); + } +} + +function getVNodeSVGType(v: VNode): string | undefined { + return v.sel?.split(/#|\./)[0]; +} + +function getRelevantStyleProps(v: VNode): string[] { + const type = getVNodeSVGType(v); + switch (type) { + case "g": + case "svg": + return []; + case "text": + return ["font-size", "font-family", "font-weight", "text-anchor", "opacity"]; + default: + return [ + "fill", + "stroke", + "stroke-width", + "stroke-dasharray", + "stroke-linecap", + "stroke-linejoin", + "opacity", + ]; + } +} + +function getDefaultValues(key: string) { + switch (key) { + case "stroke-dasharray": + return "none"; + case "stroke-linecap": + return "butt"; + case "stroke-linejoin": + return "miter"; + case "opacity": + return 1; + default: + return undefined; + } } From dad256eb21475110adfd714bfd77987e928ba295 Mon Sep 17 00:00:00 2001 From: Alex | Kronox Date: Fri, 6 Feb 2026 11:34:12 +0100 Subject: [PATCH 4/7] add pdf support --- frontend/webEditor/package-lock.json | 280 +++++++++ frontend/webEditor/package.json | 4 + .../commandPalette/commandPaletteProvider.ts | 3 +- .../src/serialize/defaultDiagram.json | 586 +----------------- frontend/webEditor/src/serialize/image.ts | 245 +++++--- 5 files changed, 470 insertions(+), 648 deletions(-) diff --git a/frontend/webEditor/package-lock.json b/frontend/webEditor/package-lock.json index d2df2c1e..55fcac57 100644 --- a/frontend/webEditor/package-lock.json +++ b/frontend/webEditor/package-lock.json @@ -7,6 +7,10 @@ "": { "name": "data-flow-analysis-web-editor", "version": "0.0.0", + "dependencies": { + "jspdf": "^4.1.0", + "svg2pdf.js": "^2.7.0" + }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.2", @@ -30,6 +34,15 @@ "vite": "^7.3.1" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -1028,6 +1041,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.54.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", @@ -1397,6 +1430,16 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1438,6 +1481,26 @@ "node": ">=6" } }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1532,6 +1595,18 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", + "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1547,6 +1622,28 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1572,6 +1669,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/elkjs": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.8.2.tgz", @@ -1852,6 +1959,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -1923,6 +2047,12 @@ "dev": true, "license": "ISC" }, + "node_modules/font-family-papandreou": { + "version": "0.2.0-patch2", + "resolved": "https://registry.npmjs.org/font-family-papandreou/-/font-family-papandreou-0.2.0-patch2.tgz", + "integrity": "sha512-l/YiRdBSH/eWv6OF3sLGkwErL+n0MqCICi9mppTZBOCL5vixWGDqCYvRcuxB2h7RGCTzaTKOHT2caHvCXQPRlw==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1987,6 +2117,20 @@ "node": ">=8" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -2054,6 +2198,12 @@ "reflect-metadata": "~0.2.2" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2144,6 +2294,23 @@ "dev": true, "license": "MIT" }, + "node_modules/jspdf": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.1.0.tgz", + "integrity": "sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2458,6 +2625,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2501,6 +2674,13 @@ "node": ">=8" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2599,6 +2779,16 @@ "node": ">=6" } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -2606,6 +2796,13 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2640,6 +2837,16 @@ "dev": true, "license": "MIT" }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rollup": { "version": "4.52.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", @@ -2797,6 +3004,15 @@ "node": ">=0.10.0" } }, + "node_modules/specificity": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/specificity/-/specificity-0.4.1.tgz", + "integrity": "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg==", + "license": "MIT", + "bin": { + "specificity": "bin/specificity" + } + }, "node_modules/sprotty": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/sprotty/-/sprotty-1.4.0.tgz", @@ -2843,6 +3059,16 @@ "node": ">=8.3.0" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -2912,6 +3138,50 @@ "node": ">=8" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/svg2pdf.js": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/svg2pdf.js/-/svg2pdf.js-2.7.0.tgz", + "integrity": "sha512-nXK4Wx28H0KtOktanm5nsphl1KMEoLNMelAT/776qxPAj9DshwYcqgdpKuBnY1nrcYOriQFHVQLE4tIag+aDJA==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "font-family-papandreou": "^0.2.0-patch1", + "specificity": "^0.4.1", + "svgpath": "^2.3.0" + }, + "peerDependencies": { + "jspdf": "^4.0.0 || ^3.0.0 || ^2.0.0" + } + }, + "node_modules/svgpath": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/svgpath/-/svgpath-2.6.0.tgz", + "integrity": "sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg==", + "license": "MIT", + "funding": { + "url": "https://github.com/fontello/svg2ttf?sponsor=1" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3054,6 +3324,16 @@ "punycode": "^2.1.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/frontend/webEditor/package.json b/frontend/webEditor/package.json index 5c9aa7a6..d041dceb 100644 --- a/frontend/webEditor/package.json +++ b/frontend/webEditor/package.json @@ -46,5 +46,9 @@ "npm run lint", "npm run format" ] + }, + "dependencies": { + "jspdf": "^4.1.0", + "svg2pdf.js": "^2.7.0" } } diff --git a/frontend/webEditor/src/commandPalette/commandPaletteProvider.ts b/frontend/webEditor/src/commandPalette/commandPaletteProvider.ts index bfb31452..53fb8b63 100644 --- a/frontend/webEditor/src/commandPalette/commandPaletteProvider.ts +++ b/frontend/webEditor/src/commandPalette/commandPaletteProvider.ts @@ -40,7 +40,8 @@ export class WebEditorCommandPaletteActionProvider implements ICommandPaletteAct [ new LabeledAction("Save diagram as JSON", [SaveJsonFileAction.create()], "json"), new LabeledAction("Save diagram as DFD and DD", [SaveDfdAndDdFileAction.create()], "coffee"), - new LabeledAction("Save viewport as image", [SaveImageAction.create()], "device-camera"), + new LabeledAction("Save diagram as SVG", [SaveImageAction.create("svg")], "file-media"), + new LabeledAction("Save diagram as PDF", [SaveImageAction.create("pdf")], "file-pdf"), ], "save", ), diff --git a/frontend/webEditor/src/serialize/defaultDiagram.json b/frontend/webEditor/src/serialize/defaultDiagram.json index cf59979a..692cd789 100644 --- a/frontend/webEditor/src/serialize/defaultDiagram.json +++ b/frontend/webEditor/src/serialize/defaultDiagram.json @@ -1,58 +1,27 @@ { "model": { - "canvasBounds": { - "x": 0, - "y": 0, - "width": 1278, - "height": 1324 - }, - "scroll": { - "x": 181.68489464915504, - "y": -12.838536201820945 - }, - "zoom": 6.057478948161569, - "position": { - "x": 0, - "y": 0 - }, - "size": { - "width": -1, - "height": -1 - }, + "canvasBounds": { "x": 0, "y": 0, "width": 1067, "height": 1606 }, + "scroll": { "x": 9, "y": -324.10309278350513 }, + "zoom": 1.86050566695728, + "position": { "x": 0, "y": 0 }, + "size": { "width": -1, "height": -1 }, "features": {}, "type": "graph", "id": "root", "children": [ { - "position": { - "x": 84, - "y": 54 - }, - "size": { - "width": -1, - "height": -1 - }, + "position": { "x": 244, "y": 29 }, + "size": { "width": -1, "height": -1 }, "strokeWidth": 0, "selected": false, "hoverFeedback": false, "opacity": 1, "text": "User", - "labels": [ - { - "labelTypeId": "gvia09", - "labelTypeValueId": "g10hr" - } - ], + "labels": [{ "labelTypeId": "gvia09", "labelTypeValueId": "g10hr" }], "ports": [ { - "position": { - "x": 58.5, - "y": 7 - }, - "size": { - "width": -1, - "height": -1 - }, + "position": { "x": 58.5, "y": 7 }, + "size": { "width": -1, "height": -1 }, "strokeWidth": 0, "selected": false, "hoverFeedback": false, @@ -63,512 +32,27 @@ "children": [] }, { - "position": { - "x": 31, - "y": 38.5 - }, - "size": { - "width": -1, - "height": -1 - }, - "strokeWidth": 0, - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "behavior": "set Sensitivity.Personal", - "features": {}, - "id": "4wbyft", - "type": "port:dfd-output", - "children": [] - }, - { - "position": { - "x": 58.5, - "y": 25.5 - }, - "size": { - "width": -1, - "height": -1 - }, + "position": { "x": 58.5, "y": 25.5 }, + "size": { "width": -1, "height": -1 }, "strokeWidth": 0, "selected": false, "hoverFeedback": false, "opacity": 1, "behavior": "set Sensitivity.Public", + "validBehavior": true, "features": {}, "id": "wksxi8", "type": "port:dfd-output", "children": [] } ], + "hideLabels": false, + "minimumWidth": 50, + "annotations": [], "features": {}, "id": "7oii5l", "type": "node:input-output", "children": [] - }, - { - "position": { - "x": 249, - "y": 67 - }, - "size": { - "width": -1, - "height": -1 - }, - "strokeWidth": 0, - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "text": "view", - "labels": [], - "ports": [ - { - "position": { - "x": -3.5, - "y": 13 - }, - "size": { - "width": -1, - "height": -1 - }, - "strokeWidth": 0, - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "features": {}, - "id": "ti4ri7", - "type": "port:dfd-input", - "children": [] - }, - { - "position": { - "x": 58.5, - "y": 13 - }, - "size": { - "width": -1, - "height": -1 - }, - "strokeWidth": 0, - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "behavior": "forward request", - "features": {}, - "id": "bsqjm", - "type": "port:dfd-output", - "children": [] - } - ], - "features": {}, - "id": "0bh7yh", - "type": "node:function", - "children": [] - }, - { - "position": { - "x": 249, - "y": 22 - }, - "size": { - "width": -1, - "height": -1 - }, - "strokeWidth": 0, - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "text": "display", - "labels": [], - "ports": [ - { - "position": { - "x": 58.5, - "y": 15 - }, - "size": { - "width": -1, - "height": -1 - }, - "strokeWidth": 0, - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "features": {}, - "id": "0hfzu", - "type": "port:dfd-input", - "children": [] - }, - { - "position": { - "x": -3.5, - "y": 9 - }, - "size": { - "width": -1, - "height": -1 - }, - "strokeWidth": 0, - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "behavior": "forward items", - "features": {}, - "id": "y1p7qq", - "type": "port:dfd-output", - "children": [] - } - ], - "features": {}, - "id": "4myuyr", - "type": "node:function", - "children": [] - }, - { - "position": { - "x": 364, - "y": 152 - }, - "size": { - "width": -1, - "height": -1 - }, - "strokeWidth": 0, - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "text": "encrypt", - "labels": [], - "ports": [ - { - "position": { - "x": -3.5, - "y": 15.5 - }, - "size": { - "width": -1, - "height": -1 - }, - "strokeWidth": 0, - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "features": {}, - "id": "kqjy4g", - "type": "port:dfd-input", - "children": [] - }, - { - "position": { - "x": 29, - "y": -3.5 - }, - "size": { - "width": -1, - "height": -1 - }, - "strokeWidth": 0, - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "behavior": "forward data\nset Encryption.Encrypted", - "features": {}, - "id": "3wntc", - "type": "port:dfd-output", - "children": [] - } - ], - "features": {}, - "id": "3n988k", - "type": "node:function", - "children": [] - }, - { - "position": { - "x": 104, - "y": 157 - }, - "size": { - "width": -1, - "height": -1 - }, - "strokeWidth": 0, - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "text": "buy", - "labels": [], - "ports": [ - { - "position": { - "x": 19, - "y": -3.5 - }, - "size": { - "width": -1, - "height": -1 - }, - "strokeWidth": 0, - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "features": {}, - "id": "2331e8", - "type": "port:dfd-input", - "children": [] - }, - { - "position": { - "x": 58.5, - "y": 10.5 - }, - "size": { - "width": -1, - "height": -1 - }, - "strokeWidth": 0, - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "behavior": "forward data", - "features": {}, - "id": "vnkg73", - "type": "port:dfd-output", - "children": [] - } - ], - "features": {}, - "id": "z9v1jp", - "type": "node:function", - "children": [] - }, - { - "position": { - "x": 233.5, - "y": 157 - }, - "size": { - "width": -1, - "height": -1 - }, - "strokeWidth": 0, - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "text": "process", - "labels": [], - "ports": [ - { - "position": { - "x": -3.5, - "y": 10.5 - }, - "size": { - "width": -1, - "height": -1 - }, - "strokeWidth": 0, - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "features": {}, - "id": "xyepdb", - "type": "port:dfd-input", - "children": [] - }, - { - "position": { - "x": 59.5, - "y": 10.5 - }, - "size": { - "width": -1, - "height": -1 - }, - "strokeWidth": 0, - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "behavior": "forward data", - "features": {}, - "id": "eedb56", - "type": "port:dfd-output", - "children": [] - } - ], - "features": {}, - "id": "js61f", - "type": "node:function", - "children": [] - }, - { - "position": { - "x": 422.5, - "y": 59 - }, - "size": { - "width": -1, - "height": -1 - }, - "strokeWidth": 0, - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "text": "Database", - "labels": [ - { - "labelTypeId": "gvia09", - "labelTypeValueId": "5hnugm" - } - ], - "ports": [ - { - "position": { - "x": -3.5, - "y": 23 - }, - "size": { - "width": -1, - "height": -1 - }, - "strokeWidth": 0, - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "features": {}, - "id": "scljwi", - "type": "port:dfd-input", - "children": [] - }, - { - "position": { - "x": -3.5, - "y": 0.5 - }, - "size": { - "width": -1, - "height": -1 - }, - "strokeWidth": 0, - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "behavior": "set Sensitivity.Public", - "features": {}, - "id": "1j7bn5", - "type": "port:dfd-output", - "children": [] - } - ], - "features": {}, - "id": "8j2r1g", - "type": "node:storage", - "children": [] - }, - { - "routingPoints": [], - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "features": {}, - "id": "vq8g3l", - "type": "edge:arrow", - "sourceId": "4wbyft", - "targetId": "2331e8", - "text": "data", - "children": [] - }, - { - "routingPoints": [], - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "features": {}, - "id": "xrzc19", - "type": "edge:arrow", - "sourceId": "vnkg73", - "targetId": "xyepdb", - "text": "data", - "children": [] - }, - { - "routingPoints": [], - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "features": {}, - "id": "ufflto", - "type": "edge:arrow", - "sourceId": "eedb56", - "targetId": "kqjy4g", - "text": "data", - "children": [] - }, - { - "routingPoints": [], - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "features": {}, - "id": "ojjvtp", - "type": "edge:arrow", - "sourceId": "3wntc", - "targetId": "scljwi", - "text": "data", - "children": [] - }, - { - "routingPoints": [], - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "features": {}, - "id": "c9n88l", - "type": "edge:arrow", - "sourceId": "bsqjm", - "targetId": "scljwi", - "text": "request", - "children": [] - }, - { - "routingPoints": [], - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "features": {}, - "id": "uflsc", - "type": "edge:arrow", - "sourceId": "wksxi8", - "targetId": "ti4ri7", - "text": "request", - "routerKind": "polyline", - "children": [] - }, - { - "routingPoints": [], - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "features": {}, - "id": "n81f3b", - "type": "edge:arrow", - "sourceId": "1j7bn5", - "targetId": "0hfzu", - "text": "items", - "children": [] - }, - { - "routingPoints": [], - "selected": false, - "hoverFeedback": false, - "opacity": 1, - "features": {}, - "id": "hi397b", - "type": "edge:arrow", - "sourceId": "y1p7qq", - "targetId": "nhcrad", - "text": "items", - "children": [] } ] }, @@ -577,47 +61,21 @@ "id": "4h3wzk", "name": "Sensitivity", "values": [ - { - "id": "zzvphn", - "text": "Personal" - }, - { - "id": "veaan9", - "text": "Public" - } + { "id": "zzvphn", "text": "Personal" }, + { "id": "veaan9", "text": "Public" } ] }, { "id": "gvia09", "name": "Location", "values": [ - { - "id": "g10hr", - "text": "EU" - }, - { - "id": "5hnugm", - "text": "nonEU" - } + { "id": "g10hr", "text": "EU" }, + { "id": "5hnugm", "text": "nonEU" } ] }, - { - "id": "84rllz", - "name": "Encryption", - "values": [ - { - "id": "2r6xe6", - "text": "Encrypted" - } - ] - } - ], - "constraints": [ - { - "name": "Test", - "constraint": "data Sensitivity.Personal neverFlows vertex Location.nonEU" - } + { "id": "84rllz", "name": "Encryption", "values": [{ "id": "2r6xe6", "text": "Encrypted" }] } ], + "constraints": [{ "name": "Test", "constraint": "data Sensitivity.Personal neverFlows vertex Location.nonEU" }], "mode": "edit", "version": 1 } diff --git a/frontend/webEditor/src/serialize/image.ts b/frontend/webEditor/src/serialize/image.ts index 80f0b880..db7bd01b 100644 --- a/frontend/webEditor/src/serialize/image.ts +++ b/frontend/webEditor/src/serialize/image.ts @@ -4,6 +4,7 @@ import { CommandReturn, IVNodePostprocessor, ModelRenderer, + SModelRootImpl, TYPES, ViewRegistry, } from "sprotty"; @@ -14,6 +15,9 @@ import { classModule, eventListenersModule, h, init, propsModule, styleModule, V import { Action } from "sprotty-protocol"; import { inject, multiInject } from "inversify"; import { FileName } from "../fileName/fileName"; +import { jsPDF } from "jspdf"; +import "svg2pdf.js"; +import { calculateTextSize } from "../utils/TextSize"; const patch = init([ // Init patch function with chosen modules @@ -23,22 +27,33 @@ const patch = init([ eventListenersModule, // attaches event listeners ]); +interface SaveImageAction extends Action { + saveType: "svg" | "pdf"; +} + export namespace SaveImageAction { export const KIND = "save-image"; - export function create(): Action { + export function create(saveType: "svg" | "pdf"): SaveImageAction { return { kind: KIND, + saveType, }; } } +interface SVGResult { + svg: string; + width: number; + height: number; +} + export class SaveImageCommand extends Command { static readonly KIND = SaveImageAction.KIND; private static readonly PADDING = 5; constructor( - @inject(TYPES.Action) _: Action, + @inject(TYPES.Action) private readonly action: SaveImageAction, @inject(FileName) private readonly fileName: FileName, @inject(TYPES.ViewRegistry) private readonly viewRegistry: ViewRegistry, @multiInject(TYPES.IVNodePostprocessor) private readonly postProcessors: IVNodePostprocessor[], @@ -46,7 +61,7 @@ export class SaveImageCommand extends Command { super(); } - execute(context: CommandExecutionContext): CommandReturn { + async execute(context: CommandExecutionContext): Promise { const dummyRoot = document.createElement("div"); dummyRoot.style.position = "absolute"; dummyRoot.style.left = "-100000px"; @@ -54,11 +69,19 @@ export class SaveImageCommand extends Command { dummyRoot.style.visibility = "hidden"; document.body.appendChild(dummyRoot); + let result: SVGResult | undefined; try { - this.makeImage(context, dummyRoot); + result = this.getSVG(context, dummyRoot); } finally { document.body.removeChild(dummyRoot); } + if (result) { + if (this.action.saveType === "svg") { + this.writeSVG(result); + } else { + this.writePDF(result); + } + } return context.root; } @@ -69,7 +92,7 @@ export class SaveImageCommand extends Command { return context.root; } - makeImage(context: CommandExecutionContext, dom: HTMLElement) { + getSVG(context: CommandExecutionContext, dom: HTMLElement): SVGResult | undefined { // render diagram virtually const renderer = new ModelRenderer(this.viewRegistry, "hidden", this.postProcessors); const svg = renderer.renderElement(context.root); @@ -85,6 +108,10 @@ export class SaveImageCommand extends Command { patch(dom, dummyDom); // apply style and clean attributes transformStyleToAttributes(dummyDom); + // Centering does not work properly for pdfs. We fix this manually + if (this.action.saveType === "pdf") { + centerText(dummyDom, 0); + } removeUnusedAttributes(dummyDom); // compute diagram offset and size @@ -95,11 +122,11 @@ export class SaveImageCommand extends Command { const maxSize = { x: 0, y: 0 }; for (const child of actualElements) { if (typeof child == "string") continue; - const childTranslate = this.getMinTranslate(child); + const childTranslate = getMinTranslate(child); minTranslate.x = Math.min(minTranslate.x, childTranslate.x); minTranslate.y = Math.min(minTranslate.y, childTranslate.y); - const childSize = this.getMaxRequieredCanvasSize(child); + const childSize = getMaxRequiredCanvasSize(child); maxSize.x = Math.max(maxSize.x, childSize.x); maxSize.y = Math.max(maxSize.y, childSize.y); } @@ -121,92 +148,109 @@ export class SaveImageCommand extends Command { svg.data.attrs.version = "1.0"; svg.data.attrs.xmlns = "http://www.w3.org/2000/svg"; - // download file - const blob = new Blob([toHTML(svg)], { type: "image/svg+xml" }); + return { svg: toHTML(svg), width, height }; + } + + async writePDF(svg: SVGResult) { + const wrapper = document.createElement("div"); + wrapper.innerHTML = svg.svg.trim(); + const svgEl = wrapper.querySelector("svg"); + if (!svgEl) return; + const doc = new jsPDF({ + orientation: svg.width > svg.height ? "landscape" : "portrait", + format: [svg.width, svg.height], + }); + await doc.svg(svgEl, { + x: 0, + y: 0, + width: svg.width, + }); + doc.save(this.fileName.getName() + ".pdf"); + } + + writeSVG(svg: SVGResult) { + const blob = new Blob([svg.svg], { type: "image/svg+xml" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = this.fileName.getName() + ".svg"; link.click(); } +} - /** - * Gets the minimum translation of an element relative to the svg. - * This is done by recursively getting the translation of all child elements - * @param e the element to get the translation from - * @param parentOffset Offset of the containing element - * @returns Minimum absolute offset of any child element relative to the svg - */ - private getMinTranslate( - e: VNode, - parentOffset: { x: number; y: number } = { x: 0, y: 0 }, - ): { x: number; y: number } { - const myTranslate = this.getTranslate(e, parentOffset); - const minTranslate = myTranslate ?? { x: Infinity, y: Infinity }; - - const children = e.children ?? []; - for (const child of children) { - if (typeof child == "string") continue; - const childTranslate = this.getMinTranslate(child, myTranslate); - minTranslate.x = Math.min(minTranslate.x, childTranslate.x); - minTranslate.y = Math.min(minTranslate.y, childTranslate.y); - } - return minTranslate; - } - - /** - * Calculates the absolute translation of an element relative to the svg. - * If the element has no translation, the offset of the parent is returned. - * @param e the element to get the translation from - * @param parentOffset Offset of the containing element - * @returns Offset of the child relative to the svg - */ - private getTranslate( - e: VNode, - parentOffset: { x: number; y: number } = { x: 0, y: 0 }, - ): { x: number; y: number } | undefined { - const transform = e.data?.attrs?.["transform"] as string | undefined; - if (!transform) return undefined; - const translateMatch = transform.match(/translate\(([^)]+)\)/); - if (!translateMatch) return parentOffset; - const translate = translateMatch[1].match(/(-?[0-9.]+)(?:, | |,)(-?[0-9.]+)/); - if (!translate) return parentOffset; - const x = parseFloat(translate[1]); - const y = parseFloat(translate[2]); - const newX = x + parentOffset.x; - const newY = y + parentOffset.y; - return { x: newX, y: newY }; +/** + * Gets the minimum translation of an element relative to the svg. + * This is done by recursively getting the translation of all child elements + * @param e the element to get the translation from + * @param parentOffset Offset of the containing element + * @returns Minimum absolute offset of any child element relative to the svg + */ +function getMinTranslate(e: VNode, parentOffset: { x: number; y: number } = { x: 0, y: 0 }): { x: number; y: number } { + const myTranslate = getTranslate(e, parentOffset); + const minTranslate = myTranslate ?? { x: Infinity, y: Infinity }; + + const children = e.children ?? []; + for (const child of children) { + if (typeof child == "string") continue; + const childTranslate = getMinTranslate(child, myTranslate); + minTranslate.x = Math.min(minTranslate.x, childTranslate.x); + minTranslate.y = Math.min(minTranslate.y, childTranslate.y); } + return minTranslate; +} - private getMaxRequieredCanvasSize( - e: VNode, - parentOffset: { x: number; y: number } = { x: 0, y: 0 }, - ): { x: number; y: number } { - const myTranslate = this.getTranslate(e, parentOffset); - const maxSize = this.getRequieredCanvasSize(e, parentOffset); +/** + * Calculates the absolute translation of an element relative to the svg. + * If the element has no translation, the offset of the parent is returned. + * @param e the element to get the translation from + * @param parentOffset Offset of the containing element + * @returns Offset of the child relative to the svg + */ +function getTranslate( + e: VNode, + parentOffset: { x: number; y: number } = { x: 0, y: 0 }, +): { x: number; y: number } | undefined { + const transform = e.data?.attrs?.["transform"] as string | undefined; + if (!transform) return undefined; + const translateMatch = transform.match(/translate\(([^)]+)\)/); + if (!translateMatch) return parentOffset; + const translate = translateMatch[1].match(/(-?[0-9.]+)(?:, | |,)(-?[0-9.]+)/); + if (!translate) return parentOffset; + const x = parseFloat(translate[1]); + const y = parseFloat(translate[2]); + const newX = x + parentOffset.x; + const newY = y + parentOffset.y; + return { x: newX, y: newY }; +} - const children = e.children ?? []; - for (const child of children) { - if (typeof child == "string") continue; - const childTranslate = this.getMaxRequieredCanvasSize(child, myTranslate); - maxSize.x = Math.max(maxSize.x, childTranslate.x); - maxSize.y = Math.max(maxSize.y, childTranslate.y); - } - return maxSize; +function getMaxRequiredCanvasSize( + e: VNode, + parentOffset: { x: number; y: number } = { x: 0, y: 0 }, +): { x: number; y: number } { + const myTranslate = getTranslate(e, parentOffset); + const maxSize = getRequiredCanvasSize(e, parentOffset); + + const children = e.children ?? []; + for (const child of children) { + if (typeof child == "string") continue; + const childTranslate = getMaxRequiredCanvasSize(child, myTranslate); + maxSize.x = Math.max(maxSize.x, childTranslate.x); + maxSize.y = Math.max(maxSize.y, childTranslate.y); } + return maxSize; +} - private getRequieredCanvasSize( - e: VNode, - parentOffset: { x: number; y: number } = { x: 0, y: 0 }, - ): { x: number; y: number } { - const width = (e.data?.attrs?.["width"] as number | undefined) ?? 0; - const height = (e.data?.attrs?.["height"] as number | undefined) ?? 0; - const translate = this.getTranslate(e, parentOffset) ?? parentOffset; - - const x = translate.x + width; - const y = translate.y + height; - return { x: x, y: y }; - } +function getRequiredCanvasSize( + e: VNode, + parentOffset: { x: number; y: number } = { x: 0, y: 0 }, +): { x: number; y: number } { + const width = (e.data?.attrs?.["width"] as number | undefined) ?? 0; + const height = (e.data?.attrs?.["height"] as number | undefined) ?? 0; + const translate = getTranslate(e, parentOffset) ?? parentOffset; + + const x = translate.x + width; + const y = translate.y + height; + return { x: x, y: y }; } function transformStyleToAttributes(v: VNode) { @@ -238,7 +282,7 @@ function transformStyleToAttributes(v: VNode) { if (value.endsWith("px")) { value = value.substring(0, value.length - 2); } - if (value != getDefaultValues(key)) { + if (value != getDefaultPropertyValues(key)) { v.data.attrs[key] = value; } } @@ -278,6 +322,41 @@ function removeUnusedAttributes(v: VNode) { } } +function centerText(v: VNode, maxSiblingSize: number = 0, maxSiblingX: number = 0) { + if (getVNodeSVGType(v) == "text") { + if (!v.data) v.data = {}; + if (!v.data.attrs) v.data.attrs = {}; + + v.data.attrs["text-anchor"] = "start"; + if (v.data.class?.["port-text"] === true) { + if (v.text === "I") { + v.data.attrs.x = 2.8; + } else { + v.data.attrs.x = 1.3; + } + } else { + const width = calculateTextSize(v.text, `${v.data.attrs["font-size"] ?? 0}px sans-serif `).width; + v.data.attrs.x = maxSiblingSize / 2 - width / 2 + maxSiblingX; + } + } + + if (!v.children) return; + + let newMaxSiblingSize = 0; + let newMaxSiblingX = 0; + for (const child of v.children) { + if (typeof child === "string") continue; + if (getVNodeSVGType(child) == "text") continue; + newMaxSiblingSize = Math.max(newMaxSiblingSize, Number(child.data?.attrs?.width ?? 0)); + newMaxSiblingX = Math.max(newMaxSiblingX, Number(child.data?.attrs?.x ?? 0)); + } + + for (const child of v.children) { + if (typeof child === "string") continue; + centerText(child, newMaxSiblingSize, newMaxSiblingX); + } +} + function getVNodeSVGType(v: VNode): string | undefined { return v.sel?.split(/#|\./)[0]; } @@ -303,7 +382,7 @@ function getRelevantStyleProps(v: VNode): string[] { } } -function getDefaultValues(key: string) { +function getDefaultPropertyValues(key: string) { switch (key) { case "stroke-dasharray": return "none"; From 2047b8798c2a60c54411d912bf66c3ced05d5e4d Mon Sep 17 00:00:00 2001 From: Alex | Kronox Date: Fri, 6 Feb 2026 12:35:41 +0100 Subject: [PATCH 5/7] add selection support --- .../commandPalette/commandPaletteProvider.ts | 15 +- .../src/serialize/defaultDiagram.json | 586 +++++++++++++++++- frontend/webEditor/src/serialize/di.config.ts | 2 +- .../src/serialize/{image.ts => export.ts} | 297 +++++---- 4 files changed, 766 insertions(+), 134 deletions(-) rename frontend/webEditor/src/serialize/{image.ts => export.ts} (62%) diff --git a/frontend/webEditor/src/commandPalette/commandPaletteProvider.ts b/frontend/webEditor/src/commandPalette/commandPaletteProvider.ts index 53fb8b63..2c57a75f 100644 --- a/frontend/webEditor/src/commandPalette/commandPaletteProvider.ts +++ b/frontend/webEditor/src/commandPalette/commandPaletteProvider.ts @@ -10,7 +10,7 @@ import { LayoutMethod } from "../layout/layoutMethod"; import { LayoutModelAction } from "../layout/command"; import { SaveJsonFileAction } from "../serialize/saveJsonFile"; import { SaveDfdAndDdFileAction } from "../serialize/saveDfdAndDdFile"; -import { SaveImageAction } from "../serialize/image"; +import { SaveImageAction } from "../serialize/export"; /** * Provides possible actions for the command palette. @@ -40,12 +40,21 @@ export class WebEditorCommandPaletteActionProvider implements ICommandPaletteAct [ new LabeledAction("Save diagram as JSON", [SaveJsonFileAction.create()], "json"), new LabeledAction("Save diagram as DFD and DD", [SaveDfdAndDdFileAction.create()], "coffee"), - new LabeledAction("Save diagram as SVG", [SaveImageAction.create("svg")], "file-media"), - new LabeledAction("Save diagram as PDF", [SaveImageAction.create("pdf")], "file-pdf"), ], "save", ), + new FolderAction( + "Export", + [ + new LabeledAction("Export diagram as SVG", [SaveImageAction.create("svg", false)], "file-media"), + new LabeledAction("Export selection as SVG", [SaveImageAction.create("svg", true)], "file-media"), + new LabeledAction("Export diagram as PDF", [SaveImageAction.create("pdf", false)], "file-pdf"), + new LabeledAction("Export selection as PDF", [SaveImageAction.create("pdf", true)], "file-pdf"), + ], + "export", + ), + new LabeledAction("Load default diagram", [LoadDefaultDiagramAction.create(), commitAction], "clear-all"), new LabeledAction("Fit to Screen", [fitToScreenAction], "screen-normal"), new FolderAction( diff --git a/frontend/webEditor/src/serialize/defaultDiagram.json b/frontend/webEditor/src/serialize/defaultDiagram.json index 692cd789..cf59979a 100644 --- a/frontend/webEditor/src/serialize/defaultDiagram.json +++ b/frontend/webEditor/src/serialize/defaultDiagram.json @@ -1,27 +1,58 @@ { "model": { - "canvasBounds": { "x": 0, "y": 0, "width": 1067, "height": 1606 }, - "scroll": { "x": 9, "y": -324.10309278350513 }, - "zoom": 1.86050566695728, - "position": { "x": 0, "y": 0 }, - "size": { "width": -1, "height": -1 }, + "canvasBounds": { + "x": 0, + "y": 0, + "width": 1278, + "height": 1324 + }, + "scroll": { + "x": 181.68489464915504, + "y": -12.838536201820945 + }, + "zoom": 6.057478948161569, + "position": { + "x": 0, + "y": 0 + }, + "size": { + "width": -1, + "height": -1 + }, "features": {}, "type": "graph", "id": "root", "children": [ { - "position": { "x": 244, "y": 29 }, - "size": { "width": -1, "height": -1 }, + "position": { + "x": 84, + "y": 54 + }, + "size": { + "width": -1, + "height": -1 + }, "strokeWidth": 0, "selected": false, "hoverFeedback": false, "opacity": 1, "text": "User", - "labels": [{ "labelTypeId": "gvia09", "labelTypeValueId": "g10hr" }], + "labels": [ + { + "labelTypeId": "gvia09", + "labelTypeValueId": "g10hr" + } + ], "ports": [ { - "position": { "x": 58.5, "y": 7 }, - "size": { "width": -1, "height": -1 }, + "position": { + "x": 58.5, + "y": 7 + }, + "size": { + "width": -1, + "height": -1 + }, "strokeWidth": 0, "selected": false, "hoverFeedback": false, @@ -32,27 +63,512 @@ "children": [] }, { - "position": { "x": 58.5, "y": 25.5 }, - "size": { "width": -1, "height": -1 }, + "position": { + "x": 31, + "y": 38.5 + }, + "size": { + "width": -1, + "height": -1 + }, + "strokeWidth": 0, + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "behavior": "set Sensitivity.Personal", + "features": {}, + "id": "4wbyft", + "type": "port:dfd-output", + "children": [] + }, + { + "position": { + "x": 58.5, + "y": 25.5 + }, + "size": { + "width": -1, + "height": -1 + }, "strokeWidth": 0, "selected": false, "hoverFeedback": false, "opacity": 1, "behavior": "set Sensitivity.Public", - "validBehavior": true, "features": {}, "id": "wksxi8", "type": "port:dfd-output", "children": [] } ], - "hideLabels": false, - "minimumWidth": 50, - "annotations": [], "features": {}, "id": "7oii5l", "type": "node:input-output", "children": [] + }, + { + "position": { + "x": 249, + "y": 67 + }, + "size": { + "width": -1, + "height": -1 + }, + "strokeWidth": 0, + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "text": "view", + "labels": [], + "ports": [ + { + "position": { + "x": -3.5, + "y": 13 + }, + "size": { + "width": -1, + "height": -1 + }, + "strokeWidth": 0, + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "features": {}, + "id": "ti4ri7", + "type": "port:dfd-input", + "children": [] + }, + { + "position": { + "x": 58.5, + "y": 13 + }, + "size": { + "width": -1, + "height": -1 + }, + "strokeWidth": 0, + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "behavior": "forward request", + "features": {}, + "id": "bsqjm", + "type": "port:dfd-output", + "children": [] + } + ], + "features": {}, + "id": "0bh7yh", + "type": "node:function", + "children": [] + }, + { + "position": { + "x": 249, + "y": 22 + }, + "size": { + "width": -1, + "height": -1 + }, + "strokeWidth": 0, + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "text": "display", + "labels": [], + "ports": [ + { + "position": { + "x": 58.5, + "y": 15 + }, + "size": { + "width": -1, + "height": -1 + }, + "strokeWidth": 0, + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "features": {}, + "id": "0hfzu", + "type": "port:dfd-input", + "children": [] + }, + { + "position": { + "x": -3.5, + "y": 9 + }, + "size": { + "width": -1, + "height": -1 + }, + "strokeWidth": 0, + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "behavior": "forward items", + "features": {}, + "id": "y1p7qq", + "type": "port:dfd-output", + "children": [] + } + ], + "features": {}, + "id": "4myuyr", + "type": "node:function", + "children": [] + }, + { + "position": { + "x": 364, + "y": 152 + }, + "size": { + "width": -1, + "height": -1 + }, + "strokeWidth": 0, + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "text": "encrypt", + "labels": [], + "ports": [ + { + "position": { + "x": -3.5, + "y": 15.5 + }, + "size": { + "width": -1, + "height": -1 + }, + "strokeWidth": 0, + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "features": {}, + "id": "kqjy4g", + "type": "port:dfd-input", + "children": [] + }, + { + "position": { + "x": 29, + "y": -3.5 + }, + "size": { + "width": -1, + "height": -1 + }, + "strokeWidth": 0, + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "behavior": "forward data\nset Encryption.Encrypted", + "features": {}, + "id": "3wntc", + "type": "port:dfd-output", + "children": [] + } + ], + "features": {}, + "id": "3n988k", + "type": "node:function", + "children": [] + }, + { + "position": { + "x": 104, + "y": 157 + }, + "size": { + "width": -1, + "height": -1 + }, + "strokeWidth": 0, + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "text": "buy", + "labels": [], + "ports": [ + { + "position": { + "x": 19, + "y": -3.5 + }, + "size": { + "width": -1, + "height": -1 + }, + "strokeWidth": 0, + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "features": {}, + "id": "2331e8", + "type": "port:dfd-input", + "children": [] + }, + { + "position": { + "x": 58.5, + "y": 10.5 + }, + "size": { + "width": -1, + "height": -1 + }, + "strokeWidth": 0, + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "behavior": "forward data", + "features": {}, + "id": "vnkg73", + "type": "port:dfd-output", + "children": [] + } + ], + "features": {}, + "id": "z9v1jp", + "type": "node:function", + "children": [] + }, + { + "position": { + "x": 233.5, + "y": 157 + }, + "size": { + "width": -1, + "height": -1 + }, + "strokeWidth": 0, + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "text": "process", + "labels": [], + "ports": [ + { + "position": { + "x": -3.5, + "y": 10.5 + }, + "size": { + "width": -1, + "height": -1 + }, + "strokeWidth": 0, + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "features": {}, + "id": "xyepdb", + "type": "port:dfd-input", + "children": [] + }, + { + "position": { + "x": 59.5, + "y": 10.5 + }, + "size": { + "width": -1, + "height": -1 + }, + "strokeWidth": 0, + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "behavior": "forward data", + "features": {}, + "id": "eedb56", + "type": "port:dfd-output", + "children": [] + } + ], + "features": {}, + "id": "js61f", + "type": "node:function", + "children": [] + }, + { + "position": { + "x": 422.5, + "y": 59 + }, + "size": { + "width": -1, + "height": -1 + }, + "strokeWidth": 0, + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "text": "Database", + "labels": [ + { + "labelTypeId": "gvia09", + "labelTypeValueId": "5hnugm" + } + ], + "ports": [ + { + "position": { + "x": -3.5, + "y": 23 + }, + "size": { + "width": -1, + "height": -1 + }, + "strokeWidth": 0, + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "features": {}, + "id": "scljwi", + "type": "port:dfd-input", + "children": [] + }, + { + "position": { + "x": -3.5, + "y": 0.5 + }, + "size": { + "width": -1, + "height": -1 + }, + "strokeWidth": 0, + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "behavior": "set Sensitivity.Public", + "features": {}, + "id": "1j7bn5", + "type": "port:dfd-output", + "children": [] + } + ], + "features": {}, + "id": "8j2r1g", + "type": "node:storage", + "children": [] + }, + { + "routingPoints": [], + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "features": {}, + "id": "vq8g3l", + "type": "edge:arrow", + "sourceId": "4wbyft", + "targetId": "2331e8", + "text": "data", + "children": [] + }, + { + "routingPoints": [], + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "features": {}, + "id": "xrzc19", + "type": "edge:arrow", + "sourceId": "vnkg73", + "targetId": "xyepdb", + "text": "data", + "children": [] + }, + { + "routingPoints": [], + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "features": {}, + "id": "ufflto", + "type": "edge:arrow", + "sourceId": "eedb56", + "targetId": "kqjy4g", + "text": "data", + "children": [] + }, + { + "routingPoints": [], + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "features": {}, + "id": "ojjvtp", + "type": "edge:arrow", + "sourceId": "3wntc", + "targetId": "scljwi", + "text": "data", + "children": [] + }, + { + "routingPoints": [], + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "features": {}, + "id": "c9n88l", + "type": "edge:arrow", + "sourceId": "bsqjm", + "targetId": "scljwi", + "text": "request", + "children": [] + }, + { + "routingPoints": [], + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "features": {}, + "id": "uflsc", + "type": "edge:arrow", + "sourceId": "wksxi8", + "targetId": "ti4ri7", + "text": "request", + "routerKind": "polyline", + "children": [] + }, + { + "routingPoints": [], + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "features": {}, + "id": "n81f3b", + "type": "edge:arrow", + "sourceId": "1j7bn5", + "targetId": "0hfzu", + "text": "items", + "children": [] + }, + { + "routingPoints": [], + "selected": false, + "hoverFeedback": false, + "opacity": 1, + "features": {}, + "id": "hi397b", + "type": "edge:arrow", + "sourceId": "y1p7qq", + "targetId": "nhcrad", + "text": "items", + "children": [] } ] }, @@ -61,21 +577,47 @@ "id": "4h3wzk", "name": "Sensitivity", "values": [ - { "id": "zzvphn", "text": "Personal" }, - { "id": "veaan9", "text": "Public" } + { + "id": "zzvphn", + "text": "Personal" + }, + { + "id": "veaan9", + "text": "Public" + } ] }, { "id": "gvia09", "name": "Location", "values": [ - { "id": "g10hr", "text": "EU" }, - { "id": "5hnugm", "text": "nonEU" } + { + "id": "g10hr", + "text": "EU" + }, + { + "id": "5hnugm", + "text": "nonEU" + } ] }, - { "id": "84rllz", "name": "Encryption", "values": [{ "id": "2r6xe6", "text": "Encrypted" }] } + { + "id": "84rllz", + "name": "Encryption", + "values": [ + { + "id": "2r6xe6", + "text": "Encrypted" + } + ] + } + ], + "constraints": [ + { + "name": "Test", + "constraint": "data Sensitivity.Personal neverFlows vertex Location.nonEU" + } ], - "constraints": [{ "name": "Test", "constraint": "data Sensitivity.Personal neverFlows vertex Location.nonEU" }], "mode": "edit", "version": 1 } diff --git a/frontend/webEditor/src/serialize/di.config.ts b/frontend/webEditor/src/serialize/di.config.ts index e6a5e6c3..6093eb0b 100644 --- a/frontend/webEditor/src/serialize/di.config.ts +++ b/frontend/webEditor/src/serialize/di.config.ts @@ -9,7 +9,7 @@ import { SaveJsonFileCommand } from "./saveJsonFile"; import { SaveDfdAndDdFileCommand } from "./saveDfdAndDdFile"; import { AnalyzeCommand } from "./analyze"; import { LoadFromUrlCommand } from "./LoadUrl"; -import { SaveImageCommand } from "./image"; +import { SaveImageCommand } from "./export"; export const serializeModule = new ContainerModule((bind, unbind, isBound, rebind) => { const context = { bind, unbind, isBound, rebind }; diff --git a/frontend/webEditor/src/serialize/image.ts b/frontend/webEditor/src/serialize/export.ts similarity index 62% rename from frontend/webEditor/src/serialize/image.ts rename to frontend/webEditor/src/serialize/export.ts index db7bd01b..7c94d493 100644 --- a/frontend/webEditor/src/serialize/image.ts +++ b/frontend/webEditor/src/serialize/export.ts @@ -2,9 +2,13 @@ import { Command, CommandExecutionContext, CommandReturn, + isSelectable, IVNodePostprocessor, ModelRenderer, + SChildElementImpl, + SModelElementImpl, SModelRootImpl, + SParentElementImpl, TYPES, ViewRegistry, } from "sprotty"; @@ -29,15 +33,17 @@ const patch = init([ interface SaveImageAction extends Action { saveType: "svg" | "pdf"; + selectionOnly: boolean; } export namespace SaveImageAction { export const KIND = "save-image"; - export function create(saveType: "svg" | "pdf"): SaveImageAction { + export function create(saveType: "svg" | "pdf", selectionOnly: boolean): SaveImageAction { return { kind: KIND, saveType, + selectionOnly, }; } } @@ -94,6 +100,9 @@ export class SaveImageCommand extends Command { getSVG(context: CommandExecutionContext, dom: HTMLElement): SVGResult | undefined { // render diagram virtually + if (this.action.selectionOnly) { + this.postProcessors.push(new SelectionPostProcessor()); + } const renderer = new ModelRenderer(this.viewRegistry, "hidden", this.postProcessors); const svg = renderer.renderElement(context.root); if (!svg) return; @@ -105,14 +114,17 @@ export class SaveImageCommand extends Command { // render svg into dom const dummyDom = h("div", {}, [svg]); + // remove selection as we do not want it on an export + this.removeSelectedClass(dummyDom); + this.removeRoutingHandles(dummyDom); patch(dom, dummyDom); // apply style and clean attributes - transformStyleToAttributes(dummyDom); + this.transformStyleToAttributes(dummyDom); // Centering does not work properly for pdfs. We fix this manually if (this.action.saveType === "pdf") { - centerText(dummyDom, 0); + this.centerText(dummyDom, 0); } - removeUnusedAttributes(dummyDom); + this.removeUnusedAttributes(dummyDom); // compute diagram offset and size const holderG = svg.children?.[0]; @@ -176,6 +188,134 @@ export class SaveImageCommand extends Command { link.download = this.fileName.getName() + ".svg"; link.click(); } + + private removeSelectedClass(v: VNode) { + if (v.data?.class?.selected) { + v.data.class.selected = false; + } + + if (!v.children) return; + for (const child of v.children) { + if (typeof child === "string") continue; + this.removeSelectedClass(child); + } + } + + private removeRoutingHandles(v: VNode) { + if (!v.children) return; + v.children = v.children?.filter((c) => { + if (typeof c == "string") return true; + return c.data?.class?.["sprotty-routing-handle"] !== true; + }); + for (const child of v.children) { + if (typeof child === "string") continue; + this.removeRoutingHandles(child); + } + } + + private transformStyleToAttributes(v: VNode) { + if (!v.elm) return; + + if (!v.data) v.data = {}; + if (!v.data.style) v.data.style = {}; + if (!v.data.attrs) v.data.attrs = {}; + + const computedStyle = getComputedStyle(v.elm as Element) as VNodeStyle; + for (const key of getRelevantStyleProps(v)) { + let value = v.data.style[key] ?? computedStyle[key]; + if (key == "fill" && value.startsWith("color(srgb")) { + const srgb = /color\(srgb ([^ ]+) ([^ ]+) ([^ ]+)(?: ?\/ ?([^ ]+))?\)/.exec(value); + if (srgb) { + const r = Math.round(Number(srgb[1]) * 255); + const g = Math.round(Number(srgb[2]) * 255); + const b = Math.round(Number(srgb[3]) * 255); + const a = srgb[4] ? Number(srgb[4]) : 1; + value = `rgb(${r},${g},${b})`; + + v.data.attrs["fill-opacity"] = a; + } + } + if (key == "font-family") { + value = "sans-serif"; + } + + if (value.endsWith("px")) { + value = value.substring(0, value.length - 2); + } + if (value != getDefaultPropertyValues(key)) { + v.data.attrs[key] = value; + } + } + + if (getVNodeSVGType(v) == "text") { + const oldY = (v.data.attrs.y as number | undefined) ?? 0; + const fontSize = computedStyle.fontSize + ? Number(computedStyle.fontSize.substring(0, computedStyle.fontSize.length - 2)) + : 12; + const newY = oldY + 0.35 * fontSize; + v.data.attrs.y = newY; + } + + if (!v.children) return; + for (const child of v.children) { + if (typeof child === "string") continue; + this.transformStyleToAttributes(child); + } + } + + private removeUnusedAttributes(v: VNode) { + if (!v.data) v.data = {}; + if (v.data.attrs) { + delete v.data.attrs["id"]; + delete v.data.attrs["tabindex"]; + } + if (v.data.class) { + for (const clas in v.data.class) { + v.data.class[clas] = false; + } + } + + if (!v.children) return; + for (const child of v.children) { + if (typeof child === "string") continue; + this.removeUnusedAttributes(child); + } + } + + private centerText(v: VNode, maxSiblingSize: number = 0, maxSiblingX: number = 0) { + if (getVNodeSVGType(v) == "text") { + if (!v.data) v.data = {}; + if (!v.data.attrs) v.data.attrs = {}; + + v.data.attrs["text-anchor"] = "start"; + if (v.data.class?.["port-text"] === true) { + if (v.text === "I") { + v.data.attrs.x = 2.8; + } else { + v.data.attrs.x = 1.3; + } + } else { + const width = calculateTextSize(v.text, `${v.data.attrs["font-size"] ?? 0}px sans-serif `).width; + v.data.attrs.x = maxSiblingSize / 2 - width / 2 + maxSiblingX; + } + } + + if (!v.children) return; + + let newMaxSiblingSize = undefined; + let newMaxSiblingX = undefined; + for (const child of v.children) { + if (typeof child === "string") continue; + if (getVNodeSVGType(child) == "text") continue; + newMaxSiblingSize = Math.max(newMaxSiblingSize ?? -Infinity, Number(child.data?.attrs?.width ?? 0)); + newMaxSiblingX = Math.max(newMaxSiblingX ?? -Infinity, Number(child.data?.attrs?.x ?? 0)); + } + + for (const child of v.children) { + if (typeof child === "string") continue; + this.centerText(child, newMaxSiblingSize, newMaxSiblingX); + } + } } /** @@ -253,110 +393,6 @@ function getRequiredCanvasSize( return { x: x, y: y }; } -function transformStyleToAttributes(v: VNode) { - if (!v.elm) return; - - if (!v.data) v.data = {}; - if (!v.data.style) v.data.style = {}; - if (!v.data.attrs) v.data.attrs = {}; - - const computedStyle = getComputedStyle(v.elm as Element) as VNodeStyle; - for (const key of getRelevantStyleProps(v)) { - let value = v.data.style[key] ?? computedStyle[key]; - if (key == "fill" && value.startsWith("color(srgb")) { - const srgb = /color\(srgb ([^ ]+) ([^ ]+) ([^ ]+)(?: ?\/ ?([^ ]+))?\)/.exec(value); - if (srgb) { - const r = Math.round(Number(srgb[1]) * 255); - const g = Math.round(Number(srgb[2]) * 255); - const b = Math.round(Number(srgb[3]) * 255); - const a = srgb[4] ? Number(srgb[4]) : 1; - value = `rgb(${r},${g},${b})`; - - v.data.attrs["fill-opacity"] = a; - } - } - if (key == "font-family") { - value = "sans-serif"; - } - - if (value.endsWith("px")) { - value = value.substring(0, value.length - 2); - } - if (value != getDefaultPropertyValues(key)) { - v.data.attrs[key] = value; - } - } - - if (getVNodeSVGType(v) == "text") { - const oldY = (v.data.attrs.y as number | undefined) ?? 0; - const fontSize = computedStyle.fontSize - ? Number(computedStyle.fontSize.substring(0, computedStyle.fontSize.length - 2)) - : 12; - const newY = oldY + 0.35 * fontSize; - v.data.attrs.y = newY; - } - - if (!v.children) return; - for (const child of v.children) { - if (typeof child === "string") continue; - transformStyleToAttributes(child); - } -} - -function removeUnusedAttributes(v: VNode) { - if (!v.data) v.data = {}; - if (v.data.attrs) { - delete v.data.attrs["id"]; - delete v.data.attrs["tabindex"]; - } - if (v.data.class) { - for (const clas in v.data.class) { - v.data.class[clas] = false; - } - } - - if (!v.children) return; - for (const child of v.children) { - if (typeof child === "string") continue; - removeUnusedAttributes(child); - } -} - -function centerText(v: VNode, maxSiblingSize: number = 0, maxSiblingX: number = 0) { - if (getVNodeSVGType(v) == "text") { - if (!v.data) v.data = {}; - if (!v.data.attrs) v.data.attrs = {}; - - v.data.attrs["text-anchor"] = "start"; - if (v.data.class?.["port-text"] === true) { - if (v.text === "I") { - v.data.attrs.x = 2.8; - } else { - v.data.attrs.x = 1.3; - } - } else { - const width = calculateTextSize(v.text, `${v.data.attrs["font-size"] ?? 0}px sans-serif `).width; - v.data.attrs.x = maxSiblingSize / 2 - width / 2 + maxSiblingX; - } - } - - if (!v.children) return; - - let newMaxSiblingSize = 0; - let newMaxSiblingX = 0; - for (const child of v.children) { - if (typeof child === "string") continue; - if (getVNodeSVGType(child) == "text") continue; - newMaxSiblingSize = Math.max(newMaxSiblingSize, Number(child.data?.attrs?.width ?? 0)); - newMaxSiblingX = Math.max(newMaxSiblingX, Number(child.data?.attrs?.x ?? 0)); - } - - for (const child of v.children) { - if (typeof child === "string") continue; - centerText(child, newMaxSiblingSize, newMaxSiblingX); - } -} - function getVNodeSVGType(v: VNode): string | undefined { return v.sel?.split(/#|\./)[0]; } @@ -396,3 +432,48 @@ function getDefaultPropertyValues(key: string) { return undefined; } } + +class SelectionPostProcessor implements IVNodePostprocessor { + decorate(v: VNode, element: SModelElementImpl): VNode { + let shouldRender = this.isSelected(element); + if (element instanceof SChildElementImpl && this.hasSelectedParent(element)) { + shouldRender = true; + } + if (element instanceof SParentElementImpl && this.hasSelectedChild(element)) { + shouldRender = true; + } + if (shouldRender) { + return v; + } + return h("g"); + } + postUpdate() {} + + private hasSelectedParent(element: Readonly) { + if (this.isSelected(element.parent)) { + return true; + } + if (element.parent instanceof SChildElementImpl) { + if (this.hasSelectedParent(element.parent)) { + return true; + } + } + return false; + } + + private hasSelectedChild(element: Readonly) { + for (const child of element.children) { + if (this.isSelected(child)) { + return true; + } + if (this.hasSelectedChild(child)) { + return true; + } + } + return false; + } + + private isSelected(element: Readonly) { + return isSelectable(element) && element.selected; + } +} From fb887b1b9fe3fc5b9344c290972dfe712668619d Mon Sep 17 00:00:00 2001 From: Alex | Kronox Date: Fri, 6 Feb 2026 13:25:52 +0100 Subject: [PATCH 6/7] add documentation --- .../commandPalette/commandPaletteProvider.ts | 10 +-- frontend/webEditor/src/serialize/export.ts | 77 ++++++++++++++++--- 2 files changed, 72 insertions(+), 15 deletions(-) diff --git a/frontend/webEditor/src/commandPalette/commandPaletteProvider.ts b/frontend/webEditor/src/commandPalette/commandPaletteProvider.ts index 2c57a75f..e5115dec 100644 --- a/frontend/webEditor/src/commandPalette/commandPaletteProvider.ts +++ b/frontend/webEditor/src/commandPalette/commandPaletteProvider.ts @@ -10,7 +10,7 @@ import { LayoutMethod } from "../layout/layoutMethod"; import { LayoutModelAction } from "../layout/command"; import { SaveJsonFileAction } from "../serialize/saveJsonFile"; import { SaveDfdAndDdFileAction } from "../serialize/saveDfdAndDdFile"; -import { SaveImageAction } from "../serialize/export"; +import { ExportAction } from "../serialize/export"; /** * Provides possible actions for the command palette. @@ -47,10 +47,10 @@ export class WebEditorCommandPaletteActionProvider implements ICommandPaletteAct new FolderAction( "Export", [ - new LabeledAction("Export diagram as SVG", [SaveImageAction.create("svg", false)], "file-media"), - new LabeledAction("Export selection as SVG", [SaveImageAction.create("svg", true)], "file-media"), - new LabeledAction("Export diagram as PDF", [SaveImageAction.create("pdf", false)], "file-pdf"), - new LabeledAction("Export selection as PDF", [SaveImageAction.create("pdf", true)], "file-pdf"), + new LabeledAction("Export diagram as SVG", [ExportAction.create("svg", false)], "file-media"), + new LabeledAction("Export selection as SVG", [ExportAction.create("svg", true)], "file-media"), + new LabeledAction("Export diagram as PDF", [ExportAction.create("pdf", false)], "file-pdf"), + new LabeledAction("Export selection as PDF", [ExportAction.create("pdf", true)], "file-pdf"), ], "export", ), diff --git a/frontend/webEditor/src/serialize/export.ts b/frontend/webEditor/src/serialize/export.ts index 7c94d493..d3ce0606 100644 --- a/frontend/webEditor/src/serialize/export.ts +++ b/frontend/webEditor/src/serialize/export.ts @@ -31,15 +31,15 @@ const patch = init([ eventListenersModule, // attaches event listeners ]); -interface SaveImageAction extends Action { +interface ExportAction extends Action { saveType: "svg" | "pdf"; selectionOnly: boolean; } -export namespace SaveImageAction { +export namespace ExportAction { export const KIND = "save-image"; - export function create(saveType: "svg" | "pdf", selectionOnly: boolean): SaveImageAction { + export function create(saveType: "svg" | "pdf", selectionOnly: boolean): ExportAction { return { kind: KIND, saveType, @@ -54,12 +54,15 @@ interface SVGResult { height: number; } -export class SaveImageCommand extends Command { - static readonly KIND = SaveImageAction.KIND; +/** + * Exports the diagram as either a svg or pdf + */ +export class ExportCommand extends Command { + static readonly KIND = ExportAction.KIND; private static readonly PADDING = 5; constructor( - @inject(TYPES.Action) private readonly action: SaveImageAction, + @inject(TYPES.Action) private readonly action: ExportAction, @inject(FileName) private readonly fileName: FileName, @inject(TYPES.ViewRegistry) private readonly viewRegistry: ViewRegistry, @multiInject(TYPES.IVNodePostprocessor) private readonly postProcessors: IVNodePostprocessor[], @@ -98,6 +101,11 @@ export class SaveImageCommand extends Command { return context.root; } + /** + * Generates saveable svg code + * @param dom A dummy root element attached to the Dom + * @returns The generated svg + */ getSVG(context: CommandExecutionContext, dom: HTMLElement): SVGResult | undefined { // render diagram virtually if (this.action.selectionOnly) { @@ -147,11 +155,11 @@ export class SaveImageCommand extends Command { if (!holderG.data) holderG.data = {}; if (!holderG.data.attrs) holderG.data.attrs = {}; holderG.data.attrs["transform"] = - `translate(${-minTranslate.x + SaveImageCommand.PADDING},${-minTranslate.y + SaveImageCommand.PADDING})`; + `translate(${-minTranslate.x + ExportCommand.PADDING},${-minTranslate.y + ExportCommand.PADDING})`; if (!svg.data) svg.data = {}; if (!svg.data.attrs) svg.data.attrs = {}; - const width = maxSize.x - minTranslate.x + 2 * SaveImageCommand.PADDING; - const height = maxSize.y - minTranslate.y + 2 * SaveImageCommand.PADDING; + const width = maxSize.x - minTranslate.x + 2 * ExportCommand.PADDING; + const height = maxSize.y - minTranslate.y + 2 * ExportCommand.PADDING; svg.data.attrs.width = width; svg.data.attrs.height = height; svg.data.attrs.viewBox = `0 0 ${width} ${height}`; @@ -189,6 +197,11 @@ export class SaveImageCommand extends Command { link.click(); } + /** + * Recursively removes the selected class + * Should be called before rendering + * @param v Root VNode + */ private removeSelectedClass(v: VNode) { if (v.data?.class?.selected) { v.data.class.selected = false; @@ -201,6 +214,12 @@ export class SaveImageCommand extends Command { } } + /** + * Recursively removes the routing handles of edges. + * Needs to happen before removing classes + * @param v + * @returns + */ private removeRoutingHandles(v: VNode) { if (!v.children) return; v.children = v.children?.filter((c) => { @@ -213,6 +232,10 @@ export class SaveImageCommand extends Command { } } + /** + * Recursively transforms the computed style of the html elements to properties on the VNode + * @param v The current VNode + */ private transformStyleToAttributes(v: VNode) { if (!v.elm) return; @@ -263,6 +286,10 @@ export class SaveImageCommand extends Command { } } + /** + * Recursively removes html attributes the svg file does not need. + * @param v The current VNode + */ private removeUnusedAttributes(v: VNode) { if (!v.data) v.data = {}; if (v.data.attrs) { @@ -282,6 +309,13 @@ export class SaveImageCommand extends Command { } } + /** + * Recursively iterates the VNodes an centers the text position manually. + * This should happen after transforming the style to attributes + * @param v Current VNode + * @param maxSiblingSize biggest size of siblings + * @param maxSiblingX biggest x of siblings + */ private centerText(v: VNode, maxSiblingSize: number = 0, maxSiblingX: number = 0) { if (getVNodeSVGType(v) == "text") { if (!v.data) v.data = {}; @@ -341,7 +375,6 @@ function getMinTranslate(e: VNode, parentOffset: { x: number; y: number } = { x: /** * Calculates the absolute translation of an element relative to the svg. - * If the element has no translation, the offset of the parent is returned. * @param e the element to get the translation from * @param parentOffset Offset of the containing element * @returns Offset of the child relative to the svg @@ -363,6 +396,13 @@ function getTranslate( return { x: newX, y: newY }; } +/** + * Gets the maximum size the canvas needs by adding its position and size and finding the maximum of this among children. + * This is done by recursively. + * @param e the root element for the sizing + * @param parentOffset Offset of the containing element + * @returns Required canvas size + */ function getMaxRequiredCanvasSize( e: VNode, parentOffset: { x: number; y: number } = { x: 0, y: 0 }, @@ -380,6 +420,12 @@ function getMaxRequiredCanvasSize( return maxSize; } +/** + * Calculates the size the canvas needs to be to accommodate the given element + * @param e the element to calculate for + * @param parentOffset Offset of the containing element + * @returns The size required for the element + */ function getRequiredCanvasSize( e: VNode, parentOffset: { x: number; y: number } = { x: 0, y: 0 }, @@ -397,6 +443,10 @@ function getVNodeSVGType(v: VNode): string | undefined { return v.sel?.split(/#|\./)[0]; } +/** + * @param v VNode to check + * @returns The relevant style properties for the node type + */ function getRelevantStyleProps(v: VNode): string[] { const type = getVNodeSVGType(v); switch (type) { @@ -418,6 +468,10 @@ function getRelevantStyleProps(v: VNode): string[] { } } +/** + * @param key CSS key + * @returns The default value for a given CSS key + */ function getDefaultPropertyValues(key: string) { switch (key) { case "stroke-dasharray": @@ -433,6 +487,9 @@ function getDefaultPropertyValues(key: string) { } } +/** + * VNodePostprocessor removing all non-selected elements + */ class SelectionPostProcessor implements IVNodePostprocessor { decorate(v: VNode, element: SModelElementImpl): VNode { let shouldRender = this.isSelected(element); From e5a58089273a7f2a35203b4ee7e787fc0876d8b6 Mon Sep 17 00:00:00 2001 From: Alex | Kronox Date: Fri, 6 Feb 2026 13:30:45 +0100 Subject: [PATCH 7/7] fix build --- frontend/webEditor/src/serialize/di.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/webEditor/src/serialize/di.config.ts b/frontend/webEditor/src/serialize/di.config.ts index 6093eb0b..6ab16f09 100644 --- a/frontend/webEditor/src/serialize/di.config.ts +++ b/frontend/webEditor/src/serialize/di.config.ts @@ -9,7 +9,7 @@ import { SaveJsonFileCommand } from "./saveJsonFile"; import { SaveDfdAndDdFileCommand } from "./saveDfdAndDdFile"; import { AnalyzeCommand } from "./analyze"; import { LoadFromUrlCommand } from "./LoadUrl"; -import { SaveImageCommand } from "./export"; +import { ExportCommand } from "./export"; export const serializeModule = new ContainerModule((bind, unbind, isBound, rebind) => { const context = { bind, unbind, isBound, rebind }; @@ -21,7 +21,7 @@ export const serializeModule = new ContainerModule((bind, unbind, isBound, rebin configureCommand(context, SaveJsonFileCommand); configureCommand(context, SaveDfdAndDdFileCommand); configureCommand(context, AnalyzeCommand); - configureCommand(context, SaveImageCommand); + configureCommand(context, ExportCommand); rebind(TYPES.IModelFactory).to(DfdModelFactory); });