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