UNPKG

svgedit

Version:

Powerful SVG-Editor for your browser

734 lines (629 loc) 24.4 kB
/** * @file ext-connector.js * * @license MIT * * @copyright 2010 Alexis Deveria * @copyright 2023 Optimistik SAS * */ const name = 'connector' const loadExtensionTranslation = async function (svgEditor) { let translationModule const lang = svgEditor.configObj.pref('lang') try { translationModule = await import(`./locale/${lang}.js`) } catch (_error) { // eslint-disable-next-line no-console console.warn(`Missing translation (${lang}) for ${name} - using 'en'`) translationModule = await import('./locale/en.js') } svgEditor.i18next.addResourceBundle(lang, name, translationModule.default) } export default { name, async init (S) { const svgEditor = this const { svgCanvas } = svgEditor const { getElement, $id, $click, addSVGElementsFromJson } = svgCanvas const { svgroot, selectorManager } = S const seNs = svgCanvas.getEditorNS() await loadExtensionTranslation(svgEditor) let startX let startY let curLine let startElem let endElem let started = false let connections = [] // Save the original groupSelectedElements method const originalGroupSelectedElements = svgCanvas.groupSelectedElements // Override the original groupSelectedElements to exclude connectors svgCanvas.groupSelectedElements = function (...args) { // Remove connectors from selection svgCanvas.removeFromSelection(document.querySelectorAll('[id^="conn_"]')) // Call the original method return originalGroupSelectedElements.apply(this, args) } // Save the original moveSelectedElements method const originalMoveSelectedElements = svgCanvas.moveSelectedElements // Override the original moveSelectedElements to handle connectors svgCanvas.moveSelectedElements = function (...args) { // Call the original method and store its result const cmd = originalMoveSelectedElements.apply(this, args) // Update connectors updateConnectors(svgCanvas.getSelectedElements()) // Return the result of the original method return cmd } /** * getBBintersect * @param {Float} x * @param {Float} y * @param {module:utilities.BBoxObject} bb * @param {Float} offset * @returns {module:math.XYObject} */ const getBBintersect = (x, y, bb, offset) => { // Adjust bounding box if offset is provided if (offset) { bb = { ...bb } // Create a shallow copy bb.width += offset bb.height += offset bb.x -= offset / 2 bb.y -= offset / 2 } // Calculate center of bounding box const midX = bb.x + bb.width / 2 const midY = bb.y + bb.height / 2 // Calculate lengths from (x, y) to center const lenX = x - midX const lenY = y - midY // Calculate slope of line from (x, y) to center const slope = Math.abs(lenY / lenX) // Calculate ratio to find intersection point let ratio if (slope < bb.height / bb.width) { ratio = bb.width / 2 / Math.abs(lenX) } else { ratio = lenY ? bb.height / 2 / Math.abs(lenY) : 0 } // Calculate intersection point return { x: midX + lenX * ratio, y: midY + lenY * ratio } } /** * getOffset * @param {"start"|"end"} side - The side of the line ("start" or "end") where the marker may be present. * @param {Element} line - The line element to check for a marker. * @returns {Float} - Returns the calculated offset if a marker is present, otherwise returns 0. */ const getOffset = (side, line) => { // Check for marker attribute on the given side ("marker-start" or "marker-end") const hasMarker = line.getAttribute('marker-' + side) // Calculate size based on stroke-width, multiplied by a constant factor (here, 5) // TODO: This factor should ideally be based on the actual size of the marker. const size = line.getAttribute('stroke-width') * 5 // Return calculated size if marker is present, otherwise return 0. return hasMarker ? size : 0 } /** * showPanel * @param {boolean} on - Determines whether to show or hide the elements. * @returns {void} */ const showPanel = on => { // Find the 'connector_rules' or create it if it doesn't exist. let connRules = $id('connector_rules') if (!connRules) { connRules = document.createElement('style') connRules.setAttribute('id', 'connector_rules') document.getElementsByTagName('head')[0].appendChild(connRules) } // Update the content of <style> element to either hide or show certain elements. connRules.textContent = !on ? '' : '#tool_clone, #tool_topath, #tool_angle, #xy_panel { display: none !important; }' // Update the display property of the <style> element itself based on the 'on' value. if ($id('connector_rules')) { $id('connector_rules').style.display = on ? 'block' : 'none' } } /** * setPoint * @param {Element} elem - The SVG element. * @param {Integer|"end"} pos - The position index or "end". * @param {Float} x - The x-coordinate. * @param {Float} y - The y-coordinate. * @param {boolean} [setMid] - Whether to set the midpoint. * @returns {void} */ const setPoint = (elem, pos, x, y, setMid) => { // Create a new SVG point const pts = elem.points const pt = svgroot.createSVGPoint() pt.x = x pt.y = y // If position is "end", set it to the last index if (pos === 'end') { pos = pts.numberOfItems - 1 } // Try replacing the point at the specified position pts.replaceItem(pt, pos) // Optionally, set the midpoint if (setMid) { const ptStart = pts.getItem(0) const ptEnd = pts.getItem(pts.numberOfItems - 1) setPoint(elem, 1, (ptEnd.x + ptStart.x) / 2, (ptEnd.y + ptStart.y) / 2) } } /** * @param {Float} diffX * @param {Float} diffY * @returns {void} */ const updatePoints = (line, conn, bb, altBB, pre, altPre) => { const srcX = altBB.x + altBB.width / 2 const srcY = altBB.y + altBB.height / 2 const pt = getBBintersect(srcX, srcY, bb, getOffset(pre, line)) setPoint(line, conn.is_start ? 0 : 'end', pt.x, pt.y, true) const pt2 = getBBintersect(pt.x, pt.y, altBB, getOffset(altPre, line)) setPoint(line, conn.is_start ? 'end' : 0, pt2.x, pt2.y, true) } const updateLine = (diffX, diffY) => { const dataStorage = svgCanvas.getDataStorage() for (const conn of connections) { const { connector: line, is_start: isStart, start_x: startX, start_y: startY } = conn const pre = isStart ? 'start' : 'end' const altPre = isStart ? 'end' : 'start' // Update bbox for this element const bb = { ...dataStorage.get(line, `${pre}_bb`) } bb.x = startX + diffX bb.y = startY + diffY dataStorage.put(line, `${pre}_bb`, bb) // Get center point of connected element const altBB = dataStorage.get(line, `${altPre}_bb`) updatePoints(line, conn, bb, altBB, pre, altPre) } } // Finds connectors associated with selected elements const findConnectors = (elems = []) => { // Fetch data storage object from svgCanvas const dataStorage = svgCanvas.getDataStorage() // Query all connector elements (id startss with conn_) const connectors = document.querySelectorAll('[id^="conn_"]') // Reset connections array connections = [] // Loop through each connector for (const connector of connectors) { let addThis = false // Flag to indicate whether to add this connector const parts = [] // To hold the starting and ending elements connected by the connector // Loop through the connector ends ("start" and "end") for (const [i, pos] of ['start', 'end'].entries()) { // Fetch connected element and its bounding box let part = dataStorage.get(connector, `c_${pos}`) // If part is null or undefined, fetch it and store it if (!part) { part = document.getElementById( connector.attributes['se:connector'].value.split(' ')[i] ) dataStorage.put(connector, `c_${pos}`, part.id) dataStorage.put( connector, `${pos}_bb`, svgCanvas.getStrokedBBox([part]) ) } else { // If part is already stored, fetch it by ID part = document.getElementById(part) } // Add the part to the parts array parts.push(part) } // Loop through the starting and ending elements connected by the connector for (let i = 0; i < 2; i++) { const cElem = parts[i] const parents = svgCanvas.getParents(cElem?.parentNode) // Check if the element is part of a selected group for (const el of parents) { if (elems.includes(el)) { addThis = true break } } // If element is missing or parent is null, remove the connector if (!cElem || !cElem.parentNode) { connector.remove() continue } // If element is in the selection or part of a selected group if (elems.includes(cElem) || addThis) { const bb = svgCanvas.getStrokedBBox([cElem]) // Add connection information to the connections array connections.push({ elem: cElem, connector, is_start: i === 0, start_x: bb.x, start_y: bb.y }) } } } } /** * Updates the connectors based on selected elements. * @param {Element[]} [elems] - Optional array of selected elements. * @returns {void} */ const updateConnectors = elems => { const dataStorage = svgCanvas.getDataStorage() // Find connectors associated with selected elements findConnectors(elems) if (connections.length) { // Iterate through each connection to update its state for (const conn of connections) { const { elem, connector: line, is_start: isStart, start_x: startX, start_y: startY } = conn // Determine whether the connection starts or ends with this element const pre = isStart ? 'start' : 'end' // Update the bounding box for this element const bb = svgCanvas.getStrokedBBox([elem]) bb.x = startX bb.y = startY dataStorage.put(line, `${pre}_bb`, bb) // Determine the opposite end ('start' or 'end') of the connection const altPre = isStart ? 'end' : 'start' // Retrieve the bounding box for the connected element at the opposite end const bb2 = dataStorage.get(line, `${altPre}_bb`) // Calculate the center point of the connected element const srcX = bb2?.x + bb2?.width / 2 const srcY = bb2?.y + bb2?.height / 2 // Update the point of the element being moved const pt = getBBintersect(srcX, srcY, bb, getOffset(pre, line)) setPoint(line, isStart ? 0 : 'end', pt.x, pt.y, true) // Update the point of the connected element at the opposite end const pt2 = getBBintersect( pt.x, pt.y, dataStorage.get(line, `${altPre}_bb`), getOffset(altPre, line) ) setPoint(line, isStart ? 'end' : 0, pt2.x, pt2.y, true) } } } /** * Do on reset. * @returns {void} */ const reset = () => { const dataStorage = svgCanvas.getDataStorage() // Make sure all connectors have data set const svgContent = svgCanvas.getSvgContent() const elements = svgContent.querySelectorAll('*') elements.forEach(element => { const conn = element.getAttributeNS(seNs, 'connector') if (conn) { const connData = conn.split(' ') const sbb = svgCanvas.getStrokedBBox([getElement(connData[0])]) const ebb = svgCanvas.getStrokedBBox([getElement(connData[1])]) dataStorage.put(element, 'c_start', connData[0]) dataStorage.put(element, 'c_end', connData[1]) dataStorage.put(element, 'start_bb', sbb) dataStorage.put(element, 'end_bb', ebb) svgCanvas.getEditorNS(true) } }) } reset() return { name: svgEditor.i18next.t(`${name}:name`), callback () { // Add the button and its handler(s) const buttonTemplate = document.createElement('template') const title = `${name}:buttons.0.title` buttonTemplate.innerHTML = ` <se-button id="tool_connect" title="${title}" src="conn.svg"></se-button> ` $id('tools_left').append(buttonTemplate.content.cloneNode(true)) $click($id('tool_connect'), () => { if (this.leftPanel.updateLeftPanel('tool_connect')) { svgCanvas.setMode('connector') } }) }, mouseDown (opts) { // Retrieve necessary data from the SVG canvas and the event object const dataStorage = svgCanvas.getDataStorage() const svgContent = svgCanvas.getSvgContent() const { event: e, start_x: startX, start_y: startY } = opts const mode = svgCanvas.getMode() const { curConfig: { initStroke } } = svgEditor.configObj if (mode === 'connector') { // Return if the line is already started if (started) return undefined const mouseTarget = e.target const parents = svgCanvas.getParents(mouseTarget.parentNode) // Check if the target is a child of the main SVG content if (parents.includes(svgContent)) { // Identify the connectable element, considering foreignObject elements const fo = svgCanvas.getClosest( mouseTarget.parentNode, 'foreignObject' ) startElem = fo || mouseTarget // Retrieve the bounding box and calculate the center of the start element const bb = svgCanvas.getStrokedBBox([startElem]) const x = bb.x + bb.width / 2 const y = bb.y + bb.height / 2 // Set the flag to indicate the line has started started = true // Create a new polyline element curLine = addSVGElementsFromJson({ element: 'polyline', attr: { id: 'conn_' + svgCanvas.getNextId(), points: `${x},${y} ${x},${y} ${startX},${startY}`, stroke: `#${initStroke.color}`, 'stroke-width': !startElem.stroke_width || startElem.stroke_width === 0 ? initStroke.width : startElem.stroke_width, fill: 'none', opacity: initStroke.opacity, style: 'pointer-events:none' } }) // Store the bounding box of the start element dataStorage.put(curLine, 'start_bb', bb) } return { started: true } } if (mode === 'select') { // Find connectors if the mode is 'select' findConnectors(opts.selectedElements) } return undefined }, mouseMove (opts) { // Exit early if there are no connectors if (connections.length === 0) return const dataStorage = svgCanvas.getDataStorage() const zoom = svgCanvas.getZoom() // const e = opts.event; const x = opts.mouse_x / zoom const y = opts.mouse_y / zoom /** @todo We have a concern if startX or startY are undefined */ if (!startX || !startY) return const diffX = x - startX const diffY = y - startY const mode = svgCanvas.getMode() if (mode === 'connector' && started) { // const sw = curLine.getAttribute('stroke-width') * 3; // Set start point (adjusts based on bb) const pt = getBBintersect( x, y, dataStorage.get(curLine, 'start_bb'), getOffset('start', curLine) ) startX = pt.x startY = pt.y setPoint(curLine, 0, pt.x, pt.y, true) // Set end point setPoint(curLine, 'end', x, y, true) } else if (mode === 'select') { for (const elem of svgCanvas.getSelectedElements()) { if (elem && dataStorage.has(elem, 'c_start')) { svgCanvas.removeFromSelection([elem]) elem.transform.baseVal.clear() } } if (connections.length) { updateLine(diffX, diffY) } } }, mouseUp (opts) { // Get necessary data and initial setups const dataStorage = svgCanvas.getDataStorage() const svgContent = svgCanvas.getSvgContent() const { event: e } = opts let mouseTarget = e.target // Early exit if not in connector mode if (svgCanvas.getMode() !== 'connector') return undefined // Check for a foreignObject parent and update mouseTarget if found const fo = svgCanvas.getClosest(mouseTarget.parentNode, 'foreignObject') if (fo) mouseTarget = fo // Check if the target is a child of the main SVG content const parents = svgCanvas.getParents(mouseTarget.parentNode) const isInSvgContent = parents.includes(svgContent) if (mouseTarget === startElem) { // Case: Started drawing line via click started = true return { keep: true, element: null, started } } if (!isInSvgContent) { // Case: Invalid target element; remove the line curLine?.remove() started = false return { keep: false, element: null, started } } // Valid target element for the end of the line endElem = mouseTarget const startId = startElem?.id || '' const endId = endElem?.id || '' const connStr = `${startId} ${endId}` const altStr = `${endId} ${startId}` // Prevent duplicate connectors const dupe = Array.from( document.querySelectorAll('[id^="conn_"]') ).filter( conn => conn.getAttributeNS(seNs, 'connector') === connStr || conn.getAttributeNS(seNs, 'connector') === altStr ) if (dupe.length) { curLine.remove() return { keep: false, element: null, started: false } } // Update the end point of the connector const bb = svgCanvas.getStrokedBBox([endElem]) const pt = getBBintersect( startX, startY, bb, getOffset('start', curLine) ) setPoint(curLine, 'end', pt.x, pt.y, true) // Save metadata to the connector dataStorage.put(curLine, 'c_start', startId) dataStorage.put(curLine, 'c_end', endId) dataStorage.put(curLine, 'end_bb', bb) curLine.setAttributeNS(seNs, 'se:connector', connStr) curLine.setAttribute('opacity', 1) // Finalize the connector svgCanvas.addToSelection([curLine]) svgCanvas.moveToBottomSelectedElement() selectorManager.requestSelector(curLine).showGrips(false) started = false return { keep: true, element: curLine, started } }, selectedChanged (opts) { // Get necessary data storage and SVG content const dataStorage = svgCanvas.getDataStorage() const svgContent = svgCanvas.getSvgContent() // Exit early if there are no connectors if (!svgContent.querySelectorAll('[id^="conn_"]').length) return // If the current mode is 'connector', switch to 'select' if (svgCanvas.getMode() === 'connector') { svgCanvas.setMode('select') } // Get currently selected elements const { elems: selElems } = opts // Iterate through selected elements for (const elem of selElems) { // If the element has a connector start, handle it if (elem && dataStorage.has(elem, 'c_start')) { selectorManager.requestSelector(elem).showGrips(false) // Show panel depending on selection state showPanel(opts.selectedElement && !opts.multiselected) } else { // Hide panel if no connector start showPanel(false) } } // Update connectors based on selected elements updateConnectors(svgCanvas.getSelectedElements()) }, elementChanged (opts) { // Get the necessary data storage const dataStorage = svgCanvas.getDataStorage() // Get the first element from the options; exit early if it's null let [elem] = opts.elems if (!elem) return // Reinitialize if it's the main SVG content if (elem.tagName === 'svg' && elem.id === 'svgcontent') { reset() } // Check for marker attributes and update offsets const { markerStart, markerMid, markerEnd } = elem.attributes if (markerStart || markerMid || markerEnd) { curLine = elem dataStorage.put(elem, 'start_off', Boolean(markerStart)) dataStorage.put(elem, 'end_off', Boolean(markerEnd)) // Convert lines to polyline if there's a mid-marker if (elem.tagName === 'line' && markerMid) { const { x1, x2, y1, y2, id } = elem.attributes const midPt = `${(Number(x1.value) + Number(x2.value)) / 2},${ (Number(y1.value) + Number(y2.value)) / 2 }` const pline = addSVGElementsFromJson({ element: 'polyline', attr: { points: `${x1.value},${y1.value} ${midPt} ${x2.value},${y2.value}`, stroke: elem.getAttribute('stroke'), 'stroke-width': elem.getAttribute('stroke-width'), 'marker-mid': markerMid.value, fill: 'none', opacity: elem.getAttribute('opacity') || 1 } }) elem.insertAdjacentElement('afterend', pline) elem.remove() svgCanvas.clearSelection() pline.id = id.value svgCanvas.addToSelection([pline]) elem = pline } } // Update connectors based on the current element if (elem?.id.startsWith('conn_')) { const start = getElement(dataStorage.get(elem, 'c_start')) updateConnectors([start]) } else { updateConnectors(svgCanvas.getSelectedElements()) } }, IDsUpdated (input) { const remove = [] input.elems.forEach(function (elem) { if ('se:connector' in elem.attr) { elem.attr['se:connector'] = elem.attr['se:connector'] .split(' ') .map(function (oldID) { return input.changes[oldID] }) .join(' ') // Check validity - the field would be something like 'svg_21 svg_22', but // if one end is missing, it would be 'svg_21' and therefore fail this test if (!/. ./.test(elem.attr['se:connector'])) { remove.push(elem.attr.id) } } }) return { remove } }, toolButtonStateUpdate (opts) { const button = document.getElementById('tool_connect') if (opts.nostroke && button.pressed === true) { svgEditor.clickSelect() } button.disabled = opts.nostroke } } } }