LCOV - code coverage report
Current view: top level - src/components - SSVGItem.vue.js (source / functions) Hit Total Coverage
Test: lcov.info Lines: 83 155 53.5 %
Date: 2025-06-29 02:18:36 Functions: 27 44 61.4 %

          Line data    Source code
       1             : // @ts-check
       2             : /* eslint-disable max-lines */
       3             : 
       4             : import { get, invoke, noop } from "lodash";
       5             : import Vue from "vue";
       6             : import { mapState } from "vuex";
       7             : import FileDropZone from "./FileDropZone.vue";
       8             : import { BaseKeyboardEventMixin as KBMixin, BaseLogger as logger } from "@cern/base-vue";
       9             : import d from "debug";
      10             : import { History } from "../store";
      11             : import axios from "axios";
      12             : 
      13           1 : const debug = d("ssvg:editor");
      14             : 
      15             : /**
      16             :  * @typedef {{ ViewMode: ViewMode, History: History }} Opts
      17             :  * @typedef {{ svg: HTMLIFrameElement, svgShadow: HTMLIFrameElement }} Refs
      18             :  */
      19             : 
      20           1 : const ViewMode = {
      21             :   normal: "normal",
      22             :   shadow: "shadow",
      23             :   jump: "jump"
      24             : };
      25             : 
      26           1 : const iframeStyle = `
      27             : .hovered {
      28             :   cursor: crosshair!important;
      29             :   stroke: red!important;
      30             :   stroke-width: 2px!important;
      31             :   opacity: 1!important;
      32             : }
      33             : .x-ssvg-editor-selected {
      34             :   stroke: purple!important;
      35             :   stroke-width: 2px!important;
      36             :   opacity: 1!important;
      37             : }
      38             : body {
      39             :   overflow: clip;
      40             :   contain: paint;
      41             : }
      42             : `;
      43             : 
      44           1 : const component = /** @type {V.Constructor<Opts, Refs>} */ (Vue).extend({
      45             :   name: "SSVGItem",
      46             :   components: { FileDropZone },
      47             :   directives: {
      48             :     visible(el, binding) {
      49           7 :       el.style.visibility = binding.value ? "visible" : "hidden";
      50             :     }
      51             :   },
      52             :   mixins: [ KBMixin({ local: false }) ],
      53             :   ...{ ViewMode, History },
      54             :   /**
      55             :    * @return {{
      56             :    *   initTimer: NodeJS.Timeout|null,
      57             :    *   viewBox: string,
      58             :    *   zoom: { scale: 1, x: number, y: number },
      59             :    *   hasSession: boolean
      60             :    * }}
      61             :    */
      62             :   data() {
      63           3 :     return {
      64             :       initTimer: null,
      65             :       viewBox: "0 0 100 100",
      66             :       zoom: { scale: 1, x: 0, y: 0 },
      67             :       hasSession: false
      68             :     };
      69             :   },
      70             :   computed: {
      71             :     .../** @type {{directSvg(): SVGElement|null, svg(): SVGElement|null}} */(
      72             :       mapState("engine", {
      73           6 :         directSvg: (/** @type {AppStore.SSVGEngine} */state) => get(state, [ "directEngine", "svg" ]),
      74           6 :         svg: (/** @type {AppStore.SSVGEngine} */state) => get(state, [ "engine", "svg" ])
      75             :       })),
      76             :     /** @returns {string} */
      77           3 :     viewMode() { return this.$store?.state?.ui?.viewMode ?? ViewMode.shadow; },
      78             :     /** @return {boolean} */
      79           3 :     canUndo() { return History.canUndo; },
      80             :     /** @return {boolean} */
      81           3 :     canRedo() { return History.canRedo; },
      82             :     /** @return {boolean} */
      83             :     isZoom() {
      84           4 :       return this.zoom.x !== 0 ||
      85             :         this.zoom.y !== 0 ||
      86             :         this.zoom.scale !== 1;
      87             :     }
      88             :   },
      89             :   watch: {
      90           3 :     svg() { this.loadSvg(); },
      91           3 :     directSvg() { this.loadDirectSvg(); },
      92             :     viewBox() {
      93           4 :       this.updateViewBox(this.$refs.svg);
      94           4 :       this.updateViewBox(this.$refs.svgShadow);
      95             :     },
      96             :     zoom: {
      97             :       deep: true,
      98             :       handler() {
      99           1 :         this.updateViewBox(this.$refs.svg);
     100           1 :         this.updateViewBox(this.$refs.svgShadow);
     101             :       }
     102             :     }
     103             : 
     104             :   },
     105             :   mounted() {
     106           3 :     this.onKey("ctrl-z", History.undo);
     107           3 :     this.onKey("ctrl-y", History.redo);
     108           3 :     this.loadDocument();
     109           3 :     this.loadSvg();
     110           3 :     this.loadDirectSvg();
     111             :   },
     112             :   methods: {
     113             :     saveClipboard() {
     114           0 :       if (!navigator.clipboard) {
     115           0 :         logger.error("clipboard not available");
     116             :       }
     117             :       else {
     118           0 :         navigator.clipboard.writeText(this.$store.state?.engine?.text);
     119             :       }
     120             :     },
     121             :     saveFile() {
     122           0 :       const elt = document.createElement("a");
     123           0 :       elt.setAttribute("href", "data:image/svg+xml;charset=utf-8," +
     124             :         encodeURIComponent(this.$store.state?.engine?.text));
     125           0 :       elt.setAttribute("download", get(this.$store, [ "state", "engine", "fileName" ], "image"));
     126           0 :       document.body.appendChild(elt);
     127           0 :       elt.click();
     128           0 :       elt.remove();
     129             :     },
     130             :     saveSession() {
     131           0 :       if (!sessionStorage) {
     132           0 :         logger.error("session-storage not available");
     133           0 :         return;
     134             :       }
     135           0 :       try {
     136           0 :         const ssvg = JSON.parse(sessionStorage.getItem("ssvg") ?? "undefined");
     137           0 :         ssvg.document = this.$store.state?.engine?.text;
     138           0 :         sessionStorage.setItem("ssvg", JSON.stringify(ssvg));
     139           0 :         if (ssvg.callback) {
     140           0 :           window.location = ssvg.callback;
     141             :         }
     142             :       }
     143             :       catch (e) {
     144           0 :         logger.error(e);
     145             :       }
     146             : 
     147             :     },
     148             :     loadSvg() {
     149           9 :       if (this.initTimer) {
     150           0 :         clearTimeout(this.initTimer);
     151           0 :         this.initTimer = null;
     152             :       }
     153             : 
     154           9 :       const elt = /** @type {HTMLElement} */(this.getRootSvg(this.$refs.svg));
     155           9 :       if (!elt) { return; }
     156           9 :       this.clearElement(elt);
     157           9 :       if (this.svg) {
     158           7 :         debug("loading svg");
     159           7 :         elt.appendChild(this.svg);
     160           7 :         this.initDoc();
     161             :       }
     162             :       else {
     163           2 :         this.resetZoom();
     164             :       }
     165             :     },
     166             :     loadDirectSvg() {
     167             :       const elt =
     168           9 :         /** @type {HTMLElement} */(this.getRootSvg(this.$refs.svgShadow));
     169           9 :       if (!elt) { return; }
     170             : 
     171           9 :       this.clearElement(elt);
     172           9 :       if (this.directSvg) {
     173           7 :         elt.appendChild(this.directSvg);
     174             :       }
     175             :     },
     176             :     async loadDocument() {
     177           3 :       if (!await this.loadLocation()) {
     178           3 :         await this.loadSession();
     179             :       }
     180             :     },
     181             :     async loadSession() {
     182           3 :       try {
     183           3 :         const ssvg = JSON.parse(sessionStorage?.getItem("ssvg") ?? "undefined");
     184           0 :         if (ssvg && ssvg.document) {
     185           0 :           this.loadDoc(ssvg.document);
     186           0 :           this.hasSession = true;
     187           0 :           return true;
     188             :         }
     189             :       }
     190             :       catch (e) {
     191           3 :         console.warn("loadSession error: ", e);
     192             :       }
     193           3 :       return false;
     194             :     },
     195             :     async loadLocation() {
     196           3 :       try {
     197           3 :         const params = new URLSearchParams(document.location.search);
     198           3 :         const url = params.get("url");
     199           3 :         if (url) {
     200           0 :           await axios.get(url)
     201             :           .then(
     202             :             (ret) => {
     203           0 :               this.loadDoc(ret.data);
     204             :               // @ts-ignore: logger.info is checked
     205           0 :               logger?.info?.("external document loaded");
     206             :             },
     207             :             (err) => {
     208           0 :               logger.error("failed to retrieve external document: " + err.message);
     209           0 :               throw err;
     210             :             })
     211             :           .finally(() => {
     212           0 :             const url = new URL(document.location.href);
     213           0 :             url.searchParams.delete("url");
     214           0 :             window.history.pushState(null, "external document loaded", url.href);
     215             :           });
     216           0 :           return true;
     217             :         }
     218             :       }
     219             :       catch (e) {
     220           0 :         console.warn("loadLocation error: ", e);
     221             :       }
     222           3 :       return false;
     223             :     },
     224             :     onDirectSvgFrameLoaded() {
     225           3 :       this.initIFrame(this.$refs.svgShadow);
     226           3 :       this.loadDirectSvg();
     227             :     },
     228             :     onSvgFrameLoaded() {
     229           3 :       this.initIFrame(this.$refs.svg);
     230           3 :       this.loadSvg();
     231             :     },
     232             :     /** @param {HTMLIFrameElement} frame */
     233             :     initIFrame(frame) {
     234           6 :       const doc = get(frame, [ "contentDocument" ]);
     235           6 :       if (!doc || !doc.body) { return; }
     236             : 
     237           6 :       const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg");
     238           6 :       svg.setAttribute("width", "100%");
     239           6 :       svg.setAttribute("height", "100%");
     240           6 :       svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
     241           6 :       svg.setAttribute("version", "1.1");
     242           6 :       svg.setAttribute("style", "overflow: visible;");
     243             : 
     244           6 :       svg.setAttribute("viewBox", this.viewBox);
     245           6 :       doc.body.appendChild(svg);
     246             : 
     247             :       /* add style for SVGSelector */
     248             :       /** @type {HTMLElement} */
     249           6 :       const style = doc.createElement("style");
     250           6 :       style.textContent = iframeStyle;
     251           6 :       doc.body.appendChild(style);
     252             : 
     253             :       /* handle svg navigation */ /* eslint-disable-next-line complexity */
     254           6 :       doc.body.onkeydown = (event) => {
     255           0 :         switch (event.key.toLowerCase()) {
     256             :         case "8":
     257           0 :         case "arrowup": this.zoom.y += 10; break;
     258             :         case "2":
     259           0 :         case "arrowdown": this.zoom.y -= 10; break;
     260             :         case "6":
     261           0 :         case "arrowright": this.zoom.x -= 10; break;
     262             :         case "4":
     263           0 :         case "arrowleft": this.zoom.x += 10; break;
     264             :         case "9":
     265             :         case "pageup":
     266           0 :         case "+": this.doZoom(true); break;
     267             :         case "3":
     268             :         case "pagedown":
     269           0 :         case "-": this.doZoom(false); break;
     270             :         }
     271             :       };
     272           6 :       doc.body.onpointerdown = (event) => {
     273             :         /* main button */
     274           0 :         if (event?.button !== 0) { return; }
     275           0 :         const pos = { x: -this.zoom.x + event.clientX, y: -this.zoom.y + event.clientY };
     276           0 :         doc.body.setPointerCapture(event.pointerId);
     277           0 :         doc.body.onpointermove = (event) => {
     278           0 :           if (pos) {
     279           0 :             this.zoom.x = event.clientX - pos.x;
     280           0 :             this.zoom.y = event.clientY - pos.y;
     281             :           }
     282             :         };
     283           0 :         doc.body.onpointerup = (event) => {
     284           0 :           doc.body.releasePointerCapture(event.pointerId);
     285           0 :           doc.body.onpointermove = null;
     286           0 :           doc.body.onpointerup = null;
     287             :         };
     288             :       };
     289           6 :       doc.body.onwheel = (event) => this.doZoom(event.deltaY < 0);
     290             :     },
     291             :     /** @param {boolean} zoomIn */
     292             :     doZoom(zoomIn) {
     293           0 :       if (zoomIn) {
     294           0 :         this.zoom.scale *= 1.1;
     295           0 :         this.zoom.x *= 1.1;
     296           0 :         this.zoom.y *= 1.1;
     297             :       }
     298             :       else {
     299           0 :         this.zoom.scale /= 1.1;
     300           0 :         this.zoom.x /= 1.1;
     301           0 :         this.zoom.y /= 1.1;
     302             :       }
     303             :     },
     304             :     resetZoom() {
     305           2 :       this.zoom = { x: 0, y: 0, scale: 1 };
     306             :     },
     307             :     /** @param  {HTMLIFrameElement} frame */
     308             :     updateViewBox(frame) {
     309          10 :       const svg = /** @type {HTMLElement} */(this.getRootSvg(frame));
     310          10 :       if (svg) {
     311          10 :         svg.setAttribute("viewBox", this.viewBox);
     312          10 :         svg.setAttribute("transform",
     313             :           `translate(${this.zoom.x}, ${this.zoom.y}) scale(${this.zoom.scale})`);
     314             :       }
     315             :     },
     316             :     /** @param  {HTMLIFrameElement} frame */
     317             :     getRootSvg(frame) {
     318          28 :       return get(frame,
     319             :         [ "contentDocument", "body", "firstChild" ]);
     320             :     },
     321             :     /** @param  {File} file */
     322             :     async onFileDrop(file) {
     323           0 :       this.loadDoc(await file.text());
     324             :     },
     325             :     /** @param  {string} text */
     326             :     async onTextDrop(text) {
     327           0 :       this.loadDoc(text);
     328             :     },
     329             :     /**
     330             :      * @param  {string} text
     331             :      */
     332             :     async loadDoc(text) {
     333           3 :       return this.$store.dispatch("engine/load", text).catch(noop);
     334             :     },
     335             :     /**
     336             :      * @param  {Element} elt
     337             :      */
     338             :     clearElement(elt) {
     339          18 :       while (elt && elt.firstChild) {
     340           8 :         elt.removeChild(elt.firstChild);
     341             :       }
     342             :     },
     343             :     initDoc() {
     344           7 :       this.initTimer = null;
     345           7 :       if (this.svg) {
     346           7 :         try {
     347             :           // const bbox = this.svg.getBBox();
     348             :           // if (!bbox.width || !bbox.height) { throw undefined; }
     349             :           // const width = get(this.svg, [ "width", "baseVal", "value" ], 100);
     350             :           // const height = get(this.svg, [ "height", "baseVal", "value" ], 100);
     351             :           // this.viewBox = `0 0 ${bbox.width} ${bbox.height}`;
     352           7 :           const width = get(this.svg, [ "width", "baseVal", "value" ], 100);
     353           7 :           const height = get(this.svg, [ "height", "baseVal", "value" ], 100);
     354           7 :           this.viewBox = `0 0 ${width} ${height}`;
     355             :         }
     356             :         catch (e) {
     357           0 :           this.initTimer = setTimeout(this.initDoc, 250);
     358             :         }
     359             :       }
     360             :     },
     361             :     doToggleViewMode() {
     362           0 :       if (this.viewMode === ViewMode.normal) {
     363           0 :         this.$store.commit("ui/update", { viewMode: ViewMode.shadow });
     364             :       }
     365           0 :       else if (this.viewMode === ViewMode.shadow) {
     366           0 :         this.$store.commit("ui/update", { viewMode: ViewMode.jump });
     367             :       }
     368             :       else {
     369           0 :         this.$store.commit("ui/update", { viewMode: ViewMode.normal });
     370             :       }
     371             :     },
     372             :     async doRelease() {
     373           0 :       if (await invoke(this.$refs["releaseDialog"], "request")) {
     374           0 :         this.$store.commit("engine/release");
     375           0 :         sessionStorage?.removeItem("ssvg");
     376             :       }
     377             :     },
     378             :     async doRefresh() {
     379           0 :       return this.$store.dispatch("engine/reload").catch(noop);
     380             :     }
     381             :   }
     382             : });
     383             : export default component;

Generated by: LCOV version 1.16