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;
|