1class ImageMappingEditor {
2
3    static COORDREGEX = /@\s*((\d+(,\s*)?)+)/;
4
5    /** @var {string} URL to load the image of the map */
6    imgurl = '';
7    /** @var {string} prefix for the map syntax to change */
8    header = '';
9    /** @var {string} syntax to add the coordinates to */
10    syntax = '';
11    /** @var {string} suffix for the map syntax to change */
12    footer = '';
13    /** @var {selection_class} the selection in the editor that will be replaced*/
14    selection = null;
15
16    /** @var {array} the coordinates of the click area */
17    coordinates = [];
18    /** @var {SVGSVGElement} the editor SVG */
19    svg = null;
20    /** @var {int} width of the image */
21    width = 0;
22    /** @var {int} height of the image */
23    height = 0;
24
25    /** @var {JQuery} the dialog */
26    $dialog = null;
27
28    /**
29     * @param {selection_class} selection
30     */
31    constructor(selection) {
32        this.selection = selection;
33        if (!this.initializeSyntaxData()) return;
34        this.initializeSvg();
35        this.showDialog();
36    }
37
38
39    /**
40     * Initializes the editor values from the current selection
41     *
42     * @returns {boolean} true if the editor can be shown, false if the selection is invalid
43     */
44    initializeSyntaxData() {
45        const area = this.selection.obj; // the editor text area
46
47        // find the map syntax surrounding the selection
48        const map = this.elementBoundary('{{map>', '{{<map}}');
49
50        // the following code figures out if we are inside an image or an existing map, and if a link
51        // inside the map is selected. It will then adjust the selection to mark the part of the syntax
52        // that will be replaced when the editor is closed.
53        // The selection will be replaced by header + syntax + footer, where the syntax will have the new
54        // coordinates appended.
55
56        if (!map) {
57            // no map syntax found, check if we are inside an image
58            const img = this.elementBoundary('{{', '}}');
59            if (!img) {
60                alert(LANG.plugins.imagemapping.wrongcontext);
61                return false;
62            }
63
64            // we are inside an image, create a new map
65            this.header = '{{map>' + area.value.substring(img.start, img.end) + "}}\n";
66            this.footer = "\n{{<map}}";
67            this.imgurl = this.constructImgUrl(area.value.substring(img.start, img.end));
68            this.selection.start = img.start - 2;
69            this.selection.end = img.end + 2;
70            // syntax stays empty and will be filled on save
71        } else {
72            // we are inside an existing map
73            this.imgurl = this.constructImgUrl(area.value.substring(map.start, area.value.indexOf('}}', map.start)));
74
75            // check if a link is selected
76            let link = this.elementBoundary('[[', ']]', map.start, map.end);
77            if (link) {
78                // we are in a link, adjust it if it's an image link
79                const imglink = this.elementBoundary('{{', '}}', link.start, link.end);
80                if (imglink) link = imglink;
81
82                this.syntax = area.value.substring(link.start, link.end);
83                this.selection.start = link.start;
84                this.selection.end = link.end;
85
86                // add title separator if needed
87                if (this.syntax.indexOf('|') === -1) this.syntax += '|';
88
89                // check for current coordinates
90                const match = this.syntax.match(ImageMappingEditor.COORDREGEX);
91                if (match) {
92                    // we are in a link with coordinates, remember them and remove them from the syntax
93                    this.coordinates = match[1].split(',').map((v) => parseInt(v, 10)).filter(Number);
94                    this.syntax = this.syntax.replace(ImageMappingEditor.COORDREGEX, '');
95                }
96            }
97        }
98
99        DWsetSelection(this.selection);
100        return true;
101    }
102
103    /**
104     * Search around the current selection start for the boundaries of the wanted syntax element
105     *
106     * We care only for selection start, because the full selection might cross map boundaries
107     * Returned are the indexes *inside* the element, excluding the open/close syntax.
108     *
109     * @param {string} open The opening syntax
110     * @param {string} close The closing syntax
111     * @param {string} [min] Lower boundary for the search
112     * @param {string} [max] Upper boundary for the search
113     * @returns {Object|false} false if not in the element, {start: int, end: int} if inside
114     */
115    elementBoundary(open, close, min, max) {
116        const area = this.selection.obj; // the editor text area
117        if (min === undefined) min = 0;
118        if (max === undefined) max = area.value.length;
119
120        // potential boundaries
121        const start = area.value.lastIndexOf(open, this.selection.start);
122        const end = area.value.indexOf(close, this.selection.start);
123
124        // boundaries of the previous and next elements of the same type
125        const prev = area.value.lastIndexOf(close, this.selection.start - close.length);
126        const next = area.value.indexOf(open, this.selection.start + open.length);
127
128        // out of bounds?
129        if (start < min) return false;
130        if (prev > -1 && prev > min && start < prev) return false;
131        if (end > max) return false;
132        if (next > -1 && next < end && end > next) return false;
133
134        // still here? we are inside a boundary
135        return {
136            start: start + open.length,
137            end: end
138        };
139    }
140
141    /**
142     * Creates the Editor SVG visualizing the current click area
143     */
144    initializeSvg() {
145        // create an SVG element with the image as background
146        this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
147
148        const img = new Image();
149        img.onload = function () {
150            this.svg.setAttribute('viewBox', '0 0 ' + img.width + ' ' + img.height);
151
152            // background image
153            const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
154            image.setAttribute('href', img.src);
155            image.setAttribute('x', 0);
156            image.setAttribute('y', 0);
157            image.setAttribute('width', img.width);
158            image.setAttribute('height', img.height);
159            this.svg.appendChild(image);
160            this.width = img.width;
161            this.height = img.height;
162
163            // group for the polygon
164            const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
165            this.svg.appendChild(group);
166
167            // initialize polgon, handles and drag handler
168            this.initializeDragHandler();
169            this.drawPolygon();
170            this.initializeHandles();
171        }.bind(this);
172        img.src = this.imgurl;
173    }
174
175    /**
176     * Display the editor dialog
177     */
178    showDialog() {
179        this.$dialog = jQuery('<div></div>');
180        this.$dialog.addClass('plugin-imagemapping');
181        this.$dialog.append(this.svg);
182        this.$dialog.dialog({
183            title: LANG.plugins.imagemapping.title,
184            width: Math.max(500, jQuery(window).width() * 0.75),
185            height: Math.max(300, jQuery(window).height() * 0.75),
186            modal: true,
187            closeText: LANG.plugins.imagemapping.btn_cancel,
188            buttons: [
189                {
190                    text: LANG.plugins.imagemapping.btn_fewer,
191                    click: this.removePoint.bind(this),
192                },
193                {
194                    text: LANG.plugins.imagemapping.btn_more,
195                    click: this.addPoint.bind(this),
196                },
197                {
198                    text: LANG.plugins.imagemapping.btn_save,
199                    click: this.save.bind(this),
200                },
201                {
202                    text: LANG.plugins.imagemapping.btn_cancel,
203                    click: function () {
204                        jQuery(this).dialog('close');
205                    }
206                }
207            ]
208        });
209    }
210
211    /**
212     * Adds drag handles for all points of the current click area
213     */
214    initializeHandles() {
215        // remove old handles
216        Array.from(this.svg.querySelectorAll('circle.handle')).forEach((h) => h.remove());
217
218        // for circles, we need to convert the center and radius to x/y coordinates
219        let isCircle = false;
220        let coords = this.coordinates;
221        if (this.coordinates.length === 3) {
222            coords = [
223                this.coordinates[0], this.coordinates[1],
224                this.coordinates[0] + this.coordinates[2], this.coordinates[1]
225            ];
226            isCircle = true;
227        }
228
229        // draw new handles
230        for (let i = 0; i < coords.length; i += 2) {
231            const handle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
232            handle.setAttribute('class', 'handle ' + (isCircle ? 'circle' : ''));
233            handle.setAttribute('cx', coords[i]);
234            handle.setAttribute('cy', coords[i + 1]);
235            handle.setAttribute('r', 15);
236            this.svg.appendChild(handle);
237        }
238    }
239
240    /**
241     * Update the internal coordinates array from the current SVG handle positions
242     */
243    updateCoordinates() {
244        const handles = Array.from(this.svg.querySelectorAll('circle.handle'));
245
246        if (handles.length === 2 && handles[0].classList.contains('circle')) {
247            this.coordinates = [
248                handles[0].getAttribute('cx'),
249                handles[0].getAttribute('cy'),
250                Math.sqrt(
251                    Math.pow(handles[0].getAttribute('cx') - handles[1].getAttribute('cx'), 2) +
252                    Math.pow(handles[0].getAttribute('cy') - handles[1].getAttribute('cy'), 2)
253                )
254            ];
255        } else {
256            this.coordinates = handles.map((h) => [h.getAttribute('cx'), h.getAttribute('cy')]).flat();
257        }
258
259        this.coordinates = this.coordinates.map((v) => parseInt(v, 10));
260    }
261
262    /**
263     * Creates a polgon of the currently defined click area
264     */
265    drawPolygon() {
266        // polgons go to a group that we clear first
267        const group = this.svg.querySelector('g');
268        group.innerHTML = '';
269
270        // draw the polygon
271        if (this.coordinates.length === 3) {
272            const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
273            circle.setAttribute('cx', this.coordinates[0]);
274            circle.setAttribute('cy', this.coordinates[1]);
275            circle.setAttribute('r', this.coordinates[2]);
276            group.appendChild(circle);
277        } else if (this.coordinates.length === 4) {
278            const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
279            rect.setAttribute('x', Math.min(this.coordinates[0], this.coordinates[2]));
280            rect.setAttribute('y', Math.min(this.coordinates[1], this.coordinates[3]));
281            rect.setAttribute('width', Math.abs(this.coordinates[2] - this.coordinates[0]));
282            rect.setAttribute('height', Math.abs(this.coordinates[3] - this.coordinates[1]));
283            group.appendChild(rect);
284        } else if (this.coordinates.length > 4) {
285            const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
286            polygon.setAttribute('points', this.coordinates.join(' '));
287            group.appendChild(polygon);
288        }
289
290    }
291
292
293    /**
294     * Makes circles draggable
295     *
296     * @link https://www.petercollingridge.co.uk/tutorials/svg/interactive/dragging/
297     */
298    initializeDragHandler() {
299        const svg = this.svg;
300        svg.addEventListener('mousedown', startDrag);
301        svg.addEventListener('mousemove', drag);
302        svg.addEventListener('mouseup', endDrag);
303        svg.addEventListener('mouseleave', endDrag);
304        svg.addEventListener('touchstart', startDrag);
305        svg.addEventListener('touchmove', drag);
306        svg.addEventListener('touchend', endDrag);
307        svg.addEventListener('touchleave', endDrag);
308        svg.addEventListener('touchcancel', endDrag);
309
310        let selectedElement, offset;
311        const self = this;
312
313        function getMousePosition(evt) {
314            const CTM = svg.getScreenCTM();
315            if (evt.touches) {
316                evt = evt.touches[0];
317            }
318            return {
319                x: (evt.clientX - CTM.e) / CTM.a,
320                y: (evt.clientY - CTM.f) / CTM.d
321            };
322        }
323
324        function startDrag(evt) {
325            if (evt.target.nodeName !== 'circle') return;
326
327            selectedElement = evt.target;
328            offset = getMousePosition(evt);
329            offset.x -= parseFloat(selectedElement.getAttributeNS(null, "cx"));
330            offset.y -= parseFloat(selectedElement.getAttributeNS(null, "cy"));
331        }
332
333        function drag(evt) {
334            if (!selectedElement) return;
335
336            evt.preventDefault();
337            const coord = getMousePosition(evt);
338            selectedElement.setAttributeNS(null, "cx", coord.x - offset.x);
339            selectedElement.setAttributeNS(null, "cy", coord.y - offset.y);
340
341            self.updateCoordinates(svg);
342            self.drawPolygon(svg, self.coords);
343        }
344
345        function endDrag() {
346            selectedElement = null;
347        }
348    }
349
350    /**
351     * Adds a new point to the polygon
352     */
353    addPoint() {
354        let c = this.coordinates;
355
356        if (c.length < 3) {
357            // add a centered circle
358            c = [Math.ceil(this.width / 2), Math.ceil(this.height / 2), Math.ceil(this.width / 10)];
359        } else if (c.length === 3) {
360            // convert circle to rectangle
361            c = [c[0], c[1], c[0] + c[2], c[1] + c[2]];
362        } else {
363            // add new point in the middle of the last two
364            c = c.concat([
365                Math.ceil(Math.abs(c[c.length - 4] - c[c.length - 2]) / 2) + Math.min(c[c.length - 4], c[c.length - 2]),
366                Math.ceil(Math.abs(c[c.length - 3] - c[c.length - 1]) / 2) + Math.min(c[c.length - 3], c[c.length - 1])
367            ]);
368        }
369
370        this.coordinates = c;
371        this.initializeHandles();
372        this.drawPolygon();
373    }
374
375    /**
376     * Removes a point from the polygon
377     */
378    removePoint() {
379        let c = this.coordinates;
380
381        if (c.length < 4) {
382            // remove all points
383            c = [];
384        } else if (c.length === 4) {
385            // convert to circle
386            c = [c[0], c[1], Math.abs(c[1] - c[2])];
387        } else {
388            // remove last point
389            c = c.slice(0, -2);
390        }
391
392        this.coordinates = c;
393        this.initializeHandles();
394        this.drawPolygon();
395    }
396
397    /**
398     * Saves the coordinates to the textarea
399     */
400    save() {
401        if (this.syntax === '') {
402            if (this.coordinates.length >= 3) {
403                // we had no previous syntax, so we add a new dummy link
404                this.header += "\n  * [[new link|title ";
405                this.footer = "]]" + this.footer;
406            }
407        }
408
409        let coords = '';
410        if (this.coordinates.length >= 0) {
411            coords = '@' + this.coordinates.join(',');
412        }
413
414        pasteText(
415            this.selection,
416            this.header + this.syntax + coords + this.footer,
417            {
418                // select the new coordinates
419                startofs: (this.header + this.syntax).length,
420                endofs: this.footer.length
421            }
422        );
423        this.$dialog.dialog('close');
424        this.$dialog.remove();
425    }
426
427    /**
428     * Create the image URL from the image syntax
429     *
430     * @param {string} img Image syntax without the {{ and }}
431     * @returns {string}
432     */
433    constructImgUrl(img) {
434        let url = img.split('|')[0].split('?')[0];
435        if (url.match(/^https?:\/\//)) {
436            return url;
437        }
438        return DOKU_BASE + 'lib/exe/fetch.php?media=' + url;
439    }
440
441}
442