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