/* globals jQuery */ /** * DOM element selection box tools * @module select * @license MIT * * @copyright 2010 Alexis Deveria, 2010 Jeff Schiller */ import {isTouch, isWebkit} from './browser.js'; // , isOpera import {getRotationAngle, getBBox, getStrokedBBox, isNullish} from './utilities.js'; import {transformListToTransform, transformBox, transformPoint} from './math.js'; import {getTransformList} from './svgtransformlist.js'; const $ = jQuery; let svgFactory_; let config_; let selectorManager_; // A Singleton const gripRadius = isTouch() ? 10 : 4; /** * Private class for DOM element selection boxes. */ export class Selector { /** * @param {Integer} id - Internally identify the selector * @param {Element} elem - DOM element associated with this selector * @param {module:utilities.BBoxObject} [bbox] - Optional bbox to use for initialization (prevents duplicate `getBBox` call). */ constructor (id, elem, bbox) { // this is the selector's unique number this.id = id; // this holds a reference to the element for which this selector is being used this.selectedElement = elem; // this is a flag used internally to track whether the selector is being used or not this.locked = true; // this holds a reference to the element that holds all visual elements of the selector this.selectorGroup = svgFactory_.createSVGElement({ element: 'g', attr: {id: ('selectorGroup' + this.id)} }); // this holds a reference to the path rect this.selectorRect = this.selectorGroup.appendChild( svgFactory_.createSVGElement({ element: 'path', attr: { id: ('selectedBox' + this.id), fill: 'none', stroke: '#22C', 'stroke-width': '1', 'stroke-dasharray': '5,5', // need to specify this so that the rect is not selectable style: 'pointer-events:none' } }) ); // this holds a reference to the grip coordinates for this selector this.gripCoords = { nw: null, n: null, ne: null, e: null, se: null, s: null, sw: null, w: null }; this.reset(this.selectedElement, bbox); } /** * Used to reset the id and element that the selector is attached to. * @param {Element} e - DOM element associated with this selector * @param {module:utilities.BBoxObject} bbox - Optional bbox to use for reset (prevents duplicate getBBox call). * @returns {void} */ reset (e, bbox) { this.locked = true; this.selectedElement = e; this.resize(bbox); this.selectorGroup.setAttribute('display', 'inline'); } /** * Show the resize grips of this selector. * @param {boolean} show - Indicates whether grips should be shown or not * @returns {void} */ showGrips (show) { const bShow = show ? 'inline' : 'none'; selectorManager_.selectorGripsGroup.setAttribute('display', bShow); const elem = this.selectedElement; this.hasGrips = show; if (elem && show) { this.selectorGroup.append(selectorManager_.selectorGripsGroup); Selector.updateGripCursors(getRotationAngle(elem)); } } /** * Updates the selector to match the element's size. * @param {module:utilities.BBoxObject} [bbox] - BBox to use for resize (prevents duplicate getBBox call). * @returns {void} */ resize (bbox) { const selectedBox = this.selectorRect, mgr = selectorManager_, selectedGrips = mgr.selectorGrips, selected = this.selectedElement, sw = selected.getAttribute('stroke-width'), currentZoom = svgFactory_.getCurrentZoom(); let offset = 1 / currentZoom; if (selected.getAttribute('stroke') !== 'none' && !isNaN(sw)) { offset += (sw / 2); } const {tagName} = selected; if (tagName === 'text') { offset += 2 / currentZoom; } // loop and transform our bounding box until we reach our first rotation const tlist = getTransformList(selected); const m = transformListToTransform(tlist).matrix; // This should probably be handled somewhere else, but for now // it keeps the selection box correctly positioned when zoomed m.e *= currentZoom; m.f *= currentZoom; if (!bbox) { bbox = getBBox(selected); } // TODO: getBBox (previous line) already knows to call getStrokedBBox when tagName === 'g'. Remove this? // TODO: getBBox doesn't exclude 'gsvg' and calls getStrokedBBox for any 'g'. Should getBBox be updated? if (tagName === 'g' && !$.data(selected, 'gsvg')) { // The bbox for a group does not include stroke vals, so we // get the bbox based on its children. const strokedBbox = getStrokedBBox([selected.childNodes]); if (strokedBbox) { bbox = strokedBbox; } } // apply the transforms const l = bbox.x, t = bbox.y, w = bbox.width, h = bbox.height; // bbox = {x: l, y: t, width: w, height: h}; // Not in use // we need to handle temporary transforms too // if skewed, get its transformed box, then find its axis-aligned bbox // * offset *= currentZoom; const nbox = transformBox(l * currentZoom, t * currentZoom, w * currentZoom, h * currentZoom, m), {aabox} = nbox; let nbax = aabox.x - offset, nbay = aabox.y - offset, nbaw = aabox.width + (offset * 2), nbah = aabox.height + (offset * 2); // now if the shape is rotated, un-rotate it const cx = nbax + nbaw / 2, cy = nbay + nbah / 2; const angle = getRotationAngle(selected); if (angle) { const rot = svgFactory_.svgRoot().createSVGTransform(); rot.setRotate(-angle, cx, cy); const rotm = rot.matrix; nbox.tl = transformPoint(nbox.tl.x, nbox.tl.y, rotm); nbox.tr = transformPoint(nbox.tr.x, nbox.tr.y, rotm); nbox.bl = transformPoint(nbox.bl.x, nbox.bl.y, rotm); nbox.br = transformPoint(nbox.br.x, nbox.br.y, rotm); // calculate the axis-aligned bbox const {tl} = nbox; let minx = tl.x, miny = tl.y, maxx = tl.x, maxy = tl.y; const {min, max} = Math; minx = min(minx, min(nbox.tr.x, min(nbox.bl.x, nbox.br.x))) - offset; miny = min(miny, min(nbox.tr.y, min(nbox.bl.y, nbox.br.y))) - offset; maxx = max(maxx, max(nbox.tr.x, max(nbox.bl.x, nbox.br.x))) + offset; maxy = max(maxy, max(nbox.tr.y, max(nbox.bl.y, nbox.br.y))) + offset; nbax = minx; nbay = miny; nbaw = (maxx - minx); nbah = (maxy - miny); } const dstr = 'M' + nbax + ',' + nbay + ' L' + (nbax + nbaw) + ',' + nbay + ' ' + (nbax + nbaw) + ',' + (nbay + nbah) + ' ' + nbax + ',' + (nbay + nbah) + 'z'; selectedBox.setAttribute('d', dstr); const xform = angle ? 'rotate(' + [angle, cx, cy].join(',') + ')' : ''; this.selectorGroup.setAttribute('transform', xform); // TODO(codedread): Is this needed? // if (selected === selectedElements[0]) { this.gripCoords = { nw: [nbax, nbay], ne: [nbax + nbaw, nbay], sw: [nbax, nbay + nbah], se: [nbax + nbaw, nbay + nbah], n: [nbax + (nbaw) / 2, nbay], w: [nbax, nbay + (nbah) / 2], e: [nbax + nbaw, nbay + (nbah) / 2], s: [nbax + (nbaw) / 2, nbay + nbah] }; Object.entries(this.gripCoords).forEach(([dir, coords]) => { selectedGrips[dir].setAttribute('cx', coords[0]); selectedGrips[dir].setAttribute('cy', coords[1]); }); // we want to go 20 pixels in the negative transformed y direction, ignoring scale mgr.rotateGripConnector.setAttribute('x1', nbax + (nbaw) / 2); mgr.rotateGripConnector.setAttribute('y1', nbay); mgr.rotateGripConnector.setAttribute('x2', nbax + (nbaw) / 2); mgr.rotateGripConnector.setAttribute('y2', nbay - (gripRadius * 5)); mgr.rotateGrip.setAttribute('cx', nbax + (nbaw) / 2); mgr.rotateGrip.setAttribute('cy', nbay - (gripRadius * 5)); // } } // STATIC methods /** * Updates cursors for corner grips on rotation so arrows point the right way. * @param {Float} angle - Current rotation angle in degrees * @returns {void} */ static updateGripCursors (angle) { const dirArr = Object.keys(selectorManager_.selectorGrips); let steps = Math.round(angle / 45); if (steps < 0) { steps += 8; } while (steps > 0) { dirArr.push(dirArr.shift()); steps--; } Object.values(selectorManager_.selectorGrips).forEach((gripElement, i) => { gripElement.setAttribute('style', ('cursor:' + dirArr[i] + '-resize')); }); } } /** * Manage all selector objects (selection boxes). */ export class SelectorManager { constructor () { // this will hold the element that contains all selector rects/grips this.selectorParentGroup = null; // this is a special rect that is used for multi-select this.rubberBandBox = null; // this will hold objects of type Selector (see above) this.selectors = []; // this holds a map of SVG elements to their Selector object this.selectorMap = {}; // this holds a reference to the grip elements this.selectorGrips = { nw: null, n: null, ne: null, e: null, se: null, s: null, sw: null, w: null }; this.selectorGripsGroup = null; this.rotateGripConnector = null; this.rotateGrip = null; this.initGroup(); } /** * Resets the parent selector group element. * @returns {void} */ initGroup () { // remove old selector parent group if it existed if (this.selectorParentGroup && this.selectorParentGroup.parentNode) { this.selectorParentGroup.remove(); } // create parent selector group and add it to svgroot this.selectorParentGroup = svgFactory_.createSVGElement({ element: 'g', attr: {id: 'selectorParentGroup'} }); this.selectorGripsGroup = svgFactory_.createSVGElement({ element: 'g', attr: {display: 'none'} }); this.selectorParentGroup.append(this.selectorGripsGroup); svgFactory_.svgRoot().append(this.selectorParentGroup); this.selectorMap = {}; this.selectors = []; this.rubberBandBox = null; // add the corner grips Object.keys(this.selectorGrips).forEach((dir) => { const grip = svgFactory_.createSVGElement({ element: 'circle', attr: { id: ('selectorGrip_resize_' + dir), fill: '#22C', r: gripRadius, style: ('cursor:' + dir + '-resize'), // This expands the mouse-able area of the grips making them // easier to grab with the mouse. // This works in Opera and WebKit, but does not work in Firefox // see https://bugzilla.mozilla.org/show_bug.cgi?id=500174 'stroke-width': 2, 'pointer-events': 'all' } }); $.data(grip, 'dir', dir); $.data(grip, 'type', 'resize'); this.selectorGrips[dir] = this.selectorGripsGroup.appendChild(grip); }); // add rotator elems this.rotateGripConnector = this.selectorGripsGroup.appendChild( svgFactory_.createSVGElement({ element: 'line', attr: { id: ('selectorGrip_rotateconnector'), stroke: '#22C', 'stroke-width': '1' } }) ); this.rotateGrip = this.selectorGripsGroup.appendChild( svgFactory_.createSVGElement({ element: 'circle', attr: { id: 'selectorGrip_rotate', fill: 'lime', r: gripRadius, stroke: '#22C', 'stroke-width': 2, style: 'cursor:url(' + config_.imgPath + 'rotate.png) 12 12, auto;' } }) ); $.data(this.rotateGrip, 'type', 'rotate'); if ($('#canvasBackground').length) { return; } const [width, height] = config_.dimensions; const canvasbg = svgFactory_.createSVGElement({ element: 'svg', attr: { id: 'canvasBackground', width, height, x: 0, y: 0, overflow: (isWebkit() ? 'none' : 'visible'), // Chrome 7 has a problem with this when zooming out style: 'pointer-events:none' } }); const rect = svgFactory_.createSVGElement({ element: 'rect', attr: { width: '100%', height: '100%', x: 0, y: 0, 'stroke-width': 1, stroke: '#000', fill: '#FFF', style: 'pointer-events:none' } }); // Both Firefox and WebKit are too slow with this filter region (especially at higher // zoom levels) and Opera has at least one bug // if (!isOpera()) rect.setAttribute('filter', 'url(#canvashadow)'); canvasbg.append(rect); svgFactory_.svgRoot().insertBefore(canvasbg, svgFactory_.svgContent()); // Ok to replace above with `svgFactory_.svgContent().before(canvasbg);`? } /** * * @param {Element} elem - DOM element to get the selector for * @param {module:utilities.BBoxObject} [bbox] - Optional bbox to use for reset (prevents duplicate getBBox call). * @returns {Selector} The selector based on the given element */ requestSelector (elem, bbox) { if (isNullish(elem)) { return null; } const N = this.selectors.length; // If we've already acquired one for this element, return it. if (typeof this.selectorMap[elem.id] === 'object') { this.selectorMap[elem.id].locked = true; return this.selectorMap[elem.id]; } for (let i = 0; i < N; ++i) { if (this.selectors[i] && !this.selectors[i].locked) { this.selectors[i].locked = true; this.selectors[i].reset(elem, bbox); this.selectorMap[elem.id] = this.selectors[i]; return this.selectors[i]; } } // if we reached here, no available selectors were found, we create one this.selectors[N] = new Selector(N, elem, bbox); this.selectorParentGroup.append(this.selectors[N].selectorGroup); this.selectorMap[elem.id] = this.selectors[N]; return this.selectors[N]; } /** * Removes the selector of the given element (hides selection box). * * @param {Element} elem - DOM element to remove the selector for * @returns {void} */ releaseSelector (elem) { if (isNullish(elem)) { return; } const N = this.selectors.length, sel = this.selectorMap[elem.id]; if (!sel.locked) { // TODO(codedread): Ensure this exists in this module. console.log('WARNING! selector was released but was already unlocked'); // eslint-disable-line no-console } for (let i = 0; i < N; ++i) { if (this.selectors[i] && this.selectors[i] === sel) { delete this.selectorMap[elem.id]; sel.locked = false; sel.selectedElement = null; sel.showGrips(false); // remove from DOM and store reference in JS but only if it exists in the DOM try { sel.selectorGroup.setAttribute('display', 'none'); } catch (e) {} break; } } } /** * @returns {SVGRectElement} The rubberBandBox DOM element. This is the rectangle drawn by * the user for selecting/zooming */ getRubberBandBox () { if (!this.rubberBandBox) { this.rubberBandBox = this.selectorParentGroup.appendChild( svgFactory_.createSVGElement({ element: 'rect', attr: { id: 'selectorRubberBand', fill: '#22C', 'fill-opacity': 0.15, stroke: '#22C', 'stroke-width': 0.5, display: 'none', style: 'pointer-events:none' } }) ); } return this.rubberBandBox; } } /** * An object that creates SVG elements for the canvas. * * @interface module:select.SVGFactory */ /** * @function module:select.SVGFactory#createSVGElement * @param {module:utilities.EditorContext#addSVGElementFromJson} jsonMap * @returns {SVGElement} */ /** * @function module:select.SVGFactory#svgRoot * @returns {SVGSVGElement} */ /** * @function module:select.SVGFactory#svgContent * @returns {SVGSVGElement} */ /** * @function module:select.SVGFactory#getCurrentZoom * @returns {Float} The current zoom level */ /** * @typedef {GenericArray} module:select.Dimensions * @property {Integer} length 2 * @property {Float} 0 Width * @property {Float} 1 Height */ /** * @typedef {PlainObject} module:select.Config * @property {string} imgPath * @property {module:select.Dimensions} dimensions */ /** * Initializes this module. * @function module:select.init * @param {module:select.Config} config - An object containing configurable parameters (imgPath) * @param {module:select.SVGFactory} svgFactory - An object implementing the SVGFactory interface. * @returns {void} */ export const init = function (config, svgFactory) { config_ = config; svgFactory_ = svgFactory; selectorManager_ = new SelectorManager(); }; /** * @function module:select.getSelectorManager * @returns {module:select.SelectorManager} The SelectorManager instance. */ export const getSelectorManager = () => selectorManager_;