/**
* @file ext-markers.js
*
* @license Apache-2.0
*
* @copyright 2010 Will Schleter based on ext-arrows.js by Copyright(c) 2010 Alexis Deveria
* @copyright 2021 OptimistikSAS
*
* This extension provides for the addition of markers to the either end
* or the middle of a line, polyline, path, polygon.
*
* Markers are graphics
*
* to simplify the coding and make the implementation as robust as possible,
* markers are not shared - every object has its own set of markers.
* this relationship is maintained by a naming convention between the
* ids of the markers and the ids of the object
*
* The following restrictions exist for simplicty of use and programming
* objects and their markers to have the same color
* marker size is fixed
* an application specific attribute - se_type - is added to each marker element
* to store the type of marker
*
* @todo
* remove some of the restrictions above
*
*/
export default {
name: 'markers',
async init () {
const svgEditor = this
const { svgCanvas } = svgEditor
const { BatchCommand, RemoveElementCommand, InsertElementCommand } = svgCanvas.history
const { $id, addSVGElementsFromJson: addElem } = svgCanvas
const mtypes = ['start', 'mid', 'end']
const markerElems = ['line', 'path', 'polyline', 'polygon']
// note - to add additional marker types add them below with a unique id
// and add the associated icon(s) to marker-icons.svg
// the geometry is normalized to a 100x100 box with the origin at lower left
// Safari did not like negative values for low left of viewBox
// remember that the coordinate system has +y downward
const markerTypes = {
nomarker: {},
leftarrow:
{ element: 'path', attr: { d: 'M0,50 L100,90 L70,50 L100,10 Z' } },
rightarrow:
{ element: 'path', attr: { d: 'M100,50 L0,90 L30,50 L0,10 Z' } },
box:
{ element: 'path', attr: { d: 'M20,20 L20,80 L80,80 L80,20 Z' } },
mcircle:
{ element: 'circle', attr: { r: 30, cx: 50, cy: 50 } }
};
// duplicate shapes to support unfilled (open) marker types with an _o suffix
['leftarrow', 'rightarrow', 'box', 'mcircle'].forEach((v) => {
markerTypes[v + '_o'] = markerTypes[v]
})
/**
* @param {Element} elem - A graphic element will have an attribute like marker-start
* @param {"marker-start"|"marker-mid"|"marker-end"} attr
* @returns {Element} The marker element that is linked to the graphic element
*/
const getLinked = (elem, attr) => {
const str = elem.getAttribute(attr)
if (!str) { return null }
const m = str.match(/\(#(.*)\)/)
// "url(#mkr_end_svg_1)" would give m[1] = "mkr_end_svg_1"
if (!m || m.length !== 2) {
return null
}
return svgCanvas.getElement(m[1])
}
/**
* Toggles context tool panel off/on.
* @param {boolean} on
* @returns {void}
*/
const showPanel = (on, elem) => {
$id('marker_panel').style.display = (on) ? 'block' : 'none'
if (on && elem) {
mtypes.forEach((pos) => {
const marker = getLinked(elem, 'marker-' + pos)
if (marker?.attributes?.se_type) {
$id(`${pos}_marker_list_opts`).setAttribute('value', marker.attributes.se_type.value)
} else {
$id(`${pos}_marker_list_opts`).setAttribute('value', 'nomarker')
}
})
}
}
/**
* @param {string} id
* @param {""|"nomarker"|"nomarker"|"leftarrow"|"rightarrow"|"textmarker"|"forwardslash"|"reverseslash"|"verticalslash"|"box"|"star"|"xmark"|"triangle"|"mcircle"} seType
* @returns {SVGMarkerElement}
*/
const addMarker = (id, seType) => {
const selElems = svgCanvas.getSelectedElements()
let marker = svgCanvas.getElement(id)
if (marker) { return undefined }
if (seType === '' || seType === 'nomarker') { return undefined }
const el = selElems[0]
const color = el.getAttribute('stroke')
const strokeWidth = 10
const refX = 50
const refY = 50
const viewBox = '0 0 100 100'
const markerWidth = 5
const markerHeight = 5
if (!markerTypes[seType]) {
console.error(`unknown marker type: ${seType}`)
return undefined
}
// create a generic marker
marker = addElem({
element: 'marker',
attr: {
id,
markerUnits: 'strokeWidth',
orient: 'auto',
style: 'pointer-events:none',
se_type: seType
}
})
const mel = addElem(markerTypes[seType])
const fillcolor = (seType.substr(-2) === '_o')
? 'none'
: color
mel.setAttribute('fill', fillcolor)
mel.setAttribute('stroke', color)
mel.setAttribute('stroke-width', strokeWidth)
marker.append(mel)
marker.setAttribute('viewBox', viewBox)
marker.setAttribute('markerWidth', markerWidth)
marker.setAttribute('markerHeight', markerHeight)
marker.setAttribute('refX', refX)
marker.setAttribute('refY', refY)
svgCanvas.findDefs().append(marker)
return marker
}
/**
* @param {Element} elem
* @returns {SVGPolylineElement}
*/
const convertline = (elem) => {
// this routine came from the connectors extension
// it is needed because midpoint markers don't work with line elements
if (elem.tagName !== 'line') { return elem }
// Convert to polyline to accept mid-arrow
const x1 = Number(elem.getAttribute('x1'))
const x2 = Number(elem.getAttribute('x2'))
const y1 = Number(elem.getAttribute('y1'))
const y2 = Number(elem.getAttribute('y2'))
const { id } = elem
const midPt = (' ' + ((x1 + x2) / 2) + ',' + ((y1 + y2) / 2) + ' ')
const pline = addElem({
element: 'polyline',
attr: {
points: (x1 + ',' + y1 + midPt + x2 + ',' + y2),
stroke: elem.getAttribute('stroke'),
'stroke-width': elem.getAttribute('stroke-width'),
fill: 'none',
opacity: elem.getAttribute('opacity') || 1
}
})
mtypes.forEach((pos) => { // get any existing marker definitions
const nam = 'marker-' + pos
const m = elem.getAttribute(nam)
if (m) { pline.setAttribute(nam, elem.getAttribute(nam)) }
})
const batchCmd = new BatchCommand()
batchCmd.addSubCommand(new RemoveElementCommand(elem, elem.parentNode))
batchCmd.addSubCommand(new InsertElementCommand(pline))
elem.insertAdjacentElement('afterend', pline)
elem.remove()
svgCanvas.clearSelection()
pline.id = id
svgCanvas.addToSelection([pline])
svgCanvas.addCommandToHistory(batchCmd)
return pline
}
/**
*
* @returns {void}
*/
const setMarker = (pos, markerType) => {
const selElems = svgCanvas.getSelectedElements()
if (selElems.length === 0) return
const markerName = 'marker-' + pos
const el = selElems[0]
const marker = getLinked(el, markerName)
if (marker) { marker.remove() }
el.removeAttribute(markerName)
let val = markerType
if (val === '') { val = 'nomarker' }
if (val === 'nomarker') {
svgCanvas.call('changed', selElems)
return
}
// Set marker on element
const id = 'mkr_' + pos + '_' + el.id
addMarker(id, val)
svgCanvas.changeSelectedAttribute(markerName, 'url(#' + id + ')')
if (el.tagName === 'line' && pos === 'mid') {
convertline(el)
}
svgCanvas.call('changed', selElems)
}
/**
* Called when the main system modifies an object. This routine changes
* the associated markers to be the same color.
* @param {Element} elem
* @returns {void}
*/
const colorChanged = (elem) => {
const color = elem.getAttribute('stroke')
mtypes.forEach((pos) => {
const marker = getLinked(elem, 'marker-' + pos)
if (!marker) { return }
if (!marker.attributes.se_type) { return } // not created by this extension
const ch = marker.lastElementChild
if (!ch) { return }
const curfill = ch.getAttribute('fill')
const curstroke = ch.getAttribute('stroke')
if (curfill && curfill !== 'none') { ch.setAttribute('fill', color) }
if (curstroke && curstroke !== 'none') { ch.setAttribute('stroke', color) }
})
}
/**
* Called when the main system creates or modifies an object.
* Its primary purpose is to create new markers for cloned objects.
* @param {Element} el
* @returns {void}
*/
const updateReferences = (el) => {
const selElems = svgCanvas.getSelectedElements()
mtypes.forEach((pos) => {
const markerName = 'marker-' + pos
const marker = getLinked(el, markerName)
if (!marker || !marker.attributes.se_type) { return } // not created by this extension
const url = el.getAttribute(markerName)
if (url) {
const len = el.id.length
const linkid = url.substr(-len - 1, len)
if (el.id !== linkid) {
const newMarkerId = 'mkr_' + pos + '_' + el.id
addMarker(newMarkerId, marker.attributes.se_type.value)
svgCanvas.changeSelectedAttribute(markerName, 'url(#' + newMarkerId + ')')
svgCanvas.call('changed', selElems)
}
}
})
}
return {
name: svgEditor.i18next.t(`${name}:name`),
// The callback should be used to load the DOM with the appropriate UI items
callback () {
// Add the context panel and its handler(s)
const panelTemplate = document.createElement('template')
// create the marker panel
let innerHTML = '<div id="marker_panel">'
mtypes.forEach((pos) => {
innerHTML += `<se-list id="${pos}_marker_list_opts" title="tools.${pos}_marker_list_opts" label="" width="22px" height="22px">`
Object.entries(markerTypes).forEach(([marker, _mkr]) => {
innerHTML += `<se-list-item id="mkr_${pos}_${marker}" value="${marker}" title="tools.mkr_${marker}" src="${marker}.svg" img-height="22px"></se-list-item>`
})
innerHTML += '</se-list>'
})
innerHTML += '</div>'
panelTemplate.innerHTML = innerHTML
$id('tools_top').appendChild(panelTemplate.content.cloneNode(true))
// don't display the panels on start
showPanel(false)
mtypes.forEach((pos) => {
$id(`${pos}_marker_list_opts`).addEventListener('change', (evt) => {
setMarker(pos, evt.detail.value)
})
})
},
selectedChanged (opts) {
// Use this to update the current selected elements
if (opts.elems.length === 0) showPanel(false)
opts.elems.forEach((elem) => {
if (elem && markerElems.includes(elem.tagName)) {
if (opts.selectedElement && !opts.multiselected) {
showPanel(true, elem)
} else {
showPanel(false)
}
} else {
showPanel(false)
}
})
},
elementChanged (opts) {
const elem = opts.elems[0]
if (elem && (
elem.getAttribute('marker-start') ||
elem.getAttribute('marker-mid') ||
elem.getAttribute('marker-end')
)) {
colorChanged(elem)
updateReferences(elem)
}
}
}
}
}