separate svgcanvas from svgedit
now you can use directlt svgcanvas. see readme.md * configure workspaces * move svgcanvas to packages folder * move utils to common and paint to svgcanvas * make svgcanvas a dependency of svgedit * update deps * workspaces requires npm 7 at least so the ci needs a new node version * update github actions to V3 * update snapshots using custom svg exports * remove unmaintained cypress snapshot plugin * new github action to add coverage in PR * Update onpushandpullrequest.yml * svgcanvas v7.1.6
This commit is contained in:
156
packages/svgcanvas/blur-event.js
Normal file
156
packages/svgcanvas/blur-event.js
Normal 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()
|
||||
}
|
||||
}
|
||||
43
packages/svgcanvas/clear.js
Normal file
43
packages/svgcanvas/clear.js
Normal 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)
|
||||
}
|
||||
298
packages/svgcanvas/coords.js
Normal file
298
packages/svgcanvas/coords.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
45
packages/svgcanvas/copy-elem.js
Normal file
45
packages/svgcanvas/copy-elem.js
Normal 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
|
||||
}
|
||||
28
packages/svgcanvas/dataStorage.js
Normal file
28
packages/svgcanvas/dataStorage.js
Normal 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
|
||||
68
packages/svgcanvas/demos/canvas.html
Normal file
68
packages/svgcanvas/demos/canvas.html
Normal file
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Minimal demo of SvgCanvas</title>
|
||||
<style> #svgroot { overflow: hidden; } </style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Minimal demo of SvgCanvas</h1>
|
||||
|
||||
<div id="editorContainer"></div>
|
||||
|
||||
<div>
|
||||
[<button onclick="canvas.setMode('select')">Select</button>
|
||||
<button onclick="canvas.setMode('circle')">Circle</button>
|
||||
<button onclick="canvas.setMode('rect')">Rect</button>
|
||||
<button onclick="canvas.setMode('text')">Text</button>]
|
||||
<button onclick="fill('#ff0000')">Fill Red</button>
|
||||
<button onclick="canvas.deleteSelectedElements()">Delete Selected</button>
|
||||
<button onclick="canvas.clear(); canvas.updateCanvas(width, height);">Clear All</button>
|
||||
<button onclick="alert(canvas.getSvgString())">Get SVG</button>
|
||||
</div>
|
||||
<!-- Not visible, but useful -->
|
||||
<input id="text" style="width:0;height:0;opacity: 0"/>
|
||||
<script type="module">
|
||||
/* globals canvas */
|
||||
import SvgCanvas from '@svgedit/svgcanvas'
|
||||
|
||||
const container = document.querySelector('#editorContainer')
|
||||
const { width, height } = { width: 500, height: 300 }
|
||||
window.width = width
|
||||
window.height = height
|
||||
|
||||
const hiddenTextTagId = 'text'
|
||||
|
||||
const config = {
|
||||
initFill: { color: 'FFFFFF', opacity: 1 },
|
||||
initStroke: { color: '000000', opacity: 1, width: 1 },
|
||||
text: { stroke_width: 0, font_size: 24, font_family: 'serif' },
|
||||
initOpacity: 1,
|
||||
imgPath: '/src/editor/images',
|
||||
dimensions: [ width, height ],
|
||||
baseUnit: 'px'
|
||||
}
|
||||
|
||||
window.canvas = new SvgCanvas(container, config)
|
||||
canvas.updateCanvas(width, height)
|
||||
|
||||
window.fill = function (colour) {
|
||||
canvas.getSelectedElements().forEach((el) => {
|
||||
el.setAttribute('fill', colour)
|
||||
})
|
||||
}
|
||||
|
||||
const hiddenTextTag = window.canvas.$id(hiddenTextTagId)
|
||||
window.canvas.textActions.setInputElem(hiddenTextTag)
|
||||
|
||||
const addListenerMulti = (element, eventNames, listener) => {
|
||||
eventNames.split(' ').forEach((eventName) => element.addEventListener(eventName, listener, false))
|
||||
}
|
||||
|
||||
addListenerMulti(hiddenTextTag, 'keyup input', (evt) => {
|
||||
window.canvas.setTextContent(evt.currentTarget.value)
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1064
packages/svgcanvas/draw.js
Normal file
1064
packages/svgcanvas/draw.js
Normal file
File diff suppressed because it is too large
Load Diff
1077
packages/svgcanvas/elem-get-set.js
Normal file
1077
packages/svgcanvas/elem-get-set.js
Normal file
File diff suppressed because it is too large
Load Diff
1388
packages/svgcanvas/event.js
Normal file
1388
packages/svgcanvas/event.js
Normal file
File diff suppressed because it is too large
Load Diff
619
packages/svgcanvas/history.js
Normal file
619
packages/svgcanvas/history.js
Normal 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
|
||||
}
|
||||
}
|
||||
161
packages/svgcanvas/historyrecording.js
Normal file
161
packages/svgcanvas/historyrecording.js
Normal 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
|
||||
110
packages/svgcanvas/json.js
Normal file
110
packages/svgcanvas/json.js
Normal 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
|
||||
}
|
||||
228
packages/svgcanvas/layer.js
Normal file
228
packages/svgcanvas/layer.js
Normal 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
|
||||
221
packages/svgcanvas/math.js
Normal file
221
packages/svgcanvas/math.js
Normal 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
|
||||
}
|
||||
40
packages/svgcanvas/namespaces.js
Normal file
40
packages/svgcanvas/namespaces.js
Normal 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
|
||||
}
|
||||
13
packages/svgcanvas/package-lock.json
generated
Normal file
13
packages/svgcanvas/package-lock.json
generated
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@svgedit/svgcanvas",
|
||||
"version": "7.1.5",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@svgedit/svgcanvas",
|
||||
"version": "7.1.5",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
56
packages/svgcanvas/package.json
Normal file
56
packages/svgcanvas/package.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "@svgedit/svgcanvas",
|
||||
"version": "7.1.6",
|
||||
"description": "SVG Canvas",
|
||||
"main": "dist/svgcanvas.js",
|
||||
"author": "Narendra Sisodiya",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/SVG-Edit/svgedit/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/SVG-Edit/svgedit.git"
|
||||
},
|
||||
"homepage": "https://github.com/SVG-Edit/svgedit#readme",
|
||||
"contributors": [
|
||||
"Pavol Rusnak",
|
||||
"Jeff Schiller",
|
||||
"Vidar Hokstad",
|
||||
"Alexis Deveria",
|
||||
"Brett Zamir",
|
||||
"Fabien Jacq",
|
||||
"OptimistikSAS"
|
||||
],
|
||||
"keywords": [
|
||||
"svg-editor",
|
||||
"javascript",
|
||||
"svg-edit",
|
||||
"svg",
|
||||
"svgcanvas"
|
||||
],
|
||||
"license": "MIT",
|
||||
"browserslist": [
|
||||
"defaults",
|
||||
"not IE 11",
|
||||
"not OperaMini all"
|
||||
],
|
||||
"standard": {
|
||||
"ignore": ["dist"],
|
||||
"globals": [
|
||||
"cy",
|
||||
"assert"
|
||||
],
|
||||
"env": [
|
||||
"mocha",
|
||||
"browser"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rollup -c",
|
||||
"prebuild": "standard . && npm i",
|
||||
"prepublishOnly": "npm run build"
|
||||
}
|
||||
}
|
||||
88
packages/svgcanvas/paint.js
Normal file
88
packages/svgcanvas/paint.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
127
packages/svgcanvas/paste-elem.js
Normal file
127
packages/svgcanvas/paste-elem.js
Normal 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)
|
||||
}
|
||||
1237
packages/svgcanvas/path-actions.js
Normal file
1237
packages/svgcanvas/path-actions.js
Normal file
File diff suppressed because it is too large
Load Diff
1012
packages/svgcanvas/path-method.js
Normal file
1012
packages/svgcanvas/path-method.js
Normal file
File diff suppressed because it is too large
Load Diff
781
packages/svgcanvas/path.js
Normal file
781
packages/svgcanvas/path.js
Normal 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
|
||||
794
packages/svgcanvas/recalculate.js
Normal file
794
packages/svgcanvas/recalculate.js
Normal file
@@ -0,0 +1,794 @@
|
||||
/**
|
||||
* 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 '../../src/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
|
||||
batchCmd.addSubCommand(recalculateDimensions(child))
|
||||
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)
|
||||
}
|
||||
batchCmd.addSubCommand(recalculateDimensions(child))
|
||||
// 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)
|
||||
|
||||
batchCmd.addSubCommand(recalculateDimensions(child))
|
||||
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)
|
||||
}
|
||||
|
||||
batchCmd.addSubCommand(recalculateDimensions(child))
|
||||
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
|
||||
}
|
||||
38
packages/svgcanvas/rollup.config.js
Normal file
38
packages/svgcanvas/rollup.config.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/* eslint-env node */
|
||||
// This rollup script is run by the command:
|
||||
// 'npm run build'
|
||||
|
||||
import rimraf from 'rimraf'
|
||||
import babel from '@rollup/plugin-babel'
|
||||
import { nodeResolve } from '@rollup/plugin-node-resolve'
|
||||
import commonjs from '@rollup/plugin-commonjs'
|
||||
import { terser } from 'rollup-plugin-terser'
|
||||
// import progress from 'rollup-plugin-progress';
|
||||
import filesize from 'rollup-plugin-filesize'
|
||||
|
||||
// remove existing distribution
|
||||
rimraf('./dist', () => console.info('recreating dist'))
|
||||
|
||||
// config for svgedit core module
|
||||
const config = [{
|
||||
input: ['./svgcanvas.js'],
|
||||
output: [
|
||||
{
|
||||
format: 'es',
|
||||
inlineDynamicImports: true,
|
||||
sourcemap: true,
|
||||
file: 'dist/svgcanvas.js'
|
||||
}
|
||||
],
|
||||
plugins: [
|
||||
nodeResolve({
|
||||
browser: true,
|
||||
preferBuiltins: false
|
||||
}),
|
||||
commonjs(),
|
||||
babel({ babelHelpers: 'bundled', exclude: [/\/core-js\//] }), // exclude core-js to avoid circular dependencies.
|
||||
terser({ keep_fnames: true }), // keep_fnames is needed to avoid an error when calling extensions.
|
||||
filesize()
|
||||
]
|
||||
}]
|
||||
export default config
|
||||
252
packages/svgcanvas/sanitize.js
Normal file
252
packages/svgcanvas/sanitize.js
Normal 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]) }
|
||||
}
|
||||
}
|
||||
543
packages/svgcanvas/select.js
Normal file
543
packages/svgcanvas/select.js
Normal file
@@ -0,0 +1,543 @@
|
||||
/**
|
||||
* DOM element selection box tools.
|
||||
* @module select
|
||||
* @license MIT
|
||||
*
|
||||
* @copyright 2010 Alexis Deveria, 2010 Jeff Schiller
|
||||
*/
|
||||
|
||||
import { isWebkit } from '../../src/common/browser.js'
|
||||
import { getRotationAngle, getBBox, getStrokedBBox } from './utilities.js'
|
||||
import { transformListToTransform, transformBox, transformPoint } from './math.js'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// loop and transform our bounding box until we reach our first rotation
|
||||
const tlist = selected.transform.baseVal
|
||||
const m = transformListToTransform(tlist).matrix
|
||||
|
||||
// This should probably be handled somewhere else, but for now
|
||||
// it keeps the selection box correctly positioned when zoomed
|
||||
m.e *= 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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_
|
||||
1297
packages/svgcanvas/selected-elem.js
Normal file
1297
packages/svgcanvas/selected-elem.js
Normal file
File diff suppressed because it is too large
Load Diff
482
packages/svgcanvas/selection.js
Normal file
482
packages/svgcanvas/selection.js
Normal 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 '../../src/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 (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)
|
||||
}
|
||||
}
|
||||
1289
packages/svgcanvas/svg-exec.js
Normal file
1289
packages/svgcanvas/svg-exec.js
Normal file
File diff suppressed because it is too large
Load Diff
1351
packages/svgcanvas/svgcanvas.js
Normal file
1351
packages/svgcanvas/svgcanvas.js
Normal file
File diff suppressed because it is too large
Load Diff
36
packages/svgcanvas/svgroot.js
Normal file
36
packages/svgcanvas/svgroot.js
Normal 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
|
||||
)
|
||||
}
|
||||
530
packages/svgcanvas/text-actions.js
Normal file
530
packages/svgcanvas/text-actions.js
Normal 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 '../../src/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)
|
||||
}
|
||||
}
|
||||
}())
|
||||
51
packages/svgcanvas/touch.js
Normal file
51
packages/svgcanvas/touch.js
Normal 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)
|
||||
}
|
||||
279
packages/svgcanvas/undo.js
Normal file
279
packages/svgcanvas/undo.js
Normal 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 '../../src/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)
|
||||
}
|
||||
}
|
||||
260
packages/svgcanvas/units.js
Normal file
260
packages/svgcanvas/units.js
Normal 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
|
||||
}
|
||||
1214
packages/svgcanvas/utilities.js
Normal file
1214
packages/svgcanvas/utilities.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user