diff --git a/src/svgcanvas/draw.js b/src/svgcanvas/draw.js index 6a52be34..6b4f646b 100644 --- a/src/svgcanvas/draw.js +++ b/src/svgcanvas/draw.js @@ -5,1051 +5,1050 @@ * @copyright 2011 Jeff Schiller */ - import Layer from './layer.js'; - import HistoryRecordingService from './historyrecording.js'; - - import { NS } from '../common/namespaces.js'; - import { isOpera } from '../common/browser.js'; - import { - toXml, getElem - } from './utilities.js'; - import { - copyElem as utilCopyElem - } from './copy-elem.js'; - import { - BatchCommand, RemoveElementCommand, MoveElementCommand, ChangeElementCommand - } from './history.js'; - import { getParentsUntil } from '../editor/components/jgraduate/Util.js'; - - const visElems = 'a,circle,ellipse,foreignObject,g,image,line,path,polygon,polyline,rect,svg,text,tspan,use'.split(','); - - const RandomizeModes = { - LET_DOCUMENT_DECIDE: 0, - ALWAYS_RANDOMIZE: 1, - NEVER_RANDOMIZE: 2 - }; - let randIds = RandomizeModes.LET_DOCUMENT_DECIDE; - // Array with current disabled elements (for in-group editing) - let disabledElems = []; - - /** - * Get a HistoryRecordingService. - * @param {module:history.HistoryRecordingService} [hrService] - if exists, return it instead of creating a new service. - * @returns {module:history.HistoryRecordingService} +import Layer from './layer.js'; +import HistoryRecordingService from './historyrecording.js'; + +import { NS } from '../common/namespaces.js'; +import { isOpera } from '../common/browser.js'; +import { + toXml, getElem +} from './utilities.js'; +import { + copyElem as utilCopyElem +} from './copy-elem.js'; +import { + BatchCommand, RemoveElementCommand, MoveElementCommand, ChangeElementCommand +} from './history.js'; +import { getParentsUntil } from '../editor/components/jgraduate/Util.js'; + +const visElems = 'a,circle,ellipse,foreignObject,g,image,line,path,polygon,polyline,rect,svg,text,tspan,use'.split(','); + +const RandomizeModes = { + LET_DOCUMENT_DECIDE: 0, + ALWAYS_RANDOMIZE: 1, + NEVER_RANDOMIZE: 2 +}; +let randIds = RandomizeModes.LET_DOCUMENT_DECIDE; +// Array with current disabled elements (for in-group editing) +let disabledElems = []; + +/** + * Get a HistoryRecordingService. + * @param {module:history.HistoryRecordingService} [hrService] - if exists, return it instead of creating a new service. + * @returns {module:history.HistoryRecordingService} + */ +function historyRecordingService (hrService) { + return hrService || new HistoryRecordingService(canvas_.undoMgr); +} + +/** + * Find the layer name in a group element. + * @param {Element} group The group element to search in. + * @returns {string} The layer name or empty string. + */ +function findLayerNameInGroup (group) { + return group.querySelector('title').textContent || + (isOpera() && group.querySelectorAll + // Hack for Opera 10.60 + ? group.querySelector('title').textContent + : ''); +} + +/** + * Given a set of names, return a new unique name. + * @param {string[]} existingLayerNames - Existing layer names. + * @returns {string} - The new name. + */ +function getNewLayerName (existingLayerNames) { + let i = 1; + // TODO(codedread): What about internationalization of "Layer"? + while (existingLayerNames.includes(('Layer ' + i))) { i++; } + return 'Layer ' + i; +} + +/** + * This class encapsulates the concept of a SVG-edit drawing. + */ +export class Drawing { + /** + * @param {SVGSVGElement} svgElem - The SVG DOM Element that this JS object + * encapsulates. If the svgElem has a se:nonce attribute on it, then + * IDs will use the nonce as they are generated. + * @param {string} [optIdPrefix=svg_] - The ID prefix to use. + * @throws {Error} If not initialized with an SVG element */ - function historyRecordingService (hrService) { - return hrService || new HistoryRecordingService(canvas_.undoMgr); - } - - /** - * Find the layer name in a group element. - * @param {Element} group The group element to search in. - * @returns {string} The layer name or empty string. + constructor (svgElem, optIdPrefix) { + if (!svgElem || !svgElem.tagName || !svgElem.namespaceURI || + svgElem.tagName !== 'svg' || svgElem.namespaceURI !== NS.SVG) { + throw new Error('Error: svgedit.draw.Drawing instance initialized without a element'); + } + + /** + * The SVG DOM Element that represents this drawing. + * @type {SVGSVGElement} + */ + this.svgElem_ = svgElem; + + /** + * The latest object number used in this drawing. + * @type {Integer} + */ + this.obj_num = 0; + + /** + * The prefix to prepend to each element id in the drawing. + * @type {string} + */ + this.idPrefix = optIdPrefix || 'svg_'; + + /** + * An array of released element ids to immediately reuse. + * @type {Integer[]} + */ + this.releasedNums = []; + + /** + * The z-ordered array of Layer objects. Each layer has a name + * and group element. + * The first layer is the one at the bottom of the rendering. + * @type {Layer[]} + */ + this.all_layers = []; + + /** + * Map of all_layers by name. + * + * Note: Layers are ordered, but referenced externally by name; so, we need both container + * types depending on which function is called (i.e. all_layers and layer_map). + * + * @type {PlainObject} + */ + this.layer_map = {}; + + /** + * The current layer being used. + * @type {Layer} + */ + this.current_layer = null; + + /** + * The nonce to use to uniquely identify elements across drawings. + * @type {!string} + */ + this.nonce_ = ''; + const n = this.svgElem_.getAttributeNS(NS.SE, 'nonce'); + // If already set in the DOM, use the nonce throughout the document + // else, if randomizeIds(true) has been called, create and set the nonce. + if (n && randIds !== RandomizeModes.NEVER_RANDOMIZE) { + this.nonce_ = n; + } else if (randIds === RandomizeModes.ALWAYS_RANDOMIZE) { + this.setNonce(Math.floor(Math.random() * 100001)); + } + } + + /** + * @param {string} id Element ID to retrieve + * @returns {Element} SVG element within the root SVGSVGElement */ - function findLayerNameInGroup (group) { - return group.querySelector('title').textContent || - (isOpera() && group.querySelectorAll - // Hack for Opera 10.60 - ? group.querySelector('title').textContent - : ''); - } - - /** - * Given a set of names, return a new unique name. - * @param {string[]} existingLayerNames - Existing layer names. - * @returns {string} - The new name. - */ - function getNewLayerName (existingLayerNames) { - let i = 1; - // TODO(codedread): What about internationalization of "Layer"? - while (existingLayerNames.includes(('Layer ' + i))) { i++; } - return 'Layer ' + i; - } - - /** - * This class encapsulates the concept of a SVG-edit drawing. - */ - export class Drawing { - /** - * @param {SVGSVGElement} svgElem - The SVG DOM Element that this JS object - * encapsulates. If the svgElem has a se:nonce attribute on it, then - * IDs will use the nonce as they are generated. - * @param {string} [optIdPrefix=svg_] - The ID prefix to use. - * @throws {Error} If not initialized with an SVG element + getElem_ (id) { + if (this.svgElem_.querySelector) { + // querySelector lookup + return this.svgElem_.querySelector('#' + id); + } + // jQuery lookup: twice as slow as xpath in FF + return this.svgElem_.querySelector('[id=' + id + ']'); + } + + /** + * @returns {SVGSVGElement} */ - constructor (svgElem, optIdPrefix) { - if (!svgElem || !svgElem.tagName || !svgElem.namespaceURI || - svgElem.tagName !== 'svg' || svgElem.namespaceURI !== NS.SVG) { - throw new Error('Error: svgedit.draw.Drawing instance initialized without a element'); - } - - /** - * The SVG DOM Element that represents this drawing. - * @type {SVGSVGElement} - */ - this.svgElem_ = svgElem; - - /** - * The latest object number used in this drawing. - * @type {Integer} - */ - this.obj_num = 0; - - /** - * The prefix to prepend to each element id in the drawing. - * @type {string} - */ - this.idPrefix = optIdPrefix || 'svg_'; - - /** - * An array of released element ids to immediately reuse. - * @type {Integer[]} - */ - this.releasedNums = []; - - /** - * The z-ordered array of Layer objects. Each layer has a name - * and group element. - * The first layer is the one at the bottom of the rendering. - * @type {Layer[]} - */ - this.all_layers = []; - - /** - * Map of all_layers by name. - * - * Note: Layers are ordered, but referenced externally by name; so, we need both container - * types depending on which function is called (i.e. all_layers and layer_map). - * - * @type {PlainObject} - */ - this.layer_map = {}; - - /** - * The current layer being used. - * @type {Layer} - */ - this.current_layer = null; - - /** - * The nonce to use to uniquely identify elements across drawings. - * @type {!string} - */ - this.nonce_ = ''; - const n = this.svgElem_.getAttributeNS(NS.SE, 'nonce'); - // If already set in the DOM, use the nonce throughout the document - // else, if randomizeIds(true) has been called, create and set the nonce. - if (n && randIds !== RandomizeModes.NEVER_RANDOMIZE) { - this.nonce_ = n; - } else if (randIds === RandomizeModes.ALWAYS_RANDOMIZE) { - this.setNonce(Math.floor(Math.random() * 100001)); - } - } - - /** - * @param {string} id Element ID to retrieve - * @returns {Element} SVG element within the root SVGSVGElement + getSvgElem () { + return this.svgElem_; + } + + /** + * @returns {!(string|Integer)} The previously set nonce */ - getElem_ (id) { - if (this.svgElem_.querySelector) { - // querySelector lookup - return this.svgElem_.querySelector('#' + id); - } - // jQuery lookup: twice as slow as xpath in FF - return this.svgElem_.querySelector('[id=' + id + ']'); - } - - /** - * @returns {SVGSVGElement} - */ - getSvgElem () { - return this.svgElem_; - } - - /** - * @returns {!(string|Integer)} The previously set nonce - */ - getNonce () { - return this.nonce_; - } - - /** - * @param {!(string|Integer)} n The nonce to set - * @returns {void} - */ - setNonce (n) { - this.svgElem_.setAttributeNS(NS.XMLNS, 'xmlns:se', NS.SE); - this.svgElem_.setAttributeNS(NS.SE, 'se:nonce', n); - this.nonce_ = n; - } - - /** - * Clears any previously set nonce. - * @returns {void} - */ - clearNonce () { - // We deliberately leave any se:nonce attributes alone, - // we just don't use it to randomize ids. - this.nonce_ = ''; - } - - /** - * Returns the latest object id as a string. - * @returns {string} The latest object Id. - */ - getId () { - return this.nonce_ - ? this.idPrefix + this.nonce_ + '_' + this.obj_num - : this.idPrefix + this.obj_num; - } - - /** - * Returns the next object Id as a string. - * @returns {string} The next object Id to use. - */ - getNextId () { - const oldObjNum = this.obj_num; - let restoreOldObjNum = false; - - // If there are any released numbers in the release stack, - // use the last one instead of the next obj_num. - // We need to temporarily use obj_num as that is what getId() depends on. - if (this.releasedNums.length > 0) { - this.obj_num = this.releasedNums.pop(); - restoreOldObjNum = true; - } else { - // If we are not using a released id, then increment the obj_num. - this.obj_num++; - } - - // Ensure the ID does not exist. - let id = this.getId(); - while (this.getElem_(id)) { - if (restoreOldObjNum) { - this.obj_num = oldObjNum; - restoreOldObjNum = false; - } - this.obj_num++; - id = this.getId(); - } - // Restore the old object number if required. - if (restoreOldObjNum) { - this.obj_num = oldObjNum; - } - return id; - } - - /** - * Releases the object Id, letting it be used as the next id in getNextId(). - * This method DOES NOT remove any elements from the DOM, it is expected - * that client code will do this. - * @param {string} id - The id to release. - * @returns {boolean} True if the id was valid to be released, false otherwise. - */ - releaseId (id) { - // confirm if this is a valid id for this Document, else return false - const front = this.idPrefix + (this.nonce_ ? this.nonce_ + '_' : ''); - if (typeof id !== 'string' || !id.startsWith(front)) { - return false; - } - // extract the obj_num of this id - const num = Number.parseInt(id.substr(front.length)); - - // if we didn't get a positive number or we already released this number - // then return false. - if (typeof num !== 'number' || num <= 0 || this.releasedNums.includes(num)) { - return false; - } - - // push the released number into the released queue - this.releasedNums.push(num); - - return true; - } - - /** - * Returns the number of layers in the current drawing. - * @returns {Integer} The number of layers in the current drawing. - */ - getNumLayers () { - return this.all_layers.length; - } - - /** - * Check if layer with given name already exists. - * @param {string} name - The layer name to check - * @returns {boolean} - */ - hasLayer (name) { - return this.layer_map[name] !== undefined; - } - - /** - * Returns the name of the ith layer. If the index is out of range, an empty string is returned. - * @param {Integer} i - The zero-based index of the layer you are querying. - * @returns {string} The name of the ith layer (or the empty string if none found) - */ - getLayerName (i) { - return i >= 0 && i < this.getNumLayers() ? this.all_layers[i].getName() : ''; - } - - /** - * @returns {SVGGElement|null} The SVGGElement representing the current layer. - */ - getCurrentLayer () { - return this.current_layer ? this.current_layer.getGroup() : null; - } - - /** - * Get a layer by name. - * @param {string} name - * @returns {SVGGElement} The SVGGElement representing the named layer or null. - */ - getLayerByName (name) { - const layer = this.layer_map[name]; - return layer ? layer.getGroup() : null; - } - - /** - * Returns the name of the currently selected layer. If an error occurs, an empty string - * is returned. - * @returns {string} The name of the currently active layer (or the empty string if none found). - */ - getCurrentLayerName () { - return this.current_layer ? this.current_layer.getName() : ''; - } - - /** - * Set the current layer's name. - * @param {string} name - The new name. - * @param {module:history.HistoryRecordingService} hrService - History recording service - * @returns {string|null} The new name if changed; otherwise, null. - */ - setCurrentLayerName (name, hrService) { - let finalName = null; - if (this.current_layer) { - const oldName = this.current_layer.getName(); - finalName = this.current_layer.setName(name, hrService); - if (finalName) { - delete this.layer_map[oldName]; - this.layer_map[finalName] = this.current_layer; - } - } - return finalName; - } - - /** - * Set the current layer's position. - * @param {Integer} newpos - The zero-based index of the new position of the layer. Range should be 0 to layers-1 - * @returns {{title: SVGGElement, previousName: string}|null} If the name was changed, returns {title:SVGGElement, previousName:string}; otherwise null. - */ - setCurrentLayerPosition (newpos) { - const layerCount = this.getNumLayers(); - if (!this.current_layer || newpos < 0 || newpos >= layerCount) { - return null; - } - - let oldpos; - for (oldpos = 0; oldpos < layerCount; ++oldpos) { - if (this.all_layers[oldpos] === this.current_layer) { break; } - } - // some unknown error condition (current_layer not in all_layers) - if (oldpos === layerCount) { return null; } - - if (oldpos !== newpos) { - // if our new position is below us, we need to insert before the node after newpos - const currentGroup = this.current_layer.getGroup(); - const oldNextSibling = currentGroup.nextSibling; - - let refGroup = null; - if (newpos > oldpos) { - if (newpos < layerCount - 1) { - refGroup = this.all_layers[newpos + 1].getGroup(); - } - // if our new position is above us, we need to insert before the node at newpos - } else { - refGroup = this.all_layers[newpos].getGroup(); - } - this.svgElem_.insertBefore(currentGroup, refGroup); // Ok to replace with `refGroup.before(currentGroup);`? - - this.identifyLayers(); - this.setCurrentLayer(this.getLayerName(newpos)); - - return { - currentGroup, - oldNextSibling - }; - } - return null; - } - - /** - * @param {module:history.HistoryRecordingService} hrService + getNonce () { + return this.nonce_; + } + + /** + * @param {!(string|Integer)} n The nonce to set * @returns {void} */ - mergeLayer (hrService) { - const currentGroup = this.current_layer.getGroup(); - const prevGroup = currentGroup.previousElementSibling; - if (!prevGroup) { return; } - - hrService.startBatchCommand('Merge Layer'); - - const layerNextSibling = currentGroup.nextSibling; - hrService.removeElement(currentGroup, layerNextSibling, this.svgElem_); - - while (currentGroup.firstChild) { - const child = currentGroup.firstChild; - if (child.localName === 'title') { - hrService.removeElement(child, child.nextSibling, currentGroup); - child.remove(); - continue; - } - const oldNextSibling = child.nextSibling; - prevGroup.append(child); - hrService.moveElement(child, oldNextSibling, currentGroup); - } - - // Remove current layer's group - this.current_layer.removeGroup(); - // Remove the current layer and set the previous layer as the new current layer - const index = this.all_layers.indexOf(this.current_layer); - if (index > 0) { - const name = this.current_layer.getName(); - this.current_layer = this.all_layers[index - 1]; - this.all_layers.splice(index, 1); - delete this.layer_map[name]; - } - - hrService.endBatchCommand(); - } - - /** - * @param {module:history.HistoryRecordingService} hrService + setNonce (n) { + this.svgElem_.setAttributeNS(NS.XMLNS, 'xmlns:se', NS.SE); + this.svgElem_.setAttributeNS(NS.SE, 'se:nonce', n); + this.nonce_ = n; + } + + /** + * Clears any previously set nonce. * @returns {void} */ - mergeAllLayers (hrService) { - // Set the current layer to the last layer. - this.current_layer = this.all_layers[this.all_layers.length - 1]; - - hrService.startBatchCommand('Merge all Layers'); - while (this.all_layers.length > 1) { - this.mergeLayer(hrService); - } - hrService.endBatchCommand(); - } - - /** - * Sets the current layer. If the name is not a valid layer name, then this - * function returns `false`. Otherwise it returns `true`. This is not an - * undo-able action. - * @param {string} name - The name of the layer you want to switch to. - * @returns {boolean} `true` if the current layer was switched, otherwise `false` - */ - setCurrentLayer (name) { - const layer = this.layer_map[name]; - if (layer) { - if (this.current_layer) { - this.current_layer.deactivate(); - } - this.current_layer = layer; - this.current_layer.activate(); - return true; - } - return false; - } - - /** - * Deletes the current layer from the drawing and then clears the selection. - * This function then calls the 'changed' handler. This is an undoable action. - * @todo Does this actually call the 'changed' handler? - * @returns {SVGGElement} The SVGGElement of the layer removed or null. - */ - deleteCurrentLayer () { - if (this.current_layer && this.getNumLayers() > 1) { - const oldLayerGroup = this.current_layer.removeGroup(); - this.identifyLayers(); - return oldLayerGroup; - } - return null; - } - - /** - * Updates layer system and sets the current layer to the - * top-most layer (last `` child of this drawing). - * @returns {void} + clearNonce () { + // We deliberately leave any se:nonce attributes alone, + // we just don't use it to randomize ids. + this.nonce_ = ''; + } + + /** + * Returns the latest object id as a string. + * @returns {string} The latest object Id. */ - identifyLayers () { - this.all_layers = []; - this.layer_map = {}; - const numchildren = this.svgElem_.childNodes.length; - // loop through all children of SVG element - const orphans = [], layernames = []; - let layer = null; - let childgroups = false; - for (let i = 0; i < numchildren; ++i) { - const child = this.svgElem_.childNodes.item(i); - // for each g, find its layer name - if (child && child.nodeType === 1) { - if (child.tagName === 'g') { - childgroups = true; - const name = findLayerNameInGroup(child); - if (name) { - layernames.push(name); - layer = new Layer(name, child); - this.all_layers.push(layer); - this.layer_map[name] = layer; - } else { - // if group did not have a name, it is an orphan - orphans.push(child); - } - } else if (visElems.includes(child.nodeName)) { - // Child is "visible" (i.e. not a or element), so it is an orphan - orphans.push(child); - } - } - } - - // If orphans or no layers found, create a new layer and add all the orphans to it - if (orphans.length > 0 || !childgroups) { - layer = new Layer(getNewLayerName(layernames), null, this.svgElem_); - layer.appendChildren(orphans); - this.all_layers.push(layer); - this.layer_map[name] = layer; - } else { - layer.activate(); - } - this.current_layer = layer; - } - - /** - * Creates a new top-level layer in the drawing with the given name and - * makes it the current layer. - * @param {string} name - The given name. If the layer name exists, a new name will be generated. - * @param {module:history.HistoryRecordingService} hrService - History recording service - * @returns {SVGGElement} The SVGGElement of the new layer, which is - * also the current layer of this drawing. + getId () { + return this.nonce_ + ? this.idPrefix + this.nonce_ + '_' + this.obj_num + : this.idPrefix + this.obj_num; + } + + /** + * Returns the next object Id as a string. + * @returns {string} The next object Id to use. */ - createLayer (name, hrService) { - if (this.current_layer) { - this.current_layer.deactivate(); - } - // Check for duplicate name. - if (name === undefined || name === null || name === '' || this.layer_map[name]) { - name = getNewLayerName(Object.keys(this.layer_map)); - } - - // Crate new layer and add to DOM as last layer - const layer = new Layer(name, null, this.svgElem_); - // Like to assume hrService exists, but this is backwards compatible with old version of createLayer. - if (hrService) { - hrService.startBatchCommand('Create Layer'); - hrService.insertElement(layer.getGroup()); - hrService.endBatchCommand(); - } - - this.all_layers.push(layer); - this.layer_map[name] = layer; - this.current_layer = layer; - return layer.getGroup(); - } - - /** - * Creates a copy of the current layer with the given name and makes it the current layer. - * @param {string} name - The given name. If the layer name exists, a new name will be generated. - * @param {module:history.HistoryRecordingService} hrService - History recording service - * @returns {SVGGElement} The SVGGElement of the new layer, which is - * also the current layer of this drawing. + getNextId () { + const oldObjNum = this.obj_num; + let restoreOldObjNum = false; + + // If there are any released numbers in the release stack, + // use the last one instead of the next obj_num. + // We need to temporarily use obj_num as that is what getId() depends on. + if (this.releasedNums.length > 0) { + this.obj_num = this.releasedNums.pop(); + restoreOldObjNum = true; + } else { + // If we are not using a released id, then increment the obj_num. + this.obj_num++; + } + + // Ensure the ID does not exist. + let id = this.getId(); + while (this.getElem_(id)) { + if (restoreOldObjNum) { + this.obj_num = oldObjNum; + restoreOldObjNum = false; + } + this.obj_num++; + id = this.getId(); + } + // Restore the old object number if required. + if (restoreOldObjNum) { + this.obj_num = oldObjNum; + } + return id; + } + + /** + * Releases the object Id, letting it be used as the next id in getNextId(). + * This method DOES NOT remove any elements from the DOM, it is expected + * that client code will do this. + * @param {string} id - The id to release. + * @returns {boolean} True if the id was valid to be released, false otherwise. + */ + releaseId (id) { + // confirm if this is a valid id for this Document, else return false + const front = this.idPrefix + (this.nonce_ ? this.nonce_ + '_' : ''); + if (typeof id !== 'string' || !id.startsWith(front)) { + return false; + } + // extract the obj_num of this id + const num = Number.parseInt(id.substr(front.length)); + + // if we didn't get a positive number or we already released this number + // then return false. + if (typeof num !== 'number' || num <= 0 || this.releasedNums.includes(num)) { + return false; + } + + // push the released number into the released queue + this.releasedNums.push(num); + + return true; + } + + /** + * Returns the number of layers in the current drawing. + * @returns {Integer} The number of layers in the current drawing. + */ + getNumLayers () { + return this.all_layers.length; + } + + /** + * Check if layer with given name already exists. + * @param {string} name - The layer name to check + * @returns {boolean} + */ + hasLayer (name) { + return this.layer_map[name] !== undefined; + } + + /** + * Returns the name of the ith layer. If the index is out of range, an empty string is returned. + * @param {Integer} i - The zero-based index of the layer you are querying. + * @returns {string} The name of the ith layer (or the empty string if none found) + */ + getLayerName (i) { + return i >= 0 && i < this.getNumLayers() ? this.all_layers[i].getName() : ''; + } + + /** + * @returns {SVGGElement|null} The SVGGElement representing the current layer. */ - cloneLayer (name, hrService) { - if (!this.current_layer) { return null; } - this.current_layer.deactivate(); - // Check for duplicate name. - if (name === undefined || name === null || name === '' || this.layer_map[name]) { - name = getNewLayerName(Object.keys(this.layer_map)); - } - - // Create new group and add to DOM just after current_layer - const currentGroup = this.current_layer.getGroup(); - const layer = new Layer(name, currentGroup, this.svgElem_); - const group = layer.getGroup(); - - // Clone children - const children = [ ...currentGroup.childNodes ]; - children.forEach((child) => { - if (child.localName === 'title') { return; } - group.append(this.copyElem(child)); - }); - - if (hrService) { - hrService.startBatchCommand('Duplicate Layer'); - hrService.insertElement(group); - hrService.endBatchCommand(); - } - - // Update layer containers and current_layer. - const index = this.all_layers.indexOf(this.current_layer); - if (index >= 0) { - this.all_layers.splice(index + 1, 0, layer); - } else { - this.all_layers.push(layer); - } - this.layer_map[name] = layer; - this.current_layer = layer; - return group; - } - - /** - * Returns whether the layer is visible. If the layer name is not valid, - * then this function returns `false`. - * @param {string} layerName - The name of the layer which you want to query. - * @returns {boolean} The visibility state of the layer, or `false` if the layer name was invalid. + getCurrentLayer () { + return this.current_layer ? this.current_layer.getGroup() : null; + } + + /** + * Get a layer by name. + * @param {string} name + * @returns {SVGGElement} The SVGGElement representing the named layer or null. */ - getLayerVisibility (layerName) { - const layer = this.layer_map[layerName]; - return layer ? layer.isVisible() : false; - } - - /** - * Sets the visibility of the layer. If the layer name is not valid, this - * function returns `null`, otherwise it returns the `SVGElement` representing - * the layer. This is an undo-able action. - * @param {string} layerName - The name of the layer to change the visibility - * @param {boolean} bVisible - Whether the layer should be visible - * @returns {?SVGGElement} The SVGGElement representing the layer if the - * `layerName` was valid, otherwise `null`. + getLayerByName (name) { + const layer = this.layer_map[name]; + return layer ? layer.getGroup() : null; + } + + /** + * Returns the name of the currently selected layer. If an error occurs, an empty string + * is returned. + * @returns {string} The name of the currently active layer (or the empty string if none found). + */ + getCurrentLayerName () { + return this.current_layer ? this.current_layer.getName() : ''; + } + + /** + * Set the current layer's name. + * @param {string} name - The new name. + * @param {module:history.HistoryRecordingService} hrService - History recording service + * @returns {string|null} The new name if changed; otherwise, null. */ - setLayerVisibility (layerName, bVisible) { - if (typeof bVisible !== 'boolean') { - return null; - } - const layer = this.layer_map[layerName]; - if (!layer) { return null; } - layer.setVisible(bVisible); - return layer.getGroup(); - } - - /** - * Returns the opacity of the given layer. If the input name is not a layer, `null` is returned. - * @param {string} layerName - name of the layer on which to get the opacity - * @returns {?Float} The opacity value of the given layer. This will be a value between 0.0 and 1.0, or `null` - * if `layerName` is not a valid layer + setCurrentLayerName (name, hrService) { + let finalName = null; + if (this.current_layer) { + const oldName = this.current_layer.getName(); + finalName = this.current_layer.setName(name, hrService); + if (finalName) { + delete this.layer_map[oldName]; + this.layer_map[finalName] = this.current_layer; + } + } + return finalName; + } + + /** + * Set the current layer's position. + * @param {Integer} newpos - The zero-based index of the new position of the layer. Range should be 0 to layers-1 + * @returns {{title: SVGGElement, previousName: string}|null} If the name was changed, returns {title:SVGGElement, previousName:string}; otherwise null. */ - getLayerOpacity (layerName) { - const layer = this.layer_map[layerName]; - if (!layer) { return null; } - return layer.getOpacity(); - } - - /** - * Sets the opacity of the given layer. If the input name is not a layer, - * nothing happens. If opacity is not a value between 0.0 and 1.0, then - * nothing happens. - * NOTE: this function exists solely to apply a highlighting/de-emphasis - * effect to a layer. When it is possible for a user to affect the opacity - * of a layer, we will need to allow this function to produce an undo-able - * action. - * @param {string} layerName - Name of the layer on which to set the opacity - * @param {Float} opacity - A float value in the range 0.0-1.0 - * @returns {void} - */ - setLayerOpacity (layerName, opacity) { - if (typeof opacity !== 'number' || opacity < 0.0 || opacity > 1.0) { - return; - } - const layer = this.layer_map[layerName]; - if (layer) { - layer.setOpacity(opacity); - } - } - - /** - * Create a clone of an element, updating its ID and its children's IDs when needed. - * @param {Element} el - DOM element to clone - * @returns {Element} - */ - copyElem (el) { - const that = this; - const getNextIdClosure = function () { return that.getNextId(); }; - return utilCopyElem(el, getNextIdClosure); - } - } - - /** - * Called to ensure that drawings will or will not have randomized ids. - * The currentDrawing will have its nonce set if it doesn't already. - * @function module:draw.randomizeIds - * @param {boolean} enableRandomization - flag indicating if documents should have randomized ids - * @param {draw.Drawing} currentDrawing + setCurrentLayerPosition (newpos) { + const layerCount = this.getNumLayers(); + if (!this.current_layer || newpos < 0 || newpos >= layerCount) { + return null; + } + + let oldpos; + for (oldpos = 0; oldpos < layerCount; ++oldpos) { + if (this.all_layers[oldpos] === this.current_layer) { break; } + } + // some unknown error condition (current_layer not in all_layers) + if (oldpos === layerCount) { return null; } + + if (oldpos !== newpos) { + // if our new position is below us, we need to insert before the node after newpos + const currentGroup = this.current_layer.getGroup(); + const oldNextSibling = currentGroup.nextSibling; + + let refGroup = null; + if (newpos > oldpos) { + if (newpos < layerCount - 1) { + refGroup = this.all_layers[newpos + 1].getGroup(); + } + // if our new position is above us, we need to insert before the node at newpos + } else { + refGroup = this.all_layers[newpos].getGroup(); + } + this.svgElem_.insertBefore(currentGroup, refGroup); // Ok to replace with `refGroup.before(currentGroup);`? + + this.identifyLayers(); + this.setCurrentLayer(this.getLayerName(newpos)); + + return { + currentGroup, + oldNextSibling + }; + } + return null; + } + + /** + * @param {module:history.HistoryRecordingService} hrService * @returns {void} */ - export const randomizeIds = function (enableRandomization, currentDrawing) { - randIds = enableRandomization === false - ? RandomizeModes.NEVER_RANDOMIZE - : RandomizeModes.ALWAYS_RANDOMIZE; - - if (randIds === RandomizeModes.ALWAYS_RANDOMIZE && !currentDrawing.getNonce()) { - currentDrawing.setNonce(Math.floor(Math.random() * 100001)); - } else if (randIds === RandomizeModes.NEVER_RANDOMIZE && currentDrawing.getNonce()) { - currentDrawing.clearNonce(); - } - }; - - // Layer API Functions - - /** - * Group: Layers. - */ - - /** - * @see {@link https://api.jquery.com/jQuery.data/} - * @name external:jQuery.data - */ - - /** - * @interface module:draw.DrawCanvasInit - * @property {module:path.pathActions} pathActions - * @property {module:history.UndoManager} undoMgr - */ - /** - * @function module:draw.DrawCanvasInit#getCurrentGroup - * @returns {Element} - */ - /** - * @function module:draw.DrawCanvasInit#setCurrentGroup - * @param {Element} cg - * @returns {void} - */ - /** - * @function module:draw.DrawCanvasInit#getSelectedElements - * @returns {Element[]} the array with selected DOM elements - */ - /** - * @function module:draw.DrawCanvasInit#getSVGContent - * @returns {SVGSVGElement} - */ - /** - * @function module:draw.DrawCanvasInit#getCurrentDrawing - * @returns {module:draw.Drawing} - */ - /** - * @function module:draw.DrawCanvasInit#clearSelection - * @param {boolean} [noCall] - When `true`, does not call the "selected" handler - * @returns {void} - */ - /** - * Run the callback function associated with the given event. - * @function module:draw.DrawCanvasInit#call - * @param {"changed"|"contextset"} ev - String with the event name - * @param {module:svgcanvas.SvgCanvas#event:changed|module:svgcanvas.SvgCanvas#event:contextset} arg - Argument to pass through to the callback - * function. If the event is "changed", a (single-item) array of `Element`s is - * passed. If the event is "contextset", the arg is `null` or `Element`. + mergeLayer (hrService) { + const currentGroup = this.current_layer.getGroup(); + const prevGroup = currentGroup.previousElementSibling; + if (!prevGroup) { return; } + + hrService.startBatchCommand('Merge Layer'); + + const layerNextSibling = currentGroup.nextSibling; + hrService.removeElement(currentGroup, layerNextSibling, this.svgElem_); + + while (currentGroup.firstChild) { + const child = currentGroup.firstChild; + if (child.localName === 'title') { + hrService.removeElement(child, child.nextSibling, currentGroup); + child.remove(); + continue; + } + const oldNextSibling = child.nextSibling; + prevGroup.append(child); + hrService.moveElement(child, oldNextSibling, currentGroup); + } + + // Remove current layer's group + this.current_layer.removeGroup(); + // Remove the current layer and set the previous layer as the new current layer + const index = this.all_layers.indexOf(this.current_layer); + if (index > 0) { + const name = this.current_layer.getName(); + this.current_layer = this.all_layers[index - 1]; + this.all_layers.splice(index, 1); + delete this.layer_map[name]; + } + + hrService.endBatchCommand(); + } + + /** + * @param {module:history.HistoryRecordingService} hrService * @returns {void} */ - /** - * @function module:draw.DrawCanvasInit#addCommandToHistory - * @param {Command} cmd - * @returns {void} - */ - /** - * @function module:draw.DrawCanvasInit#changeSVGContent - * @returns {void} + mergeAllLayers (hrService) { + // Set the current layer to the last layer. + this.current_layer = this.all_layers[this.all_layers.length - 1]; + + hrService.startBatchCommand('Merge all Layers'); + while (this.all_layers.length > 1) { + this.mergeLayer(hrService); + } + hrService.endBatchCommand(); + } + + /** + * Sets the current layer. If the name is not a valid layer name, then this + * function returns `false`. Otherwise it returns `true`. This is not an + * undo-able action. + * @param {string} name - The name of the layer you want to switch to. + * @returns {boolean} `true` if the current layer was switched, otherwise `false` + */ + setCurrentLayer (name) { + const layer = this.layer_map[name]; + if (layer) { + if (this.current_layer) { + this.current_layer.deactivate(); + } + this.current_layer = layer; + this.current_layer.activate(); + return true; + } + return false; + } + + /** + * Deletes the current layer from the drawing and then clears the selection. + * This function then calls the 'changed' handler. This is an undoable action. + * @todo Does this actually call the 'changed' handler? + * @returns {SVGGElement} The SVGGElement of the layer removed or null. + */ + deleteCurrentLayer () { + if (this.current_layer && this.getNumLayers() > 1) { + const oldLayerGroup = this.current_layer.removeGroup(); + this.identifyLayers(); + return oldLayerGroup; + } + return null; + } + + /** + * Updates layer system and sets the current layer to the + * top-most layer (last `` child of this drawing). + * @returns {void} */ - - let canvas_; - /** - * @function module:draw.init - * @param {module:draw.DrawCanvasInit} canvas + identifyLayers () { + this.all_layers = []; + this.layer_map = {}; + const numchildren = this.svgElem_.childNodes.length; + // loop through all children of SVG element + const orphans = [], layernames = []; + let layer = null; + let childgroups = false; + for (let i = 0; i < numchildren; ++i) { + const child = this.svgElem_.childNodes.item(i); + // for each g, find its layer name + if (child && child.nodeType === 1) { + if (child.tagName === 'g') { + childgroups = true; + const name = findLayerNameInGroup(child); + if (name) { + layernames.push(name); + layer = new Layer(name, child); + this.all_layers.push(layer); + this.layer_map[name] = layer; + } else { + // if group did not have a name, it is an orphan + orphans.push(child); + } + } else if (visElems.includes(child.nodeName)) { + // Child is "visible" (i.e. not a or <defs> element), so it is an orphan + orphans.push(child); + } + } + } + + // If orphans or no layers found, create a new layer and add all the orphans to it + if (orphans.length > 0 || !childgroups) { + layer = new Layer(getNewLayerName(layernames), null, this.svgElem_); + layer.appendChildren(orphans); + this.all_layers.push(layer); + this.layer_map[name] = layer; + } else { + layer.activate(); + } + this.current_layer = layer; + } + + /** + * Creates a new top-level layer in the drawing with the given name and + * makes it the current layer. + * @param {string} name - The given name. If the layer name exists, a new name will be generated. + * @param {module:history.HistoryRecordingService} hrService - History recording service + * @returns {SVGGElement} The SVGGElement of the new layer, which is + * also the current layer of this drawing. + */ + createLayer (name, hrService) { + if (this.current_layer) { + this.current_layer.deactivate(); + } + // Check for duplicate name. + if (name === undefined || name === null || name === '' || this.layer_map[name]) { + name = getNewLayerName(Object.keys(this.layer_map)); + } + + // Crate new layer and add to DOM as last layer + const layer = new Layer(name, null, this.svgElem_); + // Like to assume hrService exists, but this is backwards compatible with old version of createLayer. + if (hrService) { + hrService.startBatchCommand('Create Layer'); + hrService.insertElement(layer.getGroup()); + hrService.endBatchCommand(); + } + + this.all_layers.push(layer); + this.layer_map[name] = layer; + this.current_layer = layer; + return layer.getGroup(); + } + + /** + * Creates a copy of the current layer with the given name and makes it the current layer. + * @param {string} name - The given name. If the layer name exists, a new name will be generated. + * @param {module:history.HistoryRecordingService} hrService - History recording service + * @returns {SVGGElement} The SVGGElement of the new layer, which is + * also the current layer of this drawing. + */ + cloneLayer (name, hrService) { + if (!this.current_layer) { return null; } + this.current_layer.deactivate(); + // Check for duplicate name. + if (name === undefined || name === null || name === '' || this.layer_map[name]) { + name = getNewLayerName(Object.keys(this.layer_map)); + } + + // Create new group and add to DOM just after current_layer + const currentGroup = this.current_layer.getGroup(); + const layer = new Layer(name, currentGroup, this.svgElem_); + const group = layer.getGroup(); + + // Clone children + const children = [ ...currentGroup.childNodes ]; + children.forEach((child) => { + if (child.localName === 'title') { return; } + group.append(this.copyElem(child)); + }); + + if (hrService) { + hrService.startBatchCommand('Duplicate Layer'); + hrService.insertElement(group); + hrService.endBatchCommand(); + } + + // Update layer containers and current_layer. + const index = this.all_layers.indexOf(this.current_layer); + if (index >= 0) { + this.all_layers.splice(index + 1, 0, layer); + } else { + this.all_layers.push(layer); + } + this.layer_map[name] = layer; + this.current_layer = layer; + return group; + } + + /** + * Returns whether the layer is visible. If the layer name is not valid, + * then this function returns `false`. + * @param {string} layerName - The name of the layer which you want to query. + * @returns {boolean} The visibility state of the layer, or `false` if the layer name was invalid. + */ + getLayerVisibility (layerName) { + const layer = this.layer_map[layerName]; + return layer ? layer.isVisible() : false; + } + + /** + * Sets the visibility of the layer. If the layer name is not valid, this + * function returns `null`, otherwise it returns the `SVGElement` representing + * the layer. This is an undo-able action. + * @param {string} layerName - The name of the layer to change the visibility + * @param {boolean} bVisible - Whether the layer should be visible + * @returns {?SVGGElement} The SVGGElement representing the layer if the + * `layerName` was valid, otherwise `null`. + */ + setLayerVisibility (layerName, bVisible) { + if (typeof bVisible !== 'boolean') { + return null; + } + const layer = this.layer_map[layerName]; + if (!layer) { return null; } + layer.setVisible(bVisible); + return layer.getGroup(); + } + + /** + * Returns the opacity of the given layer. If the input name is not a layer, `null` is returned. + * @param {string} layerName - name of the layer on which to get the opacity + * @returns {?Float} The opacity value of the given layer. This will be a value between 0.0 and 1.0, or `null` + * if `layerName` is not a valid layer + */ + getLayerOpacity (layerName) { + const layer = this.layer_map[layerName]; + if (!layer) { return null; } + return layer.getOpacity(); + } + + /** + * Sets the opacity of the given layer. If the input name is not a layer, + * nothing happens. If opacity is not a value between 0.0 and 1.0, then + * nothing happens. + * NOTE: this function exists solely to apply a highlighting/de-emphasis + * effect to a layer. When it is possible for a user to affect the opacity + * of a layer, we will need to allow this function to produce an undo-able + * action. + * @param {string} layerName - Name of the layer on which to set the opacity + * @param {Float} opacity - A float value in the range 0.0-1.0 + * @returns {void} + */ + setLayerOpacity (layerName, opacity) { + if (typeof opacity !== 'number' || opacity < 0.0 || opacity > 1.0) { + return; + } + const layer = this.layer_map[layerName]; + if (layer) { + layer.setOpacity(opacity); + } + } + + /** + * Create a clone of an element, updating its ID and its children's IDs when needed. + * @param {Element} el - DOM element to clone + * @returns {Element} + */ + copyElem (el) { + const that = this; + const getNextIdClosure = function () { return that.getNextId(); }; + return utilCopyElem(el, getNextIdClosure); + } +} + +/** + * Called to ensure that drawings will or will not have randomized ids. + * The currentDrawing will have its nonce set if it doesn't already. + * @function module:draw.randomizeIds + * @param {boolean} enableRandomization - flag indicating if documents should have randomized ids + * @param {draw.Drawing} currentDrawing * @returns {void} */ - export const init = function (canvas) { - canvas_ = canvas; - }; - - /** - * Updates layer system. - * @function module:draw.identifyLayers +export const randomizeIds = function (enableRandomization, currentDrawing) { + randIds = enableRandomization === false + ? RandomizeModes.NEVER_RANDOMIZE + : RandomizeModes.ALWAYS_RANDOMIZE; + + if (randIds === RandomizeModes.ALWAYS_RANDOMIZE && !currentDrawing.getNonce()) { + currentDrawing.setNonce(Math.floor(Math.random() * 100001)); + } else if (randIds === RandomizeModes.NEVER_RANDOMIZE && currentDrawing.getNonce()) { + currentDrawing.clearNonce(); + } +}; + +// Layer API Functions + +/** +* Group: Layers. +*/ + +/** + * @see {@link https://api.jquery.com/jQuery.data/} + * @name external:jQuery.data + */ + +/** + * @interface module:draw.DrawCanvasInit + * @property {module:path.pathActions} pathActions + * @property {module:history.UndoManager} undoMgr + */ +/** + * @function module:draw.DrawCanvasInit#getCurrentGroup + * @returns {Element} + */ +/** + * @function module:draw.DrawCanvasInit#setCurrentGroup + * @param {Element} cg + * @returns {void} +*/ +/** + * @function module:draw.DrawCanvasInit#getSelectedElements + * @returns {Element[]} the array with selected DOM elements +*/ +/** + * @function module:draw.DrawCanvasInit#getSVGContent + * @returns {SVGSVGElement} + */ +/** + * @function module:draw.DrawCanvasInit#getCurrentDrawing + * @returns {module:draw.Drawing} + */ +/** + * @function module:draw.DrawCanvasInit#clearSelection + * @param {boolean} [noCall] - When `true`, does not call the "selected" handler + * @returns {void} +*/ +/** + * Run the callback function associated with the given event. + * @function module:draw.DrawCanvasInit#call + * @param {"changed"|"contextset"} ev - String with the event name + * @param {module:svgcanvas.SvgCanvas#event:changed|module:svgcanvas.SvgCanvas#event:contextset} arg - Argument to pass through to the callback + * function. If the event is "changed", a (single-item) array of `Element`s is + * passed. If the event is "contextset", the arg is `null` or `Element`. * @returns {void} */ - export const identifyLayers = function () { - leaveContext(); - canvas_.getCurrentDrawing().identifyLayers(); - }; - - /** - * Creates a new top-level layer in the drawing with the given name, sets the current layer +/** + * @function module:draw.DrawCanvasInit#addCommandToHistory + * @param {Command} cmd + * @returns {void} +*/ +/** + * @function module:draw.DrawCanvasInit#changeSVGContent + * @returns {void} + */ + +let canvas_; +/** +* @function module:draw.init +* @param {module:draw.DrawCanvasInit} canvas +* @returns {void} +*/ +export const init = function (canvas) { + canvas_ = canvas; +}; + +/** +* Updates layer system. +* @function module:draw.identifyLayers +* @returns {void} +*/ +export const identifyLayers = function () { + leaveContext(); + canvas_.getCurrentDrawing().identifyLayers(); +}; + +/** +* Creates a new top-level layer in the drawing with the given name, sets the current layer +* to it, and then clears the selection. This function then calls the 'changed' handler. +* This is an undoable action. +* @function module:draw.createLayer +* @param {string} name - The given name +* @param {module:history.HistoryRecordingService} hrService +* @fires module:svgcanvas.SvgCanvas#event:changed +* @returns {void} +*/ +export const createLayer = function (name, hrService) { + const newLayer = canvas_.getCurrentDrawing().createLayer( + name, + historyRecordingService(hrService) + ); + canvas_.clearSelection(); + canvas_.call('changed', [ newLayer ]); +}; + +/** + * Creates a new top-level layer in the drawing with the given name, copies all the current layer's contents * to it, and then clears the selection. This function then calls the 'changed' handler. * This is an undoable action. - * @function module:draw.createLayer - * @param {string} name - The given name - * @param {module:history.HistoryRecordingService} hrService + * @function module:draw.cloneLayer + * @param {string} name - The given name. If the layer name exists, a new name will be generated. + * @param {module:history.HistoryRecordingService} hrService - History recording service * @fires module:svgcanvas.SvgCanvas#event:changed * @returns {void} */ - export const createLayer = function (name, hrService) { - const newLayer = canvas_.getCurrentDrawing().createLayer( - name, - historyRecordingService(hrService) - ); - canvas_.clearSelection(); - canvas_.call('changed', [ newLayer ]); - }; - - /** - * Creates a new top-level layer in the drawing with the given name, copies all the current layer's contents - * to it, and then clears the selection. This function then calls the 'changed' handler. - * This is an undoable action. - * @function module:draw.cloneLayer - * @param {string} name - The given name. If the layer name exists, a new name will be generated. - * @param {module:history.HistoryRecordingService} hrService - History recording service - * @fires module:svgcanvas.SvgCanvas#event:changed - * @returns {void} - */ - export const cloneLayer = function (name, hrService) { - // Clone the current layer and make the cloned layer the new current layer - const newLayer = canvas_.getCurrentDrawing().cloneLayer(name, historyRecordingService(hrService)); - - canvas_.clearSelection(); - leaveContext(); - canvas_.call('changed', [ newLayer ]); - }; - - /** - * Deletes the current layer from the drawing and then clears the selection. This function - * then calls the 'changed' handler. This is an undoable action. - * @function module:draw.deleteCurrentLayer - * @fires module:svgcanvas.SvgCanvas#event:changed - * @returns {boolean} `true` if an old layer group was found to delete - */ - export const deleteCurrentLayer = function () { - let currentLayer = canvas_.getCurrentDrawing().getCurrentLayer(); - const { nextSibling } = currentLayer; - const parent = currentLayer.parentNode; - currentLayer = canvas_.getCurrentDrawing().deleteCurrentLayer(); - if (currentLayer) { - const batchCmd = new BatchCommand('Delete Layer'); - // store in our Undo History - batchCmd.addSubCommand(new RemoveElementCommand(currentLayer, nextSibling, parent)); - canvas_.addCommandToHistory(batchCmd); - canvas_.clearSelection(); - canvas_.call('changed', [ parent ]); - return true; - } - return false; - }; - - /** - * Sets the current layer. If the name is not a valid layer name, then this function returns - * false. Otherwise it returns true. This is not an undo-able action. - * @function module:draw.setCurrentLayer - * @param {string} name - The name of the layer you want to switch to. - * @returns {boolean} true if the current layer was switched, otherwise false - */ - export const setCurrentLayer = function (name) { - const result = canvas_.getCurrentDrawing().setCurrentLayer(toXml(name)); - if (result) { - canvas_.clearSelection(); - } - return result; - }; - - /** - * Renames the current layer. If the layer name is not valid (i.e. unique), then this function - * does nothing and returns `false`, otherwise it returns `true`. This is an undo-able action. - * @function module:draw.renameCurrentLayer - * @param {string} newName - the new name you want to give the current layer. This name must - * be unique among all layer names. - * @fires module:svgcanvas.SvgCanvas#event:changed - * @returns {boolean} Whether the rename succeeded - */ - export const renameCurrentLayer = function (newName) { - const drawing = canvas_.getCurrentDrawing(); - const layer = drawing.getCurrentLayer(); - if (layer) { - const result = drawing.setCurrentLayerName(newName, historyRecordingService()); - if (result) { - canvas_.call('changed', [ layer ]); - return true; - } - } - return false; - }; - - /** - * Changes the position of the current layer to the new value. If the new index is not valid, - * this function does nothing and returns false, otherwise it returns true. This is an - * undo-able action. - * @function module:draw.setCurrentLayerPosition - * @param {Integer} newPos - The zero-based index of the new position of the layer. This should be between - * 0 and (number of layers - 1) - * @returns {boolean} `true` if the current layer position was changed, `false` otherwise. - */ - export const setCurrentLayerPosition = function (newPos) { - const drawing = canvas_.getCurrentDrawing(); - const result = drawing.setCurrentLayerPosition(newPos); - if (result) { - canvas_.addCommandToHistory(new MoveElementCommand(result.currentGroup, result.oldNextSibling, canvas_.getSVGContent())); - return true; - } - return false; - }; - - /** - * Sets the visibility of the layer. If the layer name is not valid, this function return - * `false`, otherwise it returns `true`. This is an undo-able action. - * @function module:draw.setLayerVisibility - * @param {string} layerName - The name of the layer to change the visibility - * @param {boolean} bVisible - Whether the layer should be visible - * @returns {boolean} true if the layer's visibility was set, false otherwise - */ - export const setLayerVisibility = function (layerName, bVisible) { - const drawing = canvas_.getCurrentDrawing(); - const prevVisibility = drawing.getLayerVisibility(layerName); - const layer = drawing.setLayerVisibility(layerName, bVisible); - if (layer) { - const oldDisplay = prevVisibility ? 'inline' : 'none'; - canvas_.addCommandToHistory(new ChangeElementCommand(layer, { display: oldDisplay }, 'Layer Visibility')); - } else { - return false; - } - - if (layer === drawing.getCurrentLayer()) { - canvas_.clearSelection(); - canvas_.pathActions.clear(); - } - // call('changed', [selected]); - return true; - }; - - /** - * Moves the selected elements to layerName. If the name is not a valid layer name, then `false` - * is returned. Otherwise it returns `true`. This is an undo-able action. - * @function module:draw.moveSelectedToLayer - * @param {string} layerName - The name of the layer you want to which you want to move the selected elements - * @returns {boolean} Whether the selected elements were moved to the layer. - */ - export const moveSelectedToLayer = function (layerName) { - // find the layer - const drawing = canvas_.getCurrentDrawing(); - const layer = drawing.getLayerByName(layerName); - if (!layer) { return false; } - - const batchCmd = new BatchCommand('Move Elements to Layer'); - - // loop for each selected element and move it - const selElems = canvas_.getSelectedElements(); - let i = selElems.length; - while (i--) { - const elem = selElems[i]; - if (!elem) { continue; } - const oldNextSibling = elem.nextSibling; - // TODO: this is pretty brittle! - const oldLayer = elem.parentNode; - layer.append(elem); - batchCmd.addSubCommand(new MoveElementCommand(elem, oldNextSibling, oldLayer)); - } - - canvas_.addCommandToHistory(batchCmd); - - return true; - }; - - /** - * @function module:draw.mergeLayer - * @param {module:history.HistoryRecordingService} hrService - * @returns {void} - */ - export const mergeLayer = function (hrService) { - canvas_.getCurrentDrawing().mergeLayer(historyRecordingService(hrService)); - canvas_.clearSelection(); - leaveContext(); - canvas_.changeSVGContent(); - }; - - /** - * @function module:draw.mergeAllLayers - * @param {module:history.HistoryRecordingService} hrService - * @returns {void} - */ - export const mergeAllLayers = function (hrService) { - canvas_.getCurrentDrawing().mergeAllLayers(historyRecordingService(hrService)); - canvas_.clearSelection(); - leaveContext(); - canvas_.changeSVGContent(); - }; - - /** - * Return from a group context to the regular kind, make any previously - * disabled elements enabled again. - * @function module:draw.leaveContext - * @fires module:svgcanvas.SvgCanvas#event:contextset - * @returns {void} - */ - export const leaveContext = function () { - const len = disabledElems.length; - const dataStorage = canvas_.getDataStorage(); - if (len) { - for (let i = 0; i < len; i++) { - const elem = disabledElems[i]; - const orig = dataStorage.get(elem, 'orig_opac'); - if (orig !== 1) { - elem.setAttribute('opacity', orig); - } else { - elem.removeAttribute('opacity'); - } - elem.setAttribute('style', 'pointer-events: inherit'); - } - disabledElems = []; - canvas_.clearSelection(true); - canvas_.call('contextset', null); - } - canvas_.setCurrentGroup(null); - }; - - /** - * Set the current context (for in-group editing). - * @function module:draw.setContext - * @param {Element} elem - * @fires module:svgcanvas.SvgCanvas#event:contextset - * @returns {void} - */ - export const setContext = function (elem) { - const dataStorage = canvas_.getDataStorage(); - leaveContext(); - if (typeof elem === 'string') { - elem = getElem(elem); - } - - // Edit inside this group - canvas_.setCurrentGroup(elem); - - // Disable other elements - const parentsUntil = getParentsUntil(elem, '#svgcontent'); - let siblings = []; - parentsUntil.forEach(function (parent) { - const elements = Array.prototype.filter.call(parent.parentNode.children, function(child){ - return child !== parent; - }); - elements.forEach(function (element) { - siblings.push(element); - }); - }); - - siblings.forEach(function (curthis) { - const opac = curthis.getAttribute('opacity') || 1; - // Store the original's opacity - dataStorage.put(curthis, 'orig_opac', opac); - curthis.setAttribute('opacity', opac * 0.33); - curthis.setAttribute('style', 'pointer-events: none'); - disabledElems.push(curthis); - }); - canvas_.clearSelection(); - canvas_.call('contextset', canvas_.getCurrentGroup()); - }; - - /** - * @memberof module:draw - * @class Layer - * @see {@link module:layer.Layer} - */ - export { Layer }; - \ No newline at end of file +export const cloneLayer = function (name, hrService) { + // Clone the current layer and make the cloned layer the new current layer + const newLayer = canvas_.getCurrentDrawing().cloneLayer(name, historyRecordingService(hrService)); + + canvas_.clearSelection(); + leaveContext(); + canvas_.call('changed', [ newLayer ]); +}; + +/** +* Deletes the current layer from the drawing and then clears the selection. This function +* then calls the 'changed' handler. This is an undoable action. +* @function module:draw.deleteCurrentLayer +* @fires module:svgcanvas.SvgCanvas#event:changed +* @returns {boolean} `true` if an old layer group was found to delete +*/ +export const deleteCurrentLayer = function () { + let currentLayer = canvas_.getCurrentDrawing().getCurrentLayer(); + const { nextSibling } = currentLayer; + const parent = currentLayer.parentNode; + currentLayer = canvas_.getCurrentDrawing().deleteCurrentLayer(); + if (currentLayer) { + const batchCmd = new BatchCommand('Delete Layer'); + // store in our Undo History + batchCmd.addSubCommand(new RemoveElementCommand(currentLayer, nextSibling, parent)); + canvas_.addCommandToHistory(batchCmd); + canvas_.clearSelection(); + canvas_.call('changed', [ parent ]); + return true; + } + return false; +}; + +/** +* Sets the current layer. If the name is not a valid layer name, then this function returns +* false. Otherwise it returns true. This is not an undo-able action. +* @function module:draw.setCurrentLayer +* @param {string} name - The name of the layer you want to switch to. +* @returns {boolean} true if the current layer was switched, otherwise false +*/ +export const setCurrentLayer = function (name) { + const result = canvas_.getCurrentDrawing().setCurrentLayer(toXml(name)); + if (result) { + canvas_.clearSelection(); + } + return result; +}; + +/** +* Renames the current layer. If the layer name is not valid (i.e. unique), then this function +* does nothing and returns `false`, otherwise it returns `true`. This is an undo-able action. +* @function module:draw.renameCurrentLayer +* @param {string} newName - the new name you want to give the current layer. This name must +* be unique among all layer names. +* @fires module:svgcanvas.SvgCanvas#event:changed +* @returns {boolean} Whether the rename succeeded +*/ +export const renameCurrentLayer = function (newName) { + const drawing = canvas_.getCurrentDrawing(); + const layer = drawing.getCurrentLayer(); + if (layer) { + const result = drawing.setCurrentLayerName(newName, historyRecordingService()); + if (result) { + canvas_.call('changed', [ layer ]); + return true; + } + } + return false; +}; + +/** +* Changes the position of the current layer to the new value. If the new index is not valid, +* this function does nothing and returns false, otherwise it returns true. This is an +* undo-able action. +* @function module:draw.setCurrentLayerPosition +* @param {Integer} newPos - The zero-based index of the new position of the layer. This should be between +* 0 and (number of layers - 1) +* @returns {boolean} `true` if the current layer position was changed, `false` otherwise. +*/ +export const setCurrentLayerPosition = function (newPos) { + const drawing = canvas_.getCurrentDrawing(); + const result = drawing.setCurrentLayerPosition(newPos); + if (result) { + canvas_.addCommandToHistory(new MoveElementCommand(result.currentGroup, result.oldNextSibling, canvas_.getSVGContent())); + return true; + } + return false; +}; + +/** +* Sets the visibility of the layer. If the layer name is not valid, this function return +* `false`, otherwise it returns `true`. This is an undo-able action. +* @function module:draw.setLayerVisibility +* @param {string} layerName - The name of the layer to change the visibility +* @param {boolean} bVisible - Whether the layer should be visible +* @returns {boolean} true if the layer's visibility was set, false otherwise +*/ +export const setLayerVisibility = function (layerName, bVisible) { + const drawing = canvas_.getCurrentDrawing(); + const prevVisibility = drawing.getLayerVisibility(layerName); + const layer = drawing.setLayerVisibility(layerName, bVisible); + if (layer) { + const oldDisplay = prevVisibility ? 'inline' : 'none'; + canvas_.addCommandToHistory(new ChangeElementCommand(layer, { display: oldDisplay }, 'Layer Visibility')); + } else { + return false; + } + + if (layer === drawing.getCurrentLayer()) { + canvas_.clearSelection(); + canvas_.pathActions.clear(); + } + // call('changed', [selected]); + return true; +}; + +/** +* Moves the selected elements to layerName. If the name is not a valid layer name, then `false` +* is returned. Otherwise it returns `true`. This is an undo-able action. +* @function module:draw.moveSelectedToLayer +* @param {string} layerName - The name of the layer you want to which you want to move the selected elements +* @returns {boolean} Whether the selected elements were moved to the layer. +*/ +export const moveSelectedToLayer = function (layerName) { + // find the layer + const drawing = canvas_.getCurrentDrawing(); + const layer = drawing.getLayerByName(layerName); + if (!layer) { return false; } + + const batchCmd = new BatchCommand('Move Elements to Layer'); + + // loop for each selected element and move it + const selElems = canvas_.getSelectedElements(); + let i = selElems.length; + while (i--) { + const elem = selElems[i]; + if (!elem) { continue; } + const oldNextSibling = elem.nextSibling; + // TODO: this is pretty brittle! + const oldLayer = elem.parentNode; + layer.append(elem); + batchCmd.addSubCommand(new MoveElementCommand(elem, oldNextSibling, oldLayer)); + } + + canvas_.addCommandToHistory(batchCmd); + + return true; +}; + +/** +* @function module:draw.mergeLayer +* @param {module:history.HistoryRecordingService} hrService +* @returns {void} +*/ +export const mergeLayer = function (hrService) { + canvas_.getCurrentDrawing().mergeLayer(historyRecordingService(hrService)); + canvas_.clearSelection(); + leaveContext(); + canvas_.changeSVGContent(); +}; + +/** +* @function module:draw.mergeAllLayers +* @param {module:history.HistoryRecordingService} hrService +* @returns {void} +*/ +export const mergeAllLayers = function (hrService) { + canvas_.getCurrentDrawing().mergeAllLayers(historyRecordingService(hrService)); + canvas_.clearSelection(); + leaveContext(); + canvas_.changeSVGContent(); +}; + +/** +* Return from a group context to the regular kind, make any previously +* disabled elements enabled again. +* @function module:draw.leaveContext +* @fires module:svgcanvas.SvgCanvas#event:contextset +* @returns {void} +*/ +export const leaveContext = function () { + const len = disabledElems.length; + const dataStorage = canvas_.getDataStorage(); + if (len) { + for (let i = 0; i < len; i++) { + const elem = disabledElems[i]; + const orig = dataStorage.get(elem, 'orig_opac'); + if (orig !== 1) { + elem.setAttribute('opacity', orig); + } else { + elem.removeAttribute('opacity'); + } + elem.setAttribute('style', 'pointer-events: inherit'); + } + disabledElems = []; + canvas_.clearSelection(true); + canvas_.call('contextset', null); + } + canvas_.setCurrentGroup(null); +}; + +/** +* Set the current context (for in-group editing). +* @function module:draw.setContext +* @param {Element} elem +* @fires module:svgcanvas.SvgCanvas#event:contextset +* @returns {void} +*/ +export const setContext = function (elem) { + const dataStorage = canvas_.getDataStorage(); + leaveContext(); + if (typeof elem === 'string') { + elem = getElem(elem); + } + + // Edit inside this group + canvas_.setCurrentGroup(elem); + + // Disable other elements + const parentsUntil = getParentsUntil(elem, '#svgcontent'); + let siblings = []; + parentsUntil.forEach(function (parent) { + const elements = Array.prototype.filter.call(parent.parentNode.children, function(child){ + return child !== parent; + }); + elements.forEach(function (element) { + siblings.push(element); + }); + }); + + siblings.forEach(function (curthis) { + const opac = curthis.getAttribute('opacity') || 1; + // Store the original's opacity + dataStorage.put(curthis, 'orig_opac', opac); + curthis.setAttribute('opacity', opac * 0.33); + curthis.setAttribute('style', 'pointer-events: none'); + disabledElems.push(curthis); + }); + canvas_.clearSelection(); + canvas_.call('contextset', canvas_.getCurrentGroup()); +}; + +/** +* @memberof module:draw +* @class Layer +* @see {@link module:layer.Layer} +*/ +export { Layer };