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:
JFH
2022-08-14 15:01:51 +02:00
committed by GitHub
parent 614a361558
commit 43bf93968a
204 changed files with 5206 additions and 20903 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1388
packages/svgcanvas/event.js Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

110
packages/svgcanvas/json.js Normal file
View File

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

228
packages/svgcanvas/layer.js Normal file
View File

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

221
packages/svgcanvas/math.js Normal file
View File

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

View File

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

13
packages/svgcanvas/package-lock.json generated Normal file
View 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"
}
}
}

View 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"
}
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

781
packages/svgcanvas/path.js Normal file
View File

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

View File

@@ -0,0 +1,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
}

View 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

View File

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

View File

@@ -0,0 +1,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_

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,482 @@
/**
* Tools for selection.
* @module selection
* @license MIT
* @copyright 2011 Jeff Schiller
*/
import { NS } from './namespaces.js'
import {
getBBox,
getStrokedBBoxDefaultVisible
} from './utilities.js'
import {
transformPoint,
transformListToTransform,
rectsIntersect
} from './math.js'
import * as hstry from './history.js'
import { getClosest } from '../../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)
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,530 @@
/**
* @module text-actions Tools for Text edit functions
* @license MIT
*
* @copyright 2010 Alexis Deveria, 2010 Jeff Schiller
*/
import { NS } from './namespaces.js'
import {
transformPoint, getMatrix
} from './math.js'
import {
assignAttributes, getElement, getBBox as utilsGetBBox
} from './utilities.js'
import {
supportsGoodTextCharPos
} from '../../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)
}
}
}())

View File

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

279
packages/svgcanvas/undo.js Normal file
View File

@@ -0,0 +1,279 @@
/**
* Tools for undo.
* @module undo
* @license MIT
* @copyright 2011 Jeff Schiller
*/
import * as draw from './draw.js'
import * as hstry from './history.js'
import {
getRotationAngle, getBBox as utilsGetBBox, setHref, getStrokedBBoxDefaultVisible
} from './utilities.js'
import {
isGecko
} from '../../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
View File

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

File diff suppressed because it is too large Load Diff