Update packages and remove the instrument step (#854)

* several updates
* avoid the instrumented step in tests
This commit is contained in:
JFH
2022-11-27 23:01:27 +01:00
committed by GitHub
parent c0d0db4d7e
commit 00a7d61122
68 changed files with 1472 additions and 765 deletions

View File

@@ -0,0 +1,156 @@
/**
* Tools for blur event.
* @module blur
* @license MIT
* @copyright 2011 Jeff Schiller
*/
let svgCanvas = null
/**
* @function module:blur.init
* @param {module:blur.blurContext} blurContext
* @returns {void}
*/
export const init = (canvas) => {
svgCanvas = canvas
}
/**
* Sets the `stdDeviation` blur value on the selected element without being undoable.
* @function module:svgcanvas.SvgCanvas#setBlurNoUndo
* @param {Float} val - The new `stdDeviation` value
* @returns {void}
*/
export const setBlurNoUndo = function (val) {
const selectedElements = svgCanvas.getSelectedElements()
if (!svgCanvas.getFilter()) {
svgCanvas.setBlur(val)
return
}
if (val === 0) {
// Don't change the StdDev, as that will hide the element.
// Instead, just remove the value for "filter"
svgCanvas.changeSelectedAttributeNoUndo('filter', '')
svgCanvas.setFilterHidden(true)
} else {
const elem = selectedElements[0]
if (svgCanvas.getFilterHidden()) {
svgCanvas.changeSelectedAttributeNoUndo('filter', 'url(#' + elem.id + '_blur)')
}
const filter = svgCanvas.getFilter()
svgCanvas.changeSelectedAttributeNoUndo('stdDeviation', val, [filter.firstChild])
svgCanvas.setBlurOffsets(filter, val)
}
}
/**
*
* @returns {void}
*/
function finishChange () {
const bCmd = svgCanvas.undoMgr.finishUndoableChange()
svgCanvas.getCurCommand().addSubCommand(bCmd)
svgCanvas.addCommandToHistory(svgCanvas.getCurCommand())
svgCanvas.setCurCommand(null)
svgCanvas.setFilter(null)
}
/**
* Sets the `x`, `y`, `width`, `height` values of the filter element in order to
* make the blur not be clipped. Removes them if not neeeded.
* @function module:svgcanvas.SvgCanvas#setBlurOffsets
* @param {Element} filterElem - The filter DOM element to update
* @param {Float} stdDev - The standard deviation value on which to base the offset size
* @returns {void}
*/
export const setBlurOffsets = function (filterElem, stdDev) {
if (stdDev > 3) {
// TODO: Create algorithm here where size is based on expected blur
svgCanvas.assignAttributes(filterElem, {
x: '-50%',
y: '-50%',
width: '200%',
height: '200%'
}, 100)
} else {
filterElem.removeAttribute('x')
filterElem.removeAttribute('y')
filterElem.removeAttribute('width')
filterElem.removeAttribute('height')
}
}
/**
* Adds/updates the blur filter to the selected element.
* @function module:svgcanvas.SvgCanvas#setBlur
* @param {Float} val - Float with the new `stdDeviation` blur value
* @param {boolean} complete - Whether or not the action should be completed (to add to the undo manager)
* @returns {void}
*/
export const setBlur = function (val, complete) {
const {
InsertElementCommand, ChangeElementCommand, BatchCommand
} = svgCanvas.history
const selectedElements = svgCanvas.getSelectedElements()
if (svgCanvas.getCurCommand()) {
finishChange()
return
}
// Looks for associated blur, creates one if not found
const elem = selectedElements[0]
const elemId = elem.id
svgCanvas.setFilter(svgCanvas.getElement(elemId + '_blur'))
val -= 0
const batchCmd = new BatchCommand()
// Blur found!
if (svgCanvas.getFilter()) {
if (val === 0) {
svgCanvas.setFilter(null)
}
} else {
// Not found, so create
const newblur = svgCanvas.addSVGElementsFromJson({
element: 'feGaussianBlur',
attr: {
in: 'SourceGraphic',
stdDeviation: val
}
})
svgCanvas.setFilter(svgCanvas.addSVGElementsFromJson({
element: 'filter',
attr: {
id: elemId + '_blur'
}
}))
svgCanvas.getFilter().append(newblur)
svgCanvas.findDefs().append(svgCanvas.getFilter())
batchCmd.addSubCommand(new InsertElementCommand(svgCanvas.getFilter()))
}
const changes = { filter: elem.getAttribute('filter') }
if (val === 0) {
elem.removeAttribute('filter')
batchCmd.addSubCommand(new ChangeElementCommand(elem, changes))
return
}
svgCanvas.changeSelectedAttribute('filter', 'url(#' + elemId + '_blur)')
batchCmd.addSubCommand(new ChangeElementCommand(elem, changes))
svgCanvas.setBlurOffsets(svgCanvas.getFilter(), val)
const filter = svgCanvas.getFilter()
svgCanvas.setCurCommand(batchCmd)
svgCanvas.undoMgr.beginUndoableChange('stdDeviation', [filter ? filter.firstChild : null])
if (complete) {
svgCanvas.setBlurNoUndo(val)
finishChange()
}
}

View File

@@ -0,0 +1,43 @@
/**
* Tools for clear.
* @module clear
* @license MIT
* @copyright 2011 Jeff Schiller
*/
import { NS } from './namespaces.js'
let svgCanvas = null
/**
* @function module:clear.init
* @param {module:clear.SvgCanvas#init} clearContext
* @returns {void}
*/
export const init = (canvas) => {
svgCanvas = canvas
}
export const clearSvgContentElementInit = () => {
const curConfig = svgCanvas.getCurConfig()
const { dimensions } = curConfig
const el = svgCanvas.getSvgContent()
// empty
while (el.firstChild) { el.removeChild(el.firstChild) }
// TODO: Clear out all other attributes first?
const pel = svgCanvas.getSvgRoot()
el.setAttribute('id', 'svgcontent')
el.setAttribute('width', dimensions[0])
el.setAttribute('height', dimensions[1])
el.setAttribute('x', dimensions[0])
el.setAttribute('y', dimensions[1])
el.setAttribute('overflow', curConfig.show_outside_canvas ? 'visible' : 'hidden')
el.setAttribute('xmlns', NS.SVG)
el.setAttribute('xmlns:se', NS.SE)
el.setAttribute('xmlns:xlink', NS.XLINK)
pel.appendChild(el)
// TODO: make this string optional and set by the client
const comment = svgCanvas.getDOMDocument().createComment(' Created with SVG-edit - https://github.com/SVG-Edit/svgedit')
svgCanvas.getSvgContent().append(comment)
}

View File

@@ -0,0 +1,298 @@
/**
* Manipulating coordinates.
* @module coords
* @license MIT
*/
import {
snapToGrid, assignAttributes, getBBox, getRefElem, findDefs
} from './utilities.js'
import {
transformPoint, transformListToTransform, matrixMultiply, transformBox
} from './math.js'
// this is how we map paths to our preferred relative segment types
const pathMap = [
0, 'z', 'M', 'm', 'L', 'l', 'C', 'c', 'Q', 'q', 'A', 'a',
'H', 'h', 'V', 'v', 'S', 's', 'T', 't'
]
/**
* @interface module:coords.EditorContext
*/
/**
* @function module:coords.EditorContext#getGridSnapping
* @returns {boolean}
*/
/**
* @function module:coords.EditorContext#getSvgRoot
* @returns {SVGSVGElement}
*/
let svgCanvas = null
/**
* @function module:coords.init
* @param {module:svgcanvas.SvgCanvas#event:pointsAdded} editorContext
* @returns {void}
*/
export const init = (canvas) => {
svgCanvas = canvas
}
/**
* Applies coordinate changes to an element based on the given matrix.
* @name module:coords.remapElement
* @type {module:path.EditorContext#remapElement}
*/
export const remapElement = (selected, changes, m) => {
const remap = (x, y) => transformPoint(x, y, m)
const scalew = (w) => m.a * w
const scaleh = (h) => m.d * h
const doSnapping = svgCanvas.getGridSnapping() && selected.parentNode.parentNode.localName === 'svg'
const finishUp = () => {
if (doSnapping) {
Object.entries(changes).forEach(([o, value]) => {
changes[o] = snapToGrid(value)
})
}
assignAttributes(selected, changes, 1000, true)
}
const box = getBBox(selected);
['fill', 'stroke'].forEach((type) => {
const attrVal = selected.getAttribute(type)
if (attrVal?.startsWith('url(') && (m.a < 0 || m.d < 0)) {
const grad = getRefElem(attrVal)
const newgrad = grad.cloneNode(true)
if (m.a < 0) {
// flip x
const x1 = newgrad.getAttribute('x1')
const x2 = newgrad.getAttribute('x2')
newgrad.setAttribute('x1', -(x1 - 1))
newgrad.setAttribute('x2', -(x2 - 1))
}
if (m.d < 0) {
// flip y
const y1 = newgrad.getAttribute('y1')
const y2 = newgrad.getAttribute('y2')
newgrad.setAttribute('y1', -(y1 - 1))
newgrad.setAttribute('y2', -(y2 - 1))
}
newgrad.id = svgCanvas.getCurrentDrawing().getNextId()
findDefs().append(newgrad)
selected.setAttribute(type, 'url(#' + newgrad.id + ')')
}
})
const elName = selected.tagName
if (elName === 'g' || elName === 'text' || elName === 'tspan' || elName === 'use') {
// if it was a translate, then just update x,y
if (m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1 && (m.e !== 0 || m.f !== 0)) {
// [T][M] = [M][T']
// therefore [T'] = [M_inv][T][M]
const existing = transformListToTransform(selected).matrix
const tNew = matrixMultiply(existing.inverse(), m, existing)
changes.x = Number.parseFloat(changes.x) + tNew.e
changes.y = Number.parseFloat(changes.y) + tNew.f
} else {
// we just absorb all matrices into the element and don't do any remapping
const chlist = selected.transform.baseVal
const mt = svgCanvas.getSvgRoot().createSVGTransform()
mt.setMatrix(matrixMultiply(transformListToTransform(chlist).matrix, m))
chlist.clear()
chlist.appendItem(mt)
}
}
// now we have a set of changes and an applied reduced transform list
// we apply the changes directly to the DOM
switch (elName) {
case 'foreignObject':
case 'rect':
case 'image': {
// Allow images to be inverted (give them matrix when flipped)
if (elName === 'image' && (m.a < 0 || m.d < 0)) {
// Convert to matrix
const chlist = selected.transform.baseVal
const mt = svgCanvas.getSvgRoot().createSVGTransform()
mt.setMatrix(matrixMultiply(transformListToTransform(chlist).matrix, m))
chlist.clear()
chlist.appendItem(mt)
} else {
const pt1 = remap(changes.x, changes.y)
changes.width = scalew(changes.width)
changes.height = scaleh(changes.height)
changes.x = pt1.x + Math.min(0, changes.width)
changes.y = pt1.y + Math.min(0, changes.height)
changes.width = Math.abs(changes.width)
changes.height = Math.abs(changes.height)
}
finishUp()
break
} case 'ellipse': {
const c = remap(changes.cx, changes.cy)
changes.cx = c.x
changes.cy = c.y
changes.rx = scalew(changes.rx)
changes.ry = scaleh(changes.ry)
changes.rx = Math.abs(changes.rx)
changes.ry = Math.abs(changes.ry)
finishUp()
break
} case 'circle': {
const c = remap(changes.cx, changes.cy)
changes.cx = c.x
changes.cy = c.y
// take the minimum of the new selected box's dimensions for the new circle radius
const tbox = transformBox(box.x, box.y, box.width, box.height, m)
const w = tbox.tr.x - tbox.tl.x; const h = tbox.bl.y - tbox.tl.y
changes.r = Math.min(w / 2, h / 2)
if (changes.r) { changes.r = Math.abs(changes.r) }
finishUp()
break
} case 'line': {
const pt1 = remap(changes.x1, changes.y1)
const pt2 = remap(changes.x2, changes.y2)
changes.x1 = pt1.x
changes.y1 = pt1.y
changes.x2 = pt2.x
changes.y2 = pt2.y
} // Fallthrough
case 'text':
case 'tspan':
case 'use': {
finishUp()
break
} case 'g': {
const dataStorage = svgCanvas.getDataStorage()
const gsvg = dataStorage.get(selected, 'gsvg')
if (gsvg) {
assignAttributes(gsvg, changes, 1000, true)
}
break
} case 'polyline':
case 'polygon': {
changes.points.forEach((pt) => {
const { x, y } = remap(pt.x, pt.y)
pt.x = x
pt.y = y
})
// const len = changes.points.length;
let pstr = ''
changes.points.forEach((pt) => {
pstr += pt.x + ',' + pt.y + ' '
})
selected.setAttribute('points', pstr)
break
} case 'path': {
const segList = selected.pathSegList
let len = segList.numberOfItems
changes.d = []
for (let i = 0; i < len; ++i) {
const seg = segList.getItem(i)
changes.d[i] = {
type: seg.pathSegType,
x: seg.x,
y: seg.y,
x1: seg.x1,
y1: seg.y1,
x2: seg.x2,
y2: seg.y2,
r1: seg.r1,
r2: seg.r2,
angle: seg.angle,
largeArcFlag: seg.largeArcFlag,
sweepFlag: seg.sweepFlag
}
}
len = changes.d.length
const firstseg = changes.d[0]
let currentpt
if (len > 0) {
currentpt = remap(firstseg.x, firstseg.y)
changes.d[0].x = currentpt.x
changes.d[0].y = currentpt.y
}
for (let i = 1; i < len; ++i) {
const seg = changes.d[i]
const { type } = seg
// if absolute or first segment, we want to remap x, y, x1, y1, x2, y2
// if relative, we want to scalew, scaleh
if (type % 2 === 0) { // absolute
const thisx = (seg.x !== undefined) ? seg.x : currentpt.x // for V commands
const thisy = (seg.y !== undefined) ? seg.y : currentpt.y // for H commands
const pt = remap(thisx, thisy)
const pt1 = remap(seg.x1, seg.y1)
const pt2 = remap(seg.x2, seg.y2)
seg.x = pt.x
seg.y = pt.y
seg.x1 = pt1.x
seg.y1 = pt1.y
seg.x2 = pt2.x
seg.y2 = pt2.y
seg.r1 = scalew(seg.r1)
seg.r2 = scaleh(seg.r2)
} else { // relative
seg.x = scalew(seg.x)
seg.y = scaleh(seg.y)
seg.x1 = scalew(seg.x1)
seg.y1 = scaleh(seg.y1)
seg.x2 = scalew(seg.x2)
seg.y2 = scaleh(seg.y2)
seg.r1 = scalew(seg.r1)
seg.r2 = scaleh(seg.r2)
}
} // for each segment
let dstr = ''
changes.d.forEach((seg) => {
const { type } = seg
dstr += pathMap[type]
switch (type) {
case 13: // relative horizontal line (h)
case 12: // absolute horizontal line (H)
dstr += seg.x + ' '
break
case 15: // relative vertical line (v)
case 14: // absolute vertical line (V)
dstr += seg.y + ' '
break
case 3: // relative move (m)
case 5: // relative line (l)
case 19: // relative smooth quad (t)
case 2: // absolute move (M)
case 4: // absolute line (L)
case 18: // absolute smooth quad (T)
dstr += seg.x + ',' + seg.y + ' '
break
case 7: // relative cubic (c)
case 6: // absolute cubic (C)
dstr += seg.x1 + ',' + seg.y1 + ' ' + seg.x2 + ',' + seg.y2 + ' ' +
seg.x + ',' + seg.y + ' '
break
case 9: // relative quad (q)
case 8: // absolute quad (Q)
dstr += seg.x1 + ',' + seg.y1 + ' ' + seg.x + ',' + seg.y + ' '
break
case 11: // relative elliptical arc (a)
case 10: // absolute elliptical arc (A)
dstr += seg.r1 + ',' + seg.r2 + ' ' + seg.angle + ' ' + Number(seg.largeArcFlag) +
' ' + Number(seg.sweepFlag) + ' ' + seg.x + ',' + seg.y + ' '
break
case 17: // relative smooth cubic (s)
case 16: // absolute smooth cubic (S)
dstr += seg.x2 + ',' + seg.y2 + ' ' + seg.x + ',' + seg.y + ' '
break
}
})
selected.setAttribute('d', dstr)
break
}
}
}

View File

@@ -0,0 +1,45 @@
import { preventClickDefault } from './utilities.js'
/**
* Create a clone of an element, updating its ID and its children's IDs when needed.
* @function module:utilities.copyElem
* @param {Element} el - DOM element to clone
* @param {module:utilities.GetNextID} getNextId - The getter of the next unique ID.
* @returns {Element} The cloned element
*/
export const copyElem = function (el, getNextId) {
// manually create a copy of the element
const newEl = document.createElementNS(el.namespaceURI, el.nodeName)
Object.values(el.attributes).forEach((attr) => {
newEl.setAttributeNS(attr.namespaceURI, attr.nodeName, attr.value)
})
// set the copied element's new id
newEl.removeAttribute('id')
newEl.id = getNextId()
// now create copies of all children
el.childNodes.forEach(function (child) {
switch (child.nodeType) {
case 1: // element node
newEl.append(copyElem(child, getNextId))
break
case 3: // text node
newEl.textContent = child.nodeValue
break
default:
break
}
})
if (el.dataset.gsvg) {
newEl.dataset.gsvg = newEl.firstChild
} else if (el.dataset.symbol) {
const ref = el.dataset.symbol
newEl.dataset.ref = ref
newEl.dataset.symbol = ref
} else if (newEl.tagName === 'image') {
preventClickDefault(newEl)
}
return newEl
}

View File

@@ -0,0 +1,28 @@
/** A storage solution aimed at replacing jQuerys data function.
* Implementation Note: Elements are stored in a (WeakMap)[https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap].
* This makes sure the data is garbage collected when the node is removed.
*/
const dataStorage = {
_storage: new WeakMap(),
put: function (element, key, obj) {
if (!this._storage.has(element)) {
this._storage.set(element, new Map())
}
this._storage.get(element).set(key, obj)
},
get: function (element, key) {
return this._storage.get(element)?.get(key)
},
has: function (element, key) {
return this._storage.has(element) && this._storage.get(element).has(key)
},
remove: function (element, key) {
const ret = this._storage.get(element).delete(key)
if (this._storage.get(element).size === 0) {
this._storage.delete(element)
}
return ret
}
}
export default dataStorage

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,619 @@
/* eslint-disable no-console */
/**
* For command history tracking and undo functionality.
* @module history
* @license MIT
* @copyright 2010 Jeff Schiller
*/
import { getHref, setHref, getRotationAngle, getBBox } from './utilities.js'
/**
* Group: Undo/Redo history management.
*/
export const HistoryEventTypes = {
BEFORE_APPLY: 'before_apply',
AFTER_APPLY: 'after_apply',
BEFORE_UNAPPLY: 'before_unapply',
AFTER_UNAPPLY: 'after_unapply'
}
/**
* Base class for commands.
*/
export class Command {
/**
* @returns {string}
*/
getText () {
return this.text
}
/**
* @param {module:history.HistoryEventHandler} handler
* @param {callback} applyFunction
* @returns {void}
*/
apply (handler, applyFunction) {
handler && handler.handleHistoryEvent(HistoryEventTypes.BEFORE_APPLY, this)
applyFunction(handler)
handler && handler.handleHistoryEvent(HistoryEventTypes.AFTER_APPLY, this)
}
/**
* @param {module:history.HistoryEventHandler} handler
* @param {callback} unapplyFunction
* @returns {void}
*/
unapply (handler, unapplyFunction) {
handler && handler.handleHistoryEvent(HistoryEventTypes.BEFORE_UNAPPLY, this)
unapplyFunction()
handler && handler.handleHistoryEvent(HistoryEventTypes.AFTER_UNAPPLY, this)
}
/**
* @returns {Element[]} Array with element associated with this command
* This function needs to be surcharged if multiple elements are returned.
*/
elements () {
return [this.elem]
}
/**
* @returns {string} String with element associated with this command
*/
type () {
return this.constructor.name
}
}
// Todo: Figure out why the interface members aren't showing
// up (with or without modules applied), despite our apparently following
// http://usejsdoc.org/tags-interface.html#virtual-comments
/**
* An interface that all command objects must implement.
* @interface module:history.HistoryCommand
*/
/**
* Applies.
*
* @function module:history.HistoryCommand#apply
* @param {module:history.HistoryEventHandler} handler
* @fires module:history~Command#event:history
* @returns {void|true}
*/
/**
*
* Unapplies.
* @function module:history.HistoryCommand#unapply
* @param {module:history.HistoryEventHandler} handler
* @fires module:history~Command#event:history
* @returns {void|true}
*/
/**
* Returns the elements.
* @function module:history.HistoryCommand#elements
* @returns {Element[]}
*/
/**
* Gets the text.
* @function module:history.HistoryCommand#getText
* @returns {string}
*/
/**
* Gives the type.
* @function module:history.HistoryCommand.type
* @returns {string}
*/
/**
* @event module:history~Command#event:history
* @type {module:history.HistoryCommand}
*/
/**
* An interface for objects that will handle history events.
* @interface module:history.HistoryEventHandler
*/
/**
*
* @function module:history.HistoryEventHandler#handleHistoryEvent
* @param {string} eventType One of the HistoryEvent types
* @param {module:history~Command#event:history} command
* @listens module:history~Command#event:history
* @returns {void}
*
*/
/**
* History command for an element that had its DOM position changed.
* @implements {module:history.HistoryCommand}
*/
export class MoveElementCommand extends Command {
/**
* @param {Element} elem - The DOM element that was moved
* @param {Element} oldNextSibling - The element's next sibling before it was moved
* @param {Element} oldParent - The element's parent before it was moved
* @param {string} [text] - An optional string visible to user related to this change
*/
constructor (elem, oldNextSibling, oldParent, text) {
super()
this.elem = elem
this.text = text ? ('Move ' + elem.tagName + ' to ' + text) : ('Move ' + elem.tagName)
this.oldNextSibling = oldNextSibling
this.oldParent = oldParent
this.newNextSibling = elem.nextSibling
this.newParent = elem.parentNode
}
/**
* Re-positions the element.
* @param {module:history.HistoryEventHandler} handler
* @fires module:history~Command#event:history
* @returns {void}
*/
apply (handler) {
super.apply(handler, () => {
this.elem = this.newParent.insertBefore(this.elem, this.newNextSibling)
})
}
/**
* Positions the element back to its original location.
* @param {module:history.HistoryEventHandler} handler
* @fires module:history~Command#event:history
* @returns {void}
*/
unapply (handler) {
super.unapply(handler, () => {
this.elem = this.oldParent.insertBefore(this.elem, this.oldNextSibling)
})
}
}
/**
* History command for an element that was added to the DOM.
* @implements {module:history.HistoryCommand}
*/
export class InsertElementCommand extends Command {
/**
* @param {Element} elem - The newly added DOM element
* @param {string} text - An optional string visible to user related to this change
*/
constructor (elem, text) {
super()
this.elem = elem
this.text = text || ('Create ' + elem.tagName)
this.parent = elem.parentNode
this.nextSibling = this.elem.nextSibling
}
/**
* Re-inserts the new element.
* @param {module:history.HistoryEventHandler} handler
* @fires module:history~Command#event:history
* @returns {void}
*/
apply (handler) {
super.apply(handler, () => {
this.elem = this.parent.insertBefore(this.elem, this.nextSibling)
})
}
/**
* Removes the element.
* @param {module:history.HistoryEventHandler} handler
* @fires module:history~Command#event:history
* @returns {void}
*/
unapply (handler) {
super.unapply(handler, () => {
this.parent = this.elem.parentNode
this.elem.remove()
})
}
}
/**
* History command for an element removed from the DOM.
* @implements {module:history.HistoryCommand}
*/
export class RemoveElementCommand extends Command {
/**
* @param {Element} elem - The removed DOM element
* @param {Node} oldNextSibling - The DOM element's nextSibling when it was in the DOM
* @param {Element} oldParent - The DOM element's parent
* @param {string} [text] - An optional string visible to user related to this change
*/
constructor (elem, oldNextSibling, oldParent, text) {
super()
this.elem = elem
this.text = text || ('Delete ' + elem.tagName)
this.nextSibling = oldNextSibling
this.parent = oldParent
}
/**
* Re-removes the new element.
* @param {module:history.HistoryEventHandler} handler
* @fires module:history~Command#event:history
* @returns {void}
*/
apply (handler) {
super.apply(handler, () => {
this.parent = this.elem.parentNode
this.elem.remove()
})
}
/**
* Re-adds the new element.
* @param {module:history.HistoryEventHandler} handler
* @fires module:history~Command#event:history
* @returns {void}
*/
unapply (handler) {
super.unapply(handler, () => {
if (!this.nextSibling) {
console.error('Reference element was lost')
}
this.parent.insertBefore(this.elem, this.nextSibling) // Don't use `before` or `prepend` as `this.nextSibling` may be `null`
})
}
}
/**
* @typedef {"#text"|"#href"|string} module:history.CommandAttributeName
*/
/**
* @typedef {PlainObject<module:history.CommandAttributeName, string>} module:history.CommandAttributes
*/
/**
* History command to make a change to an element.
* Usually an attribute change, but can also be textcontent.
* @implements {module:history.HistoryCommand}
*/
export class ChangeElementCommand extends Command {
/**
* @param {Element} elem - The DOM element that was changed
* @param {module:history.CommandAttributes} attrs - Attributes to be changed with the values they had *before* the change
* @param {string} text - An optional string visible to user related to this change
*/
constructor (elem, attrs, text) {
super()
this.elem = elem
this.text = text ? ('Change ' + elem.tagName + ' ' + text) : ('Change ' + elem.tagName)
this.newValues = {}
this.oldValues = attrs
for (const attr in attrs) {
if (attr === '#text') {
this.newValues[attr] = (elem) ? elem.textContent : ''
} else if (attr === '#href') {
this.newValues[attr] = getHref(elem)
} else {
this.newValues[attr] = elem.getAttribute(attr)
}
}
}
/**
* Performs the stored change action.
* @param {module:history.HistoryEventHandler} handler
* @fires module:history~Command#event:history
* @returns {void}
*/
apply (handler) {
super.apply(handler, () => {
let bChangedTransform = false
Object.entries(this.newValues).forEach(([attr, value]) => {
if (value) {
if (attr === '#text') {
this.elem.textContent = value
} else if (attr === '#href') {
setHref(this.elem, value)
} else {
this.elem.setAttribute(attr, value)
}
} else if (attr === '#text') {
this.elem.textContent = ''
} else {
this.elem.setAttribute(attr, '')
this.elem.removeAttribute(attr)
}
if (attr === 'transform') { bChangedTransform = true }
})
// relocate rotational transform, if necessary
if (!bChangedTransform) {
const angle = getRotationAngle(this.elem)
if (angle) {
const bbox = getBBox(this.elem)
const cx = bbox.x + bbox.width / 2
const cy = bbox.y + bbox.height / 2
const rotate = ['rotate(', angle, ' ', cx, ',', cy, ')'].join('')
if (rotate !== this.elem.getAttribute('transform')) {
this.elem.setAttribute('transform', rotate)
}
}
}
})
}
/**
* Reverses the stored change action.
* @param {module:history.HistoryEventHandler} handler
* @fires module:history~Command#event:history
* @returns {void}
*/
unapply (handler) {
super.unapply(handler, () => {
let bChangedTransform = false
Object.entries(this.oldValues).forEach(([attr, value]) => {
if (value) {
if (attr === '#text') {
this.elem.textContent = value
} else if (attr === '#href') {
setHref(this.elem, value)
} else {
this.elem.setAttribute(attr, value)
}
} else if (attr === '#text') {
this.elem.textContent = ''
} else {
this.elem.removeAttribute(attr)
}
if (attr === 'transform') { bChangedTransform = true }
})
// relocate rotational transform, if necessary
if (!bChangedTransform) {
const angle = getRotationAngle(this.elem)
if (angle) {
const bbox = getBBox(this.elem)
const cx = bbox.x + bbox.width / 2
const cy = bbox.y + bbox.height / 2
const rotate = ['rotate(', angle, ' ', cx, ',', cy, ')'].join('')
if (rotate !== this.elem.getAttribute('transform')) {
this.elem.setAttribute('transform', rotate)
}
}
}
})
}
}
// TODO: create a 'typing' command object that tracks changes in text
// if a new Typing command is created and the top command on the stack is also a Typing
// and they both affect the same element, then collapse the two commands into one
/**
* History command that can contain/execute multiple other commands.
* @implements {module:history.HistoryCommand}
*/
export class BatchCommand extends Command {
/**
* @param {string} [text] - An optional string visible to user related to this change
*/
constructor (text) {
super()
this.text = text || 'Batch Command'
this.stack = []
}
/**
* Runs "apply" on all subcommands.
* @param {module:history.HistoryEventHandler} handler
* @fires module:history~Command#event:history
* @returns {void}
*/
apply (handler) {
super.apply(handler, () => {
this.stack.forEach((stackItem) => {
console.assert(stackItem, 'stack item should not be null')
stackItem && stackItem.apply(handler)
})
})
}
/**
* Runs "unapply" on all subcommands.
* @param {module:history.HistoryEventHandler} handler
* @fires module:history~Command#event:history
* @returns {void}
*/
unapply (handler) {
super.unapply(handler, () => {
this.stack.reverse().forEach((stackItem) => {
console.assert(stackItem, 'stack item should not be null')
stackItem && stackItem.unapply(handler)
})
})
}
/**
* Iterate through all our subcommands.
* @returns {Element[]} All the elements we are changing
*/
elements () {
const elems = []
let cmd = this.stack.length
while (cmd--) {
if (!this.stack[cmd]) continue
const thisElems = this.stack[cmd].elements()
let elem = thisElems.length
while (elem--) {
if (!elems.includes(thisElems[elem])) { elems.push(thisElems[elem]) }
}
}
return elems
}
/**
* Adds a given command to the history stack.
* @param {Command} cmd - The undo command object to add
* @returns {void}
*/
addSubCommand (cmd) {
console.assert(cmd !== null, 'cmd should not be null')
this.stack.push(cmd)
}
/**
* @returns {boolean} Indicates whether or not the batch command is empty
*/
isEmpty () {
return !this.stack.length
}
}
/**
*
*/
export class UndoManager {
/**
* @param {module:history.HistoryEventHandler} historyEventHandler
*/
constructor (historyEventHandler) {
this.handler_ = historyEventHandler || null
this.undoStackPointer = 0
this.undoStack = []
// this is the stack that stores the original values, the elements and
// the attribute name for begin/finish
this.undoChangeStackPointer = -1
this.undoableChangeStack = []
}
/**
* Resets the undo stack, effectively clearing the undo/redo history.
* @returns {void}
*/
resetUndoStack () {
this.undoStack = []
this.undoStackPointer = 0
}
/**
* @returns {Integer} Current size of the undo history stack
*/
getUndoStackSize () {
return this.undoStackPointer
}
/**
* @returns {Integer} Current size of the redo history stack
*/
getRedoStackSize () {
return this.undoStack.length - this.undoStackPointer
}
/**
* @returns {string} String associated with the next undo command
*/
getNextUndoCommandText () {
return this.undoStackPointer > 0 ? this.undoStack[this.undoStackPointer - 1].getText() : ''
}
/**
* @returns {string} String associated with the next redo command
*/
getNextRedoCommandText () {
return this.undoStackPointer < this.undoStack.length ? this.undoStack[this.undoStackPointer].getText() : ''
}
/**
* Performs an undo step.
* @returns {void}
*/
undo () {
if (this.undoStackPointer > 0) {
const cmd = this.undoStack[--this.undoStackPointer]
cmd.unapply(this.handler_)
}
}
/**
* Performs a redo step.
* @returns {void}
*/
redo () {
if (this.undoStackPointer < this.undoStack.length && this.undoStack.length > 0) {
const cmd = this.undoStack[this.undoStackPointer++]
cmd.apply(this.handler_)
}
}
/**
* Adds a command object to the undo history stack.
* @param {Command} cmd - The command object to add
* @returns {void}
*/
addCommandToHistory (cmd) {
// TODO: we MUST compress consecutive text changes to the same element
// (right now each keystroke is saved as a separate command that includes the
// entire text contents of the text element)
// TODO: consider limiting the history that we store here (need to do some slicing)
// if our stack pointer is not at the end, then we have to remove
// all commands after the pointer and insert the new command
if (this.undoStackPointer < this.undoStack.length && this.undoStack.length > 0) {
this.undoStack = this.undoStack.splice(0, this.undoStackPointer)
}
this.undoStack.push(cmd)
this.undoStackPointer = this.undoStack.length
}
/**
* This function tells the canvas to remember the old values of the
* `attrName` attribute for each element sent in. The elements and values
* are stored on a stack, so the next call to `finishUndoableChange()` will
* pop the elements and old values off the stack, gets the current values
* from the DOM and uses all of these to construct the undo-able command.
* @param {string} attrName - The name of the attribute being changed
* @param {Element[]} elems - Array of DOM elements being changed
* @returns {void}
*/
beginUndoableChange (attrName, elems) {
const p = ++this.undoChangeStackPointer
let i = elems.length
const oldValues = new Array(i); const elements = new Array(i)
while (i--) {
const elem = elems[i]
if (!elem) { continue }
elements[i] = elem
oldValues[i] = elem.getAttribute(attrName)
}
this.undoableChangeStack[p] = {
attrName,
oldValues,
elements
}
}
/**
* This function returns a `BatchCommand` object which summarizes the
* change since `beginUndoableChange` was called. The command can then
* be added to the command history.
* @returns {BatchCommand} Batch command object with resulting changes
*/
finishUndoableChange () {
const p = this.undoChangeStackPointer--
const changeset = this.undoableChangeStack[p]
const { attrName } = changeset
const batchCmd = new BatchCommand('Change ' + attrName)
let i = changeset.elements.length
while (i--) {
const elem = changeset.elements[i]
if (!elem) { continue }
const changes = {}
changes[attrName] = changeset.oldValues[i]
if (changes[attrName] !== elem.getAttribute(attrName)) {
batchCmd.addSubCommand(new ChangeElementCommand(elem, changes, attrName))
}
}
this.undoableChangeStack[p] = null
return batchCmd
}
}

View File

@@ -0,0 +1,161 @@
/**
* HistoryRecordingService component of history.
* @module history
* @license MIT
* @copyright 2016 Flint O'Brien
*/
import {
BatchCommand, MoveElementCommand, InsertElementCommand, RemoveElementCommand,
ChangeElementCommand
} from './history.js'
/**
* History recording service.
*
* A self-contained service interface for recording history. Once injected, no other dependencies
* or globals are required (example: UndoManager, command types, etc.). Easy to mock for unit tests.
* Built on top of history classes in history.js.
*
* There is a simple start/end interface for batch commands.
*
* HistoryRecordingService.NO_HISTORY is a singleton that can be passed in to functions
* that record history. This helps when the caller requires that no history be recorded.
*
* The following will record history: insert, batch, insert.
* @example
* hrService = new HistoryRecordingService(this.undoMgr);
* hrService.insertElement(elem, text); // add simple command to history.
* hrService.startBatchCommand('create two elements');
* hrService.changeElement(elem, attrs, text); // add to batchCommand
* hrService.changeElement(elem, attrs2, text); // add to batchCommand
* hrService.endBatchCommand(); // add batch command with two change commands to history.
* hrService.insertElement(elem, text); // add simple command to history.
*
* @example
* // Note that all functions return this, so commands can be chained, like so:
* hrService
* .startBatchCommand('create two elements')
* .insertElement(elem, text)
* .changeElement(elem, attrs, text)
* .endBatchCommand();
*
* @memberof module:history
*/
class HistoryRecordingService {
/**
* @param {history.UndoManager|null} undoManager - The undo manager.
* A value of `null` is valid for cases where no history recording is required.
* See singleton: {@link module:history.HistoryRecordingService.HistoryRecordingService.NO_HISTORY}
*/
constructor (undoManager) {
this.undoManager_ = undoManager
this.currentBatchCommand_ = null
this.batchCommandStack_ = []
}
/**
* Start a batch command so multiple commands can recorded as a single history command.
* Requires a corresponding call to endBatchCommand. Start and end commands can be nested.
*
* @param {string} text - Optional string describing the batch command.
* @returns {module:history.HistoryRecordingService}
*/
startBatchCommand (text) {
if (!this.undoManager_) { return this }
this.currentBatchCommand_ = new BatchCommand(text)
this.batchCommandStack_.push(this.currentBatchCommand_)
return this
}
/**
* End a batch command and add it to the history or a parent batch command.
* @returns {module:history.HistoryRecordingService}
*/
endBatchCommand () {
if (!this.undoManager_) { return this }
if (this.currentBatchCommand_) {
const batchCommand = this.currentBatchCommand_
this.batchCommandStack_.pop()
const { length: len } = this.batchCommandStack_
this.currentBatchCommand_ = len ? this.batchCommandStack_[len - 1] : null
this.addCommand_(batchCommand)
}
return this
}
/**
* Add a `MoveElementCommand` to the history or current batch command.
* @param {Element} elem - The DOM element that was moved
* @param {Element} oldNextSibling - The element's next sibling before it was moved
* @param {Element} oldParent - The element's parent before it was moved
* @param {string} [text] - An optional string visible to user related to this change
* @returns {module:history.HistoryRecordingService}
*/
moveElement (elem, oldNextSibling, oldParent, text) {
if (!this.undoManager_) { return this }
this.addCommand_(new MoveElementCommand(elem, oldNextSibling, oldParent, text))
return this
}
/**
* Add an `InsertElementCommand` to the history or current batch command.
* @param {Element} elem - The DOM element that was added
* @param {string} [text] - An optional string visible to user related to this change
* @returns {module:history.HistoryRecordingService}
*/
insertElement (elem, text) {
if (!this.undoManager_) { return this }
this.addCommand_(new InsertElementCommand(elem, text))
return this
}
/**
* Add a `RemoveElementCommand` to the history or current batch command.
* @param {Element} elem - The DOM element that was removed
* @param {Element} oldNextSibling - The element's next sibling before it was removed
* @param {Element} oldParent - The element's parent before it was removed
* @param {string} [text] - An optional string visible to user related to this change
* @returns {module:history.HistoryRecordingService}
*/
removeElement (elem, oldNextSibling, oldParent, text) {
if (!this.undoManager_) { return this }
this.addCommand_(new RemoveElementCommand(elem, oldNextSibling, oldParent, text))
return this
}
/**
* Add a `ChangeElementCommand` to the history or current batch command.
* @param {Element} elem - The DOM element that was changed
* @param {module:history.CommandAttributes} attrs - An object with the attributes to be changed and the values they had *before* the change
* @param {string} [text] - An optional string visible to user related to this change
* @returns {module:history.HistoryRecordingService}
*/
changeElement (elem, attrs, text) {
if (!this.undoManager_) { return this }
this.addCommand_(new ChangeElementCommand(elem, attrs, text))
return this
}
/**
* Private function to add a command to the history or current batch command.
* @private
* @param {Command} cmd
* @returns {module:history.HistoryRecordingService|void}
*/
addCommand_ (cmd) {
if (!this.undoManager_) { return this }
if (this.currentBatchCommand_) {
this.currentBatchCommand_.addSubCommand(cmd)
} else {
this.undoManager_.addCommandToHistory(cmd)
}
return undefined
}
}
/**
* @memberof module:history.HistoryRecordingService
* @property {module:history.HistoryRecordingService} NO_HISTORY - Singleton that can be passed to functions that record history, but the caller requires that no history be recorded.
*/
HistoryRecordingService.NO_HISTORY = new HistoryRecordingService()
export default HistoryRecordingService

View File

@@ -0,0 +1,110 @@
/**
* Tools for SVG handle on JSON format.
* @module svgcanvas
* @license MIT
*
* @copyright 2010 Alexis Deveria, 2010 Jeff Schiller
*/
import { getElement, assignAttributes, cleanupElement } from './utilities.js'
import { NS } from './namespaces.js'
let svgCanvas = null
let svgdoc_ = null
/**
* @function module:json.jsonContext#getSelectedElements
* @returns {Element[]} the array with selected DOM elements
*/
/**
* @function module:json.jsonContext#getDOMDocument
* @returns {HTMLDocument}
*/
/**
* @function module:json.init
* @param {module:json.jsonContext} jsonContext
* @returns {void}
*/
export const init = (canvas) => {
svgCanvas = canvas
svgdoc_ = canvas.getDOMDocument()
}
/**
* @function module:json.getJsonFromSvgElements Iterate element and return json format
* @param {ArgumentsArray} data - element
* @returns {svgRootElement}
*/
export const getJsonFromSvgElements = (data) => {
// Text node
if (data.nodeType === 3) return data.nodeValue
const retval = {
element: data.tagName,
// namespace: nsMap[data.namespaceURI],
attr: {},
children: []
}
// Iterate attributes
for (let i = 0, attr; (attr = data.attributes[i]); i++) {
retval.attr[attr.name] = attr.value
}
// Iterate children
for (let i = 0, node; (node = data.childNodes[i]); i++) {
retval.children[i] = getJsonFromSvgElements(node)
}
return retval
}
/**
* This should really be an intersection implementing all rather than a union.
* @name module:json.addSVGElementsFromJson
* @type {module:utilities.EditorContext#addSVGElementsFromJson|module:path.EditorContext#addSVGElementsFromJson}
*/
export const addSVGElementsFromJson = (data) => {
if (typeof data === 'string') return svgdoc_.createTextNode(data)
let shape = getElement(data.attr.id)
// if shape is a path but we need to create a rect/ellipse, then remove the path
const currentLayer = svgCanvas.getDrawing().getCurrentLayer()
if (shape && data.element !== shape.tagName) {
shape.remove()
shape = null
}
if (!shape) {
const ns = data.namespace || NS.SVG
shape = svgdoc_.createElementNS(ns, data.element)
if (currentLayer) {
(svgCanvas.getCurrentGroup() || currentLayer).append(shape)
}
}
const curShape = svgCanvas.getCurShape()
if (data.curStyles) {
assignAttributes(shape, {
fill: curShape.fill,
stroke: curShape.stroke,
'stroke-width': curShape.strokeWidth,
'stroke-dasharray': curShape.stroke_dasharray,
'stroke-linejoin': curShape.stroke_linejoin,
'stroke-linecap': curShape.stroke_linecap,
'stroke-opacity': curShape.stroke_opacity,
'fill-opacity': curShape.fill_opacity,
opacity: curShape.opacity / 2,
style: 'pointer-events:inherit'
}, 100)
}
assignAttributes(shape, data.attr, 100)
cleanupElement(shape)
// Children
if (data.children) {
data.children.forEach((child) => {
shape.append(addSVGElementsFromJson(child))
})
}
return shape
}

View File

@@ -0,0 +1,228 @@
/**
* Provides tools for the layer concept.
* @module layer
* @license MIT
*
* @copyright 2011 Jeff Schiller, 2016 Flint O'Brien
*/
import { NS } from './namespaces.js'
import { toXml, walkTree } from './utilities.js'
/**
* This class encapsulates the concept of a layer in the drawing. It can be constructed with
* an existing group element or, with three parameters, will create a new layer group element.
*
* @example
* const l1 = new Layer('name', group); // Use the existing group for this layer.
* const l2 = new Layer('name', group, svgElem); // Create a new group and add it to the DOM after group.
* const l3 = new Layer('name', null, svgElem); // Create a new group and add it to the DOM as the last layer.
* @memberof module:layer
*/
class Layer {
/**
* @param {string} name - Layer name
* @param {SVGGElement|null} group - An existing SVG group element or null.
* If group and no svgElem, use group for this layer.
* If group and svgElem, create a new group element and insert it in the DOM after group.
* If no group and svgElem, create a new group element and insert it in the DOM as the last layer.
* @param {SVGGElement} [svgElem] - The SVG DOM element. If defined, use this to add
* a new layer to the document.
*/
constructor (name, group, svgElem) {
this.name_ = name
this.group_ = svgElem ? null : group
if (svgElem) {
// Create a group element with title and add it to the DOM.
const svgdoc = svgElem.ownerDocument
this.group_ = svgdoc.createElementNS(NS.SVG, 'g')
const layerTitle = svgdoc.createElementNS(NS.SVG, 'title')
layerTitle.textContent = name
this.group_.append(layerTitle)
if (group) {
group.insertAdjacentElement('afterend', this.group_)
} else {
svgElem.append(this.group_)
}
}
addLayerClass(this.group_)
walkTree(this.group_, function (e) {
e.setAttribute('style', 'pointer-events:inherit')
})
this.group_.setAttribute('style', svgElem ? 'pointer-events:all' : 'pointer-events:none')
}
/**
* Get the layer's name.
* @returns {string} The layer name
*/
getName () {
return this.name_
}
/**
* Get the group element for this layer.
* @returns {SVGGElement} The layer SVG group
*/
getGroup () {
return this.group_
}
/**
* Active this layer so it takes pointer events.
* @returns {void}
*/
activate () {
this.group_.setAttribute('style', 'pointer-events:all')
}
/**
* Deactive this layer so it does NOT take pointer events.
* @returns {void}
*/
deactivate () {
this.group_.setAttribute('style', 'pointer-events:none')
}
/**
* Set this layer visible or hidden based on 'visible' parameter.
* @param {boolean} visible - If true, make visible; otherwise, hide it.
* @returns {void}
*/
setVisible (visible) {
const expected = visible === undefined || visible ? 'inline' : 'none'
const oldDisplay = this.group_.getAttribute('display')
if (oldDisplay !== expected) {
this.group_.setAttribute('display', expected)
}
}
/**
* Is this layer visible?
* @returns {boolean} True if visible.
*/
isVisible () {
return this.group_.getAttribute('display') !== 'none'
}
/**
* Get layer opacity.
* @returns {Float} Opacity value.
*/
getOpacity () {
const opacity = this.group_.getAttribute('opacity')
if (!opacity) {
return 1
}
return Number.parseFloat(opacity)
}
/**
* Sets the opacity of this layer. If opacity is not a value between 0.0 and 1.0,
* nothing happens.
* @param {Float} opacity - A float value in the range 0.0-1.0
* @returns {void}
*/
setOpacity (opacity) {
if (typeof opacity === 'number' && opacity >= 0.0 && opacity <= 1.0) {
this.group_.setAttribute('opacity', opacity)
}
}
/**
* Append children to this layer.
* @param {SVGGElement} children - The children to append to this layer.
* @returns {void}
*/
appendChildren (children) {
for (const child of children) {
this.group_.append(child)
}
}
/**
* @returns {SVGTitleElement|null}
*/
getTitleElement () {
const len = this.group_.childNodes.length
for (let i = 0; i < len; ++i) {
const child = this.group_.childNodes.item(i)
if (child?.tagName === 'title') {
return child
}
}
return null
}
/**
* Set the name of this layer.
* @param {string} name - The new name.
* @param {module:history.HistoryRecordingService} hrService - History recording service
* @returns {string|null} The new name if changed; otherwise, null.
*/
setName (name, hrService) {
const previousName = this.name_
name = toXml(name)
// now change the underlying title element contents
const title = this.getTitleElement()
if (title) {
while (title.firstChild) { title.removeChild(title.firstChild) }
title.textContent = name
this.name_ = name
if (hrService) {
hrService.changeElement(title, { '#text': previousName })
}
return this.name_
}
return null
}
/**
* Remove this layer's group from the DOM. No more functions on group can be called after this.
* @returns {SVGGElement} The layer SVG group that was just removed.
*/
removeGroup () {
const group = this.group_
this.group_.remove()
this.group_ = undefined
return group
}
/**
* Test whether an element is a layer or not.
* @param {SVGGElement} elem - The SVGGElement to test.
* @returns {boolean} True if the element is a layer
*/
static isLayer (elem) {
return elem && elem.tagName === 'g' && Layer.CLASS_REGEX.test(elem.getAttribute('class'))
}
}
/**
* @property {string} CLASS_NAME - class attribute assigned to all layer groups.
*/
Layer.CLASS_NAME = 'layer'
/**
* @property {RegExp} CLASS_REGEX - Used to test presence of class Layer.CLASS_NAME
*/
Layer.CLASS_REGEX = new RegExp('(\\s|^)' + Layer.CLASS_NAME + '(\\s|$)')
/**
* Add class `Layer.CLASS_NAME` to the element (usually `class='layer'`).
*
* @param {SVGGElement} elem - The SVG element to update
* @returns {void}
*/
function addLayerClass (elem) {
const classes = elem.getAttribute('class')
if (!classes || !classes.length) {
elem.setAttribute('class', Layer.CLASS_NAME)
} else if (!Layer.CLASS_REGEX.test(classes)) {
elem.setAttribute('class', classes + ' ' + Layer.CLASS_NAME)
}
}
export default Layer

View File

@@ -0,0 +1,221 @@
/**
* Mathematical utilities.
* @module math
* @license MIT
*
* @copyright 2010 Alexis Deveria, 2010 Jeff Schiller
*/
/**
* @typedef {PlainObject} module:math.AngleCoord45
* @property {Float} x - The angle-snapped x value
* @property {Float} y - The angle-snapped y value
* @property {Integer} a - The angle at which to snap
*/
/**
* @typedef {PlainObject} module:math.XYObject
* @property {Float} x
* @property {Float} y
*/
import { NS } from './namespaces.js'
// Constants
const NEAR_ZERO = 1e-14
// Throw away SVGSVGElement used for creating matrices/transforms.
const svg = document.createElementNS(NS.SVG, 'svg')
/**
* A (hopefully) quicker function to transform a point by a matrix
* (this function avoids any DOM calls and just does the math).
* @function module:math.transformPoint
* @param {Float} x - Float representing the x coordinate
* @param {Float} y - Float representing the y coordinate
* @param {SVGMatrix} m - Matrix object to transform the point with
* @returns {module:math.XYObject} An x, y object representing the transformed point
*/
export const transformPoint = function (x, y, m) {
return { x: m.a * x + m.c * y + m.e, y: m.b * x + m.d * y + m.f }
}
/**
* Helper function to check if the matrix performs no actual transform
* (i.e. exists for identity purposes).
* @function module:math.isIdentity
* @param {SVGMatrix} m - The matrix object to check
* @returns {boolean} Indicates whether or not the matrix is 1,0,0,1,0,0
*/
export const isIdentity = function (m) {
return (m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1 && m.e === 0 && m.f === 0)
}
/**
* This function tries to return a `SVGMatrix` that is the multiplication `m1 * m2`.
* We also round to zero when it's near zero.
* @function module:math.matrixMultiply
* @param {...SVGMatrix} args - Matrix objects to multiply
* @returns {SVGMatrix} The matrix object resulting from the calculation
*/
export const matrixMultiply = function (...args) {
const m = args.reduceRight((prev, m1) => {
return m1.multiply(prev)
})
if (Math.abs(m.a) < NEAR_ZERO) { m.a = 0 }
if (Math.abs(m.b) < NEAR_ZERO) { m.b = 0 }
if (Math.abs(m.c) < NEAR_ZERO) { m.c = 0 }
if (Math.abs(m.d) < NEAR_ZERO) { m.d = 0 }
if (Math.abs(m.e) < NEAR_ZERO) { m.e = 0 }
if (Math.abs(m.f) < NEAR_ZERO) { m.f = 0 }
return m
}
/**
* See if the given transformlist includes a non-indentity matrix transform.
* @function module:math.hasMatrixTransform
* @param {SVGTransformList} [tlist] - The transformlist to check
* @returns {boolean} Whether or not a matrix transform was found
*/
export const hasMatrixTransform = function (tlist) {
if (!tlist) { return false }
let num = tlist.numberOfItems
while (num--) {
const xform = tlist.getItem(num)
if (xform.type === 1 && !isIdentity(xform.matrix)) { return true }
}
return false
}
/**
* @typedef {PlainObject} module:math.TransformedBox An object with the following values
* @property {module:math.XYObject} tl - The top left coordinate
* @property {module:math.XYObject} tr - The top right coordinate
* @property {module:math.XYObject} bl - The bottom left coordinate
* @property {module:math.XYObject} br - The bottom right coordinate
* @property {PlainObject} aabox - Object with the following values:
* @property {Float} aabox.x - Float with the axis-aligned x coordinate
* @property {Float} aabox.y - Float with the axis-aligned y coordinate
* @property {Float} aabox.width - Float with the axis-aligned width coordinate
* @property {Float} aabox.height - Float with the axis-aligned height coordinate
*/
/**
* Transforms a rectangle based on the given matrix.
* @function module:math.transformBox
* @param {Float} l - Float with the box's left coordinate
* @param {Float} t - Float with the box's top coordinate
* @param {Float} w - Float with the box width
* @param {Float} h - Float with the box height
* @param {SVGMatrix} m - Matrix object to transform the box by
* @returns {module:math.TransformedBox}
*/
export const transformBox = function (l, t, w, h, m) {
const tl = transformPoint(l, t, m)
const tr = transformPoint((l + w), t, m)
const bl = transformPoint(l, (t + h), m)
const br = transformPoint((l + w), (t + h), m)
const minx = Math.min(tl.x, tr.x, bl.x, br.x)
const maxx = Math.max(tl.x, tr.x, bl.x, br.x)
const miny = Math.min(tl.y, tr.y, bl.y, br.y)
const maxy = Math.max(tl.y, tr.y, bl.y, br.y)
return {
tl,
tr,
bl,
br,
aabox: {
x: minx,
y: miny,
width: (maxx - minx),
height: (maxy - miny)
}
}
}
/**
* This returns a single matrix Transform for a given Transform List
* (this is the equivalent of `SVGTransformList.consolidate()` but unlike
* that method, this one does not modify the actual `SVGTransformList`).
* This function is very liberal with its `min`, `max` arguments.
* @function module:math.transformListToTransform
* @param {SVGTransformList} tlist - The transformlist object
* @param {Integer} [min=0] - Optional integer indicating start transform position
* @param {Integer} [max] - Optional integer indicating end transform position;
* defaults to one less than the tlist's `numberOfItems`
* @returns {SVGTransform} A single matrix transform object
*/
export const transformListToTransform = function (tlist, min, max) {
if (!tlist) {
// Or should tlist = null have been prevented before this?
return svg.createSVGTransformFromMatrix(svg.createSVGMatrix())
}
min = min || 0
max = max || (tlist.numberOfItems - 1)
min = Number.parseInt(min)
max = Number.parseInt(max)
if (min > max) { const temp = max; max = min; min = temp }
let m = svg.createSVGMatrix()
for (let i = min; i <= max; ++i) {
// if our indices are out of range, just use a harmless identity matrix
const mtom = (i >= 0 && i < tlist.numberOfItems
? tlist.getItem(i).matrix
: svg.createSVGMatrix())
m = matrixMultiply(m, mtom)
}
return svg.createSVGTransformFromMatrix(m)
}
/**
* Get the matrix object for a given element.
* @function module:math.getMatrix
* @param {Element} elem - The DOM element to check
* @returns {SVGMatrix} The matrix object associated with the element's transformlist
*/
export const getMatrix = (elem) => {
const tlist = elem.transform.baseVal
return transformListToTransform(tlist).matrix
}
/**
* Returns a 45 degree angle coordinate associated with the two given
* coordinates.
* @function module:math.snapToAngle
* @param {Integer} x1 - First coordinate's x value
* @param {Integer} y1 - First coordinate's y value
* @param {Integer} x2 - Second coordinate's x value
* @param {Integer} y2 - Second coordinate's y value
* @returns {module:math.AngleCoord45}
*/
export const snapToAngle = (x1, y1, x2, y2) => {
const snap = Math.PI / 4 // 45 degrees
const dx = x2 - x1
const dy = y2 - y1
const angle = Math.atan2(dy, dx)
const dist = Math.sqrt(dx * dx + dy * dy)
const snapangle = Math.round(angle / snap) * snap
return {
x: x1 + dist * Math.cos(snapangle),
y: y1 + dist * Math.sin(snapangle),
a: snapangle
}
}
/**
* Check if two rectangles (BBoxes objects) intersect each other.
* @function module:math.rectsIntersect
* @param {SVGRect} r1 - The first BBox-like object
* @param {SVGRect} r2 - The second BBox-like object
* @returns {boolean} True if rectangles intersect
*/
export const rectsIntersect = (r1, r2) => {
return r2.x < (r1.x + r1.width) &&
(r2.x + r2.width) > r1.x &&
r2.y < (r1.y + r1.height) &&
(r2.y + r2.height) > r1.y
}

View File

@@ -0,0 +1,40 @@
/**
* Namespaces or tools therefor.
* @module namespaces
* @license MIT
*/
/**
* Common namepaces constants in alpha order.
* @enum {string}
* @type {PlainObject}
* @memberof module:namespaces
*/
export const NS = {
HTML: 'http://www.w3.org/1999/xhtml',
MATH: 'http://www.w3.org/1998/Math/MathML',
SE: 'http://svg-edit.googlecode.com',
SVG: 'http://www.w3.org/2000/svg',
XLINK: 'http://www.w3.org/1999/xlink',
OI: 'http://www.optimistik.fr/namespace/svg/OIdata',
XML: 'http://www.w3.org/XML/1998/namespace',
XMLNS: 'http://www.w3.org/2000/xmlns/' // see http://www.w3.org/TR/REC-xml-names/#xmlReserved
// SODIPODI: 'http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd',
// INKSCAPE: 'http://www.inkscape.org/namespaces/inkscape',
// RDF: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
// OSB: 'http://www.openswatchbook.org/uri/2009/osb',
// CC: 'http://creativecommons.org/ns#',
// DC: 'http://purl.org/dc/elements/1.1/'
}
/**
* @function module:namespaces.getReverseNS
* @returns {string} The NS with key values switched and lowercase
*/
export const getReverseNS = function () {
const reverseNS = {}
Object.entries(NS).forEach(([name, URI]) => {
reverseNS[URI] = name.toLowerCase()
})
return reverseNS
}

View File

@@ -0,0 +1,88 @@
/**
*
*/
export default class Paint {
/**
* @param {module:jGraduate.jGraduatePaintOptions} [opt]
*/
constructor (opt) {
const options = opt || {}
this.alpha = isNaN(options.alpha) ? 100 : options.alpha
// copy paint object
if (options.copy) {
/**
* @name module:jGraduate~Paint#type
* @type {"none"|"solidColor"|"linearGradient"|"radialGradient"}
*/
this.type = options.copy.type
/**
* Represents opacity (0-100).
* @name module:jGraduate~Paint#alpha
* @type {Float}
*/
this.alpha = options.copy.alpha
/**
* Represents #RRGGBB hex of color.
* @name module:jGraduate~Paint#solidColor
* @type {string}
*/
this.solidColor = null
/**
* @name module:jGraduate~Paint#linearGradient
* @type {SVGLinearGradientElement}
*/
this.linearGradient = null
/**
* @name module:jGraduate~Paint#radialGradient
* @type {SVGRadialGradientElement}
*/
this.radialGradient = null
switch (this.type) {
case 'none':
break
case 'solidColor':
this.solidColor = options.copy.solidColor
break
case 'linearGradient':
this.linearGradient = options.copy.linearGradient.cloneNode(true)
break
case 'radialGradient':
this.radialGradient = options.copy.radialGradient.cloneNode(true)
break
}
// create linear gradient paint
} else if (options.linearGradient) {
this.type = 'linearGradient'
this.solidColor = null
this.radialGradient = null
if (options.linearGradient.hasAttribute('xlink:href')) {
const xhref = document.getElementById(options.linearGradient.getAttribute('xlink:href').substr(1))
this.linearGradient = xhref.cloneNode(true)
} else {
this.linearGradient = options.linearGradient.cloneNode(true)
}
// create linear gradient paint
} else if (options.radialGradient) {
this.type = 'radialGradient'
this.solidColor = null
this.linearGradient = null
if (options.radialGradient.hasAttribute('xlink:href')) {
const xhref = document.getElementById(options.radialGradient.getAttribute('xlink:href').substr(1))
this.radialGradient = xhref.cloneNode(true)
} else {
this.radialGradient = options.radialGradient.cloneNode(true)
}
// create solid color paint
} else if (options.solidColor) {
this.type = 'solidColor'
this.solidColor = options.solidColor
// create empty paint
} else {
this.type = 'none'
this.solidColor = null
this.linearGradient = null
this.radialGradient = null
}
}
}

View File

@@ -0,0 +1,127 @@
import {
getStrokedBBoxDefaultVisible
} from './utilities.js'
import * as hstry from './history.js'
const {
InsertElementCommand, BatchCommand
} = hstry
let svgCanvas = null
/**
* @function module:paste-elem.init
* @param {module:paste-elem.pasteContext} pasteContext
* @returns {void}
*/
export const init = (canvas) => {
svgCanvas = canvas
}
/**
* @function module:svgcanvas.SvgCanvas#pasteElements
* @param {"in_place"|"point"|void} type
* @param {Integer|void} x Expected if type is "point"
* @param {Integer|void} y Expected if type is "point"
* @fires module:svgcanvas.SvgCanvas#event:changed
* @fires module:svgcanvas.SvgCanvas#event:ext_IDsUpdated
* @returns {void}
*/
export const pasteElementsMethod = function (type, x, y) {
let clipb = JSON.parse(sessionStorage.getItem(svgCanvas.getClipboardID()))
if (!clipb) return
let len = clipb.length
if (!len) return
const pasted = []
const batchCmd = new BatchCommand('Paste elements')
// const drawing = getCurrentDrawing();
/**
* @typedef {PlainObject<string, string>} module:svgcanvas.ChangedIDs
*/
/**
* @type {module:svgcanvas.ChangedIDs}
*/
const changedIDs = {}
// Recursively replace IDs and record the changes
/**
*
* @param {module:svgcanvas.SVGAsJSON} elem
* @returns {void}
*/
function checkIDs (elem) {
if (elem.attr?.id) {
changedIDs[elem.attr.id] = svgCanvas.getNextId()
elem.attr.id = changedIDs[elem.attr.id]
}
if (elem.children) elem.children.forEach((child) => checkIDs(child))
}
clipb.forEach((elem) => checkIDs(elem))
// Give extensions like the connector extension a chance to reflect new IDs and remove invalid elements
/**
* Triggered when `pasteElements` is called from a paste action (context menu or key).
* @event module:svgcanvas.SvgCanvas#event:ext_IDsUpdated
* @type {PlainObject}
* @property {module:svgcanvas.SVGAsJSON[]} elems
* @property {module:svgcanvas.ChangedIDs} changes Maps past ID (on attribute) to current ID
*/
svgCanvas.runExtensions(
'IDsUpdated',
/** @type {module:svgcanvas.SvgCanvas#event:ext_IDsUpdated} */
{ elems: clipb, changes: changedIDs },
true
).forEach(function (extChanges) {
if (!extChanges || !('remove' in extChanges)) return
extChanges.remove.forEach(function (removeID) {
clipb = clipb.filter(function (clipBoardItem) {
return clipBoardItem.attr.id !== removeID
})
})
})
// Move elements to lastClickPoint
while (len--) {
const elem = clipb[len]
if (!elem) { continue }
const copy = svgCanvas.addSVGElementsFromJson(elem)
pasted.push(copy)
batchCmd.addSubCommand(new InsertElementCommand(copy))
svgCanvas.restoreRefElements(copy)
}
svgCanvas.selectOnly(pasted)
if (type !== 'in_place') {
let ctrX; let ctrY
if (!type) {
ctrX = svgCanvas.getLastClickPoint('x')
ctrY = svgCanvas.getLastClickPoint('y')
} else if (type === 'point') {
ctrX = x
ctrY = y
}
const bbox = getStrokedBBoxDefaultVisible(pasted)
const cx = ctrX - (bbox.x + bbox.width / 2)
const cy = ctrY - (bbox.y + bbox.height / 2)
const dx = []
const dy = []
pasted.forEach(function (_item) {
dx.push(cx)
dy.push(cy)
})
const cmd = svgCanvas.moveSelectedElements(dx, dy, false)
if (cmd) batchCmd.addSubCommand(cmd)
}
svgCanvas.addCommandToHistory(batchCmd)
svgCanvas.call('changed', pasted)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,781 @@
/**
* Path functionality.
* @module path
* @license MIT
*
* @copyright 2011 Alexis Deveria, 2011 Jeff Schiller
*/
import { shortFloat } from './units.js'
import { transformPoint } from './math.js'
import {
getRotationAngle, getBBox,
getRefElem, findDefs,
getBBox as utilsGetBBox
} from './utilities.js'
import {
init as pathMethodInit, ptObjToArrMethod, getGripPtMethod,
getPointFromGripMethod, addPointGripMethod, getGripContainerMethod, addCtrlGripMethod,
getCtrlLineMethod, getPointGripMethod, getControlPointsMethod, replacePathSegMethod,
getSegSelectorMethod, Path
} from './path-method.js'
import {
init as pathActionsInit, pathActionsMethod
} from './path-actions.js'
const segData = {
2: ['x', 'y'], // PATHSEG_MOVETO_ABS
4: ['x', 'y'], // PATHSEG_LINETO_ABS
6: ['x', 'y', 'x1', 'y1', 'x2', 'y2'], // PATHSEG_CURVETO_CUBIC_ABS
8: ['x', 'y', 'x1', 'y1'], // PATHSEG_CURVETO_QUADRATIC_ABS
10: ['x', 'y', 'r1', 'r2', 'angle', 'largeArcFlag', 'sweepFlag'], // PATHSEG_ARC_ABS
12: ['x'], // PATHSEG_LINETO_HORIZONTAL_ABS
14: ['y'], // PATHSEG_LINETO_VERTICAL_ABS
16: ['x', 'y', 'x2', 'y2'], // PATHSEG_CURVETO_CUBIC_SMOOTH_ABS
18: ['x', 'y'] // PATHSEG_CURVETO_QUADRATIC_SMOOTH_ABS
}
let svgCanvas
/**
* @tutorial LocaleDocs
* @typedef {module:locale.LocaleStrings|PlainObject} module:path.uiStrings
* @property {PlainObject<string, string>} ui
*/
const uiStrings = {}
/**
* @function module:path.setUiStrings
* @param {module:path.uiStrings} strs
* @returns {void}
*/
export const setUiStrings = (strs) => {
Object.assign(uiStrings, strs.ui)
}
let pathFuncs = []
let linkControlPts = true
// Stores references to paths via IDs.
// TODO: Make this cross-document happy.
let pathData = {}
/**
* @function module:path.setLinkControlPoints
* @param {boolean} lcp
* @returns {void}
*/
export const setLinkControlPoints = (lcp) => {
linkControlPts = lcp
}
/**
* @name module:path.path
* @type {null|module:path.Path}
* @memberof module:path
*/
export let path = null
/**
* @external MouseEvent
*/
/**
* Object with the following keys/values.
* @typedef {PlainObject} module:path.SVGElementJSON
* @property {string} element - Tag name of the SVG element to create
* @property {PlainObject<string, string>} attr - Has key-value attributes to assign to the new element.
* An `id` should be set so that {@link module:utilities.EditorContext#addSVGElementsFromJson} can later re-identify the element for modification or replacement.
* @property {boolean} [curStyles=false] - Indicates whether current style attributes should be applied first
* @property {module:path.SVGElementJSON[]} [children] - Data objects to be added recursively as children
* @property {string} [namespace="http://www.w3.org/2000/svg"] - Indicate a (non-SVG) namespace
*/
/**
* @interface module:path.EditorContext
* @property {module:select.SelectorManager} selectorManager
* @property {module:svgcanvas.SvgCanvas} canvas
*/
/**
* @function module:path.EditorContext#call
* @param {"selected"|"changed"} ev - String with the event name
* @param {module:svgcanvas.SvgCanvas#event:selected|module:svgcanvas.SvgCanvas#event:changed} arg - Argument to pass through to the callback function.
* If the event is "changed", an array of `Element`s is passed; if "selected", a single-item array of `Element` is passed.
* @returns {void}
*/
/**
* Note: This doesn't round to an integer necessarily.
* @function module:path.EditorContext#round
* @param {Float} val
* @returns {Float} Rounded value to nearest value based on `zoom`
*/
/**
* @function module:path.EditorContext#clearSelection
* @param {boolean} [noCall] - When `true`, does not call the "selected" handler
* @returns {void}
*/
/**
* @function module:path.EditorContext#addToSelection
* @param {Element[]} elemsToAdd - An array of DOM elements to add to the selection
* @param {boolean} showGrips - Indicates whether the resize grips should be shown
* @returns {void}
*/
/**
* @function module:path.EditorContext#addCommandToHistory
* @param {Command} cmd
* @returns {void}
*/
/**
* @function module:path.EditorContext#remapElement
* @param {Element} selected - DOM element to be changed
* @param {PlainObject<string, string>} changes - Object with changes to be remapped
* @param {SVGMatrix} m - Matrix object to use for remapping coordinates
* @returns {void}
*/
/**
* @function module:path.EditorContext#addSVGElementsFromJson
* @param {module:path.SVGElementJSON} data
* @returns {Element} The new element
*/
/**
* @function module:path.EditorContext#getGridSnapping
* @returns {boolean}
*/
/**
* @function module:path.EditorContext#getOpacity
* @returns {Float}
*/
/**
* @function module:path.EditorContext#getSelectedElements
* @returns {Element[]} the array with selected DOM elements
*/
/**
* @function module:path.EditorContext#getContainer
* @returns {Element}
*/
/**
* @function module:path.EditorContext#setStarted
* @param {boolean} s
* @returns {void}
*/
/**
* @function module:path.EditorContext#getRubberBox
* @returns {SVGRectElement}
*/
/**
* @function module:path.EditorContext#setRubberBox
* @param {SVGRectElement} rb
* @returns {SVGRectElement} Same as parameter passed in
*/
/**
* @function module:path.EditorContext#addPtsToSelection
* @param {PlainObject} cfg
* @param {boolean} cfg.closedSubpath
* @param {SVGCircleElement[]} cfg.grips
* @returns {void}
*/
/**
* @function module:path.EditorContext#endChanges
* @param {PlainObject} cfg
* @param {string} cfg.cmd
* @param {Element} cfg.elem
* @returns {void}
*/
/**
* @function module:path.EditorContext#getZoom
* @returns {Float} The current zoom level
*/
/**
* Returns the last created DOM element ID string.
* @function module:path.EditorContext#getId
* @returns {string}
*/
/**
* Creates and returns a unique ID string for a DOM element.
* @function module:path.EditorContext#getNextId
* @returns {string}
*/
/**
* Gets the desired element from a mouse event.
* @function module:path.EditorContext#getMouseTarget
* @param {external:MouseEvent} evt - Event object from the mouse event
* @returns {Element} DOM element we want
*/
/**
* @function module:path.EditorContext#getCurrentMode
* @returns {string}
*/
/**
* @function module:path.EditorContext#setCurrentMode
* @param {string} cm The mode
* @returns {string} The same mode as passed in
*/
/**
* @function module:path.EditorContext#setDrawnPath
* @param {SVGPathElement|null} dp
* @returns {SVGPathElement|null} The same value as passed in
*/
/**
* @function module:path.EditorContext#getSvgRoot
* @returns {SVGSVGElement}
*/
/**
* @function module:path.init
* @param {module:path.EditorContext} editorContext
* @returns {void}
*/
export const init = (canvas) => {
svgCanvas = canvas
svgCanvas.replacePathSeg = replacePathSegMethod
svgCanvas.addPointGrip = addPointGripMethod
svgCanvas.removePath_ = removePath_
svgCanvas.getPath_ = getPath_
svgCanvas.addCtrlGrip = addCtrlGripMethod
svgCanvas.getCtrlLine = getCtrlLineMethod
svgCanvas.getGripPt = getGripPt
svgCanvas.getPointFromGrip = getPointFromGripMethod
svgCanvas.setLinkControlPoints = setLinkControlPoints
svgCanvas.reorientGrads = reorientGrads
svgCanvas.getSegData = () => { return segData }
svgCanvas.getUIStrings = () => { return uiStrings }
svgCanvas.getPathObj = () => { return path }
svgCanvas.setPathObj = (obj) => { path = obj }
svgCanvas.getPathFuncs = () => { return pathFuncs }
svgCanvas.getLinkControlPts = () => { return linkControlPts }
pathFuncs = [0, 'ClosePath']
const pathFuncsStrs = [
'Moveto', 'Lineto', 'CurvetoCubic', 'CurvetoQuadratic', 'Arc',
'LinetoHorizontal', 'LinetoVertical', 'CurvetoCubicSmooth', 'CurvetoQuadraticSmooth'
]
pathFuncsStrs.forEach((s) => {
pathFuncs.push(s + 'Abs')
pathFuncs.push(s + 'Rel')
})
pathActionsInit(svgCanvas)
pathMethodInit(svgCanvas)
}
/* eslint-disable max-len */
/**
* @function module:path.ptObjToArr
* @todo See if this should just live in `replacePathSeg`
* @param {string} type
* @param {SVGPathSegMovetoAbs|SVGPathSegLinetoAbs|SVGPathSegCurvetoCubicAbs|SVGPathSegCurvetoQuadraticAbs|SVGPathSegArcAbs|SVGPathSegLinetoHorizontalAbs|SVGPathSegLinetoVerticalAbs|SVGPathSegCurvetoCubicSmoothAbs|SVGPathSegCurvetoQuadraticSmoothAbs} segItem
* @returns {ArgumentsArray}
*/
/* eslint-enable max-len */
export const ptObjToArr = ptObjToArrMethod
/**
* @function module:path.getGripPt
* @param {Segment} seg
* @param {module:math.XYObject} altPt
* @returns {module:math.XYObject}
*/
export const getGripPt = getGripPtMethod
/**
* @function module:path.getPointFromGrip
* @param {module:math.XYObject} pt
* @param {module:path.Path} pth
* @returns {module:math.XYObject}
*/
export const getPointFromGrip = getPointFromGripMethod
/**
* Requires prior call to `setUiStrings` if `xlink:title`
* to be set on the grip.
* @function module:path.addPointGrip
* @param {Integer} index
* @param {Integer} x
* @param {Integer} y
* @returns {SVGCircleElement}
*/
export const addPointGrip = addPointGripMethod
/**
* @function module:path.getGripContainer
* @returns {Element}
*/
export const getGripContainer = getGripContainerMethod
/**
* Requires prior call to `setUiStrings` if `xlink:title`
* to be set on the grip.
* @function module:path.addCtrlGrip
* @param {string} id
* @returns {SVGCircleElement}
*/
export const addCtrlGrip = addCtrlGripMethod
/**
* @function module:path.getCtrlLine
* @param {string} id
* @returns {SVGLineElement}
*/
export const getCtrlLine = getCtrlLineMethod
/**
* @function module:path.getPointGrip
* @param {Segment} seg
* @param {boolean} update
* @returns {SVGCircleElement}
*/
export const getPointGrip = getPointGripMethod
/**
* @function module:path.getControlPoints
* @param {Segment} seg
* @returns {PlainObject<string, SVGLineElement|SVGCircleElement>}
*/
export const getControlPoints = getControlPointsMethod
/**
* This replaces the segment at the given index. Type is given as number.
* @function module:path.replacePathSeg
* @param {Integer} type Possible values set during {@link module:path.init}
* @param {Integer} index
* @param {ArgumentsArray} pts
* @param {SVGPathElement} elem
* @returns {void}
*/
export const replacePathSeg = replacePathSegMethod
/**
* @function module:path.getSegSelector
* @param {Segment} seg
* @param {boolean} update
* @returns {SVGPathElement}
*/
export const getSegSelector = getSegSelectorMethod
/**
* @typedef {PlainObject} Point
* @property {Integer} x The x value
* @property {Integer} y The y value
*/
/**
* Takes three points and creates a smoother line based on them.
* @function module:path.smoothControlPoints
* @param {Point} ct1 - Object with x and y values (first control point)
* @param {Point} ct2 - Object with x and y values (second control point)
* @param {Point} pt - Object with x and y values (third point)
* @returns {Point[]} Array of two "smoothed" point objects
*/
export const smoothControlPoints = (ct1, ct2, pt) => {
// each point must not be the origin
const x1 = ct1.x - pt.x
const y1 = ct1.y - pt.y
const x2 = ct2.x - pt.x
const y2 = ct2.y - pt.y
if ((x1 !== 0 || y1 !== 0) && (x2 !== 0 || y2 !== 0)) {
const
r1 = Math.sqrt(x1 * x1 + y1 * y1)
const r2 = Math.sqrt(x2 * x2 + y2 * y2)
const nct1 = svgCanvas.getSvgRoot().createSVGPoint()
const nct2 = svgCanvas.getSvgRoot().createSVGPoint()
let anglea = Math.atan2(y1, x1)
let angleb = Math.atan2(y2, x2)
if (anglea < 0) { anglea += 2 * Math.PI }
if (angleb < 0) { angleb += 2 * Math.PI }
const angleBetween = Math.abs(anglea - angleb)
const angleDiff = Math.abs(Math.PI - angleBetween) / 2
let newAnglea; let newAngleb
if (anglea - angleb > 0) {
newAnglea = angleBetween < Math.PI ? (anglea + angleDiff) : (anglea - angleDiff)
newAngleb = angleBetween < Math.PI ? (angleb - angleDiff) : (angleb + angleDiff)
} else {
newAnglea = angleBetween < Math.PI ? (anglea - angleDiff) : (anglea + angleDiff)
newAngleb = angleBetween < Math.PI ? (angleb + angleDiff) : (angleb - angleDiff)
}
// rotate the points
nct1.x = r1 * Math.cos(newAnglea) + pt.x
nct1.y = r1 * Math.sin(newAnglea) + pt.y
nct2.x = r2 * Math.cos(newAngleb) + pt.x
nct2.y = r2 * Math.sin(newAngleb) + pt.y
return [nct1, nct2]
}
return undefined
}
/**
* @function module:path.getPath_
* @param {SVGPathElement} elem
* @returns {module:path.Path}
*/
export const getPath_ = (elem) => {
let p = pathData[elem.id]
if (!p) {
p = pathData[elem.id] = new Path(elem)
}
return p
}
/**
* @function module:path.removePath_
* @param {string} id
* @returns {void}
*/
export const removePath_ = (id) => {
if (id in pathData) { delete pathData[id] }
}
let newcx; let newcy; let oldcx; let oldcy; let angle
const getRotVals = (x, y) => {
let dx = x - oldcx
let dy = y - oldcy
// rotate the point around the old center
let r = Math.sqrt(dx * dx + dy * dy)
let theta = Math.atan2(dy, dx) + angle
dx = r * Math.cos(theta) + oldcx
dy = r * Math.sin(theta) + oldcy
// dx,dy should now hold the actual coordinates of each
// point after being rotated
// now we want to rotate them around the new center in the reverse direction
dx -= newcx
dy -= newcy
r = Math.sqrt(dx * dx + dy * dy)
theta = Math.atan2(dy, dx) - angle
return {
x: r * Math.cos(theta) + newcx,
y: r * Math.sin(theta) + newcy
}
}
// If the path was rotated, we must now pay the piper:
// Every path point must be rotated into the rotated coordinate system of
// its old center, then determine the new center, then rotate it back
// This is because we want the path to remember its rotation
/**
* @function module:path.recalcRotatedPath
* @todo This is still using ye olde transform methods, can probably
* be optimized or even taken care of by `recalculateDimensions`
* @returns {void}
*/
export const recalcRotatedPath = () => {
const currentPath = path.elem
angle = getRotationAngle(currentPath, true)
if (!angle) { return }
// selectedBBoxes[0] = path.oldbbox;
const oldbox = path.oldbbox // selectedBBoxes[0],
oldcx = oldbox.x + oldbox.width / 2
oldcy = oldbox.y + oldbox.height / 2
const box = getBBox(currentPath)
newcx = box.x + box.width / 2
newcy = box.y + box.height / 2
// un-rotate the new center to the proper position
const dx = newcx - oldcx
const dy = newcy - oldcy
const r = Math.sqrt(dx * dx + dy * dy)
const theta = Math.atan2(dy, dx) + angle
newcx = r * Math.cos(theta) + oldcx
newcy = r * Math.sin(theta) + oldcy
const list = currentPath.pathSegList
let i = list.numberOfItems
while (i) {
i -= 1
const seg = list.getItem(i)
const type = seg.pathSegType
if (type === 1) { continue }
const rvals = getRotVals(seg.x, seg.y)
const points = [rvals.x, rvals.y]
if (seg.x1 && seg.x2) {
const cVals1 = getRotVals(seg.x1, seg.y1)
const cVals2 = getRotVals(seg.x2, seg.y2)
points.splice(points.length, 0, cVals1.x, cVals1.y, cVals2.x, cVals2.y)
}
replacePathSeg(type, i, points)
} // loop for each point
/* box = */ getBBox(currentPath)
// selectedBBoxes[0].x = box.x; selectedBBoxes[0].y = box.y;
// selectedBBoxes[0].width = box.width; selectedBBoxes[0].height = box.height;
// now we must set the new transform to be rotated around the new center
const Rnc = svgCanvas.getSvgRoot().createSVGTransform()
const tlist = currentPath.transform.baseVal
Rnc.setRotate((angle * 180.0 / Math.PI), newcx, newcy)
tlist.replaceItem(Rnc, 0)
}
// ====================================
// Public API starts here
/**
* @function module:path.clearData
* @returns {void}
*/
export const clearData = () => {
pathData = {}
}
// Making public for mocking
/**
* @function module:path.reorientGrads
* @param {Element} elem
* @param {SVGMatrix} m
* @returns {void}
*/
export const reorientGrads = (elem, m) => {
const bb = utilsGetBBox(elem)
for (let i = 0; i < 2; i++) {
const type = i === 0 ? 'fill' : 'stroke'
const attrVal = elem.getAttribute(type)
if (attrVal && attrVal.startsWith('url(')) {
const grad = getRefElem(attrVal)
if (grad.tagName === 'linearGradient') {
let x1 = grad.getAttribute('x1') || 0
let y1 = grad.getAttribute('y1') || 0
let x2 = grad.getAttribute('x2') || 1
let y2 = grad.getAttribute('y2') || 0
// Convert to USOU points
x1 = (bb.width * x1) + bb.x
y1 = (bb.height * y1) + bb.y
x2 = (bb.width * x2) + bb.x
y2 = (bb.height * y2) + bb.y
// Transform those points
const pt1 = transformPoint(x1, y1, m)
const pt2 = transformPoint(x2, y2, m)
// Convert back to BB points
const gCoords = {
x1: (pt1.x - bb.x) / bb.width,
y1: (pt1.y - bb.y) / bb.height,
x2: (pt2.x - bb.x) / bb.width,
y2: (pt2.y - bb.y) / bb.height
}
const newgrad = grad.cloneNode(true)
for (const [key, value] of Object.entries(gCoords)) {
newgrad.setAttribute(key, value)
}
newgrad.id = svgCanvas.getNextId()
findDefs().append(newgrad)
elem.setAttribute(type, 'url(#' + newgrad.id + ')')
}
}
}
}
/**
* This is how we map paths to our preferred relative segment types.
* @name module:path.pathMap
* @type {GenericArray}
*/
const pathMap = [
0, 'z', 'M', 'm', 'L', 'l', 'C', 'c', 'Q', 'q', 'A', 'a',
'H', 'h', 'V', 'v', 'S', 's', 'T', 't'
]
/**
* Convert a path to one with only absolute or relative values.
* @todo move to pathActions.js
* @function module:path.convertPath
* @param {SVGPathElement} pth - the path to convert
* @param {boolean} toRel - true of convert to relative
* @returns {string}
*/
export const convertPath = (pth, toRel) => {
const { pathSegList } = pth
const len = pathSegList.numberOfItems
let curx = 0; let cury = 0
let d = ''
let lastM = null
for (let i = 0; i < len; ++i) {
const seg = pathSegList.getItem(i)
// if these properties are not in the segment, set them to zero
let x = seg.x || 0
let y = seg.y || 0
let x1 = seg.x1 || 0
let y1 = seg.y1 || 0
let x2 = seg.x2 || 0
let y2 = seg.y2 || 0
const type = seg.pathSegType
let letter = pathMap[type][toRel ? 'toLowerCase' : 'toUpperCase']()
switch (type) {
case 1: // z,Z closepath (Z/z)
d += 'z'
if (lastM && !toRel) {
curx = lastM[0]
cury = lastM[1]
}
break
case 12: // absolute horizontal line (H)
x -= curx
// Fallthrough
case 13: // relative horizontal line (h)
if (toRel) {
y = 0
curx += x
letter = 'l'
} else {
y = cury
x += curx
curx = x
letter = 'L'
}
// Convert to "line" for easier editing
d += pathDSegment(letter, [[x, y]])
break
case 14: // absolute vertical line (V)
y -= cury
// Fallthrough
case 15: // relative vertical line (v)
if (toRel) {
x = 0
cury += y
letter = 'l'
} else {
x = curx
y += cury
cury = y
letter = 'L'
}
// Convert to "line" for easier editing
d += pathDSegment(letter, [[x, y]])
break
case 2: // absolute move (M)
case 4: // absolute line (L)
case 18: // absolute smooth quad (T)
case 10: // absolute elliptical arc (A)
x -= curx
y -= cury
// Fallthrough
case 5: // relative line (l)
case 3: // relative move (m)
case 19: // relative smooth quad (t)
if (toRel) {
curx += x
cury += y
} else {
x += curx
y += cury
curx = x
cury = y
}
if (type === 2 || type === 3) { lastM = [curx, cury] }
d += pathDSegment(letter, [[x, y]])
break
case 6: // absolute cubic (C)
x -= curx; x1 -= curx; x2 -= curx
y -= cury; y1 -= cury; y2 -= cury
// Fallthrough
case 7: // relative cubic (c)
if (toRel) {
curx += x
cury += y
} else {
x += curx; x1 += curx; x2 += curx
y += cury; y1 += cury; y2 += cury
curx = x
cury = y
}
d += pathDSegment(letter, [[x1, y1], [x2, y2], [x, y]])
break
case 8: // absolute quad (Q)
x -= curx; x1 -= curx
y -= cury; y1 -= cury
// Fallthrough
case 9: // relative quad (q)
if (toRel) {
curx += x
cury += y
} else {
x += curx; x1 += curx
y += cury; y1 += cury
curx = x
cury = y
}
d += pathDSegment(letter, [[x1, y1], [x, y]])
break
// Fallthrough
case 11: // relative elliptical arc (a)
if (toRel) {
curx += x
cury += y
} else {
x += curx
y += cury
curx = x
cury = y
}
d += pathDSegment(letter, [[seg.r1, seg.r2]], [
seg.angle,
(seg.largeArcFlag ? 1 : 0),
(seg.sweepFlag ? 1 : 0)
], [x, y])
break
case 16: // absolute smooth cubic (S)
x -= curx; x2 -= curx
y -= cury; y2 -= cury
// Fallthrough
case 17: // relative smooth cubic (s)
if (toRel) {
curx += x
cury += y
} else {
x += curx; x2 += curx
y += cury; y2 += cury
curx = x
cury = y
}
d += pathDSegment(letter, [[x2, y2], [x, y]])
break
} // switch on path segment type
} // for each segment
return d
}
/**
* TODO: refactor callers in `convertPath` to use `getPathDFromSegments` instead of this function.
* Legacy code refactored from `svgcanvas.pathActions.convertPath`.
* @param {string} letter - path segment command (letter in potentially either case from {@link module:path.pathMap}; see [SVGPathSeg#pathSegTypeAsLetter]{@link https://www.w3.org/TR/SVG/single-page.html#paths-__svg__SVGPathSeg__pathSegTypeAsLetter})
* @param {GenericArray<GenericArray<Integer>>} points - x,y points
* @param {GenericArray<GenericArray<Integer>>} [morePoints] - x,y points
* @param {Integer[]} [lastPoint] - x,y point
* @returns {string}
*/
const pathDSegment = (letter, points, morePoints, lastPoint) => {
points.forEach((pnt, i) => {
points[i] = shortFloat(pnt)
})
let segment = letter + points.join(' ')
if (morePoints) {
segment += ' ' + morePoints.join(' ')
}
if (lastPoint) {
segment += ' ' + shortFloat(lastPoint)
}
return segment
}
/**
* Group: Path edit functions.
* Functions relating to editing path elements.
*/
export const pathActions = pathActionsMethod
// end pathActions

View File

@@ -0,0 +1,806 @@
/**
* Recalculate.
* @module recalculate
* @license MIT
*/
import { NS } from './namespaces.js'
import { convertToNum } from './units.js'
import { getRotationAngle, getHref, getBBox, getRefElem } from './utilities.js'
import { BatchCommand, ChangeElementCommand } from './history.js'
import { remapElement } from './coords.js'
import {
isIdentity, matrixMultiply, transformPoint, transformListToTransform,
hasMatrixTransform
} from './math.js'
import {
mergeDeep
} from '../common/util.js'
let svgCanvas
/**
* @interface module:recalculate.EditorContext
*/
/**
* @function module:recalculate.EditorContext#getSvgRoot
* @returns {SVGSVGElement} The root DOM element
*/
/**
* @function module:recalculate.EditorContext#getStartTransform
* @returns {string}
*/
/**
* @function module:recalculate.EditorContext#setStartTransform
* @param {string} transform
* @returns {void}
*/
/**
* @function module:recalculate.init
* @param {module:recalculate.EditorContext} editorContext
* @returns {void}
*/
export const init = (canvas) => {
svgCanvas = canvas
}
/**
* Updates a `<clipPath>`s values based on the given translation of an element.
* @function module:recalculate.updateClipPath
* @param {string} attr - The clip-path attribute value with the clipPath's ID
* @param {Float} tx - The translation's x value
* @param {Float} ty - The translation's y value
* @returns {void}
*/
export const updateClipPath = (attr, tx, ty) => {
const path = getRefElem(attr).firstChild
const cpXform = path.transform.baseVal
const newxlate = svgCanvas.getSvgRoot().createSVGTransform()
newxlate.setTranslate(tx, ty)
cpXform.appendItem(newxlate)
// Update clipPath's dimensions
recalculateDimensions(path)
}
/**
* Decides the course of action based on the element's transform list.
* @function module:recalculate.recalculateDimensions
* @param {Element} selected - The DOM element to recalculate
* @returns {Command} Undo command object with the resulting change
*/
export const recalculateDimensions = (selected) => {
if (!selected) return null
const svgroot = svgCanvas.getSvgRoot()
const dataStorage = svgCanvas.getDataStorage()
const tlist = selected.transform?.baseVal
// remove any unnecessary transforms
if (tlist?.numberOfItems > 0) {
let k = tlist.numberOfItems
const noi = k
while (k--) {
const xform = tlist.getItem(k)
if (xform.type === 0) {
tlist.removeItem(k)
// remove identity matrices
} else if (xform.type === 1) {
if (isIdentity(xform.matrix)) {
if (noi === 1) {
// Overcome Chrome bug (though only when noi is 1) with
// `removeItem` preventing `removeAttribute` from
// subsequently working
// See https://bugs.chromium.org/p/chromium/issues/detail?id=843901
selected.removeAttribute('transform')
return null
}
tlist.removeItem(k)
}
// remove zero-degree rotations
} else if (xform.type === 4 && xform.angle === 0) {
tlist.removeItem(k)
}
}
// End here if all it has is a rotation
if (tlist.numberOfItems === 1 &&
getRotationAngle(selected)) { return null }
}
// if this element had no transforms, we are done
if (!tlist || tlist.numberOfItems === 0) {
// Chrome apparently had a bug that requires clearing the attribute first.
selected.setAttribute('transform', '')
// However, this still next line currently doesn't work at all in Chrome
selected.removeAttribute('transform')
// selected.transform.baseVal.clear(); // Didn't help for Chrome bug
return null
}
// TODO: Make this work for more than 2
if (tlist) {
let mxs = []
let k = tlist.numberOfItems
while (k--) {
const xform = tlist.getItem(k)
if (xform.type === 1) {
mxs.push([xform.matrix, k])
} else if (mxs.length) {
mxs = []
}
}
if (mxs.length === 2) {
const mNew = svgroot.createSVGTransformFromMatrix(matrixMultiply(mxs[1][0], mxs[0][0]))
tlist.removeItem(mxs[0][1])
tlist.removeItem(mxs[1][1])
tlist.insertItemBefore(mNew, mxs[1][1])
}
// combine matrix + translate
k = tlist.numberOfItems
if (k >= 2 && tlist.getItem(k - 2).type === 1 && tlist.getItem(k - 1).type === 2) {
const mt = svgroot.createSVGTransform()
const m = matrixMultiply(
tlist.getItem(k - 2).matrix,
tlist.getItem(k - 1).matrix
)
mt.setMatrix(m)
tlist.removeItem(k - 2)
tlist.removeItem(k - 2)
tlist.appendItem(mt)
}
}
// If it still has a single [M] or [R][M], return null too (prevents BatchCommand from being returned).
switch (selected.tagName) {
// Ignore these elements, as they can absorb the [M]
case 'line':
case 'polyline':
case 'polygon':
case 'path':
break
default:
if ((tlist.numberOfItems === 1 && tlist.getItem(0).type === 1) ||
(tlist.numberOfItems === 2 && tlist.getItem(0).type === 1 && tlist.getItem(0).type === 4)) {
return null
}
}
// Grouped SVG element
const gsvg = (dataStorage.has(selected, 'gsvg')) ? dataStorage.get(selected, 'gsvg') : undefined
// we know we have some transforms, so set up return variable
const batchCmd = new BatchCommand('Transform')
// store initial values that will be affected by reducing the transform list
let changes = {}
let initial = null
let attrs = []
switch (selected.tagName) {
case 'line':
attrs = ['x1', 'y1', 'x2', 'y2']
break
case 'circle':
attrs = ['cx', 'cy', 'r']
break
case 'ellipse':
attrs = ['cx', 'cy', 'rx', 'ry']
break
case 'foreignObject':
case 'rect':
case 'image':
attrs = ['width', 'height', 'x', 'y']
break
case 'use':
case 'text':
case 'tspan':
attrs = ['x', 'y']
break
case 'polygon':
case 'polyline': {
initial = {}
initial.points = selected.getAttribute('points')
const list = selected.points
const len = list.numberOfItems
changes.points = new Array(len)
for (let i = 0; i < len; ++i) {
const pt = list.getItem(i)
changes.points[i] = { x: pt.x, y: pt.y }
}
break
} case 'path':
initial = {}
initial.d = selected.getAttribute('d')
changes.d = selected.getAttribute('d')
break
} // switch on element type to get initial values
if (attrs.length) {
attrs.forEach((attr) => {
changes[attr] = convertToNum(attr, selected.getAttribute(attr))
})
} else if (gsvg) {
// GSVG exception
changes = {
x: Number(gsvg.getAttribute('x')) || 0,
y: Number(gsvg.getAttribute('y')) || 0
}
}
// if we haven't created an initial array in polygon/polyline/path, then
// make a copy of initial values and include the transform
if (!initial) {
initial = mergeDeep({}, changes)
for (const [attr, val] of Object.entries(initial)) {
initial[attr] = convertToNum(attr, val)
}
}
// save the start transform value too
initial.transform = svgCanvas.getStartTransform() || ''
let oldcenter; let newcenter
// if it's a regular group, we have special processing to flatten transforms
if ((selected.tagName === 'g' && !gsvg) || selected.tagName === 'a') {
const box = getBBox(selected)
oldcenter = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
newcenter = transformPoint(
box.x + box.width / 2,
box.y + box.height / 2,
transformListToTransform(tlist).matrix
)
// let m = svgroot.createSVGMatrix();
// temporarily strip off the rotate and save the old center
const gangle = getRotationAngle(selected)
if (gangle) {
const a = gangle * Math.PI / 180
const s = Math.abs(a) > (1.0e-10) ? Math.sin(a) / (1 - Math.cos(a)) : 2 / a
for (let i = 0; i < tlist.numberOfItems; ++i) {
const xform = tlist.getItem(i)
if (xform.type === 4) {
// extract old center through mystical arts
const rm = xform.matrix
oldcenter.y = (s * rm.e + rm.f) / 2
oldcenter.x = (rm.e - s * rm.f) / 2
tlist.removeItem(i)
break
}
}
}
const N = tlist.numberOfItems
let tx = 0; let ty = 0; let operation = 0
let firstM
if (N) {
firstM = tlist.getItem(0).matrix
}
let oldStartTransform
// first, if it was a scale then the second-last transform will be it
if (N >= 3 && tlist.getItem(N - 2).type === 3 &&
tlist.getItem(N - 3).type === 2 && tlist.getItem(N - 1).type === 2) {
operation = 3 // scale
// if the children are unrotated, pass the scale down directly
// otherwise pass the equivalent matrix() down directly
const tm = tlist.getItem(N - 3).matrix
const sm = tlist.getItem(N - 2).matrix
const tmn = tlist.getItem(N - 1).matrix
const children = selected.childNodes
let c = children.length
while (c--) {
const child = children.item(c)
tx = 0
ty = 0
if (child.nodeType === 1) {
const childTlist = child.transform.baseVal
// some children might not have a transform (<metadata>, <defs>, etc)
if (!childTlist) { continue }
const m = transformListToTransform(childTlist).matrix
// Convert a matrix to a scale if applicable
// if (hasMatrixTransform(childTlist) && childTlist.numberOfItems == 1) {
// if (m.b==0 && m.c==0 && m.e==0 && m.f==0) {
// childTlist.removeItem(0);
// const translateOrigin = svgroot.createSVGTransform(),
// scale = svgroot.createSVGTransform(),
// translateBack = svgroot.createSVGTransform();
// translateOrigin.setTranslate(0, 0);
// scale.setScale(m.a, m.d);
// translateBack.setTranslate(0, 0);
// childTlist.appendItem(translateBack);
// childTlist.appendItem(scale);
// childTlist.appendItem(translateOrigin);
// }
// }
const angle = getRotationAngle(child)
oldStartTransform = svgCanvas.getStartTransform()
// const childxforms = [];
svgCanvas.setStartTransform(child.getAttribute('transform'))
if (angle || hasMatrixTransform(childTlist)) {
const e2t = svgroot.createSVGTransform()
e2t.setMatrix(matrixMultiply(tm, sm, tmn, m))
childTlist.clear()
childTlist.appendItem(e2t)
// childxforms.push(e2t);
// if not rotated or skewed, push the [T][S][-T] down to the child
} else {
// update the transform list with translate,scale,translate
// slide the [T][S][-T] from the front to the back
// [T][S][-T][M] = [M][T2][S2][-T2]
// (only bringing [-T] to the right of [M])
// [T][S][-T][M] = [T][S][M][-T2]
// [-T2] = [M_inv][-T][M]
const t2n = matrixMultiply(m.inverse(), tmn, m)
// [T2] is always negative translation of [-T2]
const t2 = svgroot.createSVGMatrix()
t2.e = -t2n.e
t2.f = -t2n.f
// [T][S][-T][M] = [M][T2][S2][-T2]
// [S2] = [T2_inv][M_inv][T][S][-T][M][-T2_inv]
const s2 = matrixMultiply(t2.inverse(), m.inverse(), tm, sm, tmn, m, t2n.inverse())
const translateOrigin = svgroot.createSVGTransform()
const scale = svgroot.createSVGTransform()
const translateBack = svgroot.createSVGTransform()
translateOrigin.setTranslate(t2n.e, t2n.f)
scale.setScale(s2.a, s2.d)
translateBack.setTranslate(t2.e, t2.f)
childTlist.appendItem(translateBack)
childTlist.appendItem(scale)
childTlist.appendItem(translateOrigin)
} // not rotated
const recalculatedDimensions = recalculateDimensions(child)
if (recalculatedDimensions) {
batchCmd.addSubCommand(recalculatedDimensions)
}
svgCanvas.setStartTransform(oldStartTransform)
} // element
} // for each child
// Remove these transforms from group
tlist.removeItem(N - 1)
tlist.removeItem(N - 2)
tlist.removeItem(N - 3)
} else if (N >= 3 && tlist.getItem(N - 1).type === 1) {
operation = 3 // scale
const m = transformListToTransform(tlist).matrix
const e2t = svgroot.createSVGTransform()
e2t.setMatrix(m)
tlist.clear()
tlist.appendItem(e2t)
// next, check if the first transform was a translate
// if we had [ T1 ] [ M ] we want to transform this into [ M ] [ T2 ]
// therefore [ T2 ] = [ M_inv ] [ T1 ] [ M ]
} else if ((N === 1 || (N > 1 && tlist.getItem(1).type !== 3)) &&
tlist.getItem(0).type === 2) {
operation = 2 // translate
const T_M = transformListToTransform(tlist).matrix
tlist.removeItem(0)
const mInv = transformListToTransform(tlist).matrix.inverse()
const M2 = matrixMultiply(mInv, T_M)
tx = M2.e
ty = M2.f
if (tx !== 0 || ty !== 0) {
// we pass the translates down to the individual children
const children = selected.childNodes
let c = children.length
const clipPathsDone = []
while (c--) {
const child = children.item(c)
if (child.nodeType === 1) {
// Check if child has clip-path
if (child.getAttribute('clip-path')) {
// tx, ty
const attr = child.getAttribute('clip-path')
if (!clipPathsDone.includes(attr)) {
updateClipPath(attr, tx, ty)
clipPathsDone.push(attr)
}
}
oldStartTransform = svgCanvas.getStartTransform()
svgCanvas.setStartTransform(child.getAttribute('transform'))
const childTlist = child.transform?.baseVal
// some children might not have a transform (<metadata>, <defs>, etc)
if (childTlist) {
const newxlate = svgroot.createSVGTransform()
newxlate.setTranslate(tx, ty)
if (childTlist.numberOfItems) {
childTlist.insertItemBefore(newxlate, 0)
} else {
childTlist.appendItem(newxlate)
}
const recalculatedDimensions = recalculateDimensions(child)
if (recalculatedDimensions) {
batchCmd.addSubCommand(recalculatedDimensions)
}
// If any <use> have this group as a parent and are
// referencing this child, then impose a reverse translate on it
// so that when it won't get double-translated
const uses = selected.getElementsByTagNameNS(NS.SVG, 'use')
const href = '#' + child.id
let u = uses.length
while (u--) {
const useElem = uses.item(u)
if (href === getHref(useElem)) {
const usexlate = svgroot.createSVGTransform()
usexlate.setTranslate(-tx, -ty)
useElem.transform.baseVal.insertItemBefore(usexlate, 0)
batchCmd.addSubCommand(recalculateDimensions(useElem))
}
}
svgCanvas.setStartTransform(oldStartTransform)
}
}
}
svgCanvas.setStartTransform(oldStartTransform)
}
// else, a matrix imposition from a parent group
// keep pushing it down to the children
} else if (N === 1 && tlist.getItem(0).type === 1 && !gangle) {
operation = 1
const m = tlist.getItem(0).matrix
const children = selected.childNodes
let c = children.length
while (c--) {
const child = children.item(c)
if (child.nodeType === 1) {
oldStartTransform = svgCanvas.getStartTransform()
svgCanvas.setStartTransform(child.getAttribute('transform'))
const childTlist = child.transform?.baseVal
if (!childTlist) { continue }
const em = matrixMultiply(m, transformListToTransform(childTlist).matrix)
const e2m = svgroot.createSVGTransform()
e2m.setMatrix(em)
childTlist.clear()
childTlist.appendItem(e2m, 0)
const recalculatedDimensions = recalculateDimensions(child)
if (recalculatedDimensions) {
batchCmd.addSubCommand(recalculatedDimensions)
}
svgCanvas.setStartTransform(oldStartTransform)
// Convert stroke
// TODO: Find out if this should actually happen somewhere else
const sw = child.getAttribute('stroke-width')
if (child.getAttribute('stroke') !== 'none' && !isNaN(sw)) {
const avg = (Math.abs(em.a) + Math.abs(em.d)) / 2
child.setAttribute('stroke-width', sw * avg)
}
}
}
tlist.clear()
// else it was just a rotate
} else {
if (gangle) {
const newRot = svgroot.createSVGTransform()
newRot.setRotate(gangle, newcenter.x, newcenter.y)
if (tlist.numberOfItems) {
tlist.insertItemBefore(newRot, 0)
} else {
tlist.appendItem(newRot)
}
}
if (tlist.numberOfItems === 0) {
selected.removeAttribute('transform')
}
return null
}
// if it was a translate, put back the rotate at the new center
if (operation === 2) {
if (gangle) {
newcenter = {
x: oldcenter.x + firstM.e,
y: oldcenter.y + firstM.f
}
const newRot = svgroot.createSVGTransform()
newRot.setRotate(gangle, newcenter.x, newcenter.y)
if (tlist.numberOfItems) {
tlist.insertItemBefore(newRot, 0)
} else {
tlist.appendItem(newRot)
}
}
// if it was a resize
} else if (operation === 3) {
const m = transformListToTransform(tlist).matrix
const roldt = svgroot.createSVGTransform()
roldt.setRotate(gangle, oldcenter.x, oldcenter.y)
const rold = roldt.matrix
const rnew = svgroot.createSVGTransform()
rnew.setRotate(gangle, newcenter.x, newcenter.y)
const rnewInv = rnew.matrix.inverse()
const mInv = m.inverse()
const extrat = matrixMultiply(mInv, rnewInv, rold, m)
tx = extrat.e
ty = extrat.f
if (tx !== 0 || ty !== 0) {
// now push this transform down to the children
// we pass the translates down to the individual children
const children = selected.childNodes
let c = children.length
while (c--) {
const child = children.item(c)
if (child.nodeType === 1) {
oldStartTransform = svgCanvas.getStartTransform()
svgCanvas.setStartTransform(child.getAttribute('transform'))
const childTlist = child.transform?.baseVal
const newxlate = svgroot.createSVGTransform()
newxlate.setTranslate(tx, ty)
if (childTlist.numberOfItems) {
childTlist.insertItemBefore(newxlate, 0)
} else {
childTlist.appendItem(newxlate)
}
const recalculatedDimensions = recalculateDimensions(child)
if (recalculatedDimensions) {
batchCmd.addSubCommand(recalculatedDimensions)
}
svgCanvas.setStartTransform(oldStartTransform)
}
}
}
if (gangle) {
if (tlist.numberOfItems) {
tlist.insertItemBefore(rnew, 0)
} else {
tlist.appendItem(rnew)
}
}
}
// else, it's a non-group
} else {
// TODO: box might be null for some elements (<metadata> etc), need to handle this
const box = getBBox(selected)
// Paths (and possbly other shapes) will have no BBox while still in <defs>,
// but we still may need to recalculate them (see issue 595).
// TODO: Figure out how to get BBox from these elements in case they
// have a rotation transform
if (!box && selected.tagName !== 'path') return null
let m // = svgroot.createSVGMatrix();
// temporarily strip off the rotate and save the old center
const angle = getRotationAngle(selected)
if (angle) {
oldcenter = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
newcenter = transformPoint(
box.x + box.width / 2,
box.y + box.height / 2,
transformListToTransform(tlist).matrix
)
const a = angle * Math.PI / 180
const s = (Math.abs(a) > (1.0e-10))
? Math.sin(a) / (1 - Math.cos(a))
// TODO: This blows up if the angle is exactly 0!
: 2 / a
for (let i = 0; i < tlist.numberOfItems; ++i) {
const xform = tlist.getItem(i)
if (xform.type === 4) {
// extract old center through mystical arts
const rm = xform.matrix
oldcenter.y = (s * rm.e + rm.f) / 2
oldcenter.x = (rm.e - s * rm.f) / 2
tlist.removeItem(i)
break
}
}
}
// 2 = translate, 3 = scale, 4 = rotate, 1 = matrix imposition
let operation = 0
const N = tlist.numberOfItems
// Check if it has a gradient with userSpaceOnUse, in which case
// adjust it by recalculating the matrix transform.
const fill = selected.getAttribute('fill')
if (fill?.startsWith('url(')) {
const paint = getRefElem(fill)
if (paint) {
let type = 'pattern'
if (paint?.tagName !== type) type = 'gradient'
const attrVal = paint.getAttribute(type + 'Units')
if (attrVal === 'userSpaceOnUse') {
// Update the userSpaceOnUse element
m = transformListToTransform(tlist).matrix
const gtlist = paint.transform.baseVal
const gmatrix = transformListToTransform(gtlist).matrix
m = matrixMultiply(m, gmatrix)
const mStr = 'matrix(' + [m.a, m.b, m.c, m.d, m.e, m.f].join(',') + ')'
paint.setAttribute(type + 'Transform', mStr)
}
}
}
// first, if it was a scale of a non-skewed element, then the second-last
// transform will be the [S]
// if we had [M][T][S][T] we want to extract the matrix equivalent of
// [T][S][T] and push it down to the element
if (N >= 3 && tlist.getItem(N - 2).type === 3 &&
tlist.getItem(N - 3).type === 2 && tlist.getItem(N - 1).type === 2) {
// Removed this so a <use> with a given [T][S][T] would convert to a matrix.
// Is that bad?
// && selected.nodeName != 'use'
operation = 3 // scale
m = transformListToTransform(tlist, N - 3, N - 1).matrix
tlist.removeItem(N - 1)
tlist.removeItem(N - 2)
tlist.removeItem(N - 3)
// if we had [T][S][-T][M], then this was a skewed element being resized
// Thus, we simply combine it all into one matrix
} else if (N === 4 && tlist.getItem(N - 1).type === 1) {
operation = 3 // scale
m = transformListToTransform(tlist).matrix
const e2t = svgroot.createSVGTransform()
e2t.setMatrix(m)
tlist.clear()
tlist.appendItem(e2t)
// reset the matrix so that the element is not re-mapped
m = svgroot.createSVGMatrix()
// if we had [R][T][S][-T][M], then this was a rotated matrix-element
// if we had [T1][M] we want to transform this into [M][T2]
// therefore [ T2 ] = [ M_inv ] [ T1 ] [ M ] and we can push [T2]
// down to the element
} else if ((N === 1 || (N > 1 && tlist.getItem(1).type !== 3)) &&
tlist.getItem(0).type === 2) {
operation = 2 // translate
const oldxlate = tlist.getItem(0).matrix
const meq = transformListToTransform(tlist, 1).matrix
const meqInv = meq.inverse()
m = matrixMultiply(meqInv, oldxlate, meq)
tlist.removeItem(0)
// else if this child now has a matrix imposition (from a parent group)
// we might be able to simplify
} else if (N === 1 && tlist.getItem(0).type === 1 && !angle) {
// Remap all point-based elements
m = transformListToTransform(tlist).matrix
switch (selected.tagName) {
case 'line':
changes = {
x1: selected.getAttribute('x1'),
y1: selected.getAttribute('y1'),
x2: selected.getAttribute('x2'),
y2: selected.getAttribute('y2')
}
// Fallthrough
case 'polyline':
case 'polygon':
changes.points = selected.getAttribute('points')
if (changes.points) {
const list = selected.points
const len = list.numberOfItems
changes.points = new Array(len)
for (let i = 0; i < len; ++i) {
const pt = list.getItem(i)
changes.points[i] = { x: pt.x, y: pt.y }
}
}
// Fallthrough
case 'path':
changes.d = selected.getAttribute('d')
operation = 1
tlist.clear()
break
default:
break
}
// if it was a rotation, put the rotate back and return without a command
// (this function has zero work to do for a rotate())
} else {
// operation = 4; // rotation
if (angle) {
const newRot = svgroot.createSVGTransform()
newRot.setRotate(angle, newcenter.x, newcenter.y)
if (tlist.numberOfItems) {
tlist.insertItemBefore(newRot, 0)
} else {
tlist.appendItem(newRot)
}
}
if (tlist.numberOfItems === 0) {
selected.removeAttribute('transform')
}
return null
}
// if it was a translate or resize, we need to remap the element and absorb the xform
if (operation === 1 || operation === 2 || operation === 3) {
remapElement(selected, changes, m)
} // if we are remapping
// if it was a translate, put back the rotate at the new center
if (operation === 2) {
if (angle) {
if (!hasMatrixTransform(tlist)) {
newcenter = {
x: oldcenter.x + m.e,
y: oldcenter.y + m.f
}
}
const newRot = svgroot.createSVGTransform()
newRot.setRotate(angle, newcenter.x, newcenter.y)
if (tlist.numberOfItems) {
tlist.insertItemBefore(newRot, 0)
} else {
tlist.appendItem(newRot)
}
}
// We have special processing for tspans: Tspans are not transformable
// but they can have x,y coordinates (sigh). Thus, if this was a translate,
// on a text element, also translate any tspan children.
if (selected.tagName === 'text') {
const children = selected.childNodes
let c = children.length
while (c--) {
const child = children.item(c)
if (child.tagName === 'tspan') {
const tspanChanges = {
x: Number(child.getAttribute('x')) || 0,
y: Number(child.getAttribute('y')) || 0
}
remapElement(child, tspanChanges, m)
}
}
}
// [Rold][M][T][S][-T] became [Rold][M]
// we want it to be [Rnew][M][Tr] where Tr is the
// translation required to re-center it
// Therefore, [Tr] = [M_inv][Rnew_inv][Rold][M]
} else if (operation === 3 && angle) {
const { matrix } = transformListToTransform(tlist)
const roldt = svgroot.createSVGTransform()
roldt.setRotate(angle, oldcenter.x, oldcenter.y)
const rold = roldt.matrix
const rnew = svgroot.createSVGTransform()
rnew.setRotate(angle, newcenter.x, newcenter.y)
const rnewInv = rnew.matrix.inverse()
const mInv = matrix.inverse()
const extrat = matrixMultiply(mInv, rnewInv, rold, matrix)
remapElement(selected, changes, extrat)
if (angle) {
if (tlist.numberOfItems) {
tlist.insertItemBefore(rnew, 0)
} else {
tlist.appendItem(rnew)
}
}
}
} // a non-group
// if the transform list has been emptied, remove it
if (tlist.numberOfItems === 0) {
selected.removeAttribute('transform')
}
batchCmd.addSubCommand(new ChangeElementCommand(selected, initial))
return batchCmd
}

View File

@@ -0,0 +1,252 @@
/**
* Tools for SVG sanitization.
* @module sanitize
* @license MIT
*
* @copyright 2010 Alexis Deveria, 2010 Jeff Schiller
*/
import { getReverseNS, NS } from './namespaces.js'
import { getHref, setHref, getUrlFromAttr } from './utilities.js'
const REVERSE_NS = getReverseNS()
// Todo: Split out into core attributes, presentation attributes, etc. so consistent
/**
* This defines which elements and attributes that we support (or at least
* don't remove).
* @type {PlainObject}
*/
/* eslint-disable max-len */
const svgGenericWhiteList = ['class', 'id', 'display', 'transform', 'style']
const svgWhiteList_ = {
// SVG Elements
a: ['clip-path', 'clip-rule', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'mask', 'opacity', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'xlink:href', 'xlink:title'],
circle: ['clip-path', 'clip-rule', 'cx', 'cy', 'enable-background', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'mask', 'opacity', 'r', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage'],
clipPath: ['clipPathUnits'],
defs: [],
desc: [],
ellipse: ['clip-path', 'clip-rule', 'cx', 'cy', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'mask', 'opacity', 'requiredFeatures', 'rx', 'ry', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage'],
feBlend: ['in', 'in2'],
feColorMatrix: ['in', 'type', 'value', 'result', 'values'],
feComposite: ['in', 'operator', 'result', 'in2'],
feFlood: ['flood-color', 'in', 'result', 'flood-opacity'],
feGaussianBlur: ['color-interpolation-filters', 'in', 'requiredFeatures', 'stdDeviation', 'result'],
feMerge: [],
feMergeNode: ['in'],
feMorphology: ['in', 'operator', 'radius'],
feOffset: ['dx', 'in', 'dy', 'result'],
filter: ['color-interpolation-filters', 'filterRes', 'filterUnits', 'height', 'primitiveUnits', 'requiredFeatures', 'width', 'x', 'xlink:href', 'y'],
foreignObject: ['font-size', 'height', 'opacity', 'requiredFeatures', 'width', 'x', 'y'],
g: ['clip-path', 'clip-rule', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'mask', 'opacity', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'font-family', 'font-size', 'font-style', 'font-weight', 'text-anchor'],
image: ['clip-path', 'clip-rule', 'filter', 'height', 'mask', 'opacity', 'requiredFeatures', 'systemLanguage', 'width', 'x', 'xlink:href', 'xlink:title', 'y'],
line: ['clip-path', 'clip-rule', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'marker-end', 'marker-mid', 'marker-start', 'mask', 'opacity', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'x1', 'x2', 'y1', 'y2'],
linearGradient: ['gradientTransform', 'gradientUnits', 'requiredFeatures', 'spreadMethod', 'systemLanguage', 'x1', 'x2', 'xlink:href', 'y1', 'y2'],
marker: ['markerHeight', 'markerUnits', 'markerWidth', 'orient', 'preserveAspectRatio', 'refX', 'refY', 'se_type', 'systemLanguage', 'viewBox'],
mask: ['height', 'maskContentUnits', 'maskUnits', 'width', 'x', 'y'],
metadata: [],
path: ['clip-path', 'clip-rule', 'd', 'enable-background', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'marker-end', 'marker-mid', 'marker-start', 'mask', 'opacity', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage'],
pattern: ['height', 'patternContentUnits', 'patternTransform', 'patternUnits', 'requiredFeatures', 'systemLanguage', 'viewBox', 'width', 'x', 'xlink:href', 'y'],
polygon: ['clip-path', 'clip-rule', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'marker-end', 'marker-mid', 'marker-start', 'mask', 'opacity', 'points', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'sides', 'shape', 'edge', 'point', 'starRadiusMultiplier', 'r', 'radialshift', 'r2', 'orient', 'cx', 'cy'],
polyline: ['clip-path', 'clip-rule', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'marker-end', 'marker-mid', 'marker-start', 'mask', 'opacity', 'points', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'se:connector'],
radialGradient: ['cx', 'cy', 'fx', 'fy', 'gradientTransform', 'gradientUnits', 'r', 'requiredFeatures', 'spreadMethod', 'systemLanguage', 'xlink:href'],
rect: ['clip-path', 'clip-rule', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'height', 'mask', 'opacity', 'requiredFeatures', 'rx', 'ry', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'width', 'x', 'y'],
stop: ['offset', 'requiredFeatures', 'stop-opacity', 'systemLanguage', 'stop-color', 'gradientUnits', 'gradientTransform'],
style: ['type'],
svg: ['clip-path', 'clip-rule', 'enable-background', 'filter', 'height', 'mask', 'preserveAspectRatio', 'requiredFeatures', 'systemLanguage', 'version', 'viewBox', 'width', 'x', 'xmlns', 'xmlns:se', 'xmlns:xlink', 'xmlns:oi', 'oi:animations', 'y', 'stroke-linejoin', 'fill-rule', 'aria-label', 'stroke-width', 'fill-rule', 'xml:space'],
switch: ['requiredFeatures', 'systemLanguage'],
symbol: ['fill', 'fill-opacity', 'fill-rule', 'filter', 'font-family', 'font-size', 'font-style', 'font-weight', 'opacity', 'overflow', 'preserveAspectRatio', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'viewBox', 'width', 'height'],
text: ['clip-path', 'clip-rule', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'font-family', 'font-size', 'font-style', 'font-weight', 'mask', 'opacity', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'text-anchor', 'letter-spacing', 'word-spacing', 'text-decoration', 'textLength', 'lengthAdjust', 'x', 'xml:space', 'y'],
textPath: ['method', 'requiredFeatures', 'spacing', 'startOffset', 'systemLanguage', 'xlink:href'],
title: [],
tspan: ['clip-path', 'clip-rule', 'dx', 'dy', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'font-family', 'font-size', 'font-style', 'font-weight', 'mask', 'opacity', 'requiredFeatures', 'rotate', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'text-anchor', 'textLength', 'x', 'xml:space', 'y'],
use: ['clip-path', 'clip-rule', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'height', 'mask', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'width', 'x', 'xlink:href', 'y', 'overflow'],
// MathML Elements
annotation: ['encoding'],
'annotation-xml': ['encoding'],
maction: ['actiontype', 'other', 'selection'],
math: ['xmlns'],
menclose: ['notation'],
merror: [],
mfrac: ['linethickness'],
mi: ['mathvariant'],
mmultiscripts: [],
mn: [],
mo: ['fence', 'lspace', 'maxsize', 'minsize', 'rspace', 'stretchy'],
mover: [],
mpadded: ['lspace', 'width', 'height', 'depth', 'voffset'],
mphantom: [],
mprescripts: [],
mroot: [],
mrow: ['xlink:href', 'xlink:type', 'xmlns:xlink'],
mspace: ['depth', 'height', 'width'],
msqrt: [],
mstyle: ['displaystyle', 'mathbackground', 'mathcolor', 'mathvariant', 'scriptlevel'],
msub: [],
msubsup: [],
msup: [],
mtable: ['align', 'columnalign', 'columnlines', 'columnspacing', 'displaystyle', 'equalcolumns', 'equalrows', 'frame', 'rowalign', 'rowlines', 'rowspacing', 'width'],
mtd: ['columnalign', 'columnspan', 'rowalign', 'rowspan'],
mtext: [],
mtr: ['columnalign', 'rowalign'],
munder: [],
munderover: [],
none: [],
semantics: []
}
/* eslint-enable max-len */
// add generic attributes to all elements of the whitelist
Object.keys(svgWhiteList_).forEach((element) => { svgWhiteList_[element] = [...svgWhiteList_[element], ...svgGenericWhiteList] })
// Produce a Namespace-aware version of svgWhitelist
const svgWhiteListNS_ = {}
Object.entries(svgWhiteList_).forEach(([elt, atts]) => {
const attNS = {}
Object.entries(atts).forEach(([_i, att]) => {
if (att.includes(':')) {
const v = att.split(':')
attNS[v[1]] = NS[(v[0]).toUpperCase()]
} else {
attNS[att] = att === 'xmlns' ? NS.XMLNS : null
}
})
svgWhiteListNS_[elt] = attNS
})
/**
* Sanitizes the input node and its children.
* It only keeps what is allowed from our whitelist defined above.
* @function module:sanitize.sanitizeSvg
* @param {Text|Element} node - The DOM element to be checked (we'll also check its children) or text node to be cleaned up
* @returns {void}
*/
export const sanitizeSvg = (node) => {
// Cleanup text nodes
if (node.nodeType === 3) { // 3 === TEXT_NODE
// Trim whitespace
node.nodeValue = node.nodeValue.trim()
// Remove if empty
if (!node.nodeValue.length) {
node.remove()
}
}
// We only care about element nodes.
// Automatically return for all non-element nodes, such as comments, etc.
if (node.nodeType !== 1) { // 1 == ELEMENT_NODE
return
}
const doc = node.ownerDocument
const parent = node.parentNode
// can parent ever be null here? I think the root node's parent is the document...
if (!doc || !parent) {
return
}
const allowedAttrs = svgWhiteList_[node.nodeName]
const allowedAttrsNS = svgWhiteListNS_[node.nodeName]
// if this element is supported, sanitize it
if (typeof allowedAttrs !== 'undefined') {
const seAttrs = []
let i = node.attributes.length
while (i--) {
// if the attribute is not in our whitelist, then remove it
const attr = node.attributes.item(i)
const attrName = attr.nodeName
const attrLocalName = attr.localName
const attrNsURI = attr.namespaceURI
// Check that an attribute with the correct localName in the correct namespace is on
// our whitelist or is a namespace declaration for one of our allowed namespaces
if (attrNsURI !== allowedAttrsNS[attrLocalName] && attrNsURI !== NS.XMLNS &&
!(attrNsURI === NS.XMLNS && REVERSE_NS[attr.value])) {
// Bypassing the whitelist to allow se: and oi: prefixes
// We can add specific namepaces on demand for now.
// Is there a more appropriate way to do this?
if (attrName.startsWith('se:') || attrName.startsWith('oi:') || attrName.startsWith('data-')) {
// We should bypass the namespace aswell
const seAttrNS = (attrName.startsWith('se:')) ? NS.SE : ((attrName.startsWith('oi:')) ? NS.OI : null)
seAttrs.push([attrName, attr.value, seAttrNS])
} else {
console.warn(`sanitizeSvg: attribute ${attrName} in element ${node.nodeName} not in whitelist is removed`)
node.removeAttributeNS(attrNsURI, attrLocalName)
}
}
// For the style attribute, rewrite it in terms of XML presentational attributes
if (attrName === 'style') {
const props = attr.value.split(';')
let p = props.length
while (p--) {
const [name, val] = props[p].split(':')
const styleAttrName = (name || '').trim()
const styleAttrVal = (val || '').trim()
// Now check that this attribute is supported
if (allowedAttrs.includes(styleAttrName)) {
node.setAttribute(styleAttrName, styleAttrVal)
}
}
node.removeAttribute('style')
}
}
Object.values(seAttrs).forEach(([att, val, ns]) => {
node.setAttributeNS(ns, att, val)
})
// for some elements that have a xlink:href, ensure the URI refers to a local element
// (but not for links)
const href = getHref(node)
if (href &&
['filter', 'linearGradient', 'pattern',
'radialGradient', 'textPath', 'use'].includes(node.nodeName) && href[0] !== '#') {
// remove the attribute (but keep the element)
setHref(node, '')
console.warn(`sanitizeSvg: attribute href in element ${node.nodeName} pointing to a non-local reference (${href}) is removed`)
node.removeAttributeNS(NS.XLINK, 'href')
}
// Safari crashes on a <use> without a xlink:href, so we just remove the node here
if (node.nodeName === 'use' && !getHref(node)) {
console.warn(`sanitizeSvg: element ${node.nodeName} without a xlink:href is removed`)
node.remove()
return
}
// if the element has attributes pointing to a non-local reference,
// need to remove the attribute
Object.values(['clip-path', 'fill', 'filter', 'marker-end', 'marker-mid', 'marker-start', 'mask', 'stroke'], (attr) => {
let val = node.getAttribute(attr)
if (val) {
val = getUrlFromAttr(val)
// simply check for first character being a '#'
if (val && val[0] !== '#') {
node.setAttribute(attr, '')
console.warn(`sanitizeSvg: attribute ${attr} in element ${node.nodeName} pointing to a non-local reference (${val}) is removed`)
node.removeAttribute(attr)
}
}
})
// recurse to children
i = node.childNodes.length
while (i--) { sanitizeSvg(node.childNodes.item(i)) }
// else (element not supported), remove it
} else {
// remove all children from this node and insert them before this node
// TODO: in the case of animation elements this will hardly ever be correct
console.warn(`sanitizeSvg: element ${node.nodeName} not supported is removed`)
const children = []
while (node.hasChildNodes()) {
children.push(parent.insertBefore(node.firstChild, node))
}
// remove this node from the document altogether
node.remove()
// call sanitizeSvg on each of those children
let i = children.length
while (i--) { sanitizeSvg(children[i]) }
}
}

View File

@@ -0,0 +1,560 @@
/**
* DOM element selection box tools.
* @module select
* @license MIT
*
* @copyright 2010 Alexis Deveria, 2010 Jeff Schiller
*/
import { isWebkit } from '../common/browser.js'
import { getRotationAngle, getBBox, getStrokedBBox } from './utilities.js'
import { transformListToTransform, transformBox, transformPoint, matrixMultiply } from './math.js'
import { NS } from './namespaces'
let svgCanvas
let selectorManager_ // A Singleton
// change radius if touch screen
const gripRadius = window.ontouchstart ? 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 <g> element that holds all visual elements of the selector
this.selectorGroup = svgCanvas.createSVGElement({
element: 'g',
attr: { id: ('selectorGroup' + this.id) }
})
// this holds a reference to the path rect
this.selectorRect = svgCanvas.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.selectorGroup.append(this.selectorRect)
// 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 dataStorage = svgCanvas.getDataStorage()
const selectedBox = this.selectorRect
const mgr = selectorManager_
const selectedGrips = mgr.selectorGrips
const selected = this.selectedElement
const zoom = svgCanvas.getZoom()
let offset = 1 / zoom
const sw = selected.getAttribute('stroke-width')
if (selected.getAttribute('stroke') !== 'none' && !isNaN(sw)) {
offset += (sw / 2)
}
const { tagName } = selected
if (tagName === 'text') {
offset += 2 / zoom
}
// find the transformations applied to the parent of the selected element
const svg = document.createElementNS(NS.SVG, 'svg')
let parentTransformationMatrix = svg.createSVGMatrix()
let currentElt = selected
while (currentElt.parentNode) {
if (currentElt.parentNode && currentElt.parentNode.tagName === 'g' && currentElt.parentNode.transform) {
if (currentElt.parentNode.transform.baseVal.numberOfItems) {
parentTransformationMatrix = matrixMultiply(transformListToTransform(selected.parentNode.transform.baseVal).matrix, parentTransformationMatrix)
}
}
currentElt = currentElt.parentNode
}
// loop and transform our bounding box until we reach our first rotation
const tlist = selected.transform.baseVal
// combines the parent transformation with that of the selected element if necessary
const m = parentTransformationMatrix ? matrixMultiply(parentTransformationMatrix, transformListToTransform(tlist).matrix) : transformListToTransform(tlist).matrix
// This should probably be handled somewhere else, but for now
// it keeps the selection box correctly positioned when zoomed
m.e *= zoom
m.f *= zoom
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' && !dataStorage.has(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
}
}
if (bbox) {
// apply the transforms
const l = bbox.x; const t = bbox.y; const w = bbox.width; const 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 *= zoom
const nbox = transformBox(l * zoom, t * zoom, w * zoom, h * zoom, m)
const { aabox } = nbox
let nbax = aabox.x - offset
let nbay = aabox.y - offset
let nbaw = aabox.width + (offset * 2)
let nbah = aabox.height + (offset * 2)
// now if the shape is rotated, un-rotate it
const cx = nbax + nbaw / 2
const cy = nbay + nbah / 2
const angle = getRotationAngle(selected)
if (angle) {
const rot = svgCanvas.getSvgRoot().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
let miny = tl.y
let maxx = tl.x
let 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'
const xform = angle ? 'rotate(' + [angle, cx, cy].join(',') + ')' : ''
// 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]
}
selectedBox.setAttribute('d', dstr)
this.selectorGroup.setAttribute('transform', xform)
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 {
/**
* Sets up properties and calls `initGroup`.
*/
constructor () {
// this will hold the <g> 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 () {
const dataStorage = svgCanvas.getDataStorage()
// remove old selector parent group if it existed
if (this.selectorParentGroup?.parentNode) {
this.selectorParentGroup.remove()
}
// create parent selector group and add it to svgroot
this.selectorParentGroup = svgCanvas.createSVGElement({
element: 'g',
attr: { id: 'selectorParentGroup' }
})
this.selectorGripsGroup = svgCanvas.createSVGElement({
element: 'g',
attr: { display: 'none' }
})
this.selectorParentGroup.append(this.selectorGripsGroup)
svgCanvas.getSvgRoot().append(this.selectorParentGroup)
this.selectorMap = {}
this.selectors = []
this.rubberBandBox = null
// add the corner grips
Object.keys(this.selectorGrips).forEach((dir) => {
const grip = svgCanvas.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'
}
})
dataStorage.put(grip, 'dir', dir)
dataStorage.put(grip, 'type', 'resize')
this.selectorGrips[dir] = grip
this.selectorGripsGroup.append(grip)
})
// add rotator elems
this.rotateGripConnector =
svgCanvas.createSVGElement({
element: 'line',
attr: {
id: ('selectorGrip_rotateconnector'),
stroke: '#22C',
'stroke-width': '1'
}
})
this.selectorGripsGroup.append(this.rotateGripConnector)
this.rotateGrip =
svgCanvas.createSVGElement({
element: 'circle',
attr: {
id: 'selectorGrip_rotate',
fill: 'lime',
r: gripRadius,
stroke: '#22C',
'stroke-width': 2,
style: `cursor:url(${svgCanvas.curConfig.imgPath}/rotate.svg) 12 12, auto;`
}
})
this.selectorGripsGroup.append(this.rotateGrip)
dataStorage.put(this.rotateGrip, 'type', 'rotate')
if (document.getElementById('canvasBackground')) { return }
const [width, height] = svgCanvas.curConfig.dimensions
const canvasbg = svgCanvas.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 = svgCanvas.createSVGElement({
element: 'rect',
attr: {
width: '100%',
height: '100%',
x: 0,
y: 0,
'stroke-width': 1,
stroke: '#000',
fill: '#FFF',
style: 'pointer-events:none'
}
})
canvasbg.append(rect)
svgCanvas.getSvgRoot().insertBefore(canvasbg, svgCanvas.getSvgContent())
}
/**
*
* @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 (!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]?.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 (!elem) { return }
const N = this.selectors.length
const sel = this.selectorMap[elem.id]
if (!sel?.locked) {
// TODO(codedread): Ensure this exists in this module.
console.warn('WARNING! selector was released but was already unlocked')
}
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) { /* empty fn */ }
break
}
}
}
/**
* @returns {SVGRectElement} The rubberBandBox DOM element. This is the rectangle drawn by
* the user for selecting/zooming
*/
getRubberBandBox () {
if (!this.rubberBandBox) {
this.rubberBandBox =
svgCanvas.createSVGElement({
element: 'rect',
attr: {
id: 'selectorRubberBand',
fill: '#22C',
'fill-opacity': 0.15,
stroke: '#22C',
'stroke-width': 0.5,
display: 'none',
style: 'pointer-events:none'
}
})
this.selectorParentGroup.append(this.rubberBandBox)
}
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#addSVGElementsFromJson} jsonMap
* @returns {SVGElement}
*/
/**
* @function module:select.SVGFactory#svgRoot
* @returns {SVGSVGElement}
*/
/**
* @function module:select.SVGFactory#svgContent
* @returns {SVGSVGElement}
*/
/**
* @function module:select.SVGFactory#getZoom
* @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 = (canvas) => {
svgCanvas = canvas
selectorManager_ = new SelectorManager()
}
/**
* @function module:select.getSelectorManager
* @returns {module:select.SelectorManager} The SelectorManager instance.
*/
export const getSelectorManager = () => selectorManager_

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,482 @@
/**
* Tools for selection.
* @module selection
* @license MIT
* @copyright 2011 Jeff Schiller
*/
import { NS } from './namespaces.js'
import {
getBBox,
getStrokedBBoxDefaultVisible
} from './utilities.js'
import {
transformPoint,
transformListToTransform,
rectsIntersect
} from './math.js'
import * as hstry from './history.js'
import { getClosest } from '../common/util.js'
const { BatchCommand } = hstry
let svgCanvas = null
/**
* @function module:selection.init
* @param {module:selection.selectionContext} selectionContext
* @returns {void}
*/
export const init = (canvas) => {
svgCanvas = canvas
svgCanvas.getMouseTarget = getMouseTargetMethod
svgCanvas.clearSelection = clearSelectionMethod
svgCanvas.addToSelection = addToSelectionMethod
svgCanvas.getIntersectionList = getIntersectionListMethod
svgCanvas.runExtensions = runExtensionsMethod
svgCanvas.groupSvgElem = groupSvgElem
svgCanvas.prepareSvg = prepareSvg
svgCanvas.recalculateAllSelectedDimensions = recalculateAllSelectedDimensions
svgCanvas.setRotationAngle = setRotationAngle
}
/**
* Clears the selection. The 'selected' handler is then optionally called.
* This should really be an intersection applying to all types rather than a union.
* @name module:selection.SvgCanvas#clearSelection
* @type {module:draw.DrawCanvasInit#clearSelection|module:path.EditorContext#clearSelection}
* @fires module:selection.SvgCanvas#event:selected
*/
const clearSelectionMethod = (noCall) => {
const selectedElements = svgCanvas.getSelectedElements()
selectedElements.forEach((elem) => {
if (!elem) {
return
}
svgCanvas.selectorManager.releaseSelector(elem)
})
svgCanvas?.setEmptySelectedElements()
if (!noCall) {
svgCanvas.call('selected', svgCanvas.getSelectedElements())
}
}
/**
* Adds a list of elements to the selection. The 'selected' handler is then called.
* @name module:selection.SvgCanvas#addToSelection
* @type {module:path.EditorContext#addToSelection}
* @fires module:selection.SvgCanvas#event:selected
*/
const addToSelectionMethod = (elemsToAdd, showGrips) => {
const selectedElements = svgCanvas.getSelectedElements()
if (!elemsToAdd.length) {
return
}
// find the first null in our selectedElements array
let firstNull = 0
while (firstNull < selectedElements.length) {
if (selectedElements[firstNull] === null) {
break
}
++firstNull
}
// now add each element consecutively
let i = elemsToAdd.length
while (i--) {
let elem = elemsToAdd[i]
if (!elem || !elem.getBBox) {
continue
}
if (elem.tagName === 'a' && elem.childNodes.length === 1) {
// Make "a" element's child be the selected element
elem = elem.firstChild
}
// if it's not already there, add it
if (!selectedElements.includes(elem)) {
selectedElements[firstNull] = elem
// only the first selectedBBoxes element is ever used in the codebase these days
// if (j === 0) selectedBBoxes[0] = utilsGetBBox(elem);
firstNull++
const sel = svgCanvas.selectorManager.requestSelector(elem)
if (selectedElements.length > 1) {
sel.showGrips(false)
}
}
}
if (!selectedElements.length) {
return
}
svgCanvas.call('selected', selectedElements)
if (selectedElements.length === 1) {
svgCanvas.selectorManager
.requestSelector(selectedElements[0])
.showGrips(showGrips)
}
// make sure the elements are in the correct order
// See: https://www.w3.org/TR/DOM-Level-3-Core/core.html#Node3-compareDocumentPosition
selectedElements.sort((a, b) => {
if (a && b && a.compareDocumentPosition) {
return 3 - (b.compareDocumentPosition(a) & 6)
}
if (!a) {
return 1
}
return 0
})
// Make sure first elements are not null
while (!selectedElements[0]) {
selectedElements.shift(0)
}
}
/**
* @name module:svgcanvas.SvgCanvas#getMouseTarget
* @type {module:path.EditorContext#getMouseTarget}
*/
const getMouseTargetMethod = (evt) => {
if (!evt) {
return null
}
let mouseTarget = evt.target
// if it was a <use>, Opera and WebKit return the SVGElementInstance
if (mouseTarget.correspondingUseElement) {
mouseTarget = mouseTarget.correspondingUseElement
}
// for foreign content, go up until we find the foreignObject
// WebKit browsers set the mouse target to the svgcanvas div
if (
[NS.MATH, NS.HTML].includes(mouseTarget.namespaceURI) &&
mouseTarget.id !== 'svgcanvas'
) {
while (mouseTarget.nodeName !== 'foreignObject') {
mouseTarget = mouseTarget.parentNode
if (!mouseTarget) {
return svgCanvas.getSvgRoot()
}
}
}
// Get the desired mouseTarget with jQuery selector-fu
// If it's root-like, select the root
const currentLayer = svgCanvas.getCurrentDrawing().getCurrentLayer()
const svgRoot = svgCanvas.getSvgRoot()
const container = svgCanvas.getDOMContainer()
const content = svgCanvas.getSvgContent()
if ([svgRoot, container, content, currentLayer].includes(mouseTarget)) {
return svgCanvas.getSvgRoot()
}
// If it's a selection grip, return the grip parent
if (getClosest(mouseTarget.parentNode, '#selectorParentGroup')) {
// While we could instead have just returned mouseTarget,
// this makes it easier to indentify as being a selector grip
return svgCanvas.selectorManager.selectorParentGroup
}
while (
!mouseTarget?.parentNode?.isSameNode(
svgCanvas.getCurrentGroup() || currentLayer
)
) {
mouseTarget = mouseTarget.parentNode
}
return mouseTarget
}
/**
* @typedef {module:svgcanvas.ExtensionMouseDownStatus|module:svgcanvas.ExtensionMouseUpStatus|module:svgcanvas.ExtensionIDsUpdatedStatus|module:locale.ExtensionLocaleData[]|void} module:svgcanvas.ExtensionStatus
* @tutorial ExtensionDocs
*/
/**
* @callback module:svgcanvas.ExtensionVarBuilder
* @param {string} name The name of the extension
* @returns {module:svgcanvas.SvgCanvas#event:ext_addLangData}
*/
/**
* @callback module:svgcanvas.ExtensionNameFilter
* @param {string} name
* @returns {boolean}
*/
/* eslint-disable max-len */
/**
* @todo Consider: Should this return an array by default, so extension results aren't overwritten?
* @todo Would be easier to document if passing in object with key of action and vars as value; could then define an interface which tied both together
* @function module:svgcanvas.SvgCanvas#runExtensions
* @param {"mouseDown"|"mouseMove"|"mouseUp"|"zoomChanged"|"IDsUpdated"|"canvasUpdated"|"toolButtonStateUpdate"|"selectedChanged"|"elementTransition"|"elementChanged"|"langReady"|"langChanged"|"addLangData"|"workareaResized"} action
* @param {module:svgcanvas.SvgCanvas#event:ext_mouseDown|module:svgcanvas.SvgCanvas#event:ext_mouseMove|module:svgcanvas.SvgCanvas#event:ext_mouseUp|module:svgcanvas.SvgCanvas#event:ext_zoomChanged|module:svgcanvas.SvgCanvas#event:ext_IDsUpdated|module:svgcanvas.SvgCanvas#event:ext_canvasUpdated|module:svgcanvas.SvgCanvas#event:ext_toolButtonStateUpdate|module:svgcanvas.SvgCanvas#event:ext_selectedChanged|module:svgcanvas.SvgCanvas#event:ext_elementTransition|module:svgcanvas.SvgCanvas#event:ext_elementChanged|module:svgcanvas.SvgCanvas#event:ext_langReady|module:svgcanvas.SvgCanvas#event:ext_langChanged|module:svgcanvas.SvgCanvas#event:ext_addLangData|module:svgcanvas.SvgCanvas#event:ext_workareaResized|module:svgcanvas.ExtensionVarBuilder} [vars]
* @param {boolean} [returnArray]
* @returns {GenericArray<module:svgcanvas.ExtensionStatus>|module:svgcanvas.ExtensionStatus|false} See {@tutorial ExtensionDocs} on the ExtensionStatus.
*/
/* eslint-enable max-len */
const runExtensionsMethod = (
action,
vars,
returnArray
) => {
let result = returnArray ? [] : false
for (const [name, ext] of Object.entries(svgCanvas.getExtensions())) {
if (typeof vars === 'function') {
vars = vars(name) // ext, action
}
if (ext.eventBased) {
const event = new CustomEvent('svgedit', {
detail: {
action,
vars
}
})
document.dispatchEvent(event)
} else if (ext[action]) {
if (returnArray) {
result.push(ext[action](vars))
} else {
result = ext[action](vars)
}
}
}
return result
}
/**
* Get all elements that have a BBox (excludes `<defs>`, `<title>`, etc).
* Note that 0-opacity, off-screen etc elements are still considered "visible"
* for this function.
* @function module:svgcanvas.SvgCanvas#getVisibleElementsAndBBoxes
* @param {Element} parent - The parent DOM element to search within
* @returns {ElementAndBBox[]} An array with objects that include:
*/
const getVisibleElementsAndBBoxes = (parent) => {
if (!parent) {
const svgContent = svgCanvas.getSvgContent()
parent = svgContent.children // Prevent layers from being included
}
const contentElems = []
const elements = parent.children
Array.from(elements).forEach((elem) => {
if (elem.getBBox) {
contentElems.push({ elem, bbox: getStrokedBBoxDefaultVisible([elem]) })
}
})
return contentElems.reverse()
}
/**
* This method sends back an array or a NodeList full of elements that
* intersect the multi-select rubber-band-box on the currentLayer only.
*
* We brute-force `getIntersectionList` for browsers that do not support it (Firefox).
*
* Reference:
* Firefox does not implement `getIntersectionList()`, see {@link https://bugzilla.mozilla.org/show_bug.cgi?id=501421}.
* @function module:svgcanvas.SvgCanvas#getIntersectionList
* @param {SVGRect} rect
* @returns {Element[]|NodeList} Bbox elements
*/
const getIntersectionListMethod = (rect) => {
const zoom = svgCanvas.getZoom()
if (!svgCanvas.getRubberBox()) {
return null
}
const parent =
svgCanvas.getCurrentGroup() ||
svgCanvas.getCurrentDrawing().getCurrentLayer()
let rubberBBox
if (!rect) {
rubberBBox = getBBox(svgCanvas.getRubberBox())
const bb = svgCanvas.getSvgContent().createSVGRect();
['x', 'y', 'width', 'height', 'top', 'right', 'bottom', 'left'].forEach(
(o) => {
bb[o] = rubberBBox[o] / zoom
}
)
rubberBBox = bb
} else {
rubberBBox = svgCanvas.getSvgContent().createSVGRect()
rubberBBox.x = rect.x
rubberBBox.y = rect.y
rubberBBox.width = rect.width
rubberBBox.height = rect.height
}
const resultList = []
if (svgCanvas.getCurBBoxes().length === 0) {
// Cache all bboxes
svgCanvas.setCurBBoxes(getVisibleElementsAndBBoxes(parent))
}
let i = svgCanvas.getCurBBoxes().length
while (i--) {
const curBBoxes = svgCanvas.getCurBBoxes()
if (!rubberBBox.width) {
continue
}
if (curBBoxes[i].bbox && rectsIntersect(rubberBBox, curBBoxes[i].bbox)) {
resultList.push(curBBoxes[i].elem)
}
}
// addToSelection expects an array, but it's ok to pass a NodeList
// because using square-bracket notation is allowed:
// https://www.w3.org/TR/DOM-Level-2-Core/ecma-script-binding.html
return resultList
}
/**
* @typedef {PlainObject} ElementAndBBox
* @property {Element} elem - The element
* @property {module:utilities.BBoxObject} bbox - The element's BBox as retrieved from `getStrokedBBoxDefaultVisible`
*/
/**
* Wrap an SVG element into a group element, mark the group as 'gsvg'.
* @function module:svgcanvas.SvgCanvas#groupSvgElem
* @param {Element} elem - SVG element to wrap
* @returns {void}
*/
const groupSvgElem = (elem) => {
const dataStorage = svgCanvas.getDataStorage()
const g = document.createElementNS(NS.SVG, 'g')
elem.replaceWith(g)
g.appendChild(elem)
dataStorage.put(g, 'gsvg', elem)
g.id = svgCanvas.getNextId()
}
/**
* Runs the SVG Document through the sanitizer and then updates its paths.
* @function module:svgcanvas.SvgCanvas#prepareSvg
* @param {XMLDocument} newDoc - The SVG DOM document
* @returns {void}
*/
const prepareSvg = (newDoc) => {
svgCanvas.sanitizeSvg(newDoc.documentElement)
// convert paths into absolute commands
const paths = [...newDoc.getElementsByTagNameNS(NS.SVG, 'path')]
paths.forEach((path) => {
const convertedPath = svgCanvas.pathActions.convertPath(path)
path.setAttribute('d', convertedPath)
svgCanvas.pathActions.fixEnd(path)
})
}
/**
* Removes any old rotations if present, prepends a new rotation at the
* transformed center.
* @function module:svgcanvas.SvgCanvas#setRotationAngle
* @param {string|Float} val - The new rotation angle in degrees
* @param {boolean} preventUndo - Indicates whether the action should be undoable or not
* @fires module:svgcanvas.SvgCanvas#event:changed
* @returns {void}
*/
const setRotationAngle = (val, preventUndo) => {
const selectedElements = svgCanvas.getSelectedElements()
// ensure val is the proper type
val = Number.parseFloat(val)
const elem = selectedElements[0]
const oldTransform = elem.getAttribute('transform')
const bbox = getBBox(elem)
const cx = bbox.x + bbox.width / 2
const cy = bbox.y + bbox.height / 2
const tlist = elem.transform.baseVal
// only remove the real rotational transform if present (i.e. at index=0)
if (tlist.numberOfItems > 0) {
const xform = tlist.getItem(0)
if (xform.type === 4) {
tlist.removeItem(0)
}
}
// find Rnc and insert it
if (val !== 0) {
const center = transformPoint(
cx,
cy,
transformListToTransform(tlist).matrix
)
const Rnc = svgCanvas.getSvgRoot().createSVGTransform()
Rnc.setRotate(val, center.x, center.y)
if (tlist.numberOfItems) {
tlist.insertItemBefore(Rnc, 0)
} else {
tlist.appendItem(Rnc)
}
} else if (tlist.numberOfItems === 0) {
elem.removeAttribute('transform')
}
if (!preventUndo) {
// we need to undo it, then redo it so it can be undo-able! :)
// TODO: figure out how to make changes to transform list undo-able cross-browser?
let newTransform = elem.getAttribute('transform')
// new transform is something like: 'rotate(5 1.39625e-8 -11)'
// we round the x so it becomes 'rotate(5 0 -11)'
if (newTransform) {
const newTransformArray = newTransform.split(' ')
const round = (num) => Math.round(Number(num) + Number.EPSILON)
const x = round(newTransformArray[1])
newTransform = `${newTransformArray[0]} ${x} ${newTransformArray[2]}`
}
if (oldTransform) {
elem.setAttribute('transform', oldTransform)
} else {
elem.removeAttribute('transform')
}
svgCanvas.changeSelectedAttribute(
'transform',
newTransform,
selectedElements
)
svgCanvas.call('changed', selectedElements)
}
// const pointGripContainer = getElement('pathpointgrip_container');
// if (elem.nodeName === 'path' && pointGripContainer) {
// pathActions.setPointContainerTransform(elem.getAttribute('transform'));
// }
const selector = svgCanvas.selectorManager.requestSelector(
selectedElements[0]
)
selector.resize()
svgCanvas.getSelector().updateGripCursors(val)
}
/**
* Runs `recalculateDimensions` on the selected elements,
* adding the changes to a single batch command.
* @function module:svgcanvas.SvgCanvas#recalculateAllSelectedDimensions
* @fires module:svgcanvas.SvgCanvas#event:changed
* @returns {void}
*/
const recalculateAllSelectedDimensions = () => {
const text =
svgCanvas.getCurrentResizeMode() === 'none' ? 'position' : 'size'
const batchCmd = new BatchCommand(text)
const selectedElements = svgCanvas.getSelectedElements()
selectedElements.forEach((elem) => {
const cmd = svgCanvas.recalculateDimensions(elem)
if (cmd) {
batchCmd.addSubCommand(cmd)
}
})
if (!batchCmd.isEmpty()) {
svgCanvas.addCommandToHistory(batchCmd)
svgCanvas.call('changed', selectedElements)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
/**
* Tools for SVG Root Element.
* @module svgcanvas
* @license MIT
*
* @copyright 2010 Alexis Deveria, 2010 Jeff Schiller
*/
import { NS } from './namespaces.js'
import { text2xml } from './utilities.js'
/**
* @function module:svgcanvas.svgRootElement svgRootElement the svg node and its children.
* @param {Element} svgdoc - window.document
* @param {ArgumentsArray} dimensions - dimensions of width and height
* @returns {svgRootElement}
*/
export const svgRootElement = function (svgdoc, dimensions) {
return svgdoc.importNode(
text2xml(
`<svg id="svgroot" xmlns="${NS.SVG}" xlinkns="${NS.XLINK}" width="${dimensions[0]}"
height="${dimensions[1]}" x="${dimensions[0]}" y="${dimensions[1]}" overflow="visible">
<defs>
<filter id="canvashadow" filterUnits="objectBoundingBox">
<feGaussianBlur in="SourceAlpha" stdDeviation="4" result="blur"/>
<feOffset in="blur" dx="5" dy="5" result="offsetBlur"/>
<feMerge>
<feMergeNode in="offsetBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
</svg>`
).documentElement,
true
)
}

View File

@@ -0,0 +1,530 @@
/**
* @module text-actions Tools for Text edit functions
* @license MIT
*
* @copyright 2010 Alexis Deveria, 2010 Jeff Schiller
*/
import { NS } from './namespaces.js'
import {
transformPoint, getMatrix
} from './math.js'
import {
assignAttributes, getElement, getBBox as utilsGetBBox
} from './utilities.js'
import {
supportsGoodTextCharPos
} from '../common/browser.js'
let svgCanvas = null
/**
* @function module:text-actions.init
* @param {module:text-actions.svgCanvas} textActionsContext
* @returns {void}
*/
export const init = (canvas) => {
svgCanvas = canvas
}
/**
* Group: Text edit functions
* Functions relating to editing text elements.
* @namespace {PlainObject} textActions
* @memberof module:svgcanvas.SvgCanvas#
*/
export const textActionsMethod = (function () {
let curtext
let textinput
let cursor
let selblock
let blinker
let chardata = []
let textbb // , transbb;
let matrix
let lastX; let lastY
let allowDbl
/**
*
* @param {Integer} index
* @returns {void}
*/
function setCursor (index) {
const empty = (textinput.value === '')
textinput.focus()
if (!arguments.length) {
if (empty) {
index = 0
} else {
if (textinput.selectionEnd !== textinput.selectionStart) { return }
index = textinput.selectionEnd
}
}
const charbb = chardata[index]
if (!empty) {
textinput.setSelectionRange(index, index)
}
cursor = getElement('text_cursor')
if (!cursor) {
cursor = document.createElementNS(NS.SVG, 'line')
assignAttributes(cursor, {
id: 'text_cursor',
stroke: '#333',
'stroke-width': 1
})
getElement('selectorParentGroup').append(cursor)
}
if (!blinker) {
blinker = setInterval(function () {
const show = (cursor.getAttribute('display') === 'none')
cursor.setAttribute('display', show ? 'inline' : 'none')
}, 600)
}
const startPt = ptToScreen(charbb.x, textbb.y)
const endPt = ptToScreen(charbb.x, (textbb.y + textbb.height))
assignAttributes(cursor, {
x1: startPt.x,
y1: startPt.y,
x2: endPt.x,
y2: endPt.y,
visibility: 'visible',
display: 'inline'
})
if (selblock) { selblock.setAttribute('d', '') }
}
/**
*
* @param {Integer} start
* @param {Integer} end
* @param {boolean} skipInput
* @returns {void}
*/
function setSelection (start, end, skipInput) {
if (start === end) {
setCursor(end)
return
}
if (!skipInput) {
textinput.setSelectionRange(start, end)
}
selblock = getElement('text_selectblock')
if (!selblock) {
selblock = document.createElementNS(NS.SVG, 'path')
assignAttributes(selblock, {
id: 'text_selectblock',
fill: 'green',
opacity: 0.5,
style: 'pointer-events:none'
})
getElement('selectorParentGroup').append(selblock)
}
const startbb = chardata[start]
const endbb = chardata[end]
cursor.setAttribute('visibility', 'hidden')
const tl = ptToScreen(startbb.x, textbb.y)
const tr = ptToScreen(startbb.x + (endbb.x - startbb.x), textbb.y)
const bl = ptToScreen(startbb.x, textbb.y + textbb.height)
const br = ptToScreen(startbb.x + (endbb.x - startbb.x), textbb.y + textbb.height)
const dstr = 'M' + tl.x + ',' + tl.y +
' L' + tr.x + ',' + tr.y +
' ' + br.x + ',' + br.y +
' ' + bl.x + ',' + bl.y + 'z'
assignAttributes(selblock, {
d: dstr,
display: 'inline'
})
}
/**
*
* @param {Float} mouseX
* @param {Float} mouseY
* @returns {Integer}
*/
function getIndexFromPoint (mouseX, mouseY) {
// Position cursor here
const pt = svgCanvas.getSvgRoot().createSVGPoint()
pt.x = mouseX
pt.y = mouseY
// No content, so return 0
if (chardata.length === 1) { return 0 }
// Determine if cursor should be on left or right of character
let charpos = curtext.getCharNumAtPosition(pt)
if (charpos < 0) {
// Out of text range, look at mouse coords
charpos = chardata.length - 2
if (mouseX <= chardata[0].x) {
charpos = 0
}
} else if (charpos >= chardata.length - 2) {
charpos = chardata.length - 2
}
const charbb = chardata[charpos]
const mid = charbb.x + (charbb.width / 2)
if (mouseX > mid) {
charpos++
}
return charpos
}
/**
*
* @param {Float} mouseX
* @param {Float} mouseY
* @returns {void}
*/
function setCursorFromPoint (mouseX, mouseY) {
setCursor(getIndexFromPoint(mouseX, mouseY))
}
/**
*
* @param {Float} x
* @param {Float} y
* @param {boolean} apply
* @returns {void}
*/
function setEndSelectionFromPoint (x, y, apply) {
const i1 = textinput.selectionStart
const i2 = getIndexFromPoint(x, y)
const start = Math.min(i1, i2)
const end = Math.max(i1, i2)
setSelection(start, end, !apply)
}
/**
*
* @param {Float} xIn
* @param {Float} yIn
* @returns {module:math.XYObject}
*/
function screenToPt (xIn, yIn) {
const out = {
x: xIn,
y: yIn
}
const zoom = svgCanvas.getZoom()
out.x /= zoom
out.y /= zoom
if (matrix) {
const pt = transformPoint(out.x, out.y, matrix.inverse())
out.x = pt.x
out.y = pt.y
}
return out
}
/**
*
* @param {Float} xIn
* @param {Float} yIn
* @returns {module:math.XYObject}
*/
function ptToScreen (xIn, yIn) {
const out = {
x: xIn,
y: yIn
}
if (matrix) {
const pt = transformPoint(out.x, out.y, matrix)
out.x = pt.x
out.y = pt.y
}
const zoom = svgCanvas.getZoom()
out.x *= zoom
out.y *= zoom
return out
}
/**
*
* @param {Event} evt
* @returns {void}
*/
function selectAll (evt) {
setSelection(0, curtext.textContent.length)
evt.target.removeEventListener('click', selectAll)
}
/**
*
* @param {Event} evt
* @returns {void}
*/
function selectWord (evt) {
if (!allowDbl || !curtext) { return }
const zoom = svgCanvas.getZoom()
const ept = transformPoint(evt.pageX, evt.pageY, svgCanvas.getrootSctm())
const mouseX = ept.x * zoom
const mouseY = ept.y * zoom
const pt = screenToPt(mouseX, mouseY)
const index = getIndexFromPoint(pt.x, pt.y)
const str = curtext.textContent
const first = str.substr(0, index).replace(/[a-z\d]+$/i, '').length
const m = str.substr(index).match(/^[a-z\d]+/i)
const last = (m ? m[0].length : 0) + index
setSelection(first, last)
// Set tripleclick
svgCanvas.$click(evt.target, selectAll)
setTimeout(function () {
evt.target.removeEventListener('click', selectAll)
}, 300)
}
return /** @lends module:svgcanvas.SvgCanvas#textActions */ {
/**
* @param {Element} target
* @param {Float} x
* @param {Float} y
* @returns {void}
*/
select (target, x, y) {
curtext = target
svgCanvas.textActions.toEditMode(x, y)
},
/**
* @param {Element} elem
* @returns {void}
*/
start (elem) {
curtext = elem
svgCanvas.textActions.toEditMode()
},
/**
* @param {external:MouseEvent} evt
* @param {Element} mouseTarget
* @param {Float} startX
* @param {Float} startY
* @returns {void}
*/
mouseDown (evt, mouseTarget, startX, startY) {
const pt = screenToPt(startX, startY)
textinput.focus()
setCursorFromPoint(pt.x, pt.y)
lastX = startX
lastY = startY
// TODO: Find way to block native selection
},
/**
* @param {Float} mouseX
* @param {Float} mouseY
* @returns {void}
*/
mouseMove (mouseX, mouseY) {
const pt = screenToPt(mouseX, mouseY)
setEndSelectionFromPoint(pt.x, pt.y)
},
/**
* @param {external:MouseEvent} evt
* @param {Float} mouseX
* @param {Float} mouseY
* @returns {void}
*/
mouseUp (evt, mouseX, mouseY) {
const pt = screenToPt(mouseX, mouseY)
setEndSelectionFromPoint(pt.x, pt.y, true)
// TODO: Find a way to make this work: Use transformed BBox instead of evt.target
// if (lastX === mouseX && lastY === mouseY
// && !rectsIntersect(transbb, {x: pt.x, y: pt.y, width: 0, height: 0})) {
// svgCanvas.textActions.toSelectMode(true);
// }
if (
evt.target !== curtext &&
mouseX < lastX + 2 &&
mouseX > lastX - 2 &&
mouseY < lastY + 2 &&
mouseY > lastY - 2
) {
svgCanvas.textActions.toSelectMode(true)
}
},
/**
* @function
* @param {Integer} index
* @returns {void}
*/
setCursor,
/**
* @param {Float} x
* @param {Float} y
* @returns {void}
*/
toEditMode (x, y) {
allowDbl = false
svgCanvas.setCurrentMode('textedit')
svgCanvas.selectorManager.requestSelector(curtext).showGrips(false)
// Make selector group accept clicks
/* const selector = */ svgCanvas.selectorManager.requestSelector(curtext) // Do we need this? Has side effect of setting lock, so keeping for now, but next line wasn't being used
// const sel = selector.selectorRect;
svgCanvas.textActions.init()
curtext.style.cursor = 'text'
// if (supportsEditableText()) {
// curtext.setAttribute('editable', 'simple');
// return;
// }
if (!arguments.length) {
setCursor()
} else {
const pt = screenToPt(x, y)
setCursorFromPoint(pt.x, pt.y)
}
setTimeout(function () {
allowDbl = true
}, 300)
},
/**
* @param {boolean|Element} selectElem
* @fires module:svgcanvas.SvgCanvas#event:selected
* @returns {void}
*/
toSelectMode (selectElem) {
svgCanvas.setCurrentMode('select')
clearInterval(blinker)
blinker = null
if (selblock) { selblock.setAttribute('display', 'none') }
if (cursor) { cursor.setAttribute('visibility', 'hidden') }
curtext.style.cursor = 'move'
if (selectElem) {
svgCanvas.clearSelection()
curtext.style.cursor = 'move'
svgCanvas.call('selected', [curtext])
svgCanvas.addToSelection([curtext], true)
}
if (!curtext?.textContent.length) {
// No content, so delete
svgCanvas.deleteSelectedElements()
}
textinput.blur()
curtext = false
// if (supportsEditableText()) {
// curtext.removeAttribute('editable');
// }
},
/**
* @param {Element} elem
* @returns {void}
*/
setInputElem (elem) {
textinput = elem
},
/**
* @returns {void}
*/
clear () {
if (svgCanvas.getCurrentMode() === 'textedit') {
svgCanvas.textActions.toSelectMode()
}
},
/**
* @param {Element} _inputElem Not in use
* @returns {void}
*/
init (_inputElem) {
if (!curtext) { return }
let i; let end
// if (supportsEditableText()) {
// curtext.select();
// return;
// }
if (!curtext.parentNode) {
// Result of the ffClone, need to get correct element
const selectedElements = svgCanvas.getSelectedElements()
curtext = selectedElements[0]
svgCanvas.selectorManager.requestSelector(curtext).showGrips(false)
}
const str = curtext.textContent
const len = str.length
const xform = curtext.getAttribute('transform')
textbb = utilsGetBBox(curtext)
matrix = xform ? getMatrix(curtext) : null
chardata = []
chardata.length = len
textinput.focus()
curtext.removeEventListener('dblclick', selectWord)
curtext.addEventListener('dblclick', selectWord)
if (!len) {
end = { x: textbb.x + (textbb.width / 2), width: 0 }
}
for (i = 0; i < len; i++) {
const start = curtext.getStartPositionOfChar(i)
end = curtext.getEndPositionOfChar(i)
if (!supportsGoodTextCharPos()) {
const zoom = svgCanvas.getZoom()
const offset = svgCanvas.contentW * zoom
start.x -= offset
end.x -= offset
start.x /= zoom
end.x /= zoom
}
// Get a "bbox" equivalent for each character. Uses the
// bbox data of the actual text for y, height purposes
// TODO: Decide if y, width and height are actually necessary
chardata[i] = {
x: start.x,
y: textbb.y, // start.y?
width: end.x - start.x,
height: textbb.height
}
}
// Add a last bbox for cursor at end of text
chardata.push({
x: end.x,
width: 0
})
setSelection(textinput.selectionStart, textinput.selectionEnd, true)
}
}
}())

View File

@@ -0,0 +1,51 @@
// http://ross.posterous.com/2008/08/19/iphone-touch-events-in-javascript/
/**
*
* @param {Event} ev
* @returns {void}
*/
const touchHandler = (ev) => {
ev.preventDefault()
const { changedTouches } = ev
const first = changedTouches[0]
let type = ''
switch (ev.type) {
case 'touchstart': type = 'mousedown'; break
case 'touchmove': type = 'mousemove'; break
case 'touchend': type = 'mouseup'; break
default: return
}
const { screenX, screenY, clientX, clientY } = first
const simulatedEvent = new MouseEvent(type, {
// Event interface
bubbles: true,
cancelable: true,
// UIEvent interface
view: window,
detail: 1, // click count
// MouseEvent interface (customized)
screenX,
screenY,
clientX,
clientY,
// MouseEvent interface (defaults) - these could be removed
ctrlKey: false,
altKey: false,
shiftKey: false,
metaKey: false,
button: 0, // main button (usually left)
relatedTarget: null
})
if (changedTouches.length < 2) {
first.target.dispatchEvent(simulatedEvent)
}
}
export const init = (svgCanvas) => {
svgCanvas.svgroot.addEventListener('touchstart', touchHandler)
svgCanvas.svgroot.addEventListener('touchmove', touchHandler)
svgCanvas.svgroot.addEventListener('touchend', touchHandler)
svgCanvas.svgroot.addEventListener('touchcancel', touchHandler)
}

View File

@@ -0,0 +1,279 @@
/**
* Tools for undo.
* @module undo
* @license MIT
* @copyright 2011 Jeff Schiller
*/
import * as draw from './draw.js'
import * as hstry from './history.js'
import {
getRotationAngle, getBBox as utilsGetBBox, setHref, getStrokedBBoxDefaultVisible
} from './utilities.js'
import {
isGecko
} from '../common/browser.js'
import {
transformPoint, transformListToTransform
} from './math.js'
const {
UndoManager, HistoryEventTypes
} = hstry
let svgCanvas = null
/**
* @function module:undo.init
* @param {module:undo.undoContext} undoContext
* @returns {void}
*/
export const init = (canvas) => {
svgCanvas = canvas
canvas.undoMgr = getUndoManager()
}
export const getUndoManager = () => {
return new UndoManager({
/**
* @param {string} eventType One of the HistoryEvent types
* @param {module:history.HistoryCommand} cmd Fulfills the HistoryCommand interface
* @fires module:undo.SvgCanvas#event:changed
* @returns {void}
*/
handleHistoryEvent (eventType, cmd) {
const EventTypes = HistoryEventTypes
// TODO: handle setBlurOffsets.
if (eventType === EventTypes.BEFORE_UNAPPLY || eventType === EventTypes.BEFORE_APPLY) {
svgCanvas.clearSelection()
} else if (eventType === EventTypes.AFTER_APPLY || eventType === EventTypes.AFTER_UNAPPLY) {
const elems = cmd.elements()
svgCanvas.pathActions.clear()
svgCanvas.call('changed', elems)
const cmdType = cmd.type()
const isApply = (eventType === EventTypes.AFTER_APPLY)
if (cmdType === 'MoveElementCommand') {
const parent = isApply ? cmd.newParent : cmd.oldParent
if (parent === svgCanvas.getSvgContent()) {
draw.identifyLayers()
}
} else if (cmdType === 'InsertElementCommand' || cmdType === 'RemoveElementCommand') {
if (cmd.parent === svgCanvas.getSvgContent()) {
draw.identifyLayers()
}
if (cmdType === 'InsertElementCommand') {
if (isApply) {
svgCanvas.restoreRefElements(cmd.elem)
}
} else if (!isApply) {
svgCanvas.restoreRefElements(cmd.elem)
}
if (cmd.elem?.tagName === 'use') {
svgCanvas.setUseData(cmd.elem)
}
} else if (cmdType === 'ChangeElementCommand') {
// if we are changing layer names, re-identify all layers
if (cmd.elem.tagName === 'title' &&
cmd.elem.parentNode.parentNode === svgCanvas.getSvgContent()
) {
draw.identifyLayers()
}
const values = isApply ? cmd.newValues : cmd.oldValues
// If stdDeviation was changed, update the blur.
if (values.stdDeviation) {
svgCanvas.setBlurOffsets(cmd.elem.parentNode, values.stdDeviation)
}
if (cmd.elem.tagName === 'text') {
const [dx, dy] = [cmd.newValues.x - cmd.oldValues.x,
cmd.newValues.y - cmd.oldValues.y]
const tspans = cmd.elem.children
for (let i = 0; i < tspans.length; i++) {
let x = Number(tspans[i].getAttribute('x'))
let y = Number(tspans[i].getAttribute('y'))
const unapply = (eventType === EventTypes.AFTER_UNAPPLY)
x = unapply ? x - dx : x + dx
y = unapply ? y - dy : y + dy
tspans[i].setAttribute('x', x)
tspans[i].setAttribute('y', y)
}
}
}
}
}
})
}
/**
* Hack for Firefox bugs where text element features aren't updated or get
* messed up. See issue 136 and issue 137.
* This function clones the element and re-selects it.
* @function module:svgcanvas~ffClone
* @todo Test for this bug on load and add it to "support" object instead of
* browser sniffing
* @param {Element} elem - The (text) DOM element to clone
* @returns {Element} Cloned element
*/
export const ffClone = function (elem) {
if (!isGecko()) { return elem }
const clone = elem.cloneNode(true)
elem.before(clone)
elem.remove()
svgCanvas.selectorManager.releaseSelector(elem)
svgCanvas.setSelectedElements(0, clone)
svgCanvas.selectorManager.requestSelector(clone).showGrips(true)
return clone
}
/**
* This function makes the changes to the elements. It does not add the change
* to the history stack.
* @param {string} attr - Attribute name
* @param {string|Float} newValue - String or number with the new attribute value
* @param {Element[]} elems - The DOM elements to apply the change to
* @returns {void}
*/
export const changeSelectedAttributeNoUndoMethod = (attr, newValue, elems) => {
if (attr === 'id') {
// if the user is changing the id, then de-select the element first
// change the ID, then re-select it with the new ID
// as this change can impact other extensions, a 'renamedElement' event is thrown
const elem = elems[0]
const oldId = elem.id
if (oldId !== newValue) {
svgCanvas.clearSelection()
elem.id = newValue
svgCanvas.addToSelection([elem], true)
svgCanvas.call('elementRenamed', { elem, oldId, newId: newValue })
}
return
}
const selectedElements = svgCanvas.getSelectedElements()
const zoom = svgCanvas.getZoom()
if (svgCanvas.getCurrentMode() === 'pathedit') {
// Editing node
svgCanvas.pathActions.moveNode(attr, newValue)
}
elems = elems ?? selectedElements
let i = elems.length
const noXYElems = ['g', 'polyline', 'path']
while (i--) {
let elem = elems[i]
if (!elem) { continue }
// Set x,y vals on elements that don't have them
if ((attr === 'x' || attr === 'y') && noXYElems.includes(elem.tagName)) {
const bbox = getStrokedBBoxDefaultVisible([elem])
const diffX = attr === 'x' ? newValue - bbox.x : 0
const diffY = attr === 'y' ? newValue - bbox.y : 0
svgCanvas.moveSelectedElements(diffX * zoom, diffY * zoom, true)
continue
}
let oldval = attr === '#text' ? elem.textContent : elem.getAttribute(attr)
if (!oldval) { oldval = '' }
if (oldval !== String(newValue)) {
if (attr === '#text') {
// const oldW = utilsGetBBox(elem).width;
elem.textContent = newValue
// FF bug occurs on on rotated elements
if ((/rotate/).test(elem.getAttribute('transform'))) {
elem = ffClone(elem)
}
// Hoped to solve the issue of moving text with text-anchor="start",
// but this doesn't actually fix it. Hopefully on the right track, though. -Fyrd
} else if (attr === '#href') {
setHref(elem, newValue)
} else if (newValue) {
elem.setAttribute(attr, newValue)
} else if (typeof newValue === 'number') {
elem.setAttribute(attr, newValue)
} else {
elem.removeAttribute(attr)
}
// Go into "select" mode for text changes
// NOTE: Important that this happens AFTER elem.setAttribute() or else attributes like
// font-size can get reset to their old value, ultimately by svgEditor.updateContextPanel(),
// after calling textActions.toSelectMode() below
if (svgCanvas.getCurrentMode() === 'textedit' && attr !== '#text' && elem.textContent.length) {
svgCanvas.textActions.toSelectMode(elem)
}
// Use the Firefox ffClone hack for text elements with gradients or
// where other text attributes are changed.
if (isGecko() &&
elem.nodeName === 'text' &&
(/rotate/).test(elem.getAttribute('transform')) &&
(String(newValue).startsWith('url') || (['font-size', 'font-family', 'x', 'y'].includes(attr) && elem.textContent))) {
elem = ffClone(elem)
}
// Timeout needed for Opera & Firefox
// codedread: it is now possible for this function to be called with elements
// that are not in the selectedElements array, we need to only request a
// selector if the element is in that array
if (selectedElements.includes(elem)) {
setTimeout(function () {
// Due to element replacement, this element may no longer
// be part of the DOM
if (!elem.parentNode) { return }
svgCanvas.selectorManager.requestSelector(elem).resize()
}, 0)
}
// if this element was rotated, and we changed the position of this element
// we need to update the rotational transform attribute
const angle = getRotationAngle(elem)
if (angle !== 0 && attr !== 'transform') {
const tlist = elem.transform?.baseVal
let n = tlist.numberOfItems
while (n--) {
const xform = tlist.getItem(n)
if (xform.type === 4) {
// remove old rotate
tlist.removeItem(n)
const box = utilsGetBBox(elem)
const center = transformPoint(
box.x + box.width / 2, box.y + box.height / 2, transformListToTransform(tlist).matrix
)
const cx = center.x
const cy = center.y
const newrot = svgCanvas.getSvgRoot().createSVGTransform()
newrot.setRotate(angle, cx, cy)
tlist.insertItemBefore(newrot, n)
break
}
}
}
} // if oldValue != newValue
} // for each elem
}
/**
* Change the given/selected element and add the original value to the history stack.
* If you want to change all `selectedElements`, ignore the `elems` argument.
* If you want to change only a subset of `selectedElements`, then send the
* subset to this function in the `elems` argument.
* @function module:svgcanvas.SvgCanvas#changeSelectedAttribute
* @param {string} attr - String with the attribute name
* @param {string|Float} val - String or number with the new attribute value
* @param {Element[]} elems - The DOM elements to apply the change to
* @returns {void}
*/
export const changeSelectedAttributeMethod = function (attr, val, elems) {
const selectedElements = svgCanvas.getSelectedElements()
elems = elems || selectedElements
svgCanvas.undoMgr.beginUndoableChange(attr, elems)
changeSelectedAttributeNoUndoMethod(attr, val, elems)
const batchCmd = svgCanvas.undoMgr.finishUndoableChange()
if (!batchCmd.isEmpty()) {
// svgCanvas.addCommandToHistory(batchCmd);
svgCanvas.undoMgr.addCommandToHistory(batchCmd)
}
}

View File

@@ -0,0 +1,260 @@
/**
* Tools for working with units.
* @module units
* @license MIT
*
* @copyright 2010 Alexis Deveria, 2010 Jeff Schiller
*/
const NSSVG = 'http://www.w3.org/2000/svg'
const wAttrs = ['x', 'x1', 'cx', 'rx', 'width']
const hAttrs = ['y', 'y1', 'cy', 'ry', 'height']
const unitAttrs = ['r', 'radius', ...wAttrs, ...hAttrs]
// Container of elements.
let elementContainer_
// Stores mapping of unit type to user coordinates.
let typeMap_ = {}
/**
* @interface module:units.ElementContainer
*/
/**
* @function module:units.ElementContainer#getBaseUnit
* @returns {string} The base unit type of the container ('em')
*/
/**
* @function module:units.ElementContainer#getElement
* @returns {?Element} An element in the container given an id
*/
/**
* @function module:units.ElementContainer#getHeight
* @returns {Float} The container's height
*/
/**
* @function module:units.ElementContainer#getWidth
* @returns {Float} The container's width
*/
/**
* @function module:units.ElementContainer#getRoundDigits
* @returns {Integer} The number of digits number should be rounded to
*/
/**
* @typedef {PlainObject} module:units.TypeMap
* @property {Float} em
* @property {Float} ex
* @property {Float} in
* @property {Float} cm
* @property {Float} mm
* @property {Float} pt
* @property {Float} pc
* @property {Integer} px
* @property {0} %
*/
/**
* Initializes this module.
*
* @function module:units.init
* @param {module:units.ElementContainer} elementContainer - An object implementing the ElementContainer interface.
* @returns {void}
*/
export const init = function (elementContainer) {
elementContainer_ = elementContainer
// Get correct em/ex values by creating a temporary SVG.
const svg = document.createElementNS(NSSVG, 'svg')
document.body.append(svg)
const rect = document.createElementNS(NSSVG, 'rect')
rect.setAttribute('width', '1em')
rect.setAttribute('height', '1ex')
rect.setAttribute('x', '1in')
svg.append(rect)
const bb = rect.getBBox()
svg.remove()
const inch = bb.x
typeMap_ = {
em: bb.width,
ex: bb.height,
in: inch,
cm: inch / 2.54,
mm: inch / 25.4,
pt: inch / 72,
pc: inch / 6,
px: 1,
'%': 0
}
}
/**
* Group: Unit conversion functions.
*/
/**
* @function module:units.getTypeMap
* @returns {module:units.TypeMap} The unit object with values for each unit
*/
export const getTypeMap = () => {
return typeMap_
}
/**
* @typedef {GenericArray} module:units.CompareNumbers
* @property {Integer} length 2
* @property {Float} 0
* @property {Float} 1
*/
/**
* Rounds a given value to a float with number of digits defined in
* `round_digits` of `saveOptions`
*
* @function module:units.shortFloat
* @param {string|Float|module:units.CompareNumbers} val - The value (or Array of two numbers) to be rounded
* @returns {Float|string} If a string/number was given, returns a Float. If an array, return a string
* with comma-separated floats
*/
export const shortFloat = (val) => {
const digits = elementContainer_.getRoundDigits()
if (!isNaN(val)) {
return Number(Number(val).toFixed(digits))
}
if (Array.isArray(val)) {
return shortFloat(val[0]) + ',' + shortFloat(val[1])
}
return Number.parseFloat(val).toFixed(digits) - 0
}
/**
* Converts the number to given unit or baseUnit.
* @function module:units.convertUnit
* @param {string|Float} val
* @param {"em"|"ex"|"in"|"cm"|"mm"|"pt"|"pc"|"px"|"%"} [unit]
* @returns {Float}
*/
export const convertUnit = (val, unit) => {
unit = unit || elementContainer_.getBaseUnit()
// baseVal.convertToSpecifiedUnits(unitNumMap[unit]);
// const val = baseVal.valueInSpecifiedUnits;
// baseVal.convertToSpecifiedUnits(1);
return shortFloat(val / typeMap_[unit])
}
/**
* Sets an element's attribute based on the unit in its current value.
*
* @function module:units.setUnitAttr
* @param {Element} elem - DOM element to be changed
* @param {string} attr - Name of the attribute associated with the value
* @param {string} val - Attribute value to convert
* @returns {void}
*/
export const setUnitAttr = (elem, attr, val) => {
elem.setAttribute(attr, val)
}
const attrsToConvert = {
line: ['x1', 'x2', 'y1', 'y2'],
circle: ['cx', 'cy', 'r'],
ellipse: ['cx', 'cy', 'rx', 'ry'],
foreignObject: ['x', 'y', 'width', 'height'],
rect: ['x', 'y', 'width', 'height'],
image: ['x', 'y', 'width', 'height'],
use: ['x', 'y', 'width', 'height'],
text: ['x', 'y']
}
/**
* Converts all applicable attributes to the configured baseUnit.
* @function module:units.convertAttrs
* @param {Element} element - A DOM element whose attributes should be converted
* @returns {void}
*/
export const convertAttrs = (element) => {
const elName = element.tagName
const unit = elementContainer_.getBaseUnit()
const attrs = attrsToConvert[elName]
if (!attrs) { return }
attrs.forEach((attr) => {
const cur = element.getAttribute(attr)
if (cur && !isNaN(cur)) {
element.setAttribute(attr, (cur / typeMap_[unit]) + unit)
}
})
}
/**
* Converts given values to numbers. Attributes must be supplied in
* case a percentage is given.
*
* @function module:units.convertToNum
* @param {string} attr - Name of the attribute associated with the value
* @param {string} val - Attribute value to convert
* @returns {Float} The converted number
*/
export const convertToNum = (attr, val) => {
// Return a number if that's what it already is
if (!isNaN(val)) { return val - 0 }
if (val.substr(-1) === '%') {
// Deal with percentage, depends on attribute
const num = val.substr(0, val.length - 1) / 100
const width = elementContainer_.getWidth()
const height = elementContainer_.getHeight()
if (wAttrs.includes(attr)) {
return num * width
}
if (hAttrs.includes(attr)) {
return num * height
}
return num * Math.sqrt((width * width) + (height * height)) / Math.sqrt(2)
}
const unit = val.substr(-2)
const num = val.substr(0, val.length - 2)
// Note that this multiplication turns the string into a number
return num * typeMap_[unit]
}
/**
* Check if an attribute's value is in a valid format.
* @function module:units.isValidUnit
* @param {string} attr - The name of the attribute associated with the value
* @param {string} val - The attribute value to check
* @param {Element} selectedElement
* @returns {boolean} Whether the unit is valid
*/
export const isValidUnit = (attr, val, selectedElement) => {
if (unitAttrs.includes(attr)) {
// True if it's just a number
if (!isNaN(val)) {
return true
}
// Not a number, check if it has a valid unit
val = val.toLowerCase()
return Object.keys(typeMap_).some((unit) => {
const re = new RegExp('^-?[\\d\\.]+' + unit + '$')
return re.test(val)
})
}
if (attr === 'id') {
// if we're trying to change the id, make sure it's not already present in the doc
// and the id value is valid.
let result = false
// because getElement() can throw an exception in the case of an invalid id
// (according to https://www.w3.org/TR/xml-id/ IDs must be a NCName)
// we wrap it in an exception and only return true if the ID was valid and
// not already present
try {
const elem = elementContainer_.getElement(val)
result = (!elem || elem === selectedElement)
} catch (e) { console.error(e) }
return result
}
return true
}

File diff suppressed because it is too large Load Diff