* fix release script * fix svgcanvas edge cases * Update path-actions.js * add modern js * update deps * Update CHANGES.md
583 lines
13 KiB
JavaScript
583 lines
13 KiB
JavaScript
/**
|
|
* @module text-actions Tools for Text edit functions
|
|
* @license MIT
|
|
*
|
|
* @copyright 2010 Alexis Deveria, 2010 Jeff Schiller
|
|
*/
|
|
|
|
import { NS } from './namespaces.js'
|
|
import { transformPoint, getMatrix } from './math.js'
|
|
import {
|
|
assignAttributes,
|
|
getElement,
|
|
getBBox as utilsGetBBox
|
|
} from './utilities.js'
|
|
import { supportsGoodTextCharPos } from '../common/browser.js'
|
|
|
|
let svgCanvas = null
|
|
|
|
/**
|
|
* @function module:text-actions.init
|
|
* @param {module:text-actions.svgCanvas} textActionsContext
|
|
* @returns {void}
|
|
*/
|
|
export const init = canvas => {
|
|
svgCanvas = canvas
|
|
}
|
|
|
|
/**
|
|
* Group: Text edit functions
|
|
* Functions relating to editing text elements.
|
|
* @class TextActions
|
|
* @memberof module:svgcanvas.SvgCanvas#
|
|
*/
|
|
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
|
|
*/
|
|
#setCursor = (index = undefined) => {
|
|
const empty = this.#textinput.value === ''
|
|
this.#textinput.focus()
|
|
|
|
if (index === undefined) {
|
|
if (empty) {
|
|
index = 0
|
|
} else {
|
|
if (this.#textinput.selectionEnd !== this.#textinput.selectionStart) {
|
|
return
|
|
}
|
|
index = this.#textinput.selectionEnd
|
|
}
|
|
}
|
|
|
|
const charbb = this.#chardata[index]
|
|
if (!empty) {
|
|
this.#textinput.setSelectionRange(index, index)
|
|
}
|
|
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(this.#cursor)
|
|
}
|
|
|
|
if (!this.#blinker) {
|
|
this.#blinker = setInterval(() => {
|
|
const show = this.#cursor.getAttribute('display') === 'none'
|
|
this.#cursor.setAttribute('display', show ? 'inline' : 'none')
|
|
}, 600)
|
|
}
|
|
|
|
const startPt = this.#ptToScreen(charbb.x, this.#textbb.y)
|
|
const endPt = this.#ptToScreen(charbb.x, this.#textbb.y + this.#textbb.height)
|
|
|
|
assignAttributes(this.#cursor, {
|
|
x1: startPt.x,
|
|
y1: startPt.y,
|
|
x2: endPt.x,
|
|
y2: endPt.y,
|
|
visibility: 'visible',
|
|
display: 'inline'
|
|
})
|
|
|
|
if (this.#selblock) {
|
|
this.#selblock.setAttribute('d', '')
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {Integer} start
|
|
* @param {Integer} end
|
|
* @param {boolean} skipInput
|
|
* @returns {void}
|
|
* @private
|
|
*/
|
|
#setSelection = (start, end, skipInput) => {
|
|
if (start === end) {
|
|
this.#setCursor(end)
|
|
return
|
|
}
|
|
|
|
if (!skipInput) {
|
|
this.#textinput.setSelectionRange(start, end)
|
|
}
|
|
|
|
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(this.#selblock)
|
|
}
|
|
|
|
const startbb = this.#chardata[start]
|
|
const endbb = this.#chardata[end]
|
|
|
|
this.#cursor.setAttribute('visibility', 'hidden')
|
|
|
|
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),
|
|
this.#textbb.y + this.#textbb.height
|
|
)
|
|
|
|
const dstr =
|
|
'M' +
|
|
tl.x +
|
|
',' +
|
|
tl.y +
|
|
' L' +
|
|
tr.x +
|
|
',' +
|
|
tr.y +
|
|
' ' +
|
|
br.x +
|
|
',' +
|
|
br.y +
|
|
' ' +
|
|
bl.x +
|
|
',' +
|
|
bl.y +
|
|
'z'
|
|
|
|
assignAttributes(this.#selblock, {
|
|
d: dstr,
|
|
display: 'inline'
|
|
})
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {Float} mouseX
|
|
* @param {Float} mouseY
|
|
* @returns {Integer}
|
|
* @private
|
|
*/
|
|
#getIndexFromPoint = (mouseX, mouseY) => {
|
|
// Position cursor here
|
|
const pt = svgCanvas.getSvgRoot().createSVGPoint()
|
|
pt.x = mouseX
|
|
pt.y = mouseY
|
|
|
|
// No content, so return 0
|
|
if (this.#chardata.length === 1) {
|
|
return 0
|
|
}
|
|
// Determine if cursor should be on left or right of character
|
|
let charpos = this.#curtext.getCharNumAtPosition(pt)
|
|
if (charpos < 0) {
|
|
// Out of text range, look at mouse coords
|
|
charpos = this.#chardata.length - 2
|
|
if (mouseX <= this.#chardata[0].x) {
|
|
charpos = 0
|
|
}
|
|
} else if (charpos >= this.#chardata.length - 2) {
|
|
charpos = this.#chardata.length - 2
|
|
}
|
|
const charbb = this.#chardata[charpos]
|
|
const mid = charbb.x + charbb.width / 2
|
|
if (mouseX > mid) {
|
|
charpos++
|
|
}
|
|
return charpos
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {Float} mouseX
|
|
* @param {Float} mouseY
|
|
* @returns {void}
|
|
* @private
|
|
*/
|
|
#setCursorFromPoint = (mouseX, mouseY) => {
|
|
this.#setCursor(this.#getIndexFromPoint(mouseX, mouseY))
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {Float} x
|
|
* @param {Float} y
|
|
* @param {boolean} apply
|
|
* @returns {void}
|
|
* @private
|
|
*/
|
|
#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)
|
|
this.#setSelection(start, end, !apply)
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {Float} xIn
|
|
* @param {Float} yIn
|
|
* @returns {module:math.XYObject}
|
|
* @private
|
|
*/
|
|
#screenToPt = (xIn, yIn) => {
|
|
const out = {
|
|
x: xIn,
|
|
y: yIn
|
|
}
|
|
const zoom = svgCanvas.getZoom()
|
|
out.x /= zoom
|
|
out.y /= zoom
|
|
|
|
if (this.#matrix) {
|
|
const pt = transformPoint(out.x, out.y, this.#matrix.inverse())
|
|
out.x = pt.x
|
|
out.y = pt.y
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {Float} xIn
|
|
* @param {Float} yIn
|
|
* @returns {module:math.XYObject}
|
|
* @private
|
|
*/
|
|
#ptToScreen = (xIn, yIn) => {
|
|
const out = {
|
|
x: xIn,
|
|
y: yIn
|
|
}
|
|
|
|
if (this.#matrix) {
|
|
const pt = transformPoint(out.x, out.y, this.#matrix)
|
|
out.x = pt.x
|
|
out.y = pt.y
|
|
}
|
|
const zoom = svgCanvas.getZoom()
|
|
out.x *= zoom
|
|
out.y *= zoom
|
|
|
|
return out
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {Event} evt
|
|
* @returns {void}
|
|
* @private
|
|
*/
|
|
#selectAll = (evt) => {
|
|
this.#setSelection(0, this.#curtext.textContent.length)
|
|
evt.target.removeEventListener('click', this.#selectAll)
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {Event} evt
|
|
* @returns {void}
|
|
* @private
|
|
*/
|
|
#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 = this.#screenToPt(mouseX, mouseY)
|
|
|
|
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
|
|
this.#setSelection(first, last)
|
|
|
|
// Set tripleclick
|
|
svgCanvas.$click(evt.target, this.#selectAll)
|
|
|
|
setTimeout(() => {
|
|
evt.target.removeEventListener('click', this.#selectAll)
|
|
}, 300)
|
|
}
|
|
|
|
/**
|
|
* @param {Element} target
|
|
* @param {Float} x
|
|
* @param {Float} y
|
|
* @returns {void}
|
|
*/
|
|
select (target, x, y) {
|
|
this.#curtext = target
|
|
svgCanvas.textActions.toEditMode(x, y)
|
|
}
|
|
|
|
/**
|
|
* @param {Element} elem
|
|
* @returns {void}
|
|
*/
|
|
start (elem) {
|
|
this.#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 = this.#screenToPt(startX, startY)
|
|
|
|
this.#textinput.focus()
|
|
this.#setCursorFromPoint(pt.x, pt.y)
|
|
this.#lastX = startX
|
|
this.#lastY = startY
|
|
|
|
// TODO: Find way to block native selection
|
|
}
|
|
|
|
/**
|
|
* @param {Float} mouseX
|
|
* @param {Float} mouseY
|
|
* @returns {void}
|
|
*/
|
|
mouseMove (mouseX, mouseY) {
|
|
const pt = this.#screenToPt(mouseX, mouseY)
|
|
this.#setEndSelectionFromPoint(pt.x, pt.y)
|
|
}
|
|
|
|
/**
|
|
* @param {external:MouseEvent} evt
|
|
* @param {Float} mouseX
|
|
* @param {Float} mouseY
|
|
* @returns {void}
|
|
*/
|
|
mouseUp (evt, mouseX, mouseY) {
|
|
const pt = this.#screenToPt(mouseX, mouseY)
|
|
|
|
this.#setEndSelectionFromPoint(pt.x, pt.y, true)
|
|
|
|
// TODO: Find a way to make this work: Use transformed BBox instead of evt.target
|
|
// if (lastX === mouseX && lastY === mouseY
|
|
// && !rectsIntersect(transbb, {x: pt.x, y: pt.y, width: 0, height: 0})) {
|
|
// svgCanvas.textActions.toSelectMode(true);
|
|
// }
|
|
|
|
if (
|
|
evt.target !== 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()
|