diff --git a/frontend/webEditor/package-lock.json b/frontend/webEditor/package-lock.json index 06d89d2a..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", @@ -20,6 +24,8 @@ "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", @@ -28,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", @@ -1026,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", @@ -1395,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", @@ -1419,6 +1464,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", @@ -1429,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", @@ -1523,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", @@ -1538,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", @@ -1563,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", @@ -1843,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", @@ -1914,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", @@ -1978,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", @@ -2045,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", @@ -2135,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", @@ -2218,6 +2394,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 +2422,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 +2549,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", @@ -2404,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", @@ -2417,6 +2644,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", @@ -2437,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", @@ -2535,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", @@ -2542,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", @@ -2576,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", @@ -2698,13 +2969,29 @@ } }, "node_modules/snabbdom": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/snabbdom/-/snabbdom-3.5.1.tgz", - "integrity": "sha512-wHMNIOjkm/YNE5EM3RCbr/+DVgPg6AqQAX1eOxO46zYNvCXjKP5Y865tqQj3EXnaMBjkxmQA5jFuDpDK/dbfiA==", + "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": ">=8.3.0" + "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", + "integrity": "sha512-R/Rc/5duxWvV4w/B3JI/lt1jXb96pIVV9Xxwqvne259cNuDRXYppuBMtoUGdda0JGhMjNmvc1z+G9ch/PQx+kg==", + "dev": true, + "license": "MIT", + "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": { @@ -2717,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", @@ -2753,6 +3049,26 @@ "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/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", @@ -2822,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", @@ -2964,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 1a1fc37f..d041dceb 100644 --- a/frontend/webEditor/package.json +++ b/frontend/webEditor/package.json @@ -20,6 +20,8 @@ "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", @@ -44,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 ad0e53e2..e5115dec 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 { ExportAction } from "../serialize/export"; /** * Provides possible actions for the command palette. @@ -39,11 +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 viewport as image", [SaveImageAction.create()], "device-camera"), ], "save", ), + new FolderAction( + "Export", + [ + 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", + ), + 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/di.config.ts b/frontend/webEditor/src/serialize/di.config.ts index 4c1c0f4a..6ab16f09 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 { ExportCommand } from "./export"; 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, ExportCommand); rebind(TYPES.IModelFactory).to(DfdModelFactory); }); diff --git a/frontend/webEditor/src/serialize/export.ts b/frontend/webEditor/src/serialize/export.ts new file mode 100644 index 00000000..d3ce0606 --- /dev/null +++ b/frontend/webEditor/src/serialize/export.ts @@ -0,0 +1,536 @@ +import { + Command, + CommandExecutionContext, + CommandReturn, + isSelectable, + IVNodePostprocessor, + ModelRenderer, + SChildElementImpl, + SModelElementImpl, + SModelRootImpl, + SParentElementImpl, + 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 { 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"; +import { jsPDF } from "jspdf"; +import "svg2pdf.js"; +import { calculateTextSize } from "../utils/TextSize"; + +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 +]); + +interface ExportAction extends Action { + saveType: "svg" | "pdf"; + selectionOnly: boolean; +} + +export namespace ExportAction { + export const KIND = "save-image"; + + export function create(saveType: "svg" | "pdf", selectionOnly: boolean): ExportAction { + return { + kind: KIND, + saveType, + selectionOnly, + }; + } +} + +interface SVGResult { + svg: string; + width: number; + height: number; +} + +/** + * 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: ExportAction, + @inject(FileName) private readonly fileName: FileName, + @inject(TYPES.ViewRegistry) private readonly viewRegistry: ViewRegistry, + @multiInject(TYPES.IVNodePostprocessor) private readonly postProcessors: IVNodePostprocessor[], + ) { + super(); + } + + async execute(context: CommandExecutionContext): Promise { + const dummyRoot = document.createElement("div"); + dummyRoot.style.position = "absolute"; + dummyRoot.style.left = "-100000px"; + dummyRoot.style.top = "-100000px"; + dummyRoot.style.visibility = "hidden"; + + document.body.appendChild(dummyRoot); + let result: SVGResult | undefined; + try { + 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; + } + undo(context: CommandExecutionContext): CommandReturn { + return context.root; + } + redo(context: CommandExecutionContext): CommandReturn { + 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) { + this.postProcessors.push(new SelectionPostProcessor()); + } + 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]); + // 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 + this.transformStyleToAttributes(dummyDom); + // Centering does not work properly for pdfs. We fix this manually + if (this.action.saveType === "pdf") { + this.centerText(dummyDom, 0); + } + this.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 = getMinTranslate(child); + minTranslate.x = Math.min(minTranslate.x, childTranslate.x); + minTranslate.y = Math.min(minTranslate.y, childTranslate.y); + + const childSize = getMaxRequiredCanvasSize(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 + 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 * 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}`; + + // 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"; + + 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(); + } + + /** + * 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; + } + + if (!v.children) return; + for (const child of v.children) { + if (typeof child === "string") continue; + this.removeSelectedClass(child); + } + } + + /** + * 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) => { + 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); + } + } + + /** + * 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; + + 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); + } + } + + /** + * 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) { + 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); + } + } + + /** + * 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 = {}; + 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); + } + } +} + +/** + * 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; +} + +/** + * Calculates the absolute translation of an element relative to the svg. + * @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 }; +} + +/** + * 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 }, +): { 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; +} + +/** + * 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 }, +): { 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 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) { + 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", + ]; + } +} + +/** + * @param key CSS key + * @returns The default value for a given CSS key + */ +function getDefaultPropertyValues(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; + } +} + +/** + * VNodePostprocessor removing all non-selected elements + */ +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; + } +}