Jan2026 fixes (#1077)

* fix release script
* fix svgcanvas edge cases
* Update path-actions.js
* add modern js
* update deps
* Update CHANGES.md
This commit is contained in:
JFH
2026-01-10 20:57:06 -03:00
committed by GitHub
parent 9dd1349599
commit 97386d20b5
76 changed files with 11654 additions and 2416 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@svgedit/react-test",
"version": "7.4.0",
"version": "7.4.1",
"description": "",
"main": "dist/index.js",
"scripts": {

View File

@@ -8,60 +8,127 @@
const NSSVG = 'http://www.w3.org/2000/svg'
const { userAgent } = navigator
/**
* Browser capabilities and detection object.
* Uses modern feature detection and lazy evaluation patterns.
*/
class BrowserDetector {
#userAgent = navigator.userAgent
#cachedResults = new Map()
// Note: Browser sniffing should only be used if no other detection method is possible
const isWebkit_ = userAgent.includes('AppleWebKit')
const isGecko_ = userAgent.includes('Gecko/')
const isChrome_ = userAgent.includes('Chrome/')
const isMac_ = userAgent.includes('Macintosh')
// text character positioning (for IE9 and now Chrome)
const supportsGoodTextCharPos_ = (function () {
const svgroot = document.createElementNS(NSSVG, 'svg')
const svgContent = document.createElementNS(NSSVG, 'svg')
document.documentElement.append(svgroot)
svgContent.setAttribute('x', 5)
svgroot.append(svgContent)
const text = document.createElementNS(NSSVG, 'text')
text.textContent = 'a'
svgContent.append(text)
try { // Chrome now fails here
const pos = text.getStartPositionOfChar(0).x
return (pos === 0)
} catch (err) {
return false
} finally {
svgroot.remove()
/**
* Detects if the browser is WebKit-based
* @returns {boolean}
*/
get isWebkit () {
if (!this.#cachedResults.has('isWebkit')) {
this.#cachedResults.set('isWebkit', this.#userAgent.includes('AppleWebKit'))
}
return this.#cachedResults.get('isWebkit')
}
}())
// Public API
/**
* Detects if the browser is Gecko-based
* @returns {boolean}
*/
get isGecko () {
if (!this.#cachedResults.has('isGecko')) {
this.#cachedResults.set('isGecko', this.#userAgent.includes('Gecko/'))
}
return this.#cachedResults.get('isGecko')
}
/**
* Detects if the browser is Chrome
* @returns {boolean}
*/
get isChrome () {
if (!this.#cachedResults.has('isChrome')) {
this.#cachedResults.set('isChrome', this.#userAgent.includes('Chrome/'))
}
return this.#cachedResults.get('isChrome')
}
/**
* Detects if the platform is macOS
* @returns {boolean}
*/
get isMac () {
if (!this.#cachedResults.has('isMac')) {
this.#cachedResults.set('isMac', this.#userAgent.includes('Macintosh'))
}
return this.#cachedResults.get('isMac')
}
/**
* Tests if the browser supports accurate text character positioning
* @returns {boolean}
*/
get supportsGoodTextCharPos () {
if (!this.#cachedResults.has('supportsGoodTextCharPos')) {
this.#cachedResults.set('supportsGoodTextCharPos', this.#testTextCharPos())
}
return this.#cachedResults.get('supportsGoodTextCharPos')
}
/**
* Private method to test text character positioning support
* @returns {boolean}
*/
#testTextCharPos () {
const svgroot = document.createElementNS(NSSVG, 'svg')
const svgContent = document.createElementNS(NSSVG, 'svg')
document.documentElement.append(svgroot)
svgContent.setAttribute('x', 5)
svgroot.append(svgContent)
const text = document.createElementNS(NSSVG, 'text')
text.textContent = 'a'
svgContent.append(text)
try {
const pos = text.getStartPositionOfChar(0).x
return pos === 0
} catch (err) {
return false
} finally {
svgroot.remove()
}
}
}
// Create singleton instance
const browser = new BrowserDetector()
// Export as functions for backward compatibility
/**
* @function module:browser.isWebkit
* @returns {boolean}
*/
export const isWebkit = () => isWebkit_
*/
export const isWebkit = () => browser.isWebkit
/**
* @function module:browser.isGecko
* @returns {boolean}
*/
export const isGecko = () => isGecko_
*/
export const isGecko = () => browser.isGecko
/**
* @function module:browser.isChrome
* @returns {boolean}
*/
export const isChrome = () => isChrome_
*/
export const isChrome = () => browser.isChrome
/**
* @function module:browser.isMac
* @returns {boolean}
*/
export const isMac = () => isMac_
*/
export const isMac = () => browser.isMac
/**
* @function module:browser.supportsGoodTextCharPos
* @returns {boolean}
*/
export const supportsGoodTextCharPos = () => supportsGoodTextCharPos_
*/
export const supportsGoodTextCharPos = () => browser.supportsGoodTextCharPos
// Export browser instance for direct access
export default browser

View File

@@ -0,0 +1,151 @@
/**
* Centralized logging utility for SVGCanvas.
* Provides configurable log levels and the ability to disable logging in production.
* @module logger
* @license MIT
*/
/**
* Log levels in order of severity
* @enum {number}
*/
export const LogLevel = {
NONE: 0,
ERROR: 1,
WARN: 2,
INFO: 3,
DEBUG: 4
}
/**
* Logger configuration
* @type {Object}
*/
const config = {
currentLevel: LogLevel.WARN,
enabled: true,
prefix: '[SVGCanvas]'
}
/**
* Set the logging level
* @param {LogLevel} level - The log level to set
* @returns {void}
*/
export const setLogLevel = (level) => {
if (Object.values(LogLevel).includes(level)) {
config.currentLevel = level
}
}
/**
* Enable or disable logging
* @param {boolean} enabled - Whether logging should be enabled
* @returns {void}
*/
export const setLoggingEnabled = (enabled) => {
config.enabled = Boolean(enabled)
}
/**
* Set the log prefix
* @param {string} prefix - The prefix to use for log messages
* @returns {void}
*/
export const setLogPrefix = (prefix) => {
config.prefix = String(prefix)
}
/**
* Format a log message with prefix and context
* @param {string} message - The log message
* @param {string} [context=''] - Optional context information
* @returns {string} Formatted message
*/
const formatMessage = (message, context = '') => {
const contextStr = context ? ` [${context}]` : ''
return `${config.prefix}${contextStr} ${message}`
}
/**
* Log an error message
* @param {string} message - The error message
* @param {Error|any} [error] - Optional error object or additional data
* @param {string} [context=''] - Optional context (e.g., module name)
* @returns {void}
*/
export const error = (message, error, context = '') => {
if (!config.enabled || config.currentLevel < LogLevel.ERROR) return
console.error(formatMessage(message, context))
if (error) {
console.error(error)
}
}
/**
* Log a warning message
* @param {string} message - The warning message
* @param {any} [data] - Optional additional data
* @param {string} [context=''] - Optional context (e.g., module name)
* @returns {void}
*/
export const warn = (message, data, context = '') => {
if (!config.enabled || config.currentLevel < LogLevel.WARN) return
console.warn(formatMessage(message, context))
if (data !== undefined) {
console.warn(data)
}
}
/**
* Log an info message
* @param {string} message - The info message
* @param {any} [data] - Optional additional data
* @param {string} [context=''] - Optional context (e.g., module name)
* @returns {void}
*/
export const info = (message, data, context = '') => {
if (!config.enabled || config.currentLevel < LogLevel.INFO) return
console.info(formatMessage(message, context))
if (data !== undefined) {
console.info(data)
}
}
/**
* Log a debug message
* @param {string} message - The debug message
* @param {any} [data] - Optional additional data
* @param {string} [context=''] - Optional context (e.g., module name)
* @returns {void}
*/
export const debug = (message, data, context = '') => {
if (!config.enabled || config.currentLevel < LogLevel.DEBUG) return
console.debug(formatMessage(message, context))
if (data !== undefined) {
console.debug(data)
}
}
/**
* Get current logger configuration
* @returns {Object} Current configuration
*/
export const getConfig = () => ({ ...config })
// Default export as namespace
export default {
LogLevel,
setLogLevel,
setLoggingEnabled,
setLogPrefix,
error,
warn,
info,
debug,
getConfig
}

View File

@@ -2,197 +2,138 @@
* @param {any} obj
* @returns {any}
*/
export function findPos (obj) {
let curleft = 0
let curtop = 0
if (obj.offsetParent) {
export const findPos = (obj) => {
let left = 0
let top = 0
if (obj?.offsetParent) {
let current = obj
do {
curleft += obj.offsetLeft
curtop += obj.offsetTop
// eslint-disable-next-line no-cond-assign
} while (obj = obj.offsetParent)
return { left: curleft, top: curtop }
left += current.offsetLeft
top += current.offsetTop
current = current.offsetParent
} while (current)
}
return { left: curleft, top: curtop }
return { left, top }
}
export function isObject (item) {
return (item && typeof item === 'object' && !Array.isArray(item))
}
export const isObject = (item) =>
item && typeof item === 'object' && !Array.isArray(item)
export const mergeDeep = (target, source) => {
const output = { ...target }
export function mergeDeep (target, source) {
const output = Object.assign({}, target)
if (isObject(target) && isObject(source)) {
Object.keys(source).forEach((key) => {
for (const key of Object.keys(source)) {
if (isObject(source[key])) {
if (!(key in target)) { Object.assign(output, { [key]: source[key] }) } else { output[key] = mergeDeep(target[key], source[key]) }
output[key] = key in target
? mergeDeep(target[key], source[key])
: source[key]
} else {
Object.assign(output, { [key]: source[key] })
output[key] = source[key]
}
})
}
}
return output
}
/**
* Get the closest matching element up the DOM tree.
* Uses native Element.closest() when possible for better performance.
* @param {Element} elem Starting element
* @param {String} selector Selector to match against (class, ID, data attribute, or tag)
* @return {Boolean|Element} Returns null if not match found
* @return {Element|null} Returns null if no match found
*/
export function getClosest (elem, selector) {
export const getClosest = (elem, selector) => {
// Use native closest for standard CSS selectors
if (elem?.closest) {
try {
return elem.closest(selector)
} catch (e) {
// Fallback for invalid selectors
}
}
// Fallback implementation for edge cases
const selectorMatcher = {
'.': (el, sel) => el.classList?.contains(sel.slice(1)),
'#': (el, sel) => el.id === sel.slice(1),
'[': (el, sel) => {
const [attr, val] = sel.slice(1, -1).split('=').map(s => s.replace(/["']/g, ''))
return val ? el.getAttribute(attr) === val : el.hasAttribute(attr)
},
tag: (el, sel) => el.tagName?.toLowerCase() === sel
}
const firstChar = selector.charAt(0)
const supports = 'classList' in document.documentElement
let attribute; let value
// If selector is a data attribute, split attribute from value
if (firstChar === '[') {
selector = selector.substr(1, selector.length - 2)
attribute = selector.split('=')
if (attribute.length > 1) {
value = true
attribute[1] = attribute[1].replace(/"/g, '').replace(/'/g, '')
}
}
// Get closest match
for (; elem && elem !== document && elem.nodeType === 1; elem = elem.parentNode) {
// If selector is a class
if (firstChar === '.') {
if (supports) {
if (elem.classList.contains(selector.substr(1))) {
return elem
}
} else {
if (new RegExp('(^|\\s)' + selector.substr(1) + '(\\s|$)').test(elem.className)) {
return elem
}
}
}
// If selector is an ID
if (firstChar === '#') {
if (elem.id === selector.substr(1)) {
return elem
}
}
// If selector is a data attribute
if (firstChar === '[') {
if (elem.hasAttribute(attribute[0])) {
if (value) {
if (elem.getAttribute(attribute[0]) === attribute[1]) {
return elem
}
} else {
return elem
}
}
}
// If selector is a tag
if (elem.tagName.toLowerCase() === selector) {
return elem
}
const matcher = selectorMatcher[firstChar] || selectorMatcher.tag
for (let current = elem; current && current !== document && current.nodeType === 1; current = current.parentNode) {
if (matcher(current, selector)) return current
}
return null
}
/**
* Get all DOM element up the tree that contain a class, ID, or data attribute
* Get all DOM elements up the tree that match a selector
* @param {Node} elem The base element
* @param {String} selector The class, id, data attribute, or tag to look for
* @return {Array} Null if no match
* @return {Array|null} Array of matching elements or null if no match
*/
export function getParents (elem, selector) {
export const getParents = (elem, selector) => {
const parents = []
const matchers = {
'.': (el, sel) => el.classList?.contains(sel.slice(1)),
'#': (el, sel) => el.id === sel.slice(1),
'[': (el, sel) => el.hasAttribute(sel.slice(1, -1)),
tag: (el, sel) => el.tagName?.toLowerCase() === sel
}
const firstChar = selector?.charAt(0)
// Get matches
for (; elem && elem !== document; elem = elem.parentNode) {
if (selector) {
// If selector is a class
if (firstChar === '.') {
if (elem.classList.contains(selector.substr(1))) {
parents.push(elem)
}
}
// If selector is an ID
if (firstChar === '#') {
if (elem.id === selector.substr(1)) {
parents.push(elem)
}
}
// If selector is a data attribute
if (firstChar === '[') {
if (elem.hasAttribute(selector.substr(1, selector.length - 1))) {
parents.push(elem)
}
}
// If selector is a tag
if (elem.tagName.toLowerCase() === selector) {
parents.push(elem)
}
} else {
parents.push(elem)
const matcher = selector ? (matchers[firstChar] || matchers.tag) : null
for (let current = elem; current && current !== document; current = current.parentNode) {
if (!selector || matcher(current, selector)) {
parents.push(current)
}
}
// Return parents if any exist
return parents.length ? parents : null
return parents.length > 0 ? parents : null
}
export function getParentsUntil (elem, parent, selector) {
export const getParentsUntil = (elem, parent, selector) => {
const parents = []
const parentType = parent?.charAt(0)
const selectorType = selector?.charAt(0)
// Get matches
for (; elem && elem !== document; elem = elem.parentNode) {
// Check if parent has been reached
if (parent) {
// If parent is a class
if (parentType === '.') {
if (elem.classList.contains(parent.substr(1))) {
break
}
}
// If parent is an ID
if (parentType === '#') {
if (elem.id === parent.substr(1)) {
break
}
}
// If parent is a data attribute
if (parentType === '[') {
if (elem.hasAttribute(parent.substr(1, parent.length - 1))) {
break
}
}
// If parent is a tag
if (elem.tagName.toLowerCase() === parent) {
break
}
const matchers = {
'.': (el, sel) => el.classList?.contains(sel.slice(1)),
'#': (el, sel) => el.id === sel.slice(1),
'[': (el, sel) => el.hasAttribute(sel.slice(1, -1)),
tag: (el, sel) => el.tagName?.toLowerCase() === sel
}
const getMatcherFn = (selectorStr) => {
if (!selectorStr) return null
const firstChar = selectorStr.charAt(0)
return matchers[firstChar] || matchers.tag
}
const parentMatcher = getMatcherFn(parent)
const selectorMatcher = getMatcherFn(selector)
for (let current = elem; current && current !== document; current = current.parentNode) {
// Check if we've reached the parent boundary
if (parent && parentMatcher?.(current, parent)) {
break
}
if (selector) {
// If selector is a class
if (selectorType === '.') {
if (elem.classList.contains(selector.substr(1))) {
parents.push(elem)
}
}
// If selector is an ID
if (selectorType === '#') {
if (elem.id === selector.substr(1)) {
parents.push(elem)
}
}
// If selector is a data attribute
if (selectorType === '[') {
if (elem.hasAttribute(selector.substr(1, selector.length - 1))) {
parents.push(elem)
}
}
// If selector is a tag
if (elem.tagName.toLowerCase() === selector) {
parents.push(elem)
}
} else {
parents.push(elem)
// Add to results if matches selector (or no selector specified)
if (!selector || selectorMatcher?.(current, selector)) {
parents.push(current)
}
}
// Return parents if any exist
return parents.length ? parents : null
return parents.length > 0 ? parents : null
}

View File

@@ -16,44 +16,92 @@ export const init = (canvas) => {
svgCanvas = canvas
}
/**
* @param {Element} filterElem
* @returns {?Element}
*/
const getFeGaussianBlurElem = (filterElem) => {
if (!filterElem || filterElem.nodeType !== 1) return null
return filterElem.querySelector('feGaussianBlur') || filterElem.firstElementChild
}
/**
* 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) {
export const setBlurNoUndo = (val) => {
const selectedElements = svgCanvas.getSelectedElements()
if (!svgCanvas.getFilter()) {
svgCanvas.setBlur(val)
return
const elem = selectedElements[0]
if (!elem) return
let filter = svgCanvas.getFilter()
if (!filter) {
filter = svgCanvas.getElement(`${elem.id}_blur`)
}
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)')
if (!filter) {
// Create the filter if missing, but don't add history.
const blurElem = svgCanvas.addSVGElementsFromJson({
element: 'feGaussianBlur',
attr: {
in: 'SourceGraphic',
stdDeviation: val
}
})
filter = svgCanvas.addSVGElementsFromJson({
element: 'filter',
attr: {
id: `${elem.id}_blur`
}
})
filter.append(blurElem)
svgCanvas.findDefs().append(filter)
}
const filter = svgCanvas.getFilter()
svgCanvas.changeSelectedAttributeNoUndo('stdDeviation', val, [filter.firstChild])
if (svgCanvas.getFilterHidden() || !elem.getAttribute('filter')) {
svgCanvas.changeSelectedAttributeNoUndo('filter', `url(#${filter.id})`)
svgCanvas.setFilterHidden(false)
}
const blurElem = getFeGaussianBlurElem(filter)
if (!blurElem) {
return
}
svgCanvas.changeSelectedAttributeNoUndo('stdDeviation', val, [blurElem])
svgCanvas.setBlurOffsets(filter, val)
}
}
/**
*
* Finishes the blur change command and adds it to history if not empty.
* @returns {void}
*/
function finishChange () {
const finishChange = () => {
const curCommand = svgCanvas.getCurCommand()
if (!curCommand) {
svgCanvas.setCurCommand(null)
svgCanvas.setFilter(null)
svgCanvas.setFilterHidden(false)
return
}
const bCmd = svgCanvas.undoMgr.finishUndoableChange()
svgCanvas.getCurCommand().addSubCommand(bCmd)
svgCanvas.addCommandToHistory(svgCanvas.getCurCommand())
if (!bCmd.isEmpty()) {
curCommand.addSubCommand(bCmd)
}
if (!curCommand.isEmpty()) {
svgCanvas.addCommandToHistory(curCommand)
}
svgCanvas.setCurCommand(null)
svgCanvas.setFilter(null)
svgCanvas.setFilterHidden(false)
}
/**
@@ -64,7 +112,13 @@ function finishChange () {
* @param {Float} stdDev - The standard deviation value on which to base the offset size
* @returns {void}
*/
export const setBlurOffsets = function (filterElem, stdDev) {
export const setBlurOffsets = (filterElem, stdDev) => {
if (!filterElem || filterElem.nodeType !== 1) {
return
}
stdDev = Number(stdDev) || 0
if (stdDev > 3) {
// TODO: Create algorithm here where size is based on expected blur
svgCanvas.assignAttributes(filterElem, {
@@ -88,7 +142,7 @@ export const setBlurOffsets = function (filterElem, stdDev) {
* @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) {
export const setBlur = (val, complete) => {
const {
InsertElementCommand, ChangeElementCommand, BatchCommand
} = svgCanvas.history
@@ -101,20 +155,33 @@ export const setBlur = function (val, complete) {
// Looks for associated blur, creates one if not found
const elem = selectedElements[0]
if (!elem) {
return
}
const elemId = elem.id
svgCanvas.setFilter(svgCanvas.getElement(elemId + '_blur'))
let filter = svgCanvas.getElement(`${elemId}_blur`)
svgCanvas.setFilter(filter)
val -= 0
val = Number(val) || 0
const batchCmd = new BatchCommand()
const batchCmd = new BatchCommand('Change blur')
// Blur found!
if (svgCanvas.getFilter()) {
if (val === 0) {
svgCanvas.setFilter(null)
if (val === 0) {
const oldFilter = elem.getAttribute('filter')
if (!oldFilter) {
return
}
} else {
// Not found, so create
const changes = { filter: oldFilter }
elem.removeAttribute('filter')
batchCmd.addSubCommand(new ChangeElementCommand(elem, changes))
svgCanvas.addCommandToHistory(batchCmd)
svgCanvas.setFilter(null)
svgCanvas.setFilterHidden(true)
return
}
// Ensure blur filter exists.
if (!filter) {
const newblur = svgCanvas.addSVGElementsFromJson({
element: 'feGaussianBlur',
attr: {
@@ -123,32 +190,29 @@ export const setBlur = function (val, complete) {
}
})
svgCanvas.setFilter(svgCanvas.addSVGElementsFromJson({
filter = svgCanvas.addSVGElementsFromJson({
element: 'filter',
attr: {
id: elemId + '_blur'
id: `${elemId}_blur`
}
}))
svgCanvas.getFilter().append(newblur)
svgCanvas.findDefs().append(svgCanvas.getFilter())
batchCmd.addSubCommand(new InsertElementCommand(svgCanvas.getFilter()))
})
filter.append(newblur)
const defs = svgCanvas.findDefs()
if (defs && defs.ownerDocument === filter.ownerDocument) {
defs.append(filter)
}
svgCanvas.setFilter(filter)
batchCmd.addSubCommand(new InsertElementCommand(filter))
}
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)')
svgCanvas.changeSelectedAttributeNoUndo('filter', `url(#${filter.id})`)
batchCmd.addSubCommand(new ChangeElementCommand(elem, changes))
svgCanvas.setBlurOffsets(svgCanvas.getFilter(), val)
const filter = svgCanvas.getFilter()
svgCanvas.setBlurOffsets(filter, val)
svgCanvas.setCurCommand(batchCmd)
svgCanvas.undoMgr.beginUndoableChange('stdDeviation', [filter ? filter.firstChild : null])
const blurElem = getFeGaussianBlurElem(filter)
svgCanvas.undoMgr.beginUndoableChange('stdDeviation', [blurElem])
if (complete) {
svgCanvas.setBlurNoUndo(val)
finishChange()

View File

@@ -24,7 +24,15 @@ export const clearSvgContentElementInit = () => {
// empty
while (el.firstChild) { el.removeChild(el.firstChild) }
// TODO: Clear out all other attributes first?
// Reset any stale attributes from the previous document.
for (const attr of Array.from(el.attributes)) {
if (attr.namespaceURI) {
el.removeAttributeNS(attr.namespaceURI, attr.localName)
} else {
el.removeAttribute(attr.name)
}
}
const pel = svgCanvas.getSvgRoot()
el.setAttribute('id', 'svgcontent')
el.setAttribute('width', dimensions[0])
@@ -35,9 +43,11 @@ export const clearSvgContentElementInit = () => {
el.setAttribute('xmlns', NS.SVG)
el.setAttribute('xmlns:se', NS.SE)
el.setAttribute('xmlns:xlink', NS.XLINK)
pel.appendChild(el)
if (el.parentNode !== pel) {
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)
el.append(comment)
}

View File

@@ -4,6 +4,8 @@
* @license MIT
*/
import { warn } from '../common/logger.js'
import {
snapToGrid,
assignAttributes,
@@ -22,6 +24,30 @@ import { convertToNum } from './units.js'
let svgCanvas = null
const flipBoxCoordinate = (value) => {
if (value === null || value === undefined) return null
const str = String(value).trim()
if (!str) return null
if (str.endsWith('%')) {
const num = Number.parseFloat(str.slice(0, -1))
return Number.isNaN(num) ? str : `${100 - num}%`
}
const num = Number.parseFloat(str)
return Number.isNaN(num) ? str : String(1 - num)
}
const flipAttributeInBoxUnits = (elem, attr) => {
const value = elem.getAttribute(attr)
if (value === null || value === undefined) return
const flipped = flipBoxCoordinate(value)
if (flipped !== null && flipped !== undefined) {
elem.setAttribute(attr, flipped)
}
}
/**
* Initialize the coords module with the SVG canvas.
* @function module:coords.init
@@ -32,28 +58,9 @@ export const init = canvas => {
svgCanvas = canvas
}
// This is how we map path segment types to their corresponding commands
// Map path segment types to their corresponding commands
const pathMap = [
0,
'z',
'M',
'm',
'L',
'l',
'C',
'c',
'Q',
'q',
'A',
'a',
'H',
'h',
'V',
'v',
'S',
's',
'T',
't'
0, 'z', 'M', 'm', 'L', 'l', 'C', 'c', 'Q', 'q', 'A', 'a', 'H', 'h', 'V', 'v', 'S', 's', 'T', 't'
]
/**
@@ -66,19 +73,21 @@ const pathMap = [
*/
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 scalew = (w) => m.a * w
const scaleh = (h) => m.d * h
const doSnapping =
svgCanvas.getGridSnapping() &&
selected.parentNode.parentNode.localName === 'svg'
svgCanvas.getGridSnapping?.() &&
selected?.parentNode?.parentNode?.localName === 'svg'
const finishUp = () => {
if (doSnapping) {
Object.entries(changes).forEach(([attr, value]) => {
for (const [attr, value] of Object.entries(changes)) {
changes[attr] = snapToGrid(value)
})
}
}
assignAttributes(selected, changes, 1000, true)
}
const box = getBBox(selected)
// Handle gradients and patterns
@@ -86,25 +95,47 @@ export const remapElement = (selected, changes, m) => {
const attrVal = selected.getAttribute(type)
if (attrVal?.startsWith('url(') && (m.a < 0 || m.d < 0)) {
const grad = getRefElem(attrVal)
if (!grad) return
const tagName = (grad.tagName || '').toLowerCase()
if (!['lineargradient', 'radialgradient'].includes(tagName)) return
// userSpaceOnUse gradients do not need object-bounding-box correction.
if (grad.getAttribute('gradientUnits') === 'userSpaceOnUse') return
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 (tagName === 'lineargradient') {
flipAttributeInBoxUnits(newgrad, 'x1')
flipAttributeInBoxUnits(newgrad, 'x2')
} else {
flipAttributeInBoxUnits(newgrad, 'cx')
flipAttributeInBoxUnits(newgrad, 'fx')
}
}
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))
if (tagName === 'lineargradient') {
flipAttributeInBoxUnits(newgrad, 'y1')
flipAttributeInBoxUnits(newgrad, 'y2')
} else {
flipAttributeInBoxUnits(newgrad, 'cy')
flipAttributeInBoxUnits(newgrad, 'fy')
}
}
newgrad.id = svgCanvas.getCurrentDrawing().getNextId()
const drawing = svgCanvas.getCurrentDrawing?.() || svgCanvas.getDrawing?.()
const generatedId = drawing?.getNextId?.() ??
(grad.id ? `${grad.id}-mirrored` : `mirrored-grad-${Date.now()}`)
if (!generatedId) {
warn('Unable to mirror gradient: no drawing context available', null, 'coords')
return
}
newgrad.id = generatedId
findDefs().append(newgrad)
selected.setAttribute(type, 'url(#' + newgrad.id + ')')
selected.setAttribute(type, `url(#${newgrad.id})`)
}
})
@@ -265,25 +296,79 @@ export const remapElement = (selected, changes, m) => {
break
}
case 'path': {
const supportsPathData =
typeof selected.getPathData === 'function' &&
typeof selected.setPathData === 'function'
// Handle path segments
const segList = selected.pathSegList
const len = segList.numberOfItems
const segList = supportsPathData ? null : selected.pathSegList
const len = supportsPathData ? selected.getPathData().length : segList.numberOfItems
const det = m.a * m.d - m.b * m.c
const shouldToggleArcSweep = det < 0
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
if (supportsPathData) {
const pathDataSegments = selected.getPathData()
for (let i = 0; i < len; ++i) {
const seg = pathDataSegments[i]
const t = seg.type
const type = pathMap.indexOf(t)
if (type === -1) continue
const values = seg.values || []
const entry = { type }
switch (t.toUpperCase()) {
case 'M':
case 'L':
case 'T':
[entry.x, entry.y] = values
break
case 'H':
[entry.x] = values
break
case 'V':
[entry.y] = values
break
case 'C':
[entry.x1, entry.y1, entry.x2, entry.y2, entry.x, entry.y] = values
break
case 'S':
[entry.x2, entry.y2, entry.x, entry.y] = values
break
case 'Q':
[entry.x1, entry.y1, entry.x, entry.y] = values
break
case 'A':
[
entry.r1,
entry.r2,
entry.angle,
entry.largeArcFlag,
entry.sweepFlag,
entry.x,
entry.y
] = values
break
default:
break
}
changes.d[i] = entry
}
} else {
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
}
}
}
@@ -302,41 +387,65 @@ export const remapElement = (selected, changes, m) => {
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)
if (seg.x1 !== undefined && seg.y1 !== undefined) {
const pt1 = remap(seg.x1, seg.y1)
seg.x1 = pt1.x
seg.y1 = pt1.y
}
if (seg.x2 !== undefined && seg.y2 !== undefined) {
const pt2 = remap(seg.x2, seg.y2)
seg.x2 = pt2.x
seg.y2 = pt2.y
}
if (type === 10) {
seg.r1 = Math.abs(scalew(seg.r1))
seg.r2 = Math.abs(scaleh(seg.r2))
if (shouldToggleArcSweep) {
seg.sweepFlag = Number(seg.sweepFlag) ? 0 : 1
if (typeof seg.angle === 'number') {
seg.angle = -seg.angle
}
}
}
} else {
// For relative segments, scale x, y, x1, y1, x2, y2
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)
if (seg.x !== undefined) seg.x = scalew(seg.x)
if (seg.y !== undefined) seg.y = scaleh(seg.y)
if (seg.x1 !== undefined) seg.x1 = scalew(seg.x1)
if (seg.y1 !== undefined) seg.y1 = scaleh(seg.y1)
if (seg.x2 !== undefined) seg.x2 = scalew(seg.x2)
if (seg.y2 !== undefined) seg.y2 = scaleh(seg.y2)
if (type === 11) {
seg.r1 = Math.abs(scalew(seg.r1))
seg.r2 = Math.abs(scaleh(seg.r2))
if (shouldToggleArcSweep) {
seg.sweepFlag = Number(seg.sweepFlag) ? 0 : 1
if (typeof seg.angle === 'number') {
seg.angle = -seg.angle
}
}
}
}
}
let dstr = ''
const newPathData = []
changes.d.forEach(seg => {
const { type } = seg
dstr += pathMap[type]
const letter = pathMap[type]
dstr += letter
switch (type) {
case 13: // relative horizontal line (h)
case 12: // absolute horizontal line (H)
dstr += seg.x + ' '
dstr += `${seg.x} `
newPathData.push({ type: letter, values: [seg.x] })
break
case 15: // relative vertical line (v)
case 14: // absolute vertical line (V)
dstr += seg.y + ' '
dstr += `${seg.y} `
newPathData.push({ type: letter, values: [seg.y] })
break
case 3: // relative move (m)
case 5: // relative line (l)
@@ -344,27 +453,21 @@ export const remapElement = (selected, changes, m) => {
case 2: // absolute move (M)
case 4: // absolute line (L)
case 18: // absolute smooth quad (T)
dstr += seg.x + ',' + seg.y + ' '
dstr += `${seg.x},${seg.y} `
newPathData.push({ type: letter, values: [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 +
' '
dstr += `${seg.x1},${seg.y1} ${seg.x2},${seg.y2} ${seg.x},${seg.y} `
newPathData.push({
type: letter,
values: [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 + ' '
dstr += `${seg.x1},${seg.y1} ${seg.x},${seg.y} `
newPathData.push({ type: letter, values: [seg.x1, seg.y1, seg.x, seg.y] })
break
case 11: // relative elliptical arc (a)
case 10: // absolute elliptical arc (A)
@@ -383,17 +486,38 @@ export const remapElement = (selected, changes, m) => {
',' +
seg.y +
' '
newPathData.push({
type: letter,
values: [
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 + ' '
dstr += `${seg.x2},${seg.y2} ${seg.x},${seg.y} `
newPathData.push({ type: letter, values: [seg.x2, seg.y2, seg.x, seg.y] })
break
default:
break
}
})
selected.setAttribute('d', dstr.trim())
const d = dstr.trim()
selected.setAttribute('d', d)
if (supportsPathData) {
try {
selected.setPathData(newPathData)
} catch (e) {
// Fallback to 'd' attribute if setPathData is unavailable or throws.
}
}
break
}
default:

View File

@@ -1,4 +1,5 @@
import { preventClickDefault } from './utilities.js'
import dataStorage from './dataStorage.js'
/**
* Create a clone of an element, updating its ID and its children's IDs when needed.
@@ -7,37 +8,50 @@ import { preventClickDefault } from './utilities.js'
* @param {module:utilities.GetNextID} getNextId - The getter of the next unique ID.
* @returns {Element} The cloned element
*/
export const copyElem = function (el, getNextId) {
export const copyElem = (el, getNextId) => {
const ownerDocument = el?.ownerDocument || document
// 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)
const newEl = ownerDocument.createElementNS(el.namespaceURI, el.nodeName)
Array.from(el.attributes).forEach((attr) => {
if (attr.namespaceURI) {
newEl.setAttributeNS(attr.namespaceURI, attr.name, attr.value)
} else {
newEl.setAttribute(attr.name, 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) {
el.childNodes.forEach((child) => {
switch (child.nodeType) {
case 1: // element node
newEl.append(copyElem(child, getNextId))
break
case 3: // text node
newEl.textContent = child.nodeValue
case 4: // cdata section node
newEl.append(ownerDocument.createTextNode(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') {
if (dataStorage.has(el, 'gsvg')) {
const firstChild = newEl.firstElementChild || newEl.firstChild
if (firstChild) {
dataStorage.put(newEl, 'gsvg', firstChild)
}
}
if (dataStorage.has(el, 'symbol')) {
dataStorage.put(newEl, 'symbol', dataStorage.get(el, 'symbol'))
}
if (dataStorage.has(el, 'ref')) {
dataStorage.put(newEl, 'ref', dataStorage.get(el, 'ref'))
}
if (newEl.tagName === 'image') {
preventClickDefault(newEl)
}

View File

@@ -1,28 +1,91 @@
/** 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())
/**
* A storage solution aimed at replacing jQuery's 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.
*
* @module dataStorage
* @license MIT
*/
class DataStorage {
#storage = new WeakMap()
/**
* Checks if the provided element is a valid WeakMap key.
* @param {any} element - The element to validate
* @returns {boolean} True if the element can be used as a WeakMap key
* @private
*/
#isValidKey = (element) => {
return element !== null && (typeof element === 'object' || typeof element === 'function')
}
/**
* Stores data associated with an element.
* @param {Object|Function} element - The element to store data for
* @param {string} key - The key to store the data under
* @param {any} obj - The data to store
* @returns {void}
*/
put (element, key, obj) {
if (!this.#isValidKey(element)) {
return
}
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)
let elementMap = this.#storage.get(element)
if (!elementMap) {
elementMap = new Map()
this.#storage.set(element, elementMap)
}
elementMap.set(key, obj)
}
/**
* Retrieves data associated with an element.
* @param {Object|Function} element - The element to retrieve data for
* @param {string} key - The key the data was stored under
* @returns {any|undefined} The stored data, or undefined if not found
*/
get (element, key) {
if (!this.#isValidKey(element)) {
return undefined
}
return this.#storage.get(element)?.get(key)
}
/**
* Checks if an element has data stored under a specific key.
* @param {Object|Function} element - The element to check
* @param {string} key - The key to check for
* @returns {boolean} True if the element has data stored under the key
*/
has (element, key) {
if (!this.#isValidKey(element)) {
return false
}
return this.#storage.get(element)?.has(key) === true
}
/**
* Removes data associated with an element.
* @param {Object|Function} element - The element to remove data from
* @param {string} key - The key the data was stored under
* @returns {boolean} True if the data was removed, false otherwise
*/
remove (element, key) {
if (!this.#isValidKey(element)) {
return false
}
const elementMap = this.#storage.get(element)
if (!elementMap) {
return false
}
const ret = elementMap.delete(key)
if (elementMap.size === 0) {
this.#storage.delete(element)
}
return ret
}
}
// Export singleton instance for backward compatibility
const dataStorage = new DataStorage()
export default dataStorage

View File

@@ -12,6 +12,7 @@ import { NS } from './namespaces.js'
import { toXml, getElement } from './utilities.js'
import { copyElem as utilCopyElem } from './copy-elem.js'
import { getParentsUntil } from '../common/util.js'
import { warn } from '../common/logger.js'
const visElems =
'a,circle,ellipse,foreignObject,g,image,line,path,polygon,polyline,rect,svg,text,tspan,use'.split(
@@ -32,7 +33,7 @@ let disabledElems = []
* @param {module:history.HistoryRecordingService} [hrService] - if exists, return it instead of creating a new service.
* @returns {module:history.HistoryRecordingService}
*/
function historyRecordingService (hrService) {
const historyRecordingService = (hrService) => {
return hrService || new HistoryRecordingService(svgCanvas.undoMgr)
}
@@ -41,18 +42,18 @@ function historyRecordingService (hrService) {
* @param {Element} group The group element to search in.
* @returns {string} The layer name or empty string.
*/
function findLayerNameInGroup (group) {
const findLayerNameInGroup = (group) => {
const sel = group.querySelector('title')
return sel ? sel.textContent : ''
}
/**
* Verify the classList of the given element : if the classList contains 'layer', return true, then return false
* Checks if the given element's classList contains 'layer'.
*
* @param {Element} element - The given element
* @returns {boolean} Return true if the classList contains 'layer' then return false
* @returns {boolean} True if the classList contains 'layer', false otherwise
*/
function isLayerElement (element) {
const isLayerElement = (element) => {
return element.classList.contains('layer')
}
@@ -61,7 +62,7 @@ function isLayerElement (element) {
* @param {string[]} existingLayerNames - Existing layer names.
* @returns {string} - The new name.
*/
function getNewLayerName (existingLayerNames) {
const getNewLayerName = (existingLayerNames) => {
let i = 1
while (existingLayerNames.includes(`Layer ${i}`)) {
i++
@@ -163,10 +164,10 @@ export class Drawing {
getElem_ (id) {
if (this.svgElem_.querySelector) {
// querySelector lookup
return this.svgElem_.querySelector('#' + id)
return this.svgElem_.querySelector(`#${id}`)
}
// jQuery lookup: twice as slow as xpath in FF
return this.svgElem_.querySelector('[id=' + id + ']')
return this.svgElem_.querySelector(`[id=${id}]`)
}
/**
@@ -209,7 +210,7 @@ export class Drawing {
*/
getId () {
return this.nonce_
? this.idPrefix + this.nonce_ + '_' + this.obj_num
? `${this.idPrefix}${this.nonce_}_${this.obj_num}`
: this.idPrefix + this.obj_num
}
@@ -258,12 +259,16 @@ export class Drawing {
*/
releaseId (id) {
// confirm if this is a valid id for this Document, else return false
const front = this.idPrefix + (this.nonce_ ? this.nonce_ + '_' : '')
const front = `${this.idPrefix}${this.nonce_ ? `${this.nonce_}_` : ''}`
if (typeof id !== 'string' || !id.startsWith(front)) {
return false
}
// extract the obj_num of this id
const num = Number.parseInt(id.substr(front.length))
const suffix = id.slice(front.length)
if (!/^[0-9]+$/.test(suffix)) {
return false
}
const num = Number.parseInt(suffix)
// if we didn't get a positive number or we already released this number
// then return false.
@@ -612,6 +617,10 @@ export class Drawing {
// Clone children
const children = [...currentGroup.childNodes]
children.forEach(child => {
if (child.nodeType !== 1) {
group.append(child.cloneNode(true))
return
}
if (child.localName === 'title') {
return
}
@@ -710,10 +719,7 @@ export class Drawing {
* @returns {Element}
*/
copyElem (el) {
const that = this
const getNextIdClosure = function () {
return that.getNextId()
}
const getNextIdClosure = () => this.getNextId()
return utilCopyElem(el, getNextIdClosure)
}
}
@@ -726,7 +732,7 @@ export class Drawing {
* @param {draw.Drawing} currentDrawing
* @returns {void}
*/
export const randomizeIds = function (enableRandomization, currentDrawing) {
export const randomizeIds = (enableRandomization, currentDrawing) => {
randIds =
enableRandomization === false
? RandomizeModes.NEVER_RANDOMIZE
@@ -868,6 +874,10 @@ export const cloneLayer = (name, hrService) => {
const newLayer = svgCanvas
.getCurrentDrawing()
.cloneLayer(name, historyRecordingService(hrService))
if (!newLayer) {
warn('cloneLayer: no layer returned', null, 'draw')
return
}
svgCanvas.clearSelection()
leaveContext()
@@ -883,15 +893,19 @@ export const cloneLayer = (name, hrService) => {
*/
export const deleteCurrentLayer = () => {
const { BatchCommand, RemoveElementCommand } = svgCanvas.history
let currentLayer = svgCanvas.getCurrentDrawing().getCurrentLayer()
const currentLayer = svgCanvas.getCurrentDrawing().getCurrentLayer()
if (!currentLayer) {
warn('deleteCurrentLayer: no current layer', null, 'draw')
return false
}
const { nextSibling } = currentLayer
const parent = currentLayer.parentNode
currentLayer = svgCanvas.getCurrentDrawing().deleteCurrentLayer()
if (currentLayer) {
const removedLayer = svgCanvas.getCurrentDrawing().deleteCurrentLayer()
if (removedLayer && parent) {
const batchCmd = new BatchCommand('Delete Layer')
// store in our Undo History
batchCmd.addSubCommand(
new RemoveElementCommand(currentLayer, nextSibling, parent)
new RemoveElementCommand(removedLayer, nextSibling, parent)
)
svgCanvas.addCommandToHistory(batchCmd)
svgCanvas.clearSelection()
@@ -978,20 +992,19 @@ export const setCurrentLayerPosition = newPos => {
export const setLayerVisibility = (layerName, bVisible) => {
const { ChangeElementCommand } = svgCanvas.history
const drawing = svgCanvas.getCurrentDrawing()
const prevVisibility = drawing.getLayerVisibility(layerName)
const layer = drawing.setLayerVisibility(layerName, bVisible)
if (layer) {
const oldDisplay = prevVisibility ? 'inline' : 'none'
svgCanvas.addCommandToHistory(
new ChangeElementCommand(
layer,
{ display: oldDisplay },
'Layer Visibility'
)
)
} else {
const layerGroup = drawing.getLayerByName(layerName)
if (!layerGroup) {
warn('setLayerVisibility: layer not found', layerName, 'draw')
return false
}
const oldDisplay = layerGroup.getAttribute('display')
const layer = drawing.setLayerVisibility(layerName, bVisible)
if (!layer) {
return false
}
svgCanvas.addCommandToHistory(
new ChangeElementCommand(layer, { display: oldDisplay }, 'Layer Visibility')
)
if (layer === drawing.getCurrentLayer()) {
svgCanvas.clearSelection()
@@ -1024,18 +1037,21 @@ export const moveSelectedToLayer = layerName => {
let i = selElems.length
while (i--) {
const elem = selElems[i]
if (!elem) {
const oldLayer = elem?.parentNode
if (!elem || !oldLayer || oldLayer === layer) {
continue
}
const oldNextSibling = elem.nextSibling
// TODO: this is pretty brittle!
const oldLayer = elem.parentNode
layer.append(elem)
batchCmd.addSubCommand(
new MoveElementCommand(elem, oldNextSibling, oldLayer)
)
}
if (batchCmd.isEmpty()) {
warn('moveSelectedToLayer: no elements moved', null, 'draw')
return false
}
svgCanvas.addCommandToHistory(batchCmd)
return true
@@ -1081,12 +1097,13 @@ export const leaveContext = () => {
for (let i = 0; i < len; i++) {
const elem = disabledElems[i]
const orig = dataStorage.get(elem, 'orig_opac')
if (orig !== 1) {
elem.setAttribute('opacity', orig)
} else {
if (orig === null || orig === undefined) {
elem.removeAttribute('opacity')
} else {
elem.setAttribute('opacity', orig)
}
elem.setAttribute('style', 'pointer-events: inherit')
dataStorage.remove(elem, 'orig_opac')
}
disabledElems = []
svgCanvas.clearSelection(true)
@@ -1106,7 +1123,22 @@ export const setContext = elem => {
const dataStorage = svgCanvas.getDataStorage()
leaveContext()
if (typeof elem === 'string') {
elem = getElement(elem)
const id = elem
try {
elem = getElement(id)
} catch (e) {
elem = null
}
if (!elem && typeof document !== 'undefined') {
const candidate = document.getElementById(id)
const svgContent = svgCanvas.getSvgContent?.()
elem = candidate && (svgContent ? svgContent.contains(candidate) : true)
? candidate
: null
}
}
if (!elem) {
return
}
// Edit inside this group
@@ -1114,8 +1146,14 @@ export const setContext = elem => {
// Disable other elements
const parentsUntil = getParentsUntil(elem, '#svgcontent')
if (!parentsUntil) {
return
}
const siblings = []
parentsUntil.forEach(function (parent) {
if (!parent?.parentNode) {
return
}
const elements = Array.prototype.filter.call(
parent.parentNode.children,
function (child) {
@@ -1128,9 +1166,11 @@ export const setContext = elem => {
})
siblings.forEach(function (curthis) {
const opac = curthis.getAttribute('opacity') || 1
// Store the original's opacity
dataStorage.put(curthis, 'orig_opac', opac)
const origOpacity = curthis.getAttribute('opacity')
dataStorage.put(curthis, 'orig_opac', origOpacity)
const parsedOpacity = Number.parseFloat(origOpacity)
const opac = Number.isFinite(parsedOpacity) ? parsedOpacity : 1
curthis.setAttribute('opacity', opac * 0.33)
curthis.setAttribute('style', 'pointer-events: none')
disabledElems.push(curthis)

View File

@@ -122,25 +122,36 @@ const setGroupTitleMethod = (val) => {
const selectedElements = svgCanvas.getSelectedElements()
const dataStorage = svgCanvas.getDataStorage()
let elem = selectedElements[0]
if (!elem) { return }
if (dataStorage.has(elem, 'gsvg')) {
elem = dataStorage.get(elem, 'gsvg')
} else if (dataStorage.has(elem, 'symbol')) {
elem = dataStorage.get(elem, 'symbol')
}
const ts = elem.querySelectorAll('title')
if (!elem) { return }
const batchCmd = new BatchCommand('Set Label')
let title
let title = null
for (const child of elem.childNodes) {
if (child.nodeName === 'title') {
title = child
break
}
}
if (val.length === 0) {
if (!title) { return }
// Remove title element
const tsNextSibling = ts.nextSibling
batchCmd.addSubCommand(new RemoveElementCommand(ts[0], tsNextSibling, elem))
ts.remove()
} else if (ts.length) {
const { nextSibling } = title
title.remove()
batchCmd.addSubCommand(new RemoveElementCommand(title, nextSibling, elem))
} else if (title) {
// Change title contents
title = ts[0]
batchCmd.addSubCommand(new ChangeElementCommand(title, { '#text': title.textContent }))
const oldText = title.textContent
if (oldText === val) { return }
title.textContent = val
batchCmd.addSubCommand(new ChangeElementCommand(title, { '#text': oldText }))
} else {
// Add title element
title = svgCanvas.getDOMDocument().createElementNS(NS.SVG, 'title')
@@ -149,7 +160,9 @@ const setGroupTitleMethod = (val) => {
batchCmd.addSubCommand(new InsertElementCommand(title))
}
svgCanvas.addCommandToHistory(batchCmd)
if (!batchCmd.isEmpty()) {
svgCanvas.addCommandToHistory(batchCmd)
}
}
/**
@@ -160,33 +173,44 @@ const setGroupTitleMethod = (val) => {
* @returns {void}
*/
const setDocumentTitleMethod = (newTitle) => {
const { ChangeElementCommand, BatchCommand } = svgCanvas.history
const childs = svgCanvas.getSvgContent().childNodes
let docTitle = false; let oldTitle = ''
const {
InsertElementCommand, RemoveElementCommand,
ChangeElementCommand, BatchCommand
} = svgCanvas.history
const svgContent = svgCanvas.getSvgContent()
const batchCmd = new BatchCommand('Change Image Title')
for (const child of childs) {
/** @type {Element|null} */
let docTitle = null
for (const child of svgContent.childNodes) {
if (child.nodeName === 'title') {
docTitle = child
oldTitle = docTitle.textContent
break
}
}
if (!docTitle) {
docTitle = svgCanvas.getDOMDocument().createElementNS(NS.SVG, 'title')
svgCanvas.getSvgContent().insertBefore(docTitle, svgCanvas.getSvgContent().firstChild)
// svgContent.firstChild.before(docTitle); // Ok to replace above with this?
}
if (newTitle.length) {
if (!docTitle) {
if (!newTitle.length) { return }
docTitle = svgCanvas.getDOMDocument().createElementNS(NS.SVG, 'title')
docTitle.textContent = newTitle
svgContent.insertBefore(docTitle, svgContent.firstChild)
batchCmd.addSubCommand(new InsertElementCommand(docTitle))
} else if (newTitle.length) {
const oldTitle = docTitle.textContent
if (oldTitle === newTitle) { return }
docTitle.textContent = newTitle
batchCmd.addSubCommand(new ChangeElementCommand(docTitle, { '#text': oldTitle }))
} else {
// No title given, so element is not necessary
const { nextSibling } = docTitle
docTitle.remove()
batchCmd.addSubCommand(new RemoveElementCommand(docTitle, nextSibling, svgContent))
}
if (!batchCmd.isEmpty()) {
svgCanvas.addCommandToHistory(batchCmd)
}
batchCmd.addSubCommand(new ChangeElementCommand(docTitle, { '#text': oldTitle }))
svgCanvas.addCommandToHistory(batchCmd)
}
/**
@@ -201,7 +225,6 @@ const setDocumentTitleMethod = (newTitle) => {
*/
const setResolutionMethod = (x, y) => {
const { ChangeElementCommand, BatchCommand } = svgCanvas.history
const zoom = svgCanvas.getZoom()
const res = svgCanvas.getResolution()
const { w, h } = res
let batchCmd
@@ -220,8 +243,10 @@ const setResolutionMethod = (x, y) => {
dy.push(bbox.y * -1)
})
const cmd = svgCanvas.moveSelectedElements(dx, dy, true)
batchCmd.addSubCommand(cmd)
const cmd = svgCanvas.moveSelectedElements(dx, dy, false)
if (cmd) {
batchCmd.addSubCommand(cmd)
}
svgCanvas.clearSelection()
x = Math.round(bbox.width)
@@ -230,26 +255,25 @@ const setResolutionMethod = (x, y) => {
return false
}
}
if (x !== w || y !== h) {
const newW = convertToNum('width', x)
const newH = convertToNum('height', y)
if (newW !== w || newH !== h) {
if (!batchCmd) {
batchCmd = new BatchCommand('Change Image Dimensions')
}
const svgContent = svgCanvas.getSvgContent()
const oldViewBox = svgContent.getAttribute('viewBox')
x = convertToNum('width', x)
y = convertToNum('height', y)
svgContent.setAttribute('width', newW)
svgContent.setAttribute('height', newH)
svgCanvas.getSvgContent().setAttribute('width', x)
svgCanvas.getSvgContent().setAttribute('height', y)
svgCanvas.contentW = x
svgCanvas.contentH = y
batchCmd.addSubCommand(new ChangeElementCommand(svgCanvas.getSvgContent(), { width: w, height: h }))
svgCanvas.getSvgContent().setAttribute('viewBox', [0, 0, x / zoom, y / zoom].join(' '))
batchCmd.addSubCommand(new ChangeElementCommand(svgCanvas.getSvgContent(), { viewBox: ['0 0', w, h].join(' ') }))
svgCanvas.contentW = newW
svgCanvas.contentH = newH
svgContent.setAttribute('viewBox', [0, 0, newW, newH].join(' '))
batchCmd.addSubCommand(new ChangeElementCommand(svgContent, { width: w, height: h, viewBox: oldViewBox }))
svgCanvas.addCommandToHistory(batchCmd)
svgCanvas.call('changed', [svgCanvas.getSvgContent()])
svgCanvas.call('changed', [svgContent])
}
return true
}
@@ -286,20 +310,36 @@ const setBBoxZoomMethod = (val, editorW, editorH) => {
let spacer = 0.85
let bb
const calcZoom = (bb) => {
if (!bb) { return false }
if (!bb) { return undefined }
if (!Number.isFinite(editorW) || !Number.isFinite(editorH) || editorW <= 0 || editorH <= 0) {
return undefined
}
if (!Number.isFinite(bb.width) || !Number.isFinite(bb.height) || bb.width <= 0 || bb.height <= 0) {
return undefined
}
const wZoom = Math.round((editorW / bb.width) * 100 * spacer) / 100
const hZoom = Math.round((editorH / bb.height) * 100 * spacer) / 100
const zoom = Math.min(wZoom, hZoom)
if (!Number.isFinite(zoom) || zoom <= 0) {
return undefined
}
svgCanvas.setZoom(zoom)
return { zoom, bbox: bb }
}
if (typeof val === 'object') {
if (val && typeof val === 'object') {
bb = val
if (bb.width === 0 || bb.height === 0) {
const newzoom = bb.zoom ? bb.zoom : zoom * bb.factor
svgCanvas.setZoom(newzoom)
return { zoom, bbox: bb }
let newzoom = zoom
if (Number.isFinite(bb.zoom) && bb.zoom > 0) {
newzoom = bb.zoom
} else if (Number.isFinite(bb.factor) && bb.factor > 0) {
newzoom = zoom * bb.factor
}
if (Number.isFinite(newzoom) && newzoom > 0) {
svgCanvas.setZoom(newzoom)
}
return { zoom: newzoom, bbox: bb }
}
return calcZoom(bb)
}
@@ -307,12 +347,7 @@ const setBBoxZoomMethod = (val, editorW, editorH) => {
switch (val) {
case 'selection': {
if (!selectedElements[0]) { return undefined }
const selectedElems = selectedElements.map((n, _) => {
if (n) {
return n
}
return undefined
})
const selectedElems = selectedElements.filter(Boolean)
bb = getStrokedBBoxDefaultVisible(selectedElems)
break
} case 'canvas': {
@@ -340,13 +375,22 @@ const setBBoxZoomMethod = (val, editorW, editorH) => {
* @returns {void}
*/
const setZoomMethod = (zoomLevel) => {
if (!Number.isFinite(zoomLevel) || zoomLevel <= 0) {
return
}
const selectedElements = svgCanvas.getSelectedElements()
const res = svgCanvas.getResolution()
svgCanvas.getSvgContent().setAttribute('viewBox', '0 0 ' + res.w / zoomLevel + ' ' + res.h / zoomLevel)
const w = res.w / zoomLevel
const h = res.h / zoomLevel
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
return
}
svgCanvas.getSvgContent().setAttribute('viewBox', `0 0 ${w} ${h}`)
svgCanvas.setZoom(zoomLevel)
selectedElements.forEach((elem) => {
if (!elem) { return }
svgCanvas.selectorManager.requestSelector(elem).resize()
const selector = svgCanvas.selectorManager.requestSelector(elem)
selector && selector.resize()
})
svgCanvas.pathActions.zoomChange()
svgCanvas.runExtensions('zoomChanged', zoomLevel)
@@ -364,7 +408,7 @@ const setZoomMethod = (zoomLevel) => {
const setColorMethod = (type, val, preventUndo) => {
const selectedElements = svgCanvas.getSelectedElements()
svgCanvas.setCurShape(type, val)
svgCanvas.setCurProperties(type + '_paint', { type: 'solidColor' })
svgCanvas.setCurProperties(`${type}_paint`, { type: 'solidColor' })
const elems = []
/**
*
@@ -408,10 +452,11 @@ const setColorMethod = (type, val, preventUndo) => {
* @returns {void}
*/
const setGradientMethod = (type) => {
if (!svgCanvas.getCurProperties(type + '_paint') ||
svgCanvas.getCurProperties(type + '_paint').type === 'solidColor') { return }
if (!svgCanvas.getCurProperties(`${type}_paint`) ||
svgCanvas.getCurProperties(`${type}_paint`).type === 'solidColor') { return }
const canvas = svgCanvas
let grad = canvas[type + 'Grad']
if (!grad) { return }
// find out if there is a duplicate gradient already in the defs
const duplicateGrad = findDuplicateGradient(grad)
const defs = findDefs()
@@ -425,7 +470,7 @@ const setGradientMethod = (type) => {
} else { // use existing gradient
grad = duplicateGrad
}
svgCanvas.setColor(type, 'url(#' + grad.id + ')')
svgCanvas.setColor(type, `url(#${grad.id})`)
}
/**
@@ -435,12 +480,21 @@ const setGradientMethod = (type) => {
* @returns {SVGGradientElement} The existing gradient if found, `null` if not
*/
const findDuplicateGradient = (grad) => {
if (!grad) {
return null
}
if (!['linearGradient', 'radialGradient'].includes(grad.tagName)) {
return null
}
const defs = findDefs()
const existingGrads = defs.querySelectorAll('linearGradient, radialGradient')
let i = existingGrads.length
const radAttrs = ['r', 'cx', 'cy', 'fx', 'fy']
while (i--) {
const og = existingGrads[i]
if (og.tagName !== grad.tagName) {
continue
}
if (grad.tagName === 'linearGradient') {
if (grad.getAttribute('x1') !== og.getAttribute('x1') ||
grad.getAttribute('y1') !== og.getAttribute('y1') ||
@@ -514,10 +568,10 @@ const setPaintMethod = (type, paint) => {
svgCanvas.setPaintOpacity(type, p.alpha / 100, true)
// now set the current paint object
svgCanvas.setCurProperties(type + '_paint', p)
svgCanvas.setCurProperties(`${type}_paint`, p)
switch (p.type) {
case 'solidColor':
svgCanvas.setColor(type, p.solidColor !== 'none' ? '#' + p.solidColor : 'none')
svgCanvas.setColor(type, p.solidColor !== 'none' ? `#${p.solidColor}` : 'none')
break
case 'linearGradient':
case 'radialGradient':
@@ -653,7 +707,7 @@ const addTextDecorationMethod = (value) => {
// Add the new text decoration value if it did not exist
if (!oldValue.includes(value)) {
batchCmd.addSubCommand(new ChangeElementCommand(elem, { 'text-decoration': oldValue }))
svgCanvas.changeSelectedAttributeNoUndo('text-decoration', (oldValue + ' ' + value).trim(), [elem])
svgCanvas.changeSelectedAttributeNoUndo('text-decoration', `${oldValue} ${value}`.trim(), [elem])
}
})
if (!batchCmd.isEmpty()) {
@@ -892,32 +946,48 @@ const setImageURLMethod = (val) => {
const setsize = (!attrs.width || !attrs.height)
const curHref = getHref(elem)
const hrefChanged = curHref !== val
// Do nothing if no URL change or size change
if (curHref === val && !setsize) {
if (!hrefChanged && !setsize) {
return
}
const batchCmd = new BatchCommand('Change Image URL')
setHref(elem, val)
batchCmd.addSubCommand(new ChangeElementCommand(elem, {
'#href': curHref
}))
if (hrefChanged) {
setHref(elem, val)
batchCmd.addSubCommand(new ChangeElementCommand(elem, {
'#href': curHref
}))
}
let finalized = false
const finalize = () => {
if (finalized) { return }
finalized = true
if (batchCmd.isEmpty()) { return }
svgCanvas.addCommandToHistory(batchCmd)
svgCanvas.call('changed', [elem])
}
const img = new Image()
img.onload = function () {
img.onload = () => {
const changes = {
width: elem.getAttribute('width'),
height: elem.getAttribute('height')
}
elem.setAttribute('width', this.width)
elem.setAttribute('height', this.height)
elem.setAttribute('width', img.width)
elem.setAttribute('height', img.height)
svgCanvas.selectorManager.requestSelector(elem).resize()
const selector = svgCanvas.selectorManager.requestSelector(elem)
selector && selector.resize()
batchCmd.addSubCommand(new ChangeElementCommand(elem, changes))
svgCanvas.addCommandToHistory(batchCmd)
svgCanvas.call('changed', [elem])
finalize()
}
img.onerror = () => {
finalize()
}
img.src = val
}
@@ -969,15 +1039,27 @@ const setRectRadiusMethod = (val) => {
const { ChangeElementCommand } = svgCanvas.history
const selectedElements = svgCanvas.getSelectedElements()
const selected = selectedElements[0]
if (selected?.tagName === 'rect') {
const r = Number(selected.getAttribute('rx'))
if (r !== val) {
selected.setAttribute('rx', val)
selected.setAttribute('ry', val)
svgCanvas.addCommandToHistory(new ChangeElementCommand(selected, { rx: r, ry: r }, 'Radius'))
svgCanvas.call('changed', [selected])
}
if (selected?.tagName !== 'rect') { return }
const radius = Number(val)
if (!Number.isFinite(radius) || radius < 0) {
return
}
const oldRx = selected.getAttribute('rx')
const oldRy = selected.getAttribute('ry')
const currentRx = Number(oldRx)
const currentRy = Number(oldRy)
const hasCurrentRx = oldRx !== null && Number.isFinite(currentRx)
const hasCurrentRy = oldRy !== null && Number.isFinite(currentRy)
const already = (radius === 0 && oldRx === null && oldRy === null) ||
(hasCurrentRx && hasCurrentRy && currentRx === radius && currentRy === radius)
if (already) { return }
selected.setAttribute('rx', radius)
selected.setAttribute('ry', radius)
svgCanvas.addCommandToHistory(new ChangeElementCommand(selected, { rx: oldRx, ry: oldRy }, 'Radius'))
svgCanvas.call('changed', [selected])
}
/**
@@ -1021,7 +1103,9 @@ const setSegTypeMethod = (newType) => {
*/
const setBackgroundMethod = (color, url) => {
const bg = getElement('canvasBackground')
if (!bg) { return }
const border = bg.querySelector('rect')
if (!border) { return }
let bgImg = getElement('background_image')
let bgPattern = getElement('background_pattern')
border.setAttribute('fill', color === 'chessboard' ? '#fff' : color)

View File

@@ -12,7 +12,7 @@ import {
convertAttrs
} from './units.js'
import {
transformPoint, hasMatrixTransform, getMatrix, snapToAngle, getTransformList
transformPoint, hasMatrixTransform, getMatrix, snapToAngle, getTransformList, transformListToTransform
} from './math.js'
import * as draw from './draw.js'
import * as pathModule from './path.js'
@@ -20,7 +20,9 @@ import * as hstry from './history.js'
import { findPos } from '../../svgcanvas/common/util.js'
const {
InsertElementCommand
InsertElementCommand,
BatchCommand,
ChangeElementCommand
} = hstry
let svgCanvas = null
@@ -84,6 +86,7 @@ const updateTransformList = (svgRoot, element, dx, dy) => {
const xform = svgRoot.createSVGTransform()
xform.setTranslate(dx, dy)
const tlist = getTransformList(element)
if (!tlist) { return }
if (tlist.numberOfItems) {
const firstItem = tlist.getItem(0)
if (firstItem.type === 2) { // SVG_TRANSFORM_TRANSLATE = 2
@@ -145,6 +148,25 @@ const mouseMoveEvent = (evt) => {
let tlist
switch (svgCanvas.getCurrentMode()) {
case 'select': {
// Insert dummy transform on first mouse move (drag start), not on click.
// This avoids creating multiple transforms that trigger unwanted flattening.
if (!svgCanvas.hasDragStartTransform && selectedElements.length > 0) {
// Store original transforms BEFORE adding the drag transform (for undo)
svgCanvas.dragStartTransforms = new Map()
for (const selectedElement of selectedElements) {
if (!selectedElement) { continue }
// Capture the transform attribute before we modify it
svgCanvas.dragStartTransforms.set(selectedElement, selectedElement.getAttribute('transform') || '')
const slist = getTransformList(selectedElement)
if (!slist) { continue }
if (slist.numberOfItems) {
slist.insertItemBefore(svgRoot.createSVGTransform(), 0)
} else {
slist.appendItem(svgRoot.createSVGTransform())
}
}
svgCanvas.hasDragStartTransform = true
}
// we temporarily use a translate on the element(s) being dragged
// this transform is removed upon mousing up and the element is
// relocated to the new location
@@ -222,6 +244,7 @@ const mouseMoveEvent = (evt) => {
// while the mouse is down, when mouse goes up, we use this to recalculate
// the shape's coordinates
tlist = getTransformList(selected)
if (!tlist) { break }
const hasMatrix = hasMatrixTransform(tlist)
box = hasMatrix ? svgCanvas.getInitBbox() : getBBox(selected)
let left = box.x
@@ -548,10 +571,21 @@ const mouseMoveEvent = (evt) => {
*
* @returns {void}
*/
const mouseOutEvent = () => {
const mouseOutEvent = (evt) => {
const { $id } = svgCanvas
if (svgCanvas.getCurrentMode() !== 'select' && svgCanvas.getStarted()) {
const event = new Event('mouseup')
const event = new MouseEvent('mouseup', {
bubbles: true,
cancelable: true,
clientX: evt?.clientX ?? 0,
clientY: evt?.clientY ?? 0,
button: evt?.button ?? 0,
buttons: evt?.buttons ?? 0,
altKey: evt?.altKey ?? false,
ctrlKey: evt?.ctrlKey ?? false,
metaKey: evt?.metaKey ?? false,
shiftKey: evt?.shiftKey ?? false
})
$id('svgcanvas').dispatchEvent(event)
}
}
@@ -637,10 +671,71 @@ const mouseUpEvent = (evt) => {
}
svgCanvas.selectorManager.requestSelector(selected).showGrips(true)
}
// always recalculate dimensions to strip off stray identity transforms
svgCanvas.recalculateAllSelectedDimensions()
// if it was being dragged/resized
if (realX !== svgCanvas.getRStartX() || realY !== svgCanvas.getRStartY()) {
// Only recalculate dimensions after actual dragging/resizing to avoid
// unwanted transform flattening on simple clicks
// Create a single batch command for all moved elements
const batchCmd = new BatchCommand('position')
selectedElements.forEach((elem) => {
if (!elem) return
const tlist = getTransformList(elem)
if (!tlist || tlist.numberOfItems === 0) return
// Get the transform from BEFORE the drag started
const oldTransform = svgCanvas.dragStartTransforms?.get(elem) || ''
// Check if the first transform is a translate (the drag transform we added)
const firstTransform = tlist.getItem(0)
const hasDragTranslate = firstTransform.type === 2 // SVG_TRANSFORM_TRANSLATE
// For groups, we always consolidate the transforms (recalculateDimensions returns null for groups)
const isGroup = elem.tagName === 'g' || elem.tagName === 'a'
// If element has 2+ transforms, or is a group with a drag translate, consolidate
if ((tlist.numberOfItems > 1 && hasDragTranslate) || (isGroup && hasDragTranslate)) {
const consolidatedMatrix = transformListToTransform(tlist).matrix
// Clear the transform list
while (tlist.numberOfItems > 0) {
tlist.removeItem(0)
}
// Add the consolidated matrix
const newTransform = svgCanvas.getSvgRoot().createSVGTransform()
newTransform.setMatrix(consolidatedMatrix)
tlist.appendItem(newTransform)
// Record the transform change for undo
batchCmd.addSubCommand(new ChangeElementCommand(elem, { transform: oldTransform }))
return
}
// For non-group elements with simple transforms, try recalculateDimensions
const cmd = svgCanvas.recalculateDimensions(elem)
if (cmd) {
batchCmd.addSubCommand(cmd)
} else {
// recalculateDimensions returned null
// Check if the transform actually changed and record it manually
const newTransform = elem.getAttribute('transform') || ''
if (newTransform !== oldTransform) {
batchCmd.addSubCommand(new ChangeElementCommand(elem, { transform: oldTransform }))
}
}
})
if (!batchCmd.isEmpty()) {
svgCanvas.addCommandToHistory(batchCmd)
}
// Clear the stored transforms AND reset the flag together
svgCanvas.dragStartTransforms = null
svgCanvas.hasDragStartTransform = false
const len = selectedElements.length
for (let i = 0; i < len; ++i) {
if (!selectedElements[i]) { break }
@@ -799,6 +894,8 @@ const mouseUpEvent = (evt) => {
svgCanvas.textActions.mouseUp(evt, mouseX, mouseY)
break
case 'rotate': {
svgCanvas.hasDragStartTransform = false
svgCanvas.dragStartTransforms = null
keep = true
element = null
svgCanvas.setCurrentMode('select')
@@ -812,8 +909,13 @@ const mouseUpEvent = (evt) => {
break
} default:
// This could occur in an extension
svgCanvas.hasDragStartTransform = false
svgCanvas.dragStartTransforms = null
break
}
// Reset drag flag after any mouseUp
svgCanvas.hasDragStartTransform = false
svgCanvas.dragStartTransforms = null
/**
* The main (left) mouse button is released (anywhere).
@@ -979,7 +1081,13 @@ const mouseDownEvent = (evt) => {
svgCanvas.cloneSelectedElements(0, 0)
}
svgCanvas.setRootSctm($id('svgcontent').querySelector('g').getScreenCTM().inverse())
// Get screenCTM from the first child group of svgcontent
// Note: svgcontent itself has x/y offset attributes, so we use its first child
const svgContent = $id('svgcontent')
const rootGroup = svgContent?.querySelector('g')
const screenCTM = rootGroup?.getScreenCTM?.()
if (!screenCTM) { return }
svgCanvas.setRootSctm(screenCTM.inverse())
const pt = transformPoint(evt.clientX, evt.clientY, svgCanvas.getrootSctm())
const mouseX = pt.x * zoom
@@ -1039,12 +1147,22 @@ const mouseDownEvent = (evt) => {
svgCanvas.setStartTransform(mouseTarget.getAttribute('transform'))
const tlist = getTransformList(mouseTarget)
// consolidate transforms using standard SVG but keep the transformation used for the move/scale
if (tlist.numberOfItems > 1) {
const firstTransform = tlist.getItem(0)
tlist.removeItem(0)
tlist.consolidate()
tlist.insertItemBefore(firstTransform, 0)
// Consolidate transforms for non-group elements to simplify dragging
// For elements with multiple transforms (e.g., after ungrouping), consolidate them
// into a single matrix so the dummy translate can be properly applied during drag
if (tlist?.numberOfItems > 1 && mouseTarget.tagName !== 'g' && mouseTarget.tagName !== 'a') {
// Compute the consolidated matrix from all transforms
const consolidatedMatrix = transformListToTransform(tlist).matrix
// Clear the transform list and add a single matrix transform
while (tlist.numberOfItems > 0) {
tlist.removeItem(0)
}
const newTransform = svgCanvas.getSvgRoot().createSVGTransform()
newTransform.setMatrix(consolidatedMatrix)
tlist.appendItem(newTransform)
}
switch (svgCanvas.getCurrentMode()) {
case 'select':
@@ -1067,19 +1185,9 @@ const mouseDownEvent = (evt) => {
}
// else if it's a path, go into pathedit mode in mouseup
if (!rightClick) {
// insert a dummy transform so if the element(s) are moved it will have
// a transform to use for its translate
for (const selectedElement of selectedElements) {
if (!selectedElement) { continue }
const slist = getTransformList(selectedElement)
if (slist.numberOfItems) {
slist.insertItemBefore(svgRoot.createSVGTransform(), 0)
} else {
slist.appendItem(svgRoot.createSVGTransform())
}
}
}
// Note: Dummy transform insertion moved to mouseMove to avoid triggering
// recalculateDimensions on simple clicks. The dummy transform is only needed
// when actually starting a drag operation.
} else if (!rightClick) {
svgCanvas.clearSelection()
svgCanvas.setCurrentMode('multiselect')
@@ -1105,13 +1213,14 @@ const mouseDownEvent = (evt) => {
}
assignAttributes(svgCanvas.getRubberBox(), {
x: realX * zoom,
y: realX * zoom,
y: realY * zoom,
width: 0,
height: 0,
display: 'inline'
}, 100)
break
case 'resize': {
if (!tlist) { break }
svgCanvas.setStarted(true)
svgCanvas.setStartX(x)
svgCanvas.setStartY(y)
@@ -1339,7 +1448,13 @@ const DOMMouseScrollEvent = (e) => {
e.preventDefault()
svgCanvas.setRootSctm($id('svgcontent').querySelector('g').getScreenCTM().inverse())
// Get screenCTM from the first child group of svgcontent
// Note: svgcontent itself has x/y offset attributes, so we use its first child
const svgContent = $id('svgcontent')
const rootGroup = svgContent?.querySelector('g')
const screenCTM = rootGroup?.getScreenCTM?.()
if (!screenCTM) { return }
svgCanvas.setRootSctm(screenCTM.inverse())
const workarea = document.getElementById('workarea')
const scrbar = 15

View File

@@ -6,6 +6,7 @@
* @copyright 2010 Jeff Schiller
*/
import { NS } from './namespaces.js'
import { getHref, setHref, getRotationAngle, getBBox } from './utilities.js'
/**
@@ -140,7 +141,7 @@ export class MoveElementCommand extends Command {
constructor (elem, oldNextSibling, oldParent, text) {
super()
this.elem = elem
this.text = text ? ('Move ' + elem.tagName + ' to ' + text) : ('Move ' + elem.tagName)
this.text = text ? `Move ${elem.tagName} to ${text}` : `Move ${elem.tagName}`
this.oldNextSibling = oldNextSibling
this.oldParent = oldParent
this.newNextSibling = elem.nextSibling
@@ -155,7 +156,11 @@ export class MoveElementCommand extends Command {
*/
apply (handler) {
super.apply(handler, () => {
this.elem = this.newParent.insertBefore(this.elem, this.newNextSibling)
const reference =
this.newNextSibling && this.newNextSibling.parentNode === this.newParent
? this.newNextSibling
: null
this.elem = this.newParent.insertBefore(this.elem, reference)
})
}
@@ -167,7 +172,11 @@ export class MoveElementCommand extends Command {
*/
unapply (handler) {
super.unapply(handler, () => {
this.elem = this.oldParent.insertBefore(this.elem, this.oldNextSibling)
const reference =
this.oldNextSibling && this.oldNextSibling.parentNode === this.oldParent
? this.oldNextSibling
: null
this.elem = this.oldParent.insertBefore(this.elem, reference)
})
}
}
@@ -184,7 +193,7 @@ export class InsertElementCommand extends Command {
constructor (elem, text) {
super()
this.elem = elem
this.text = text || ('Create ' + elem.tagName)
this.text = text || `Create ${elem.tagName}`
this.parent = elem.parentNode
this.nextSibling = this.elem.nextSibling
}
@@ -197,7 +206,11 @@ export class InsertElementCommand extends Command {
*/
apply (handler) {
super.apply(handler, () => {
this.elem = this.parent.insertBefore(this.elem, this.nextSibling)
const reference =
this.nextSibling && this.nextSibling.parentNode === this.parent
? this.nextSibling
: null
this.elem = this.parent.insertBefore(this.elem, reference)
})
}
@@ -229,7 +242,7 @@ export class RemoveElementCommand extends Command {
constructor (elem, oldNextSibling, oldParent, text) {
super()
this.elem = elem
this.text = text || ('Delete ' + elem.tagName)
this.text = text || `Delete ${elem.tagName}`
this.nextSibling = oldNextSibling
this.parent = oldParent
}
@@ -255,10 +268,11 @@ export class RemoveElementCommand extends Command {
*/
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`
const reference =
this.nextSibling && this.nextSibling.parentNode === this.parent
? this.nextSibling
: null
this.parent.insertBefore(this.elem, reference) // Don't use `before` or `prepend` as `reference` may be `null`
})
}
}
@@ -284,7 +298,7 @@ export class ChangeElementCommand extends Command {
constructor (elem, attrs, text) {
super()
this.elem = elem
this.text = text ? ('Change ' + elem.tagName + ' ' + text) : ('Change ' + elem.tagName)
this.text = text ? `Change ${elem.tagName} ${text}` : `Change ${elem.tagName}`
this.newValues = {}
this.oldValues = attrs
for (const attr in attrs) {
@@ -308,19 +322,21 @@ export class ChangeElementCommand extends Command {
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)
const isNullishOrEmpty = value === null || value === undefined || value === ''
if (attr === '#text') {
this.elem.textContent = value === null || value === undefined ? '' : String(value)
} else if (attr === '#href') {
if (isNullishOrEmpty) {
this.elem.removeAttribute('href')
this.elem.removeAttributeNS(NS.XLINK, 'href')
} else {
this.elem.setAttribute(attr, value)
setHref(this.elem, String(value))
}
} else if (attr === '#text') {
this.elem.textContent = ''
} else {
} else if (isNullishOrEmpty) {
this.elem.setAttribute(attr, '')
this.elem.removeAttribute(attr)
} else {
this.elem.setAttribute(attr, value)
}
if (attr === 'transform') { bChangedTransform = true }
@@ -331,6 +347,7 @@ export class ChangeElementCommand extends Command {
const angle = getRotationAngle(this.elem)
if (angle) {
const bbox = getBBox(this.elem)
if (!bbox) return
const cx = bbox.x + bbox.width / 2
const cy = bbox.y + bbox.height / 2
const rotate = ['rotate(', angle, ' ', cx, ',', cy, ')'].join('')
@@ -352,18 +369,20 @@ export class ChangeElementCommand extends Command {
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)
const isNullishOrEmpty = value === null || value === undefined || value === ''
if (attr === '#text') {
this.elem.textContent = value === null || value === undefined ? '' : String(value)
} else if (attr === '#href') {
if (isNullishOrEmpty) {
this.elem.removeAttribute('href')
this.elem.removeAttributeNS(NS.XLINK, 'href')
} else {
this.elem.setAttribute(attr, value)
setHref(this.elem, String(value))
}
} else if (attr === '#text') {
this.elem.textContent = ''
} else {
} else if (isNullishOrEmpty) {
this.elem.removeAttribute(attr)
} else {
this.elem.setAttribute(attr, value)
}
if (attr === 'transform') { bChangedTransform = true }
})
@@ -372,6 +391,7 @@ export class ChangeElementCommand extends Command {
const angle = getRotationAngle(this.elem)
if (angle) {
const bbox = getBBox(this.elem)
if (!bbox) return
const cx = bbox.x + bbox.width / 2
const cy = bbox.y + bbox.height / 2
const rotate = ['rotate(', angle, ' ', cx, ',', cy, ')'].join('')
@@ -602,7 +622,7 @@ export class UndoManager {
const p = this.undoChangeStackPointer--
const changeset = this.undoableChangeStack[p]
const { attrName } = changeset
const batchCmd = new BatchCommand('Change ' + attrName)
const batchCmd = new BatchCommand(`Change ${attrName}`)
let i = changeset.elements.length
while (i--) {
const elem = changeset.elements[i]

View File

@@ -79,7 +79,9 @@ class HistoryRecordingService {
this.batchCommandStack_.pop()
const { length: len } = this.batchCommandStack_
this.currentBatchCommand_ = len ? this.batchCommandStack_[len - 1] : null
this.addCommand_(batchCommand)
if (!batchCommand.isEmpty()) {
this.addCommand_(batchCommand)
}
}
return this
}
@@ -157,5 +159,5 @@ class HistoryRecordingService {
* @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()
HistoryRecordingService.NO_HISTORY = new HistoryRecordingService(null)
export default HistoryRecordingService

View File

@@ -27,7 +27,7 @@ let svgdoc_ = null
*/
export const init = (canvas) => {
svgCanvas = canvas
svgdoc_ = canvas.getDOMDocument()
svgdoc_ = canvas.getDOMDocument?.() || (typeof document !== 'undefined' ? document : null)
}
/**
* @function module:json.getJsonFromSvgElements Iterate element and return json format
@@ -35,8 +35,12 @@ export const init = (canvas) => {
* @returns {svgRootElement}
*/
export const getJsonFromSvgElements = (data) => {
if (!data) return null
// Text node
if (data.nodeType === 3) return data.nodeValue
if (data.nodeType === 3 || data.nodeType === 4) return data.nodeValue
// Ignore non-element nodes (e.g., comments)
if (data.nodeType !== 1) return null
const retval = {
element: data.tagName,
@@ -46,13 +50,25 @@ export const getJsonFromSvgElements = (data) => {
}
// Iterate attributes
for (let i = 0, attr; (attr = data.attributes[i]); i++) {
retval.attr[attr.name] = attr.value
const attributes = data.attributes
if (attributes) {
for (let i = 0; i < attributes.length; i++) {
const attr = attributes[i]
if (!attr) continue
retval.attr[attr.name] = attr.value
}
}
// Iterate children
for (let i = 0, node; (node = data.childNodes[i]); i++) {
retval.children[i] = getJsonFromSvgElements(node)
const childNodes = data.childNodes
if (childNodes) {
for (let i = 0; i < childNodes.length; i++) {
const node = childNodes[i]
const child = getJsonFromSvgElements(node)
if (child !== null && child !== undefined) {
retval.children.push(child)
}
}
}
return retval
@@ -65,11 +81,29 @@ export const getJsonFromSvgElements = (data) => {
*/
export const addSVGElementsFromJson = (data) => {
if (!svgdoc_) { return null }
if (data === null || data === undefined) return svgdoc_.createTextNode('')
if (typeof data === 'string') return svgdoc_.createTextNode(data)
let shape = getElement(data.attr.id)
const attrs = data.attr || {}
const id = attrs.id
let shape = null
if (typeof id === 'string' && id) {
try {
shape = getElement(id)
} catch (e) {
// Ignore (CSS selector may be invalid); fallback to getElementById below
}
if (!shape) {
const byId = svgdoc_.getElementById?.(id)
const svgRoot = svgCanvas?.getSvgRoot?.()
if (byId && (!svgRoot || svgRoot.contains(byId))) {
shape = byId
}
}
}
// if shape is a path but we need to create a rect/ellipse, then remove the path
const currentLayer = svgCanvas.getDrawing().getCurrentLayer()
const currentLayer = svgCanvas?.getDrawing?.()?.getCurrentLayer?.()
if (shape && data.element !== shape.tagName) {
shape.remove()
shape = null
@@ -81,8 +115,10 @@ export const addSVGElementsFromJson = (data) => {
(svgCanvas.getCurrentGroup() || currentLayer).append(shape)
}
}
const curShape = svgCanvas.getCurShape()
const curShape = svgCanvas.getCurShape?.() || {}
if (data.curStyles) {
const curOpacity = Number(curShape.opacity)
const opacity = Number.isFinite(curOpacity) ? (curOpacity / 2) : 0.5
assignAttributes(shape, {
fill: curShape.fill,
stroke: curShape.stroke,
@@ -92,17 +128,23 @@ export const addSVGElementsFromJson = (data) => {
'stroke-linecap': curShape.stroke_linecap,
'stroke-opacity': curShape.stroke_opacity,
'fill-opacity': curShape.fill_opacity,
opacity: curShape.opacity / 2,
opacity,
style: 'pointer-events:inherit'
}, 100)
}
assignAttributes(shape, data.attr, 100)
assignAttributes(shape, attrs, 100)
cleanupElement(shape)
// Children
if (data.children) {
while (shape.firstChild) {
shape.firstChild.remove()
}
data.children.forEach((child) => {
shape.append(addSVGElementsFromJson(child))
const childNode = addSVGElementsFromJson(child)
if (childNode) {
shape.append(childNode)
}
})
}

View File

@@ -40,19 +40,16 @@ class Layer {
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_)
}
group ? group.insertAdjacentElement('afterend', this.group_) : svgElem.append(this.group_)
}
addLayerClass(this.group_)
walkTree(this.group_, function (e) {
e.setAttribute('style', 'pointer-events:inherit')
e.style.pointerEvents = 'inherit'
})
this.group_.setAttribute('style', svgElem ? 'pointer-events:all' : 'pointer-events:none')
this.group_.style.pointerEvents = svgElem ? 'all' : 'none'
}
/**
@@ -76,7 +73,7 @@ class Layer {
* @returns {void}
*/
activate () {
this.group_.setAttribute('style', 'pointer-events:all')
this.group_.style.pointerEvents = 'all'
}
/**
@@ -84,7 +81,7 @@ class Layer {
* @returns {void}
*/
deactivate () {
this.group_.setAttribute('style', 'pointer-events:none')
this.group_.style.pointerEvents = 'none'
}
/**
@@ -93,7 +90,7 @@ class Layer {
* @returns {void}
*/
setVisible (visible) {
const expected = visible === undefined || visible ? 'inline' : 'none'
const expected = (visible === undefined || visible) ? 'inline' : 'none'
const oldDisplay = this.group_.getAttribute('display')
if (oldDisplay !== expected) {
this.group_.setAttribute('display', expected)
@@ -114,10 +111,7 @@ class Layer {
*/
getOpacity () {
const opacity = this.group_.getAttribute('opacity')
if (!opacity) {
return 1
}
return Number.parseFloat(opacity)
return opacity ? Number.parseFloat(opacity) : 1
}
/**
@@ -208,7 +202,7 @@ 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|$)')
Layer.CLASS_REGEX = new RegExp(`(\\s|^)${Layer.CLASS_NAME}(\\s|$)`)
/**
* Add class `Layer.CLASS_NAME` to the element (usually `class='layer'`).
@@ -216,12 +210,12 @@ Layer.CLASS_REGEX = new RegExp('(\\s|^)' + Layer.CLASS_NAME + '(\\s|$)')
* @param {SVGGElement} elem - The SVG element to update
* @returns {void}
*/
function addLayerClass (elem) {
const 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)
elem.setAttribute('class', `${classes} ${Layer.CLASS_NAME}`)
}
}

View File

@@ -20,6 +20,7 @@
*/
import { NS } from './namespaces.js'
import { warn } from '../common/logger.js'
// Constants
const NEAR_ZERO = 1e-10
@@ -27,6 +28,38 @@ const NEAR_ZERO = 1e-10
// Create a throwaway SVG element for matrix operations
const svg = document.createElementNS(NS.SVG, 'svg')
const createTransformFromMatrix = (m) => {
const createFallback = (matrix) => {
const fallback = svg.createSVGMatrix()
Object.assign(fallback, {
a: matrix.a,
b: matrix.b,
c: matrix.c,
d: matrix.d,
e: matrix.e,
f: matrix.f
})
return fallback
}
try {
return svg.createSVGTransformFromMatrix(m)
} catch (e) {
const t = svg.createSVGTransform()
try {
t.setMatrix(m)
return t
} catch (err) {
try {
return svg.createSVGTransformFromMatrix(createFallback(m))
} catch (e2) {
t.setMatrix(createFallback(m))
return t
}
}
}
}
/**
* Transforms a point by a given matrix without DOM calls.
* @function transformPoint
@@ -56,7 +89,7 @@ export const getTransformList = elem => {
if (elem.patternTransform?.baseVal) {
return elem.patternTransform.baseVal
}
console.warn('No transform list found. Check browser compatibility.', elem)
warn('No transform list found. Check browser compatibility.', elem, 'math')
}
/**
@@ -66,7 +99,12 @@ export const getTransformList = elem => {
* @returns {boolean} True if it's an identity matrix (1,0,0,1,0,0)
*/
export const isIdentity = m =>
m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1 && m.e === 0 && m.f === 0
Math.abs(m.a - 1) < NEAR_ZERO &&
Math.abs(m.b) < NEAR_ZERO &&
Math.abs(m.c) < NEAR_ZERO &&
Math.abs(m.d - 1) < NEAR_ZERO &&
Math.abs(m.e) < NEAR_ZERO &&
Math.abs(m.f) < NEAR_ZERO
/**
* Multiplies multiple matrices together (m1 * m2 * ...).
@@ -76,22 +114,54 @@ export const isIdentity = m =>
* @returns {SVGMatrix} The resulting matrix
*/
export const matrixMultiply = (...args) => {
// If no matrices are given, return an identity matrix
if (args.length === 0) {
return svg.createSVGMatrix()
}
const m = args.reduceRight((prev, curr) => curr.multiply(prev))
const normalizeNearZero = (matrix) => {
const props = ['a', 'b', 'c', 'd', 'e', 'f']
for (const prop of props) {
if (Math.abs(matrix[prop]) < NEAR_ZERO) {
matrix[prop] = 0
}
}
return matrix
}
// Round near-zero values to zero
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
if (typeof DOMMatrix === 'function' && typeof DOMMatrix.fromMatrix === 'function') {
const result = args.reduce(
(acc, curr) => acc.multiply(DOMMatrix.fromMatrix(curr)),
new DOMMatrix()
)
return m
const out = svg.createSVGMatrix()
Object.assign(out, {
a: result.a,
b: result.b,
c: result.c,
d: result.d,
e: result.e,
f: result.f
})
return normalizeNearZero(out)
}
let m = svg.createSVGMatrix()
for (const curr of args) {
const next = svg.createSVGMatrix()
Object.assign(next, {
a: m.a * curr.a + m.c * curr.b,
b: m.b * curr.a + m.d * curr.b,
c: m.a * curr.c + m.c * curr.d,
d: m.b * curr.c + m.d * curr.d,
e: m.a * curr.e + m.c * curr.f + m.e,
f: m.b * curr.e + m.d * curr.f + m.f
})
m = next
}
return normalizeNearZero(m)
}
/**
@@ -172,25 +242,34 @@ export const transformBox = (l, t, w, h, m) => {
*/
export const transformListToTransform = (tlist, min = 0, max = null) => {
if (!tlist) {
return svg.createSVGTransformFromMatrix(svg.createSVGMatrix())
return createTransformFromMatrix(svg.createSVGMatrix())
}
const start = Number.parseInt(min, 10)
const end = Number.parseInt(max ?? tlist.numberOfItems - 1, 10)
const low = Math.min(start, end)
const high = Math.max(start, end)
const [low, high] = [Math.min(start, end), Math.max(start, end)]
let combinedMatrix = svg.createSVGMatrix()
const matrices = []
for (let i = low; i <= high; i++) {
// If out of range, use identity
const currentMatrix =
i >= 0 && i < tlist.numberOfItems
? tlist.getItem(i).matrix
: svg.createSVGMatrix()
combinedMatrix = matrixMultiply(combinedMatrix, currentMatrix)
const matrix = (i >= 0 && i < tlist.numberOfItems)
? tlist.getItem(i).matrix
: svg.createSVGMatrix()
matrices.push(matrix)
}
return svg.createSVGTransformFromMatrix(combinedMatrix)
const combinedMatrix = matrixMultiply(...matrices)
const out = svg.createSVGMatrix()
Object.assign(out, {
a: combinedMatrix.a,
b: combinedMatrix.b,
c: combinedMatrix.c,
d: combinedMatrix.d,
e: combinedMatrix.e,
f: combinedMatrix.f
})
return createTransformFromMatrix(out)
}
/**

View File

@@ -5,7 +5,7 @@
*/
/**
* Common namepaces constants in alpha order.
* Common namespaces constants in alpha order.
* @enum {string}
* @type {PlainObject}
* @memberof module:namespaces
@@ -29,12 +29,12 @@ export const NS = {
/**
* @function module:namespaces.getReverseNS
* @returns {string} The NS with key values switched and lowercase
* @returns {PlainObject<string, string>} The namespace URI map with values swapped to their lowercase keys
*/
export const getReverseNS = function () {
export const getReverseNS = () => {
const reverseNS = {}
Object.entries(NS).forEach(([name, URI]) => {
for (const [name, URI] of Object.entries(NS)) {
reverseNS[URI] = name.toLowerCase()
})
}
return reverseNS
}

View File

@@ -2,12 +2,95 @@
*
*/
export default class Paint {
static #normalizeAlpha (alpha) {
const numeric = Number(alpha)
if (!Number.isFinite(numeric)) return 100
return Math.min(100, Math.max(0, numeric))
}
static #normalizeSolidColor (color) {
if (color === null || color === undefined) return null
const str = String(color).trim()
if (!str) return null
if (str === 'none') return 'none'
return str.startsWith('#') ? str.slice(1) : str
}
static #extractHrefId (hrefAttr) {
if (!hrefAttr) return null
const href = String(hrefAttr).trim()
if (!href) return null
if (href.startsWith('#')) return href.slice(1)
const urlMatch = href.match(/url\(\s*['"]?#([^'")\s]+)['"]?\s*\)/)
if (urlMatch?.[1]) return urlMatch[1]
const hashIndex = href.lastIndexOf('#')
if (hashIndex >= 0 && hashIndex < href.length - 1) {
return href.slice(hashIndex + 1)
}
return null
}
static #resolveGradient (gradient) {
if (!gradient?.cloneNode) return null
const doc = gradient.ownerDocument || document
const visited = new Set()
const clone = gradient.cloneNode(true)
let refId = Paint.#extractHrefId(
clone.getAttribute('href') || clone.getAttribute('xlink:href')
)
while (refId && !visited.has(refId)) {
visited.add(refId)
const referenced = doc.getElementById(refId)
if (!referenced?.getAttribute) break
const cloneTag = String(clone.tagName || '').toLowerCase()
const referencedTag = String(referenced.tagName || '').toLowerCase()
if (
!['lineargradient', 'radialgradient'].includes(referencedTag) ||
referencedTag !== cloneTag
) {
break
}
// Copy missing attributes from referenced gradient (matches SVG href inheritance).
for (const attr of referenced.attributes || []) {
const name = attr.name
if (name === 'id' || name === 'href' || name === 'xlink:href') continue
const current = clone.getAttribute(name)
if (current === null || current === '') {
clone.setAttribute(name, attr.value)
}
}
// If the referencing gradient has no stops, inherit stops from the referenced gradient.
if (clone.querySelectorAll('stop').length === 0) {
for (const stop of referenced.querySelectorAll?.('stop') || []) {
clone.append(stop.cloneNode(true))
}
}
// Prepare to continue resolving deeper links if present.
refId = Paint.#extractHrefId(
referenced.getAttribute('href') || referenced.getAttribute('xlink:href')
)
}
// The clone is now self-contained; remove any href.
clone.removeAttribute('href')
clone.removeAttribute('xlink:href')
return clone
}
/**
* @param {module:jGraduate.jGraduatePaintOptions} [opt]
*/
constructor (opt) {
const options = opt || {}
this.alpha = isNaN(options.alpha) ? 100 : options.alpha
this.alpha = Paint.#normalizeAlpha(options.alpha)
// copy paint object
if (options.copy) {
/**
@@ -20,7 +103,7 @@ export default class Paint {
* @name module:jGraduate~Paint#alpha
* @type {Float}
*/
this.alpha = options.copy.alpha
this.alpha = Paint.#normalizeAlpha(options.copy.alpha)
/**
* Represents #RRGGBB hex of color.
* @name module:jGraduate~Paint#solidColor
@@ -42,13 +125,17 @@ export default class Paint {
case 'none':
break
case 'solidColor':
this.solidColor = options.copy.solidColor
this.solidColor = Paint.#normalizeSolidColor(options.copy.solidColor)
break
case 'linearGradient':
this.linearGradient = options.copy.linearGradient.cloneNode(true)
this.linearGradient = options.copy.linearGradient?.cloneNode
? options.copy.linearGradient.cloneNode(true)
: null
break
case 'radialGradient':
this.radialGradient = options.copy.radialGradient.cloneNode(true)
this.radialGradient = options.copy.radialGradient?.cloneNode
? options.copy.radialGradient.cloneNode(true)
: null
break
}
// create linear gradient paint
@@ -56,33 +143,17 @@ export default class Paint {
this.type = 'linearGradient'
this.solidColor = null
this.radialGradient = null
const hrefAttr =
options.linearGradient.getAttribute('href') ||
options.linearGradient.getAttribute('xlink:href')
if (hrefAttr) {
const xhref = document.getElementById(hrefAttr.replace(/^#/, ''))
this.linearGradient = xhref.cloneNode(true)
} else {
this.linearGradient = options.linearGradient.cloneNode(true)
}
this.linearGradient = Paint.#resolveGradient(options.linearGradient)
// create linear gradient paint
} else if (options.radialGradient) {
this.type = 'radialGradient'
this.solidColor = null
this.linearGradient = null
const hrefAttr =
options.radialGradient.getAttribute('href') ||
options.radialGradient.getAttribute('xlink:href')
if (hrefAttr) {
const xhref = document.getElementById(hrefAttr.replace(/^#/, ''))
this.radialGradient = xhref.cloneNode(true)
} else {
this.radialGradient = options.radialGradient.cloneNode(true)
}
this.radialGradient = Paint.#resolveGradient(options.radialGradient)
// create solid color paint
} else if (options.solidColor) {
this.type = 'solidColor'
this.solidColor = options.solidColor
this.solidColor = Paint.#normalizeSolidColor(options.solidColor)
// create empty paint
} else {
this.type = 'none'

View File

@@ -1,5 +1,6 @@
import {
getStrokedBBoxDefaultVisible
getStrokedBBoxDefaultVisible,
getUrlFromAttr
} from './utilities.js'
import * as hstry from './history.js'
@@ -27,11 +28,15 @@ export const init = (canvas) => {
* @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
export const pasteElementsMethod = (type, x, y) => {
const rawClipboard = sessionStorage.getItem(svgCanvas.getClipboardID())
let clipb
try {
clipb = JSON.parse(rawClipboard)
} catch {
return
}
if (!Array.isArray(clipb) || !clipb.length) return
const pasted = []
const batchCmd = new BatchCommand('Paste elements')
@@ -50,7 +55,7 @@ export const pasteElementsMethod = function (type, x, y) {
* @param {module:svgcanvas.SVGAsJSON} elem
* @returns {void}
*/
function checkIDs (elem) {
const checkIDs = (elem) => {
if (elem.attr?.id) {
changedIDs[elem.attr.id] = svgCanvas.getNextId()
elem.attr.id = changedIDs[elem.attr.id]
@@ -59,6 +64,35 @@ export const pasteElementsMethod = function (type, x, y) {
}
clipb.forEach((elem) => checkIDs(elem))
// Update any internal references in the clipboard to match the new IDs.
/**
* @param {module:svgcanvas.SVGAsJSON} elem
* @returns {void}
*/
const remapReferences = (elem) => {
const attrs = elem?.attr
if (attrs) {
for (const [attrName, attrVal] of Object.entries(attrs)) {
if (typeof attrVal !== 'string' || !attrVal) continue
if ((attrName === 'href' || attrName === 'xlink:href') && attrVal.startsWith('#')) {
const refId = attrVal.slice(1)
if (refId in changedIDs) {
attrs[attrName] = `#${changedIDs[refId]}`
}
}
const url = getUrlFromAttr(attrVal)
if (url) {
const refId = url.slice(1)
if (refId in changedIDs) {
attrs[attrName] = attrVal.replace(url, `#${changedIDs[refId]}`)
}
}
}
}
if (elem.children) elem.children.forEach((child) => remapReferences(child))
}
clipb.forEach((elem) => remapReferences(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).
@@ -77,12 +111,14 @@ export const pasteElementsMethod = function (type, x, y) {
extChanges.remove.forEach(function (removeID) {
clipb = clipb.filter(function (clipBoardItem) {
return clipBoardItem.attr.id !== removeID
return clipBoardItem?.attr?.id !== removeID
})
})
})
// Move elements to lastClickPoint
let len = clipb.length
if (!len) return
while (len--) {
const elem = clipb[len]
if (!elem) { continue }
@@ -94,6 +130,7 @@ export const pasteElementsMethod = function (type, x, y) {
svgCanvas.restoreRefElements(copy)
}
if (!pasted.length) return
svgCanvas.selectOnly(pasted)
if (type !== 'in_place') {
@@ -108,18 +145,20 @@ export const pasteElementsMethod = function (type, x, 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 = []
if (bbox && Number.isFinite(ctrX) && Number.isFinite(ctrY)) {
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)
})
pasted.forEach(function (_item) {
dx.push(cx)
dy.push(cy)
})
const cmd = svgCanvas.moveSelectedElements(dx, dy, false)
if (cmd) batchCmd.addSubCommand(cmd)
const cmd = svgCanvas.moveSelectedElements(dx, dy, false)
if (cmd) batchCmd.addSubCommand(cmd)
}
}
svgCanvas.addCommandToHistory(batchCmd)

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,190 @@ import {
getElement
} from './utilities.js'
const TYPE_TO_CMD = {
1: 'Z',
2: 'M',
3: 'm',
4: 'L',
5: 'l',
6: 'C',
7: 'c',
8: 'Q',
9: 'q',
10: 'A',
11: 'a',
12: 'H',
13: 'h',
14: 'V',
15: 'v',
16: 'S',
17: 's',
18: 'T',
19: 't'
}
const CMD_TO_TYPE = Object.fromEntries(
Object.entries(TYPE_TO_CMD).map(([k, v]) => [v, Number(k)])
)
class PathDataListShim {
constructor (elem) {
this.elem = elem
}
_getData () {
return this.elem.getPathData()
}
_setData (data) {
this.elem.setPathData(data)
}
get numberOfItems () {
return this._getData().length
}
_entryToSeg (entry) {
const { type, values = [] } = entry
const cmd = CMD_TO_TYPE[type] || CMD_TO_TYPE[type?.toUpperCase?.()]
const seg = { pathSegType: cmd }
const U = String(type).toUpperCase()
switch (U) {
case 'H':
[seg.x] = values
break
case 'V':
[seg.y] = values
break
case 'M':
case 'L':
case 'T':
[seg.x, seg.y] = values
break
case 'S':
[seg.x2, seg.y2, seg.x, seg.y] = values
break
case 'C':
[seg.x1, seg.y1, seg.x2, seg.y2, seg.x, seg.y] = values
break
case 'Q':
[seg.x1, seg.y1, seg.x, seg.y] = values
break
case 'A':
[
seg.r1,
seg.r2,
seg.angle,
seg.largeArcFlag,
seg.sweepFlag,
seg.x,
seg.y
] = values
break
default:
break
}
return seg
}
_segToEntry (seg) {
const type = TYPE_TO_CMD[seg.pathSegType] || seg.type
if (!type) {
return { type: 'Z', values: [] }
}
const U = String(type).toUpperCase()
let values = []
switch (U) {
case 'H':
values = [seg.x]
break
case 'V':
values = [seg.y]
break
case 'M':
case 'L':
case 'T':
values = [seg.x, seg.y]
break
case 'S':
values = [seg.x2, seg.y2, seg.x, seg.y]
break
case 'C':
values = [seg.x1, seg.y1, seg.x2, seg.y2, seg.x, seg.y]
break
case 'Q':
values = [seg.x1, seg.y1, seg.x, seg.y]
break
case 'A':
values = [
seg.r1,
seg.r2,
seg.angle,
Number(seg.largeArcFlag),
Number(seg.sweepFlag),
seg.x,
seg.y
]
break
default:
values = []
}
return { type, values }
}
getItem (index) {
const entry = this._getData()[index]
return entry ? this._entryToSeg(entry) : null
}
replaceItem (seg, index) {
const data = this._getData()
data[index] = this._segToEntry(seg)
this._setData(data)
return seg
}
insertItemBefore (seg, index) {
const data = this._getData()
data.splice(index, 0, this._segToEntry(seg))
this._setData(data)
return seg
}
appendItem (seg) {
const data = this._getData()
data.push(this._segToEntry(seg))
this._setData(data)
return seg
}
removeItem (index) {
const data = this._getData()
data.splice(index, 1)
this._setData(data)
}
clear () {
this._setData([])
}
}
if (
typeof SVGPathElement !== 'undefined' &&
typeof SVGPathElement.prototype.getPathData === 'function' &&
typeof SVGPathElement.prototype.setPathData === 'function' &&
!('pathSegList' in SVGPathElement.prototype)
) {
Object.defineProperty(SVGPathElement.prototype, 'pathSegList', {
get () {
if (!this._pathSegListShim) {
this._pathSegListShim = new PathDataListShim(this)
}
return this._pathSegListShim
}
})
}
let svgCanvas = null
/**
@@ -36,7 +220,7 @@ export const init = (canvas) => {
* @returns {ArgumentsArray}
*/
/* eslint-enable max-len */
export const ptObjToArrMethod = function (type, segItem) {
export const ptObjToArrMethod = (type, segItem) => {
const segData = svgCanvas.getSegData()
const props = segData[type]
return props.map((prop) => {
@@ -50,7 +234,7 @@ export const ptObjToArrMethod = function (type, segItem) {
* @param {module:math.XYObject} altPt
* @returns {module:math.XYObject}
*/
export const getGripPtMethod = function (seg, altPt) {
export const getGripPtMethod = (seg, altPt) => {
const { path: pth } = seg
let out = {
x: altPt ? altPt.x : seg.item.x,
@@ -73,7 +257,7 @@ export const getGripPtMethod = function (seg, altPt) {
* @param {module:path.Path} pth
* @returns {module:math.XYObject}
*/
export const getPointFromGripMethod = function (pt, pth) {
export const getPointFromGripMethod = (pt, pth) => {
const out = {
x: pt.x,
y: pt.y
@@ -94,7 +278,7 @@ export const getPointFromGripMethod = function (pt, pth) {
* @function module:path.getGripContainer
* @returns {Element}
*/
export const getGripContainerMethod = function () {
export const getGripContainerMethod = () => {
let c = getElement('pathpointgrip_container')
if (!c) {
const parentElement = getElement('selectorParentGroup')
@@ -113,16 +297,16 @@ export const getGripContainerMethod = function () {
* @param {Integer} y
* @returns {SVGCircleElement}
*/
export const addPointGripMethod = function (index, x, y) {
export const addPointGripMethod = (index, x, y) => {
// create the container of all the point grips
const pointGripContainer = getGripContainerMethod()
let pointGrip = getElement('pathpointgrip_' + index)
let pointGrip = getElement(`pathpointgrip_${index}`)
// create it
if (!pointGrip) {
pointGrip = document.createElementNS(NS.SVG, 'circle')
const atts = {
id: 'pathpointgrip_' + index,
id: `pathpointgrip_${index}`,
display: 'none',
r: 4,
fill: '#0FF',
@@ -163,7 +347,7 @@ export const addPointGripMethod = function (index, x, y) {
* @param {string} id
* @returns {SVGCircleElement}
*/
export const addCtrlGripMethod = function (id) {
export const addCtrlGripMethod = (id) => {
let pointGrip = getElement('ctrlpointgrip_' + id)
if (pointGrip) { return pointGrip }
@@ -191,7 +375,7 @@ export const addCtrlGripMethod = function (id) {
* @param {string} id
* @returns {SVGLineElement}
*/
export const getCtrlLineMethod = function (id) {
export const getCtrlLineMethod = (id) => {
let ctrlLine = getElement('ctrlLine_' + id)
if (ctrlLine) { return ctrlLine }
@@ -211,7 +395,7 @@ export const getCtrlLineMethod = function (id) {
* @param {boolean} update
* @returns {SVGCircleElement}
*/
export const getPointGripMethod = function (seg, update) {
export const getPointGripMethod = (seg, update) => {
const { index } = seg
const pointGrip = addPointGripMethod(index)
@@ -231,7 +415,7 @@ export const getPointGripMethod = function (seg, update) {
* @param {Segment} seg
* @returns {PlainObject<string, SVGLineElement|SVGCircleElement>}
*/
export const getControlPointsMethod = function (seg) {
export const getControlPointsMethod = (seg) => {
const { item, index } = seg
if (!('x1' in item) || !('x2' in item)) { return null }
const cpt = {}
@@ -246,7 +430,7 @@ export const getControlPointsMethod = function (seg) {
for (let i = 1; i < 3; i++) {
const id = index + 'c' + i
const ctrlLine = cpt['c' + i + '_line'] = getCtrlLineMethod(id)
const ctrlLine = cpt[`c${i}_line`] = getCtrlLineMethod(id)
const pt = getGripPtMethod(seg, { x: item['x' + i], y: item['y' + i] })
const gpt = getGripPtMethod(seg, { x: segItems[i - 1].x, y: segItems[i - 1].y })
@@ -259,10 +443,10 @@ export const getControlPointsMethod = function (seg) {
display: 'inline'
})
cpt['c' + i + '_line'] = ctrlLine
cpt[`c${i}_line`] = ctrlLine
// create it
const pointGrip = cpt['c' + i] = addCtrlGripMethod(id)
const pointGrip = cpt[`c${i}`] = addCtrlGripMethod(id)
assignAttributes(pointGrip, {
cx: pt.x,
@@ -282,12 +466,29 @@ export const getControlPointsMethod = function (seg) {
* @param {SVGPathElement} elem
* @returns {void}
*/
export const replacePathSegMethod = function (type, index, pts, elem) {
export const replacePathSegMethod = (type, index, pts, elem) => {
const path = svgCanvas.getPathObj()
const pth = elem || path.elem
const pathFuncs = svgCanvas.getPathFuncs()
const func = 'createSVGPathSeg' + pathFuncs[type]
const seg = pth[func](...pts)
const segData = svgCanvas.getSegData?.()
const props = segData?.[type] || segData?.[type - 1]
if (props && pts.length < props.length) {
const currentSeg = pth.pathSegList?.getItem?.(index)
if (currentSeg) {
pts = props.map((prop, i) => (pts[i] !== undefined ? pts[i] : currentSeg[prop]))
}
}
let seg
if (typeof pth[func] === 'function') {
seg = pth[func](...pts)
} else {
const safeProps = props || []
seg = { pathSegType: type }
safeProps.forEach((prop, i) => {
seg[prop] = pts[i]
})
}
pth.pathSegList.replaceItem(seg, index)
}
@@ -297,15 +498,15 @@ export const replacePathSegMethod = function (type, index, pts, elem) {
* @param {boolean} update
* @returns {SVGPathElement}
*/
export const getSegSelectorMethod = function (seg, update) {
export const getSegSelectorMethod = (seg, update) => {
const { index } = seg
let segLine = getElement('segline_' + index)
let segLine = getElement(`segline_${index}`)
if (!segLine) {
const pointGripContainer = getGripContainerMethod()
// create segline
segLine = document.createElementNS(NS.SVG, 'path')
assignAttributes(segLine, {
id: 'segline_' + index,
id: `segline_${index}`,
display: 'none',
fill: 'none',
stroke: '#0FF',
@@ -353,7 +554,7 @@ export class Segment {
this.item = item
this.type = item.pathSegType
this.ctrlpts = []
this.ctrlpts = null
this.ptgrip = null
this.segsel = null
}
@@ -375,8 +576,8 @@ export class Segment {
* @returns {void}
*/
selectCtrls (y) {
document.getElementById('ctrlpointgrip_' + this.index + 'c1').setAttribute('fill', y ? '#0FF' : '#EEE')
document.getElementById('ctrlpointgrip_' + this.index + 'c2').setAttribute('fill', y ? '#0FF' : '#EEE')
document.getElementById(`ctrlpointgrip_${this.index}c1`)?.setAttribute('fill', y ? '#0FF' : '#EEE')
document.getElementById(`ctrlpointgrip_${this.index}c2`)?.setAttribute('fill', y ? '#0FF' : '#EEE')
}
/**
@@ -450,27 +651,25 @@ export class Segment {
move (dx, dy) {
const { item } = this
const curPts = this.ctrlpts
? [
item.x += dx, item.y += dy,
item.x1, item.y1, item.x2 += dx, item.y2 += dy
]
: [item.x += dx, item.y += dy]
item.x += dx
item.y += dy
// `x2/y2` are the control point attached to this node (when present)
if ('x2' in item) { item.x2 += dx }
if ('y2' in item) { item.y2 += dy }
replacePathSegMethod(
this.type,
this.index,
// type 10 means ARC
this.type === 10 ? ptObjToArrMethod(this.type, item) : curPts
ptObjToArrMethod(this.type, item)
)
if (this.next?.ctrlpts) {
const next = this.next.item
const nextPts = [
next.x, next.y,
next.x1 += dx, next.y1 += dy, next.x2, next.y2
]
replacePathSegMethod(this.next.type, this.next.index, nextPts)
const next = this.next?.item
// `x1/y1` are the control point attached to this node on the next segment (when present)
if (next && 'x1' in next && 'y1' in next) {
next.x1 += dx
next.y1 += dy
replacePathSegMethod(this.next.type, this.next.index, ptObjToArrMethod(this.next.type, next))
}
if (this.mate) {

View File

@@ -236,6 +236,7 @@ export const init = (canvas) => {
svgCanvas.getPointFromGrip = getPointFromGripMethod
svgCanvas.setLinkControlPoints = setLinkControlPoints
svgCanvas.reorientGrads = reorientGrads
svgCanvas.recalcRotatedPath = recalcRotatedPath
svgCanvas.getSegData = () => { return segData }
svgCanvas.getUIStrings = () => { return uiStrings }
svgCanvas.getPathObj = () => { return path }
@@ -466,14 +467,17 @@ const getRotVals = (x, y) => {
* @returns {void}
*/
export const recalcRotatedPath = () => {
const currentPath = path.elem
const currentPath = path?.elem
if (!currentPath) { return }
angle = getRotationAngle(currentPath, true)
if (!angle) { return }
// selectedBBoxes[0] = path.oldbbox;
const oldbox = path.oldbbox // selectedBBoxes[0],
if (!oldbox) { return }
oldcx = oldbox.x + oldbox.width / 2
oldcy = oldbox.y + oldbox.height / 2
const box = getBBox(currentPath)
if (!box) { return }
newcx = box.x + box.width / 2
newcy = box.y + box.height / 2
@@ -487,6 +491,7 @@ export const recalcRotatedPath = () => {
newcy = r * Math.sin(theta) + oldcy
const list = currentPath.pathSegList
if (!list) { return }
let i = list.numberOfItems
while (i) {
@@ -495,13 +500,33 @@ export const recalcRotatedPath = () => {
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)
const props = segData[type]
if (!props) { continue }
const newVals = {}
if (seg.x !== null && seg.x !== undefined && seg.y !== null && seg.y !== undefined) {
const rvals = getRotVals(seg.x, seg.y)
newVals.x = rvals.x
newVals.y = rvals.y
}
if (seg.x1 !== null && seg.x1 !== undefined && seg.y1 !== null && seg.y1 !== undefined) {
const cVals1 = getRotVals(seg.x1, seg.y1)
newVals.x1 = cVals1.x
newVals.y1 = cVals1.y
}
if (seg.x2 !== null && seg.x2 !== undefined && seg.y2 !== null && seg.y2 !== undefined) {
const cVals2 = getRotVals(seg.x2, seg.y2)
newVals.x2 = cVals2.x
newVals.y2 = cVals2.y
}
const points = props.map((prop) => {
if (Object.prototype.hasOwnProperty.call(newVals, prop)) {
return newVals[prop]
}
const val = seg[prop]
return val === null || val === undefined ? 0 : val
})
replacePathSeg(type, i, points)
} // loop for each point
@@ -512,8 +537,18 @@ export const recalcRotatedPath = () => {
// now we must set the new transform to be rotated around the new center
const Rnc = svgCanvas.getSvgRoot().createSVGTransform()
const tlist = getTransformList(currentPath)
if (!tlist) { return }
Rnc.setRotate((angle * 180.0 / Math.PI), newcx, newcy)
tlist.replaceItem(Rnc, 0)
if (tlist.numberOfItems) {
if (typeof tlist.replaceItem === 'function') {
tlist.replaceItem(Rnc, 0)
} else {
tlist.removeItem(0)
tlist.insertItemBefore(Rnc, 0)
}
} else {
tlist.appendItem(Rnc)
}
}
// ====================================
@@ -571,7 +606,7 @@ export const reorientGrads = (elem, m) => {
}
newgrad.id = svgCanvas.getNextId()
findDefs().append(newgrad)
elem.setAttribute(type, 'url(#' + newgrad.id + ')')
elem.setAttribute(type, `url(#${newgrad.id})`)
}
}
}
@@ -618,7 +653,7 @@ export const convertPath = (pth, toRel) => {
switch (type) {
case 1: // z,Z closepath (Z/z)
d += 'z'
if (lastM && !toRel) {
if (lastM) {
curx = lastM[0]
cury = lastM[1]
}
@@ -765,10 +800,10 @@ const pathDSegment = (letter, points, morePoints, lastPoint) => {
})
let segment = letter + points.join(' ')
if (morePoints) {
segment += ' ' + morePoints.join(' ')
segment += ` ${morePoints.join(' ')}`
}
if (lastPoint) {
segment += ' ' + shortFloat(lastPoint)
segment += ` ${shortFloat(lastPoint)}`
}
return segment
}

View File

@@ -5,7 +5,14 @@
*/
import { convertToNum } from './units.js'
import { getRotationAngle, getBBox, getRefElem } from './utilities.js'
import { NS } from './namespaces.js'
import {
getRotationAngle,
getBBox,
getHref,
getRefElem,
findDefs
} from './utilities.js'
import { BatchCommand, ChangeElementCommand } from './history.js'
import { remapElement } from './coords.js'
import {
@@ -36,20 +43,93 @@ export const init = canvas => {
* @param {string} attr - The clip-path attribute value containing the clipPath's ID
* @param {number} tx - The translation's x value
* @param {number} ty - The translation's y value
* @returns {void}
* @param {Element} elem - The element referencing the clipPath
* @returns {string|undefined} The clip-path attribute used after updates.
*/
export const updateClipPath = (attr, tx, ty) => {
export const updateClipPath = (attr, tx, ty, elem) => {
const clipPath = getRefElem(attr)
if (!clipPath) return
const path = clipPath.firstChild
if (!clipPath) return undefined
if (elem && clipPath.id) {
const svgContent = svgCanvas.getSvgContent?.()
if (svgContent) {
const refSelector = `[clip-path="url(#${clipPath.id})"]`
const users = svgContent.querySelectorAll(refSelector)
if (users.length > 1) {
const newClipPath = clipPath.cloneNode(true)
newClipPath.id = svgCanvas.getNextId()
findDefs().append(newClipPath)
elem.setAttribute('clip-path', `url(#${newClipPath.id})`)
return updateClipPath(`url(#${newClipPath.id})`, tx, ty)
}
}
}
const path = clipPath.firstElementChild
if (!path) return attr
const cpXform = getTransformList(path)
if (!cpXform) {
const tag = (path.tagName || '').toLowerCase()
if (tag === 'rect') {
const x = convertToNum('x', path.getAttribute('x') || 0) + tx
const y = convertToNum('y', path.getAttribute('y') || 0) + ty
path.setAttribute('x', x)
path.setAttribute('y', y)
} else if (tag === 'circle' || tag === 'ellipse') {
const cx = convertToNum('cx', path.getAttribute('cx') || 0) + tx
const cy = convertToNum('cy', path.getAttribute('cy') || 0) + ty
path.setAttribute('cx', cx)
path.setAttribute('cy', cy)
} else if (tag === 'line') {
path.setAttribute('x1', convertToNum('x1', path.getAttribute('x1') || 0) + tx)
path.setAttribute('y1', convertToNum('y1', path.getAttribute('y1') || 0) + ty)
path.setAttribute('x2', convertToNum('x2', path.getAttribute('x2') || 0) + tx)
path.setAttribute('y2', convertToNum('y2', path.getAttribute('y2') || 0) + ty)
} else if (tag === 'polyline' || tag === 'polygon') {
const points = (path.getAttribute('points') || '').trim()
if (points) {
const updated = points.split(/\s+/).map((pair) => {
const [x, y] = pair.split(',')
const nx = Number(x) + tx
const ny = Number(y) + ty
return `${nx},${ny}`
})
path.setAttribute('points', updated.join(' '))
}
} else {
path.setAttribute('transform', `translate(${tx},${ty})`)
}
return attr
}
if (cpXform.numberOfItems) {
const translate = svgCanvas.getSvgRoot().createSVGMatrix()
translate.e = tx
translate.f = ty
const combined = matrixMultiply(transformListToTransform(cpXform).matrix, translate)
const merged = svgCanvas.getSvgRoot().createSVGTransform()
merged.setMatrix(combined)
cpXform.clear()
cpXform.appendItem(merged)
return attr
}
const tag = (path.tagName || '').toLowerCase()
if ((tag === 'polyline' || tag === 'polygon') && !path.points?.numberOfItems) {
const points = (path.getAttribute('points') || '').trim()
if (points) {
const updated = points.split(/\s+/).map((pair) => {
const [x, y] = pair.split(',')
const nx = Number(x) + tx
const ny = Number(y) + ty
return `${nx},${ny}`
})
path.setAttribute('points', updated.join(' '))
}
return
}
const newTranslate = svgCanvas.getSvgRoot().createSVGTransform()
newTranslate.setTranslate(tx, ty)
cpXform.appendItem(newTranslate)
// Update clipPath's dimensions
recalculateDimensions(path)
return attr
}
/**
@@ -60,6 +140,20 @@ export const updateClipPath = (attr, tx, ty) => {
*/
export const recalculateDimensions = selected => {
if (!selected) return null
// Don't recalculate dimensions for groups - this would push their transforms down to children
// Groups should maintain their transform attribute on the group element itself
if (selected.tagName === 'g' || selected.tagName === 'a') {
return null
}
if (
(selected.getAttribute?.('clip-path')) &&
selected.querySelector?.('[clip-path]')
) {
// Keep transforms when clip-paths are present to avoid mutating defs.
return null
}
const svgroot = svgCanvas.getSvgRoot()
const dataStorage = svgCanvas.getDataStorage()
const tlist = getTransformList(selected)
@@ -211,14 +305,310 @@ export const recalculateDimensions = selected => {
// Handle group elements ('g' or 'a')
if ((selected.tagName === 'g' && !gsvg) || selected.tagName === 'a') {
// Group handling code
// [Group handling code remains unchanged]
// For brevity, group handling code is not included here
// Ensure to handle group elements correctly as per original logic
// This includes processing child elements and applying transformations appropriately
// ... [Start of group handling code]
// The group handling code is complex and extensive; it remains the same as in the original code.
// ... [End of group handling code]
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
)
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 === SVGTransform.SVG_TRANSFORM_ROTATE) {
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
if (
N >= 3 &&
tlist.getItem(N - 2).type === SVGTransform.SVG_TRANSFORM_SCALE &&
tlist.getItem(N - 3).type === SVGTransform.SVG_TRANSFORM_TRANSLATE &&
tlist.getItem(N - 1).type === SVGTransform.SVG_TRANSFORM_TRANSLATE
) {
operation = 3 // scale
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)
if (child.nodeType !== 1) continue
const childTlist = getTransformList(child)
if (!childTlist) continue
const m = transformListToTransform(childTlist).matrix
const angle = getRotationAngle(child)
oldStartTransform = svgCanvas.getStartTransform()
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)
} else {
const t2n = matrixMultiply(m.inverse(), tmn, m)
const t2 = svgroot.createSVGMatrix()
t2.e = -t2n.e
t2.f = -t2n.f
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)
}
const recalculatedDimensions = recalculateDimensions(child)
if (recalculatedDimensions) {
batchCmd.addSubCommand(recalculatedDimensions)
}
svgCanvas.setStartTransform(oldStartTransform)
}
tlist.removeItem(N - 1)
tlist.removeItem(N - 2)
tlist.removeItem(N - 3)
} else if (N >= 3 && tlist.getItem(N - 1).type === SVGTransform.SVG_TRANSFORM_MATRIX) {
operation = 3 // scale (matrix imposition)
const m = transformListToTransform(tlist).matrix
const e2t = svgroot.createSVGTransform()
e2t.setMatrix(m)
tlist.clear()
tlist.appendItem(e2t)
} else if (
(N === 1 ||
(N > 1 && tlist.getItem(1).type !== SVGTransform.SVG_TRANSFORM_SCALE)) &&
tlist.getItem(0).type === SVGTransform.SVG_TRANSFORM_TRANSLATE
) {
operation = 2 // translate
const tM = transformListToTransform(tlist).matrix
tlist.removeItem(0)
const mInv = transformListToTransform(tlist).matrix.inverse()
const m2 = matrixMultiply(mInv, tM)
tx = m2.e
ty = m2.f
if (tx !== 0 || ty !== 0) {
const selectedClipPath = selected.getAttribute?.('clip-path')
if (selectedClipPath) {
updateClipPath(selectedClipPath, tx, ty, selected)
}
const children = selected.childNodes
let c = children.length
const clipPathsDone = []
while (c--) {
const child = children.item(c)
if (child.nodeType !== 1) continue
const clipPathAttr = child.getAttribute('clip-path')
if (clipPathAttr && !clipPathsDone.includes(clipPathAttr)) {
const updatedAttr = updateClipPath(clipPathAttr, tx, ty, child)
clipPathsDone.push(updatedAttr || clipPathAttr)
}
const childTlist = getTransformList(child)
if (!childTlist) continue
oldStartTransform = svgCanvas.getStartTransform()
svgCanvas.setStartTransform(child.getAttribute('transform'))
const newxlate = svgroot.createSVGTransform()
newxlate.setTranslate(tx, ty)
if (childTlist.numberOfItems) {
childTlist.insertItemBefore(newxlate, 0)
} else {
childTlist.appendItem(newxlate)
}
const recalculatedDimensions = recalculateDimensions(child)
if (recalculatedDimensions) {
batchCmd.addSubCommand(recalculatedDimensions)
}
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)
const useTlist = getTransformList(useElem)
useTlist?.insertItemBefore(usexlate, 0)
const useRecalc = recalculateDimensions(useElem)
if (useRecalc) {
batchCmd.addSubCommand(useRecalc)
}
}
}
svgCanvas.setStartTransform(oldStartTransform)
}
}
} else if (
N === 1 &&
tlist.getItem(0).type === SVGTransform.SVG_TRANSFORM_MATRIX &&
!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) continue
const childTlist = getTransformList(child)
if (!childTlist) continue
oldStartTransform = svgCanvas.getStartTransform()
svgCanvas.setStartTransform(child.getAttribute('transform'))
const em = matrixMultiply(m, transformListToTransform(childTlist).matrix)
const e2m = svgroot.createSVGTransform()
e2m.setMatrix(em)
childTlist.clear()
childTlist.appendItem(e2m)
const recalculatedDimensions = recalculateDimensions(child)
if (recalculatedDimensions) {
batchCmd.addSubCommand(recalculatedDimensions)
}
svgCanvas.setStartTransform(oldStartTransform)
const sw = child.getAttribute('stroke-width')
if (child.getAttribute('stroke') !== 'none' && !Number.isNaN(Number(sw))) {
const avg = (Math.abs(em.a) + Math.abs(em.d)) / 2
child.setAttribute('stroke-width', Number(sw) * avg)
}
}
tlist.clear()
} 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 (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)
}
}
} 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) {
const children = selected.childNodes
let c = children.length
while (c--) {
const child = children.item(c)
if (child.nodeType !== 1) continue
const childTlist = getTransformList(child)
if (!childTlist) continue
oldStartTransform = svgCanvas.getStartTransform()
svgCanvas.setStartTransform(child.getAttribute('transform'))
const newxlate = svgroot.createSVGTransform()
newxlate.setTranslate(tx, ty)
if (childTlist.numberOfItems) {
childTlist.insertItemBefore(newxlate, 0)
} else {
childTlist.appendItem(newxlate)
}
const recalculatedDimensions = recalculateDimensions(child)
if (recalculatedDimensions) {
batchCmd.addSubCommand(recalculatedDimensions)
}
svgCanvas.setStartTransform(oldStartTransform)
}
}
if (gangle) {
if (tlist.numberOfItems) {
tlist.insertItemBefore(rnew, 0)
} else {
tlist.appendItem(rnew)
}
}
}
} else {
// Non-group elements

View File

@@ -8,6 +8,7 @@
import { getReverseNS, NS } from './namespaces.js'
import { getHref, getRefElem, setHref, getUrlFromAttr } from './utilities.js'
import { warn } from '../common/logger.js'
const REVERSE_NS = getReverseNS()
@@ -130,22 +131,24 @@ const svgWhiteList_ = {
}
// add generic attributes to all elements of the whitelist
Object.keys(svgWhiteList_).forEach((element) => { svgWhiteList_[element] = [...svgWhiteList_[element], ...svgGenericWhiteList] })
for (const [element, attrs] of Object.entries(svgWhiteList_)) {
svgWhiteList_[element] = [...attrs, ...svgGenericWhiteList]
}
// Produce a Namespace-aware version of svgWhitelist
const svgWhiteListNS_ = {}
Object.entries(svgWhiteList_).forEach(([elt, atts]) => {
for (const [elt, atts] of Object.entries(svgWhiteList_)) {
const attNS = {}
Object.entries(atts).forEach(([_i, att]) => {
for (const att of atts) {
if (att.includes(':')) {
const v = att.split(':')
attNS[v[1]] = NS[(v[0]).toUpperCase()]
const [prefix, localName] = att.split(':')
attNS[localName] = NS[prefix.toUpperCase()]
} else {
attNS[att] = att === 'xmlns' ? NS.XMLNS : null
}
})
}
svgWhiteListNS_[elt] = attNS
})
}
/**
* Sanitizes the input node and its children.
@@ -205,7 +208,7 @@ export const sanitizeSvg = (node) => {
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.outerHTML}`)
warn(`attribute ${attrName} in element ${node.nodeName} not in whitelist is removed: ${node.outerHTML}`, null, 'sanitize')
node.removeAttributeNS(attrNsURI, attrLocalName)
}
}
@@ -247,14 +250,14 @@ export const sanitizeSvg = (node) => {
'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.outerHTML}`)
warn(`attribute href in element ${node.nodeName} pointing to a non-local reference (${href}) is removed: ${node.outerHTML}`, null, 'sanitize')
node.removeAttributeNS(NS.XLINK, 'href')
node.removeAttribute('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 or href is removed: ${node.outerHTML}`)
warn(`element ${node.nodeName} without a xlink:href or href is removed: ${node.outerHTML}`, null, 'sanitize')
node.remove()
return
}
@@ -285,7 +288,7 @@ export const sanitizeSvg = (node) => {
// 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.outerHTML}`)
warn(`attribute ${attr} in element ${node.nodeName} pointing to a non-local reference (${val}) is removed: ${node.outerHTML}`, null, 'sanitize')
node.removeAttribute(attr)
}
}
@@ -298,7 +301,7 @@ export const sanitizeSvg = (node) => {
} 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: ${node.outerHTML}`)
warn(`element ${node.nodeName} not supported is removed: ${node.outerHTML}`, null, 'sanitize')
const children = []
while (node.hasChildNodes()) {
children.push(parent.insertBefore(node.firstChild, node))

View File

@@ -10,12 +10,37 @@ import { isWebkit } from '../common/browser.js'
import { getRotationAngle, getBBox, getStrokedBBox } from './utilities.js'
import { transformListToTransform, transformBox, transformPoint, matrixMultiply, getTransformList } from './math.js'
import { NS } from './namespaces'
import { warn } from '../common/logger.js'
let svgCanvas
let selectorManager_ // A Singleton
// change radius if touch screen
const gripRadius = window.ontouchstart ? 10 : 4
/**
* Private singleton manager for selector state
*/
class SelectModule {
#selectorManager = null
/**
* Initialize the select module with canvas
* @param {Object} canvas - The SVG canvas instance
* @returns {void}
*/
init (canvas) {
svgCanvas = canvas
this.#selectorManager = new SelectorManager()
}
/**
* Get the singleton SelectorManager instance
* @returns {SelectorManager} The SelectorManager instance
*/
getSelectorManager () {
return this.#selectorManager
}
}
/**
* Private class for DOM element selection boxes.
*/
@@ -38,14 +63,14 @@ export class Selector {
// 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) }
attr: { id: `selectorGroup${this.id}` }
})
// this holds a reference to the path rect
this.selectorRect = svgCanvas.createSVGElement({
element: 'path',
attr: {
id: ('selectedBox' + this.id),
id: `selectedBox${this.id}`,
fill: 'none',
stroke: '#22C',
'stroke-width': '1',
@@ -91,11 +116,11 @@ export class Selector {
*/
showGrips (show) {
const bShow = show ? 'inline' : 'none'
selectorManager_.selectorGripsGroup.setAttribute('display', bShow)
selectModule.getSelectorManager().selectorGripsGroup.setAttribute('display', bShow)
const elem = this.selectedElement
this.hasGrips = show
if (elem && show) {
this.selectorGroup.append(selectorManager_.selectorGripsGroup)
this.selectorGroup.append(selectModule.getSelectorManager().selectorGripsGroup)
Selector.updateGripCursors(getRotationAngle(elem))
}
}
@@ -108,7 +133,7 @@ export class Selector {
resize (bbox) {
const dataStorage = svgCanvas.getDataStorage()
const selectedBox = this.selectorRect
const mgr = selectorManager_
const mgr = selectModule.getSelectorManager()
const selectedGrips = mgr.selectorGrips
const selected = this.selectedElement
const zoom = svgCanvas.getZoom()
@@ -130,7 +155,7 @@ export class Selector {
while (currentElt.parentNode) {
if (currentElt.parentNode && currentElt.parentNode.tagName === 'g' && currentElt.parentNode.transform) {
if (currentElt.parentNode.transform.baseVal.numberOfItems) {
parentTransformationMatrix = matrixMultiply(transformListToTransform(getTransformList(selected.parentNode)).matrix, parentTransformationMatrix)
parentTransformationMatrix = matrixMultiply(transformListToTransform(getTransformList(currentElt.parentNode)).matrix, parentTransformationMatrix)
}
}
currentElt = currentElt.parentNode
@@ -213,10 +238,7 @@ export class Selector {
nbah = (maxy - miny)
}
const dstr = 'M' + nbax + ',' + nbay +
' L' + (nbax + nbaw) + ',' + nbay +
' ' + (nbax + nbaw) + ',' + (nbay + nbah) +
' ' + nbax + ',' + (nbay + nbah) + 'z'
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(',') + ')' : ''
@@ -257,15 +279,15 @@ export class Selector {
* @returns {void}
*/
static updateGripCursors (angle) {
const dirArr = Object.keys(selectorManager_.selectorGrips)
const dirArr = Object.keys(selectModule.getSelectorManager().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'))
Object.values(selectModule.getSelectorManager().selectorGrips).forEach((gripElement, i) => {
gripElement.setAttribute('style', `cursor:${dirArr[i]}-resize`)
})
}
}
@@ -341,10 +363,10 @@ export class SelectorManager {
const grip = svgCanvas.createSVGElement({
element: 'circle',
attr: {
id: ('selectorGrip_resize_' + dir),
id: `selectorGrip_resize_${dir}`,
fill: '#22C',
r: gripRadius,
style: ('cursor:' + dir + '-resize'),
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
@@ -462,7 +484,7 @@ export class SelectorManager {
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')
warn('WARNING! selector was released but was already unlocked', null, 'select')
}
for (let i = 0; i < N; ++i) {
if (this.selectors[i] && this.selectors[i] === sel) {
@@ -541,6 +563,9 @@ export class SelectorManager {
* @property {module:select.Dimensions} dimensions
*/
// Export singleton instance for backward compatibility
const selectModule = new SelectModule()
/**
* Initializes this module.
* @function module:select.init
@@ -549,12 +574,11 @@ export class SelectorManager {
* @returns {void}
*/
export const init = (canvas) => {
svgCanvas = canvas
selectorManager_ = new SelectorManager()
selectModule.init(canvas)
}
/**
* @function module:select.getSelectorManager
* @returns {module:select.SelectorManager} The SelectorManager instance.
*/
export const getSelectorManager = () => selectorManager_
export const getSelectorManager = () => selectModule.getSelectorManager()

View File

@@ -9,6 +9,7 @@
import { NS } from './namespaces.js'
import * as hstry from './history.js'
import * as pathModule from './path.js'
import { warn, error } from '../common/logger.js'
import {
getStrokedBBoxDefaultVisible,
setHref,
@@ -104,14 +105,17 @@ const moveToBottomSelectedElem = () => {
let t = selected
const oldParent = t.parentNode
const oldNextSibling = t.nextSibling
let { firstChild } = t.parentNode
if (firstChild.tagName === 'title') {
firstChild = firstChild.nextSibling
let firstChild = t.parentNode.firstElementChild
if (firstChild?.tagName === 'title') {
firstChild = firstChild.nextElementSibling
}
// This can probably be removed, as the defs should not ever apppear
// inside a layer group
if (firstChild.tagName === 'defs') {
firstChild = firstChild.nextSibling
if (firstChild?.tagName === 'defs') {
firstChild = firstChild.nextElementSibling
}
if (!firstChild) {
return
}
t = t.parentNode.insertBefore(t, firstChild)
// If the element actually moved position, add the command and fire the changed
@@ -179,7 +183,7 @@ const moveUpDownSelected = dir => {
// event handler.
if (oldNextSibling !== t.nextSibling) {
svgCanvas.addCommandToHistory(
new MoveElementCommand(t, oldNextSibling, oldParent, 'Move ' + dir)
new MoveElementCommand(t, oldNextSibling, oldParent, `Move ${dir}`)
)
svgCanvas.call('changed', [t])
}
@@ -208,6 +212,9 @@ const moveSelectedElements = (dx, dy, undoable = true) => {
const batchCmd = new BatchCommand('position')
selectedElements.forEach((selected, i) => {
if (selected) {
// Store the existing transform before modifying
const existingTransform = selected.getAttribute('transform') || ''
const xform = svgCanvas.getSvgRoot().createSVGTransform()
const tlist = getTransformList(selected)
@@ -227,6 +234,12 @@ const moveSelectedElements = (dx, dy, undoable = true) => {
const cmd = recalculateDimensions(selected)
if (cmd) {
batchCmd.addSubCommand(cmd)
} else if ((selected.getAttribute('transform') || '') !== existingTransform) {
// For groups and other elements where recalculateDimensions returns null,
// record the transform change directly
batchCmd.addSubCommand(
new ChangeElementCommand(selected, { transform: existingTransform })
)
}
svgCanvas
@@ -265,9 +278,11 @@ const cloneSelectedElements = (x, y) => {
const index = el => {
if (!el) return -1
let i = 0
let current = el
do {
i++
} while (el === el.previousElementSibling)
current = current.previousElementSibling
} while (current)
return i
}
@@ -702,7 +717,7 @@ const flipSelectedElements = (scaleX, scaleY) => {
* @returns {void}
*/
const copySelectedElements = () => {
const selectedElements = svgCanvas.getSelectedElements()
const selectedElements = svgCanvas.getSelectedElements().filter(Boolean)
const data = JSON.stringify(
selectedElements.map(x => svgCanvas.getJsonFromSvgElements(x))
)
@@ -712,7 +727,7 @@ const copySelectedElements = () => {
// Context menu might not exist (it is provided by editor.js).
const canvMenu = document.getElementById('se-cmenu_canvas')
canvMenu.setAttribute('enablemenuitems', '#paste,#paste_in_place')
canvMenu?.setAttribute('enablemenuitems', '#paste,#paste_in_place')
}
/**
@@ -866,10 +881,10 @@ const pushGroupProperty = (g, undoable) => {
// Change this in future for different filters
const suffix =
blurElem?.tagName === 'feGaussianBlur' ? 'blur' : 'filter'
gfilter.id = elem.id + '_' + suffix
gfilter.id = `${elem.id}_${suffix}`
svgCanvas.changeSelectedAttribute(
'filter',
'url(#' + gfilter.id + ')',
`url(#${gfilter.id})`,
[elem]
)
}
@@ -976,20 +991,29 @@ const pushGroupProperty = (g, undoable) => {
changes = {}
changes.transform = oldxform || ''
// Simply prepend the group's transform to the child's transform list
// New transform = [group transform] [child transform]
// This preserves the correct application order
const newxform = svgCanvas.getSvgRoot().createSVGTransform()
newxform.setMatrix(m)
// [ gm ] [ chm ] = [ chm ] [ gm' ]
// [ gm' ] = [ chmInv ] [ gm ] [ chm ]
const chm = transformListToTransform(chtlist).matrix
const chmInv = chm.inverse()
const gm = matrixMultiply(chmInv, m, chm)
newxform.setMatrix(gm)
chtlist.appendItem(newxform)
}
const cmd = recalculateDimensions(elem)
if (cmd) {
batchCmd.addSubCommand(cmd)
// Insert group's transform at the beginning of child's transform list
if (chtlist.numberOfItems) {
chtlist.insertItemBefore(newxform, 0)
} else {
chtlist.appendItem(newxform)
}
// Record the transform change for undo/redo
if (undoable) {
batchCmd.addSubCommand(new ChangeElementCommand(elem, changes))
}
}
// NOTE: We intentionally do NOT call recalculateDimensions here because:
// 1. It reorders transforms (moves rotate before translate), changing the visual result
// 2. It recalculates rotation centers, causing elements to jump
// 3. The prepended group transform is already in the correct position
// Just leave the transforms as-is after prepending the group's transform
}
}
@@ -1047,6 +1071,10 @@ const convertToGroup = elem => {
svgCanvas.call('selected', [elem])
} else if (dataStorage.has($elem, 'symbol')) {
elem = dataStorage.get($elem, 'symbol')
if (!elem) {
warn('Unable to convert <use>: missing symbol reference', null, 'selected-elem')
return
}
ts = $elem.getAttribute('transform') || ''
const pos = {
@@ -1065,14 +1093,15 @@ const convertToGroup = elem => {
// Not ideal, but works
ts += ' translate(' + (pos.x || 0) + ',' + (pos.y || 0) + ')'
const prev = $elem.previousElementSibling
const useParent = $elem.parentNode
const useNextSibling = $elem.nextSibling
// Remove <use> element
batchCmd.addSubCommand(
new RemoveElementCommand(
$elem,
$elem.nextElementSibling,
$elem.parentNode
useNextSibling,
useParent
)
)
$elem.remove()
@@ -1124,7 +1153,9 @@ const convertToGroup = elem => {
// now give the g itself a new id
g.id = svgCanvas.getNextId()
prev.after(g)
if (useParent) {
useParent.insertBefore(g, useNextSibling)
}
if (parent) {
if (!hasMore) {
@@ -1152,7 +1183,7 @@ const convertToGroup = elem => {
try {
recalculateDimensions(n)
} catch (e) {
console.error(e)
error('Error recalculating dimensions', e, 'selected-elem')
}
})
@@ -1173,7 +1204,7 @@ const convertToGroup = elem => {
svgCanvas.addCommandToHistory(batchCmd)
} else {
console.warn('Unexpected element to ungroup:', elem)
warn('Unexpected element to ungroup:', elem, 'selected-elem')
}
}
@@ -1197,7 +1228,16 @@ const ungroupSelectedElement = () => {
}
if (g.tagName === 'use') {
// Somehow doesn't have data set, so retrieve
const symbol = getElement(getHref(g).substr(1))
const href = getHref(g)
if (!href || !href.startsWith('#')) {
warn('Unexpected <use> without local reference:', g, 'selected-elem')
return
}
const symbol = getElement(href.slice(1))
if (!symbol) {
warn('Unexpected <use> without resolved reference:', g, 'selected-elem')
return
}
dataStorage.put(g, 'symbol', symbol)
dataStorage.put(g, 'ref', symbol)
convertToGroup(g)
@@ -1281,7 +1321,7 @@ const updateCanvas = (w, h) => {
height: svgCanvas.contentH * zoom,
x,
y,
viewBox: '0 0 ' + svgCanvas.contentW + ' ' + svgCanvas.contentH
viewBox: `0 0 ${svgCanvas.contentW} ${svgCanvas.contentH}`
})
assignAttributes(bg, {
@@ -1301,7 +1341,7 @@ const updateCanvas = (w, h) => {
svgCanvas.selectorManager.selectorParentGroup.setAttribute(
'transform',
'translate(' + x + ',' + y + ')'
`translate(${x},${y})`
)
/**

View File

@@ -409,8 +409,11 @@ const setRotationAngle = (val, preventUndo) => {
cy,
transformListToTransform(tlist).matrix
)
// Safety check: if center coordinates are invalid (NaN), fall back to untransformed bbox center
const centerX = Number.isFinite(center.x) ? center.x : cx
const centerY = Number.isFinite(center.y) ? center.y : cy
const Rnc = svgCanvas.getSvgRoot().createSVGTransform()
Rnc.setRotate(val, center.x, center.y)
Rnc.setRotate(val, centerX, centerY)
if (tlist.numberOfItems) {
tlist.insertItemBefore(Rnc, 0)
} else {
@@ -424,13 +427,20 @@ const setRotationAngle = (val, 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]}`
// Only do this manipulation if the first transform is actually a rotation
if (newTransform && newTransform.startsWith('rotate(')) {
const match = newTransform.match(/^rotate\(([\d.\-e]+)\s+([\d.\-e]+)\s+([\d.\-e]+)\)(.*)/)
if (match) {
const angle = Number.parseFloat(match[1])
const round = (num) => Math.round(Number(num) + Number.EPSILON)
const x = round(match[2])
const y = round(match[3])
const restOfTransform = match[4] || '' // Preserve any transforms after the rotate
newTransform = `rotate(${angle} ${x} ${y})${restOfTransform}`
}
}
if (oldTransform) {

View File

@@ -8,6 +8,7 @@
import { jsPDF as JsPDF } from 'jspdf'
import 'svg2pdf.js'
import * as history from './history.js'
import { error } from '../common/logger.js'
import {
text2xml,
cleanupElement,
@@ -131,7 +132,7 @@ const svgToString = (elem, indent) => {
const nsMap = svgCanvas.getNsMap()
const out = []
const unit = curConfig.baseUnit
const unitRe = new RegExp('^-?[\\d\\.]+' + unit + '$')
const unitRe = new RegExp(`^-?[\\d\\.]+${unit}$`)
if (elem) {
cleanupElement(elem)
@@ -164,7 +165,10 @@ const svgToString = (elem, indent) => {
// }
if (curConfig.dynamicOutput) {
vb = elem.getAttribute('viewBox')
out.push(' viewBox="' + vb + '" xmlns="' + NS.SVG + '"')
if (!vb) {
vb = [0, 0, res.w, res.h].join(' ')
}
out.push(` viewBox="${vb}" xmlns="${NS.SVG}"`)
} else {
if (unit !== 'px') {
res.w = convertUnit(res.w, unit) + unit
@@ -193,14 +197,14 @@ const svgToString = (elem, indent) => {
nsMap[uri] !== 'xml'
) {
nsuris[uri] = true
out.push(' xmlns:' + nsMap[uri] + '="' + uri + '"')
out.push(` xmlns:${nsMap[uri]}="${uri}"`)
}
if (el.attributes.length > 0) {
for (const [, attr] of Object.entries(el.attributes)) {
const u = attr.namespaceURI
if (u && !nsuris[u] && nsMap[u] !== 'xmlns' && nsMap[u] !== 'xml') {
nsuris[u] = true
out.push(' xmlns:' + nsMap[u] + '="' + u + '"')
out.push(` xmlns:${nsMap[u]}="${u}"`)
}
}
}
@@ -469,7 +473,7 @@ const setSvgString = (xmlString, preventUndo) => {
Object.entries(ids).forEach(([key, value]) => {
if (value > 1) {
const nodes = content.querySelectorAll('[id="' + key + '"]')
const nodes = content.querySelectorAll(`[id="${key}"]`)
for (let i = 1; i < nodes.length; i++) {
nodes[i].setAttribute('id', svgCanvas.getNextId())
}
@@ -525,14 +529,20 @@ const setSvgString = (xmlString, preventUndo) => {
if (content.getAttribute('viewBox')) {
const viBox = content.getAttribute('viewBox')
const vb = viBox.split(/[ ,]+/)
attrs.width = vb[2]
attrs.height = vb[3]
const vbWidth = Number(vb[2])
const vbHeight = Number(vb[3])
if (Number.isFinite(vbWidth)) {
attrs.width = vbWidth
}
if (Number.isFinite(vbHeight)) {
attrs.height = vbHeight
}
// handle content that doesn't have a viewBox
} else {
;['width', 'height'].forEach(dim => {
// Set to 100 if not given
const val = content.getAttribute(dim) || '100%'
if (String(val).substr(-1) === '%') {
if (String(val).slice(-1) === '%') {
// Use user units if percentage given
percs = true
} else {
@@ -558,16 +568,25 @@ const setSvgString = (xmlString, preventUndo) => {
// Percentage width/height, so let's base it on visible elements
if (percs) {
const bb = getStrokedBBoxDefaultVisible()
attrs.width = bb.width + bb.x
attrs.height = bb.height + bb.y
if (bb && typeof bb === 'object') {
attrs.width = bb.width + bb.x
attrs.height = bb.height + bb.y
} else {
if (attrs.width === null || attrs.width === undefined) {
attrs.width = 100
}
if (attrs.height === null || attrs.height === undefined) {
attrs.height = 100
}
}
}
// Just in case negative numbers are given or
// result from the percs calculation
if (attrs.width <= 0) {
if (!Number.isFinite(attrs.width) || attrs.width <= 0) {
attrs.width = 100
}
if (attrs.height <= 0) {
if (!Number.isFinite(attrs.height) || attrs.height <= 0) {
attrs.height = 100
}
@@ -596,7 +615,7 @@ const setSvgString = (xmlString, preventUndo) => {
if (!preventUndo) svgCanvas.addCommandToHistory(batchCmd)
svgCanvas.call('sourcechanged', [svgCanvas.getSvgContent()])
} catch (e) {
console.error(e)
error('Error setting SVG string', e, 'svg-exec')
return false
}
@@ -666,16 +685,26 @@ const importSvgString = (xmlString, preserveDimension) => {
// TODO: properly handle preserveAspectRatio
const // canvasw = +svgContent.getAttribute('width'),
canvash = Number(svgCanvas.getSvgContent().getAttribute('height'))
rawCanvash = Number(svgCanvas.getSvgContent().getAttribute('height'))
const canvash =
Number.isFinite(rawCanvash) && rawCanvash > 0
? rawCanvash
: (Number(svgCanvas.getCurConfig().dimensions?.[1]) || 100)
// imported content should be 1/3 of the canvas on its largest dimension
const vbWidth = vb[2]
const vbHeight = vb[3]
const importW = Number.isFinite(vbWidth) && vbWidth > 0 ? vbWidth : (innerw > 0 ? innerw : 100)
const importH = Number.isFinite(vbHeight) && vbHeight > 0 ? vbHeight : (innerh > 0 ? innerh : 100)
const safeImportW = Number.isFinite(importW) && importW > 0 ? importW : 100
const safeImportH = Number.isFinite(importH) && importH > 0 ? importH : 100
ts =
innerh > innerw
? 'scale(' + canvash / 3 / vb[3] + ')'
: 'scale(' + canvash / 3 / vb[2] + ')'
safeImportH > safeImportW
? 'scale(' + canvash / 3 / safeImportH + ')'
: 'scale(' + canvash / 3 / safeImportW + ')'
// Hack to make recalculateDimensions understand how to scale
ts = 'translate(0) ' + ts + ' translate(0)'
ts = `translate(0) ${ts} translate(0)`
symbol = svgCanvas.getDOMDocument().createElementNS(NS.SVG, 'symbol')
const defs = findDefs()
@@ -738,7 +767,7 @@ const importSvgString = (xmlString, preserveDimension) => {
svgCanvas.addCommandToHistory(batchCmd)
svgCanvas.call('changed', [svgCanvas.getSvgContent()])
} catch (e) {
console.error(e)
error('Error importing SVG string', e, 'svg-exec')
return null
}
@@ -865,8 +894,8 @@ const convertImagesToBase64 = async svgElement => {
}
reader.readAsDataURL(blob)
})
} catch (error) {
console.error('Failed to fetch image:', error)
} catch (err) {
error('Failed to fetch image', err, 'svg-exec')
}
}
})
@@ -905,10 +934,14 @@ const rasterExport = (
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('Canvas 2D context not available'))
return
}
const width = svgElement.clientWidth || svgElement.getAttribute('width')
const height =
svgElement.clientHeight || svgElement.getAttribute('height')
const res = svgCanvas.getResolution()
const width = res.w
const height = res.h
canvas.width = width
canvas.height = height
@@ -1013,7 +1046,7 @@ const exportPDF = (
}
img.onerror = err => {
console.error('Failed to load SVG into image element:', err)
error('Failed to load SVG into image element', err, 'svg-exec')
reject(err)
}
@@ -1112,7 +1145,7 @@ const uniquifyElemsMethod = g => {
let j = attrs.length
while (j--) {
const attr = attrs[j]
attr.ownerElement.setAttribute(attr.name, 'url(#' + newid + ')')
attr.ownerElement.setAttribute(attr.name, `url(#${newid})`)
}
// remap all href attributes
@@ -1142,7 +1175,11 @@ const setUseDataMethod = parent => {
Array.prototype.forEach.call(elems, (el, _) => {
const dataStorage = svgCanvas.getDataStorage()
const id = svgCanvas.getHref(el).substr(1)
const href = svgCanvas.getHref(el)
if (!href || !href.startsWith('#')) {
return
}
const id = href.substr(1)
const refElem = svgCanvas.getElement(id)
if (!refElem) {
return
@@ -1301,6 +1338,41 @@ const convertGradientsMethod = elem => {
grad.setAttribute('x2', (gCoords.x2 - bb.x) / bb.width)
grad.setAttribute('y2', (gCoords.y2 - bb.y) / bb.height)
grad.removeAttribute('gradientUnits')
} else if (grad.tagName === 'radialGradient') {
const getNum = (value, fallback) => {
const num = Number(value)
return Number.isFinite(num) ? num : fallback
}
let cx = getNum(grad.getAttribute('cx'), 0.5)
let cy = getNum(grad.getAttribute('cy'), 0.5)
let r = getNum(grad.getAttribute('r'), 0.5)
let fx = getNum(grad.getAttribute('fx'), cx)
let fy = getNum(grad.getAttribute('fy'), cy)
// If has transform, convert
const tlist = getTransformList(grad)
if (tlist?.numberOfItems > 0) {
const m = transformListToTransform(tlist).matrix
const cpt = transformPoint(cx, cy, m)
const fpt = transformPoint(fx, fy, m)
const rpt = transformPoint(cx + r, cy, m)
cx = cpt.x
cy = cpt.y
fx = fpt.x
fy = fpt.y
r = Math.hypot(rpt.x - cpt.x, rpt.y - cpt.y)
grad.removeAttribute('gradientTransform')
}
if (!bb.width || !bb.height) {
return
}
grad.setAttribute('cx', (cx - bb.x) / bb.width)
grad.setAttribute('cy', (cy - bb.y) / bb.height)
grad.setAttribute('fx', (fx - bb.x) / bb.width)
grad.setAttribute('fy', (fy - bb.y) / bb.height)
grad.setAttribute('r', r / Math.max(bb.width, bb.height))
grad.removeAttribute('gradientUnits')
}
}
})

View File

@@ -14,7 +14,7 @@ import { text2xml } from './utilities.js'
* @param {ArgumentsArray} dimensions - dimensions of width and height
* @returns {svgRootElement}
*/
export const svgRootElement = function (svgdoc, dimensions) {
export const svgRootElement = (svgdoc, dimensions) => {
return svgdoc.importNode(
text2xml(
`<svg id="svgroot" xmlns="${NS.SVG}" xlinkns="${NS.XLINK}" width="${dimensions[0]}"

View File

@@ -28,68 +28,69 @@ export const init = canvas => {
/**
* Group: Text edit functions
* Functions relating to editing text elements.
* @namespace {PlainObject} textActions
* @class 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
class TextActions {
#curtext = null
#textinput = null
#cursor = null
#selblock = null
#blinker = null
#chardata = []
#textbb = null // , transbb;
#matrix = null
#lastX = null
#lastY = null
#allowDbl = false
/**
*
* @param {Integer} index
* @returns {void}
* @private
*/
function setCursor (index) {
const empty = textinput.value === ''
textinput.focus()
#setCursor = (index = undefined) => {
const empty = this.#textinput.value === ''
this.#textinput.focus()
if (!arguments.length) {
if (index === undefined) {
if (empty) {
index = 0
} else {
if (textinput.selectionEnd !== textinput.selectionStart) {
if (this.#textinput.selectionEnd !== this.#textinput.selectionStart) {
return
}
index = textinput.selectionEnd
index = this.#textinput.selectionEnd
}
}
const charbb = chardata[index]
const charbb = this.#chardata[index]
if (!empty) {
textinput.setSelectionRange(index, index)
this.#textinput.setSelectionRange(index, index)
}
cursor = getElement('text_cursor')
if (!cursor) {
cursor = document.createElementNS(NS.SVG, 'line')
assignAttributes(cursor, {
this.#cursor = getElement('text_cursor')
if (!this.#cursor) {
this.#cursor = document.createElementNS(NS.SVG, 'line')
assignAttributes(this.#cursor, {
id: 'text_cursor',
stroke: '#333',
'stroke-width': 1
})
getElement('selectorParentGroup').append(cursor)
getElement('selectorParentGroup').append(this.#cursor)
}
if (!blinker) {
blinker = setInterval(function () {
const show = cursor.getAttribute('display') === 'none'
cursor.setAttribute('display', show ? 'inline' : 'none')
if (!this.#blinker) {
this.#blinker = setInterval(() => {
const show = this.#cursor.getAttribute('display') === 'none'
this.#cursor.setAttribute('display', show ? 'inline' : 'none')
}, 600)
}
const startPt = ptToScreen(charbb.x, textbb.y)
const endPt = ptToScreen(charbb.x, textbb.y + textbb.height)
const startPt = this.#ptToScreen(charbb.x, this.#textbb.y)
const endPt = this.#ptToScreen(charbb.x, this.#textbb.y + this.#textbb.height)
assignAttributes(cursor, {
assignAttributes(this.#cursor, {
x1: startPt.x,
y1: startPt.y,
x2: endPt.x,
@@ -98,8 +99,8 @@ export const textActionsMethod = (function () {
display: 'inline'
})
if (selblock) {
selblock.setAttribute('d', '')
if (this.#selblock) {
this.#selblock.setAttribute('d', '')
}
}
@@ -109,40 +110,41 @@ export const textActionsMethod = (function () {
* @param {Integer} end
* @param {boolean} skipInput
* @returns {void}
* @private
*/
function setSelection (start, end, skipInput) {
#setSelection = (start, end, skipInput) => {
if (start === end) {
setCursor(end)
this.#setCursor(end)
return
}
if (!skipInput) {
textinput.setSelectionRange(start, end)
this.#textinput.setSelectionRange(start, end)
}
selblock = getElement('text_selectblock')
if (!selblock) {
selblock = document.createElementNS(NS.SVG, 'path')
assignAttributes(selblock, {
this.#selblock = getElement('text_selectblock')
if (!this.#selblock) {
this.#selblock = document.createElementNS(NS.SVG, 'path')
assignAttributes(this.#selblock, {
id: 'text_selectblock',
fill: 'green',
opacity: 0.5,
style: 'pointer-events:none'
})
getElement('selectorParentGroup').append(selblock)
getElement('selectorParentGroup').append(this.#selblock)
}
const startbb = chardata[start]
const endbb = chardata[end]
const startbb = this.#chardata[start]
const endbb = this.#chardata[end]
cursor.setAttribute('visibility', 'hidden')
this.#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(
const tl = this.#ptToScreen(startbb.x, this.#textbb.y)
const tr = this.#ptToScreen(startbb.x + (endbb.x - startbb.x), this.#textbb.y)
const bl = this.#ptToScreen(startbb.x, this.#textbb.y + this.#textbb.height)
const br = this.#ptToScreen(
startbb.x + (endbb.x - startbb.x),
textbb.y + textbb.height
this.#textbb.y + this.#textbb.height
)
const dstr =
@@ -164,7 +166,7 @@ export const textActionsMethod = (function () {
bl.y +
'z'
assignAttributes(selblock, {
assignAttributes(this.#selblock, {
d: dstr,
display: 'inline'
})
@@ -175,29 +177,30 @@ export const textActionsMethod = (function () {
* @param {Float} mouseX
* @param {Float} mouseY
* @returns {Integer}
* @private
*/
function getIndexFromPoint (mouseX, mouseY) {
#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) {
if (this.#chardata.length === 1) {
return 0
}
// Determine if cursor should be on left or right of character
let charpos = curtext.getCharNumAtPosition(pt)
let charpos = this.#curtext.getCharNumAtPosition(pt)
if (charpos < 0) {
// Out of text range, look at mouse coords
charpos = chardata.length - 2
if (mouseX <= chardata[0].x) {
charpos = this.#chardata.length - 2
if (mouseX <= this.#chardata[0].x) {
charpos = 0
}
} else if (charpos >= chardata.length - 2) {
charpos = chardata.length - 2
} else if (charpos >= this.#chardata.length - 2) {
charpos = this.#chardata.length - 2
}
const charbb = chardata[charpos]
const charbb = this.#chardata[charpos]
const mid = charbb.x + charbb.width / 2
if (mouseX > mid) {
charpos++
@@ -210,9 +213,10 @@ export const textActionsMethod = (function () {
* @param {Float} mouseX
* @param {Float} mouseY
* @returns {void}
* @private
*/
function setCursorFromPoint (mouseX, mouseY) {
setCursor(getIndexFromPoint(mouseX, mouseY))
#setCursorFromPoint = (mouseX, mouseY) => {
this.#setCursor(this.#getIndexFromPoint(mouseX, mouseY))
}
/**
@@ -221,14 +225,15 @@ export const textActionsMethod = (function () {
* @param {Float} y
* @param {boolean} apply
* @returns {void}
* @private
*/
function setEndSelectionFromPoint (x, y, apply) {
const i1 = textinput.selectionStart
const i2 = getIndexFromPoint(x, y)
#setEndSelectionFromPoint = (x, y, apply) => {
const i1 = this.#textinput.selectionStart
const i2 = this.#getIndexFromPoint(x, y)
const start = Math.min(i1, i2)
const end = Math.max(i1, i2)
setSelection(start, end, !apply)
this.#setSelection(start, end, !apply)
}
/**
@@ -236,8 +241,9 @@ export const textActionsMethod = (function () {
* @param {Float} xIn
* @param {Float} yIn
* @returns {module:math.XYObject}
* @private
*/
function screenToPt (xIn, yIn) {
#screenToPt = (xIn, yIn) => {
const out = {
x: xIn,
y: yIn
@@ -246,8 +252,8 @@ export const textActionsMethod = (function () {
out.x /= zoom
out.y /= zoom
if (matrix) {
const pt = transformPoint(out.x, out.y, matrix.inverse())
if (this.#matrix) {
const pt = transformPoint(out.x, out.y, this.#matrix.inverse())
out.x = pt.x
out.y = pt.y
}
@@ -260,15 +266,16 @@ export const textActionsMethod = (function () {
* @param {Float} xIn
* @param {Float} yIn
* @returns {module:math.XYObject}
* @private
*/
function ptToScreen (xIn, yIn) {
#ptToScreen = (xIn, yIn) => {
const out = {
x: xIn,
y: yIn
}
if (matrix) {
const pt = transformPoint(out.x, out.y, matrix)
if (this.#matrix) {
const pt = transformPoint(out.x, out.y, this.#matrix)
out.x = pt.x
out.y = pt.y
}
@@ -283,279 +290,293 @@ export const textActionsMethod = (function () {
*
* @param {Event} evt
* @returns {void}
* @private
*/
function selectAll (evt) {
setSelection(0, curtext.textContent.length)
evt.target.removeEventListener('click', selectAll)
#selectAll = (evt) => {
this.#setSelection(0, this.#curtext.textContent.length)
evt.target.removeEventListener('click', this.#selectAll)
}
/**
*
* @param {Event} evt
* @returns {void}
* @private
*/
function selectWord (evt) {
if (!allowDbl || !curtext) {
#selectWord = (evt) => {
if (!this.#allowDbl || !this.#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 pt = this.#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 index = this.#getIndexFromPoint(pt.x, pt.y)
const str = this.#curtext.textContent
const first = str.slice(0, index).replace(/[a-z\d]+$/i, '').length
const m = str.slice(index).match(/^[a-z\d]+/i)
const last = (m ? m[0].length : 0) + index
setSelection(first, last)
this.#setSelection(first, last)
// Set tripleclick
svgCanvas.$click(evt.target, selectAll)
svgCanvas.$click(evt.target, this.#selectAll)
setTimeout(function () {
evt.target.removeEventListener('click', selectAll)
setTimeout(() => {
evt.target.removeEventListener('click', this.#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)
/**
* @param {Element} target
* @param {Float} x
* @param {Float} y
* @returns {void}
*/
select (target, x, y) {
this.#curtext = target
svgCanvas.textActions.toEditMode(x, y)
}
textinput.focus()
setCursorFromPoint(pt.x, pt.y)
lastX = startX
lastY = startY
/**
* @param {Element} elem
* @returns {void}
*/
start (elem) {
this.#curtext = elem
svgCanvas.textActions.toEditMode()
}
// 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)
/**
* @param {external:MouseEvent} evt
* @param {Element} mouseTarget
* @param {Float} startX
* @param {Float} startY
* @returns {void}
*/
mouseDown (evt, mouseTarget, startX, startY) {
const pt = this.#screenToPt(startX, startY)
setEndSelectionFromPoint(pt.x, pt.y, true)
this.#textinput.focus()
this.#setCursorFromPoint(pt.x, pt.y)
this.#lastX = startX
this.#lastY = startY
// 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);
// }
// TODO: Find way to block native selection
}
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;
/**
* @param {Float} mouseX
* @param {Float} mouseY
* @returns {void}
*/
mouseMove (mouseX, mouseY) {
const pt = this.#screenToPt(mouseX, mouseY)
this.#setEndSelectionFromPoint(pt.x, pt.y)
}
svgCanvas.textActions.init()
/**
* @param {external:MouseEvent} evt
* @param {Float} mouseX
* @param {Float} mouseY
* @returns {void}
*/
mouseUp (evt, mouseX, mouseY) {
const pt = this.#screenToPt(mouseX, mouseY)
curtext.style.cursor = 'text'
this.#setEndSelectionFromPoint(pt.x, pt.y, true)
// if (supportsEditableText()) {
// curtext.setAttribute('editable', 'simple');
// return;
// }
// 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 (!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)
if (
evt.target !== this.#curtext &&
mouseX < this.#lastX + 2 &&
mouseX > this.#lastX - 2 &&
mouseY < this.#lastY + 2 &&
mouseY > this.#lastY - 2
) {
svgCanvas.textActions.toSelectMode(true)
}
}
})()
/**
* @param {Integer} index
* @returns {void}
*/
setCursor (index) {
this.#setCursor(index)
}
/**
* @param {Float} x
* @param {Float} y
* @returns {void}
*/
toEditMode (x, y) {
this.#allowDbl = false
svgCanvas.setCurrentMode('textedit')
svgCanvas.selectorManager.requestSelector(this.#curtext).showGrips(false)
// Make selector group accept clicks
/* const selector = */ svgCanvas.selectorManager.requestSelector(this.#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()
this.#curtext.style.cursor = 'text'
// if (supportsEditableText()) {
// curtext.setAttribute('editable', 'simple');
// return;
// }
if (arguments.length === 0) {
this.#setCursor()
} else {
const pt = this.#screenToPt(x, y)
this.#setCursorFromPoint(pt.x, pt.y)
}
setTimeout(() => {
this.#allowDbl = true
}, 300)
}
/**
* @param {boolean|Element} selectElem
* @fires module:svgcanvas.SvgCanvas#event:selected
* @returns {void}
*/
toSelectMode (selectElem) {
svgCanvas.setCurrentMode('select')
clearInterval(this.#blinker)
this.#blinker = null
if (this.#selblock) {
this.#selblock.setAttribute('display', 'none')
}
if (this.#cursor) {
this.#cursor.setAttribute('visibility', 'hidden')
}
this.#curtext.style.cursor = 'move'
if (selectElem) {
svgCanvas.clearSelection()
this.#curtext.style.cursor = 'move'
svgCanvas.call('selected', [this.#curtext])
svgCanvas.addToSelection([this.#curtext], true)
}
if (!this.#curtext?.textContent.length) {
// No content, so delete
svgCanvas.deleteSelectedElements()
}
this.#textinput.blur()
this.#curtext = false
// if (supportsEditableText()) {
// curtext.removeAttribute('editable');
// }
}
/**
* @param {Element} elem
* @returns {void}
*/
setInputElem (elem) {
this.#textinput = elem
}
/**
* @returns {void}
*/
clear () {
if (svgCanvas.getCurrentMode() === 'textedit') {
svgCanvas.textActions.toSelectMode()
}
}
/**
* @param {Element} _inputElem Not in use
* @returns {void}
*/
init (_inputElem) {
if (!this.#curtext) {
return
}
let i
let end
// if (supportsEditableText()) {
// curtext.select();
// return;
// }
if (!this.#curtext.parentNode) {
// Result of the ffClone, need to get correct element
const selectedElements = svgCanvas.getSelectedElements()
this.#curtext = selectedElements[0]
svgCanvas.selectorManager.requestSelector(this.#curtext).showGrips(false)
}
const str = this.#curtext.textContent
const len = str.length
const xform = this.#curtext.getAttribute('transform')
this.#textbb = utilsGetBBox(this.#curtext)
this.#matrix = xform ? getMatrix(this.#curtext) : null
this.#chardata = []
this.#chardata.length = len
this.#textinput.focus()
this.#curtext.removeEventListener('dblclick', this.#selectWord)
this.#curtext.addEventListener('dblclick', this.#selectWord)
if (!len) {
end = { x: this.#textbb.x + this.#textbb.width / 2, width: 0 }
}
for (i = 0; i < len; i++) {
const start = this.#curtext.getStartPositionOfChar(i)
end = this.#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
this.#chardata[i] = {
x: start.x,
y: this.#textbb.y, // start.y?
width: end.x - start.x,
height: this.#textbb.height
}
}
// Add a last bbox for cursor at end of text
this.#chardata.push({
x: end.x,
width: 0
})
this.#setSelection(this.#textinput.selectionStart, this.#textinput.selectionEnd, true)
}
}
// Export singleton instance for backward compatibility
export const textActionsMethod = new TextActions()

View File

@@ -46,11 +46,26 @@ export const getUndoManager = () => {
if (eventType === EventTypes.BEFORE_UNAPPLY || eventType === EventTypes.BEFORE_APPLY) {
svgCanvas.clearSelection()
} else if (eventType === EventTypes.AFTER_APPLY || eventType === EventTypes.AFTER_UNAPPLY) {
const cmdType = cmd.type()
const isApply = (eventType === EventTypes.AFTER_APPLY)
if (cmdType === 'ChangeElementCommand' && cmd.elem === svgCanvas.getSvgContent()) {
const values = isApply ? cmd.newValues : cmd.oldValues
if (values.width !== null && values.width !== undefined && values.width !== '') {
const newContentW = Number(values.width)
if (Number.isFinite(newContentW) && newContentW > 0) {
svgCanvas.contentW = newContentW
}
}
if (values.height !== null && values.height !== undefined && values.height !== '') {
const newContentH = Number(values.height)
if (Number.isFinite(newContentH) && newContentH > 0) {
svgCanvas.contentH = newContentH
}
}
}
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()) {
@@ -116,7 +131,7 @@ export const getUndoManager = () => {
* @param {Element} elem - The (text) DOM element to clone
* @returns {Element} Cloned element
*/
export const ffClone = function (elem) {
export const ffClone = (elem) => {
if (!isGecko()) { return elem }
const clone = elem.cloneNode(true)
elem.before(clone)
@@ -213,7 +228,7 @@ export const changeSelectedAttributeNoUndoMethod = (attr, newValue, elems) => {
elem = ffClone(elem)
}
// Timeout needed for Opera & Firefox
// codedread: it is now possible for this function to be called with elements
// codedread: it is now possible for this 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)) {
@@ -264,7 +279,7 @@ export const changeSelectedAttributeNoUndoMethod = (attr, newValue, elems) => {
* @param {Element[]} elems - The DOM elements to apply the change to
* @returns {void}
*/
export const changeSelectedAttributeMethod = function (attr, val, elems) {
export const changeSelectedAttributeMethod = (attr, val, elems) => {
const selectedElements = svgCanvas.getSelectedElements()
elems = elems || selectedElements
svgCanvas.undoMgr.beginUndoableChange(attr, elems)

View File

@@ -6,6 +6,8 @@
* @copyright 2010 Alexis Deveria, 2010 Jeff Schiller
*/
import { error } from '../common/logger.js'
const NSSVG = 'http://www.w3.org/2000/svg'
const wAttrs = ['x', 'x1', 'cx', 'rx', 'width']
@@ -62,7 +64,7 @@ let typeMap_ = {}
* @param {module:units.ElementContainer} elementContainer - An object implementing the ElementContainer interface.
* @returns {void}
*/
export const init = function (elementContainer) {
export const init = (elementContainer) => {
elementContainer_ = elementContainer
// Get correct em/ex values by creating a temporary SVG.
@@ -124,7 +126,7 @@ export const shortFloat = (val) => {
return Number(Number(val).toFixed(digits))
}
if (Array.isArray(val)) {
return shortFloat(val[0]) + ',' + shortFloat(val[1])
return `${shortFloat(val[0])},${shortFloat(val[1])}`
}
return Number.parseFloat(val).toFixed(digits) - 0
}
@@ -214,8 +216,8 @@ export const convertToNum = (attr, val) => {
}
return num * Math.sqrt((width * width) + (height * height)) / Math.sqrt(2)
}
const unit = val.substr(-2)
const num = val.substr(0, val.length - 2)
const unit = val.slice(-2)
const num = val.slice(0, -2)
// Note that this multiplication turns the string into a number
return num * typeMap_[unit]
}
@@ -237,7 +239,7 @@ export const isValidUnit = (attr, val, selectedElement) => {
// 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 + '$')
const re = new RegExp(`^-?[\\d\\.]+${unit}$`)
return re.test(val)
})
}
@@ -253,7 +255,7 @@ export const isValidUnit = (attr, val, selectedElement) => {
try {
const elem = elementContainer_.getElement(val)
result = (!elem || elem === selectedElement)
} catch (e) { console.error(e) }
} catch (e) { error('Error getting element by ID', e, 'units') }
return result
}
return true

View File

@@ -108,15 +108,16 @@ export const dropXMLInternalSubset = str => {
* @param {string} str - The string to be converted
* @returns {string} The converted string
*/
export const toXml = str => {
// &apos; is ok in XML, but not HTML
// &gt; does not normally need escaping, though it can if within a CDATA expression (and preceded by "]]")
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;') // Note: `&apos;` is XML only
export const toXml = (str) => {
const xmlEntities = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;' // Note: `&apos;` is XML only
}
return str.replace(/[&<>"']/g, (char) => xmlEntities[char])
}
// This code was written by Tyler Akins and has been placed in the
@@ -132,10 +133,9 @@ export const toXml = str => {
* @param {string} input
* @returns {string} Base64 output
*/
export function encode64 (input) {
// base64 strings are 4/3 larger than the original string
input = encodeUTF8(input) // convert non-ASCII characters
return window.btoa(input) // Use native if available
export const encode64 = (input) => {
const encoded = encodeUTF8(input) // convert non-ASCII characters
return window.btoa(encoded) // Use native if available
}
/**
@@ -144,23 +144,20 @@ export function encode64 (input) {
* @param {string} input Base64-encoded input
* @returns {string} Decoded output
*/
export function decode64 (input) {
return decodeUTF8(window.atob(input))
}
export const decode64 = (input) => decodeUTF8(window.atob(input))
/**
* Compute a hashcode from a given string
* @param word : the string, we want to compute the hashcode
* @returns {number}: Hascode of the given string
* @param {string} word - The string we want to compute the hashcode from
* @returns {number} Hashcode of the given string
*/
export function hashCode (word) {
export const hashCode = (word) => {
if (word.length === 0) return 0
let hash = 0
let chr
if (word.length === 0) return hash
for (let i = 0; i < word.length; i++) {
chr = word.charCodeAt(i)
hash = (hash << 5) - hash + chr
hash |= 0 // Convert to 32bit integer
const chr = word.charCodeAt(i)
hash = ((hash << 5) - hash + chr) | 0 // Convert to 32bit integer
}
return hash
}
@@ -170,19 +167,14 @@ export function hashCode (word) {
* @param {string} argString
* @returns {string}
*/
export function decodeUTF8 (argString) {
return decodeURIComponent(escape(argString))
}
export const decodeUTF8 = (argString) => decodeURIComponent(escape(argString))
// codedread:does not seem to work with webkit-based browsers on OSX // Brettz9: please test again as function upgraded
/**
* @function module:utilities.encodeUTF8
* @param {string} argString
* @returns {string}
*/
export const encodeUTF8 = argString => {
return unescape(encodeURIComponent(argString))
}
export const encodeUTF8 = (argString) => unescape(encodeURIComponent(argString))
/**
* Convert dataURL to object URL.
@@ -190,7 +182,7 @@ export const encodeUTF8 = argString => {
* @param {string} dataurl
* @returns {string} object URL or empty string
*/
export const dataURLToObjectURL = dataurl => {
export const dataURLToObjectURL = (dataurl) => {
if (
typeof Uint8Array === 'undefined' ||
typeof Blob === 'undefined' ||
@@ -199,19 +191,22 @@ export const dataURLToObjectURL = dataurl => {
) {
return ''
}
const arr = dataurl.split(',')
const mime = arr[0].match(/:(.*?);/)[1]
const bstr = atob(arr[1])
/*
const [prefix, suffix] = dataurl.split(','),
{groups: {mime}} = prefix.match(/:(?<mime>.*?);/),
bstr = atob(suffix);
*/
let n = bstr.length
const u8arr = new Uint8Array(n)
while (n--) {
u8arr[n] = bstr.charCodeAt(n)
const [prefix, suffix] = dataurl.split(',')
const mimeMatch = prefix?.match(/:(.*?);/)
if (!mimeMatch?.[1] || !suffix) {
return ''
}
const mime = mimeMatch[1]
const bstr = atob(suffix)
const u8arr = new Uint8Array(bstr.length)
for (let i = 0; i < bstr.length; i++) {
u8arr[i] = bstr.charCodeAt(i)
}
const blob = new Blob([u8arr], { type: mime })
return URL.createObjectURL(blob)
}
@@ -222,7 +217,7 @@ export const dataURLToObjectURL = dataurl => {
* @param {Blob} blob A Blob object or File object
* @returns {string} object URL or empty string
*/
export const createObjectURL = blob => {
export const createObjectURL = (blob) => {
if (!blob || typeof URL === 'undefined' || !URL.createObjectURL) {
return ''
}
@@ -266,25 +261,28 @@ export const convertToXMLReferences = input => {
* @throws {Error}
* @returns {XMLDocument}
*/
export const text2xml = sXML => {
if (sXML.includes('<svg:svg')) {
sXML = sXML.replace(/<(\/?)svg:/g, '<$1').replace('xmlns:svg', 'xmlns')
export const text2xml = (sXML) => {
let xmlString = sXML
if (xmlString.includes('<svg:svg')) {
xmlString = xmlString
.replace(/<(\/?)svg:/g, '<$1')
.replace('xmlns:svg', 'xmlns')
}
let out
let dXML
let parser
try {
dXML = new DOMParser()
dXML.async = false
parser = new DOMParser()
parser.async = false
} catch (e) {
throw new Error('XML Parser could not be instantiated')
}
try {
out = dXML.parseFromString(sXML, 'text/xml')
} catch (e2) {
throw new Error('Error parsing XML string')
return parser.parseFromString(xmlString, 'text/xml')
} catch (e) {
throw new Error(`Error parsing XML string: ${e.message}`)
}
return out
}
/**
@@ -354,22 +352,24 @@ export const walkTreePost = (elem, cbFn) => {
* - `<circle fill='url("someFile.svg#foo")' />`
* @function module:utilities.getUrlFromAttr
* @param {string} attrVal The attribute value as a string
* @returns {string} String with just the URL, like "someFile.svg#foo"
* @returns {string|null} String with just the URL, like "someFile.svg#foo"
*/
export const getUrlFromAttr = function (attrVal) {
if (attrVal) {
// url('#somegrad')
if (attrVal.startsWith('url("')) {
return attrVal.substring(5, attrVal.indexOf('"', 6))
}
// url('#somegrad')
if (attrVal.startsWith("url('")) {
return attrVal.substring(5, attrVal.indexOf("'", 6))
}
if (attrVal.startsWith('url(')) {
return attrVal.substring(4, attrVal.indexOf(')'))
export const getUrlFromAttr = (attrVal) => {
if (!attrVal?.startsWith('url(')) return null
const patterns = [
{ start: 'url("', end: '"', offset: 5 },
{ start: "url('", end: "'", offset: 5 },
{ start: 'url(', end: ')', offset: 4 }
]
for (const { start, end, offset } of patterns) {
if (attrVal.startsWith(start)) {
const endIndex = attrVal.indexOf(end, offset + 1)
return endIndex > 0 ? attrVal.substring(offset, endIndex) : null
}
}
return null
}
@@ -378,10 +378,8 @@ export const getUrlFromAttr = function (attrVal) {
* @param {Element} elem
* @returns {string} The given element's `href` value
*/
export let getHref = function (elem) {
// Prefer 'href', fallback to 'xlink:href'
return elem.getAttribute('href') || elem.getAttributeNS(NS.XLINK, 'href')
}
export let getHref = (elem) =>
elem.getAttribute('href') ?? elem.getAttributeNS(NS.XLINK, 'href')
/**
* Sets the given element's `href` value.
@@ -390,7 +388,7 @@ export let getHref = function (elem) {
* @param {string} val
* @returns {void}
*/
export let setHref = function (elem, val) {
export let setHref = (elem, val) => {
elem.setAttribute('href', val)
}
@@ -398,21 +396,23 @@ export let setHref = function (elem, val) {
* @function module:utilities.findDefs
* @returns {SVGDefsElement} The document's `<defs>` element, creating it first if necessary
*/
export const findDefs = function () {
export const findDefs = () => {
const svgElement = svgCanvas.getSvgContent()
let defs = svgElement.getElementsByTagNameNS(NS.SVG, 'defs')
if (defs.length > 0) {
defs = defs[0]
} else {
defs = svgElement.ownerDocument.createElementNS(NS.SVG, 'defs')
if (svgElement.firstChild) {
// first child is a comment, so call nextSibling
svgElement.insertBefore(defs, svgElement.firstChild.nextSibling)
// svgElement.firstChild.nextSibling.before(defs); // Not safe
} else {
svgElement.append(defs)
}
const existingDefs = svgElement.getElementsByTagNameNS(NS.SVG, 'defs')
if (existingDefs.length > 0) {
return existingDefs[0]
}
const defs = svgElement.ownerDocument.createElementNS(NS.SVG, 'defs')
const insertTarget = svgElement.firstChild?.nextSibling
if (insertTarget) {
svgElement.insertBefore(defs, insertTarget)
} else {
svgElement.append(defs)
}
return defs
}
@@ -425,33 +425,28 @@ export const findDefs = function () {
* @param {SVGPathElement} path - The path DOM element to get the BBox for
* @returns {module:utilities.BBoxObject} A BBox-like object
*/
export const getPathBBox = function (path) {
export const getPathBBox = (path) => {
const seglist = path.pathSegList
const tot = seglist.numberOfItems
const totalSegments = seglist.numberOfItems
const bounds = [[], []]
const start = seglist.getItem(0)
let P0 = [start.x, start.y]
const getCalc = function (j, P1, P2, P3) {
return function (t) {
return (
1 -
t ** 3 * P0[j] +
3 * 1 -
t ** 2 * t * P1[j] +
3 * (1 - t) * t ** 2 * P2[j] +
t ** 3 * P3[j]
)
}
const getCalc = (j, P1, P2, P3) => (t) => {
const oneMinusT = 1 - t
return (
oneMinusT ** 3 * P0[j] +
3 * oneMinusT ** 2 * t * P1[j] +
3 * oneMinusT * t ** 2 * P2[j] +
t ** 3 * P3[j]
)
}
for (let i = 0; i < tot; i++) {
for (let i = 0; i < totalSegments; i++) {
const seg = seglist.getItem(i)
if (seg.x === undefined) {
continue
}
if (seg.x === undefined) continue
// Add actual points to limits
bounds[0].push(P0[0])
@@ -499,15 +494,14 @@ export const getPathBBox = function (path) {
}
}
const x = Math.min.apply(null, bounds[0])
const w = Math.max.apply(null, bounds[0]) - x
const y = Math.min.apply(null, bounds[1])
const h = Math.max.apply(null, bounds[1]) - y
const x = Math.min(...bounds[0])
const y = Math.min(...bounds[1])
return {
x,
y,
width: w,
height: h
width: Math.max(...bounds[0]) - x,
height: Math.max(...bounds[1]) - y
}
}
@@ -516,13 +510,12 @@ export const getPathBBox = function (path) {
* usable when necessary.
* @function module:utilities.getBBox
* @param {Element} elem - Optional DOM element to get the BBox for
* @returns {module:utilities.BBoxObject} Bounding box object
* @returns {module:utilities.BBoxObject|null} Bounding box object
*/
export const getBBox = function (elem) {
const selected = elem || svgCanvas.getSelectedElements()[0]
if (elem.nodeType !== 1) {
return null
}
export const getBBox = (elem) => {
const selected = elem ?? svgCanvas.getSelectedElements()[0]
if (elem.nodeType !== 1) return null
const elname = selected.nodeName
let ret = null
@@ -642,26 +635,23 @@ export const getBBox = function (elem) {
* @param {module:utilities.PathSegmentArray[]} pathSegments - An array of path segments to be converted
* @returns {string} The converted path d attribute.
*/
export const getPathDFromSegments = function (pathSegments) {
let d = ''
pathSegments.forEach(function ([singleChar, pts], _j) {
d += singleChar
for (let i = 0; i < pts.length; i += 2) {
d += pts[i] + ',' + pts[i + 1] + ' '
export const getPathDFromSegments = (pathSegments) => {
return pathSegments.map(([command, points]) => {
const coords = []
for (let i = 0; i < points.length; i += 2) {
coords.push(`${points[i]},${points[i + 1]}`)
}
})
return d
return command + coords.join(' ')
}).join(' ')
}
/**
* Make a path 'd' attribute from a simple SVG element shape.
* @function module:utilities.getPathDFromElement
* @param {Element} elem - The element to be converted
* @returns {string} The path d attribute or `undefined` if the element type is unknown.
* @returns {string|undefined} The path d attribute or `undefined` if the element type is unknown.
*/
export const getPathDFromElement = function (elem) {
export const getPathDFromElement = (elem) => {
// Possibly the cubed root of 6, but 1.81 works best
let num = 1.81
let d
@@ -691,20 +681,19 @@ export const getPathDFromElement = function (elem) {
case 'path':
d = elem.getAttribute('d')
break
case 'line':
{
const x1 = elem.getAttribute('x1')
const y1 = elem.getAttribute('y1')
const x2 = elem.getAttribute('x2')
const y2 = elem.getAttribute('y2')
d = 'M' + x1 + ',' + y1 + 'L' + x2 + ',' + y2
}
case 'line': {
const x1 = elem.getAttribute('x1')
const y1 = elem.getAttribute('y1')
const x2 = elem.getAttribute('x2')
const y2 = elem.getAttribute('y2')
d = `M${x1},${y1}L${x2},${y2}`
break
}
case 'polyline':
d = 'M' + elem.getAttribute('points')
d = `M${elem.getAttribute('points')}`
break
case 'polygon':
d = 'M' + elem.getAttribute('points') + ' Z'
d = `M${elem.getAttribute('points')} Z`
break
case 'rect': {
rx = Number(elem.getAttribute('rx'))
@@ -762,19 +751,16 @@ export const getPathDFromElement = function (elem) {
* @param {Element} elem - The element to be probed
* @returns {PlainObject<"marker-start"|"marker-end"|"marker-mid"|"filter"|"clip-path", string>} An object with attributes.
*/
export const getExtraAttributesForConvertToPath = function (elem) {
const attrs = {}
export const getExtraAttributesForConvertToPath = (elem) => {
// TODO: make this list global so that we can properly maintain it
// TODO: what about @transform, @clip-rule, @fill-rule, etc?
;['marker-start', 'marker-end', 'marker-mid', 'filter', 'clip-path'].forEach(
function (item) {
const a = elem.getAttribute(item)
if (a) {
attrs[item] = a
}
}
)
return attrs
const attributeNames = ['marker-start', 'marker-end', 'marker-mid', 'filter', 'clip-path']
return attributeNames.reduce((attrs, name) => {
const value = elem.getAttribute(name)
if (value) attrs[name] = value
return attrs
}, {})
}
/**
@@ -785,11 +771,11 @@ export const getExtraAttributesForConvertToPath = function (elem) {
* @param {module:path.pathActions} pathActions - If a transform exists, `pathActions.resetOrientation()` is used. See: canvas.pathActions.
* @returns {DOMRect|false} The resulting path's bounding box object.
*/
export const getBBoxOfElementAsPath = function (
export const getBBoxOfElementAsPath = (
elem,
addSVGElementsFromJson,
pathActions
) {
) => {
const path = addSVGElementsFromJson({
element: 'path',
attr: getExtraAttributesForConvertToPath(elem)
@@ -801,11 +787,7 @@ export const getBBoxOfElementAsPath = function (
}
const { parentNode } = elem
if (elem.nextSibling) {
elem.before(path)
} else {
parentNode.append(path)
}
elem.nextSibling ? elem.before(path) : parentNode.append(path)
const d = getPathDFromElement(elem)
if (d) {
@@ -936,7 +918,7 @@ export const convertToPath = (elem, attrs, svgCanvas) => {
* @param {boolean} hasAMatrixTransform - True if there is a matrix transform
* @returns {boolean} True if the bbox can be optimized.
*/
function bBoxCanBeOptimizedOverNativeGetBBox (angle, hasAMatrixTransform) {
const bBoxCanBeOptimizedOverNativeGetBBox = (angle, hasAMatrixTransform) => {
const angleModulo90 = angle % 90
const closeTo90 = angleModulo90 < -89.99 || angleModulo90 > 89.99
const closeTo0 = angleModulo90 > -0.001 && angleModulo90 < 0.001
@@ -949,24 +931,21 @@ function bBoxCanBeOptimizedOverNativeGetBBox (angle, hasAMatrixTransform) {
* @param {Element} elem - The DOM element to be converted
* @param {module:utilities.EditorContext#addSVGElementsFromJson} addSVGElementsFromJson - Function to add the path element to the current layer. See canvas.addSVGElementsFromJson
* @param {module:path.pathActions} pathActions - If a transform exists, pathActions.resetOrientation() is used. See: canvas.pathActions.
* @returns {module:utilities.BBoxObject|module:math.TransformedBox|DOMRect} A single bounding box object
* @returns {module:utilities.BBoxObject|module:math.TransformedBox|DOMRect|null} A single bounding box object
*/
export const getBBoxWithTransform = function (
export const getBBoxWithTransform = (
elem,
addSVGElementsFromJson,
pathActions
) {
) => {
// TODO: Fix issue with rotated groups. Currently they work
// fine in FF, but not in other browsers (same problem mentioned
// in Issue 339 comment #2).
let bb = getBBox(elem)
if (!bb) return null
if (!bb) {
return null
}
const transformAttr = elem.getAttribute?.('transform') || ''
const transformAttr = elem.getAttribute?.('transform') ?? ''
const hasMatrixAttr = transformAttr.includes('matrix(')
if (transformAttr.includes('rotate(') && !hasMatrixAttr) {
const nums = transformAttr.match(/-?\d*\.?\d+/g)?.map(Number) || []
@@ -1263,7 +1242,7 @@ export const getRefElem = attrVal => {
if (!attrVal) return null
const url = getUrlFromAttr(attrVal)
if (!url) return null
const id = url[0] === '#' ? url.substr(1) : url
const id = url[0] === '#' ? url.slice(1) : url
return getElement(id)
}
/**
@@ -1295,7 +1274,7 @@ export const getFeGaussianBlur = ele => {
*/
export const getElement = id => {
// querySelector lookup
return svgroot_.querySelector('#' + id)
return svgroot_.querySelector(`#${id}`)
}
/**
@@ -1310,9 +1289,9 @@ export const getElement = id => {
export const assignAttributes = (elem, attrs, suspendLength, unitCheck) => {
for (const [key, value] of Object.entries(attrs)) {
const ns =
key.substr(0, 4) === 'xml:'
key.startsWith('xml:')
? NS.XML
: key.substr(0, 6) === 'xlink:'
: key.startsWith('xlink:')
? NS.XLINK
: null
if (value === undefined) {

View File

@@ -1,8 +1,9 @@
{
"name": "@svgedit/svgcanvas",
"version": "7.4.0",
"version": "7.4.1",
"description": "SVG Canvas",
"main": "dist/svgcanvas.js",
"types": "svgcanvas.d.ts",
"author": "Narendra Sisodiya",
"publishConfig": {
"access": "public"

225
packages/svgcanvas/svgcanvas.d.ts vendored Normal file
View File

@@ -0,0 +1,225 @@
/**
* TypeScript definitions for @svgedit/svgcanvas
* @module @svgedit/svgcanvas
*/
// Core types
export interface SVGElementJSON {
element: string
attr: Record<string, string>
curStyles?: boolean
children?: SVGElementJSON[]
namespace?: string
}
export interface Config {
canvasName?: string
canvas_expansion?: number
initFill?: {
color?: string
opacity?: number
}
initStroke?: {
width?: number
color?: string
opacity?: number
}
text?: {
stroke_width?: number
font_size?: number
font_family?: string
}
selectionColor?: string
imgPath?: string
extensions?: string[]
initTool?: string
wireframe?: boolean
showlayers?: boolean
no_save_warning?: boolean
imgImport?: boolean
baseUnit?: string
snappingStep?: number
gridSnapping?: boolean
gridColor?: string
dimensions?: [number, number]
initOpacity?: number
colorPickerCSS?: string | null
initRight?: string
initBottom?: string
show_outside_canvas?: boolean
selectNew?: boolean
}
export interface Resolution {
w: number
h: number
zoom?: number
}
export interface BBox {
x: number
y: number
width: number
height: number
}
export interface EditorContext {
getSvgContent(): SVGSVGElement
addSVGElementsFromJson(data: SVGElementJSON): Element
getSelectedElements(): Element[]
getDOMDocument(): HTMLDocument
getDOMContainer(): HTMLElement
getSvgRoot(): SVGSVGElement
getBaseUnit(): string
getSnappingStep(): number | string
}
// Paint types
export interface PaintOptions {
alpha?: number
solidColor?: string
type?: 'solidColor' | 'linearGradient' | 'radialGradient' | 'none'
}
// History command types
export interface HistoryCommand {
apply(handler: HistoryEventHandler): void | true
unapply(handler: HistoryEventHandler): void | true
elements(): Element[]
type(): string
}
export interface HistoryEventHandler {
handleHistoryEvent(eventType: string, cmd: HistoryCommand): void
}
export interface UndoManager {
getUndoStackSize(): number
getRedoStackSize(): number
getNextUndoCommandText(): string
getNextRedoCommandText(): string
resetUndoStack(): void
}
// Logger types
export enum LogLevel {
NONE = 0,
ERROR = 1,
WARN = 2,
INFO = 3,
DEBUG = 4
}
export interface Logger {
LogLevel: typeof LogLevel
setLogLevel(level: LogLevel): void
setLoggingEnabled(enabled: boolean): void
setLogPrefix(prefix: string): void
error(message: string, error?: Error | any, context?: string): void
warn(message: string, data?: any, context?: string): void
info(message: string, data?: any, context?: string): void
debug(message: string, data?: any, context?: string): void
getConfig(): { currentLevel: LogLevel; enabled: boolean; prefix: string }
}
// Main SvgCanvas class
export default class SvgCanvas {
constructor(container: HTMLElement, config?: Partial<Config>)
// Core methods
getSvgContent(): SVGSVGElement
getSvgRoot(): SVGSVGElement
getSvgString(): string
setSvgString(xmlString: string, preventUndo?: boolean): boolean
clearSelection(noCall?: boolean): void
selectOnly(elements: Element[], showGrips?: boolean): void
getResolution(): Resolution
setResolution(width: number | string, height: number | string): boolean
getZoom(): number
setZoom(zoomLevel: number): void
// Element manipulation
moveSelectedElements(dx: number, dy: number, undoable?: boolean): void
deleteSelectedElements(): void
cutSelectedElements(): void
copySelectedElements(): void
pasteElements(type?: string, x?: number, y?: number): void
groupSelectedElements(type?: string, urlArg?: string): Element | null
ungroupSelectedElement(): void
moveToTopSelectedElement(): void
moveToBottomSelectedElement(): void
moveUpDownSelected(dir: 'Up' | 'Down'): void
// Path operations
pathActions: {
clear: () => void
resetOrientation: (path: SVGPathElement) => boolean
zoomChange: () => void
getNodePoint: () => {x: number, y: number}
linkControlPoints: (linkPoints: boolean) => void
clonePathNode: () => void
deletePathNode: () => void
smoothPolylineIntoPath: () => void
setSegType: (type: number) => void
moveNode: (attr: string, newValue: number) => void
selectNode: (node?: Element) => void
opencloseSubPath: () => void
}
// Layer operations
getCurrentDrawing(): any
getNumLayers(): number
getLayer(name: string): any
getCurrentLayerName(): string
setCurrentLayer(name: string): boolean
renameCurrentLayer(newName: string): boolean
setCurrentLayerPosition(newPos: number): boolean
setLayerVisibility(name: string, bVisible: boolean): void
moveSelectedToLayer(layerName: string): void
cloneLayer(name?: string): void
deleteCurrentLayer(): boolean
// Drawing modes
setMode(name: string): void
getMode(): string
// Undo/Redo
undoMgr: UndoManager
undo(): void
redo(): void
// Events
call(event: string, args?: any[]): void
bind(event: string, callback: Function): void
unbind(event: string, callback: Function): void
// Attribute manipulation
changeSelectedAttribute(attr: string, val: string | number, elems?: Element[]): void
changeSelectedAttributeNoUndo(attr: string, val: string | number, elems?: Element[]): void
// Canvas properties
contentW: number
contentH: number
// Text operations
textActions: any
// Extensions
addExtension(name: string, extFunc: Function): void
// Export
getSvgString(): string
embedImage(dataURI: string): Promise<Element>
// Other utilities
getPrivateMethods(): any
}
// Export additional utilities
export * from './common/logger.js'
export { NS } from './core/namespaces.js'
export * from './core/math.js'
export * from './core/units.js'
export * from './core/utilities.js'
export { sanitizeSvg } from './core/sanitize.js'
export { default as dataStorage } from './core/dataStorage.js'

View File

@@ -201,7 +201,7 @@ class SvgCanvas {
this.curConfig.initFill.color,
fill_paint: null,
fill_opacity: this.curConfig.initFill.opacity,
stroke: '#' + this.curConfig.initStroke.color,
stroke: `#${this.curConfig.initStroke.color}`,
stroke_paint: null,
stroke_opacity: this.curConfig.initStroke.opacity,
stroke_width: this.curConfig.initStroke.width,
@@ -288,9 +288,9 @@ class SvgCanvas {
*/
const storageChange = ev => {
if (!ev.newValue) return // This is a call from removeItem.
if (ev.key === CLIPBOARD_ID + '_startup') {
if (ev.key === `${CLIPBOARD_ID}_startup`) {
// Another tab asked for our sessionStorage.
localStorage.removeItem(CLIPBOARD_ID + '_startup')
localStorage.removeItem(`${CLIPBOARD_ID}_startup`)
this.flashStorage()
} else if (ev.key === CLIPBOARD_ID) {
// Another tab sent data.
@@ -301,7 +301,7 @@ class SvgCanvas {
// Listen for changes to localStorage.
window.addEventListener('storage', storageChange, false)
// Ask other tabs for sessionStorage (this is ONLY to trigger event).
localStorage.setItem(CLIPBOARD_ID + '_startup', Math.random())
localStorage.setItem(`${CLIPBOARD_ID}_startup`, Math.random())
pasteInit(this)
@@ -902,7 +902,7 @@ class SvgCanvas {
})
Object.values(attrs).forEach(val => {
if (val?.startsWith('url(')) {
const id = getUrlFromAttr(val).substr(1)
const id = getUrlFromAttr(val).slice(1)
const ref = getElement(id)
if (!ref) {
findDefs().append(this.removedElements[id])
@@ -1138,11 +1138,11 @@ class SvgCanvas {
* @returns {void}
*/
setPaintOpacity (type, val, preventUndo) {
this.curShape[type + '_opacity'] = val
this.curShape[`${type}_opacity`] = val
if (!preventUndo) {
this.changeSelectedAttribute(type + '-opacity', val)
this.changeSelectedAttribute(`${type}-opacity`, val)
} else {
this.changeSelectedAttributeNoUndo(type + '-opacity', val)
this.changeSelectedAttributeNoUndo(`${type}-opacity`, val)
}
}
@@ -1167,7 +1167,7 @@ class SvgCanvas {
if (elem) {
const filterUrl = elem.getAttribute('filter')
if (filterUrl) {
const blur = getElement(elem.id + '_blur')
const blur = getElement(`${elem.id}_blur`)
if (blur) {
val = blur.firstChild.getAttribute('stdDeviation')
} else {