diff --git a/src/editor/ConfigObj.js b/src/editor/ConfigObj.js index f4095ba6..142740c9 100644 --- a/src/editor/ConfigObj.js +++ b/src/editor/ConfigObj.js @@ -175,8 +175,7 @@ export default class ConfigObj { 'ext-eyedropper', 'ext-grid', 'ext-imagelib', - // 'ext-arrows', - // 'ext-markers', + 'ext-markers', 'ext-overview_window', 'ext-panning', 'ext-shapes', diff --git a/src/editor/extensions/ext-markers/ext-markers.js b/src/editor/extensions/ext-markers/ext-markers.js new file mode 100644 index 00000000..857a3365 --- /dev/null +++ b/src/editor/extensions/ext-markers/ext-markers.js @@ -0,0 +1,340 @@ +/** + * @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 + * +*/ + +const loadExtensionTranslation = async function (lang) { + let translationModule; + try { + // eslint-disable-next-line no-unsanitized/method + translationModule = await import(`./locale/${encodeURIComponent(lang)}.js`); + } catch (_error) { + // eslint-disable-next-line no-console + console.error(`Missing translation (${lang}) - using 'en'`); + translationModule = await import(`./locale/en.js`); + } + return translationModule.default; +}; + +export default { + name: 'markers', + async init (S) { + const svgEditor = this; + const strings = await loadExtensionTranslation(svgEditor.configObj.pref('lang')); + const { svgCanvas } = svgEditor; + const { $id, addSVGElementFromJson: addElem } = svgCanvas; + const mtypes = [ 'start', 'mid', 'end' ]; + + // 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(/\(#(.*)\)/); + if (!m || m.length !== 2) { + return null; + } + return svgCanvas.getElem(m[1]); + }; + + let selElems; + /** + * Toggles context tool panel off/on. + * @param {boolean} on + * @returns {void} + */ + const showPanel = (on) => { + $id('marker_panel').style.display = (on) ? 'block' : 'none'; + }; + + /** + * @param {string} id + * @param {""|"\\nomarker"|"nomarker"|"leftarrow"|"rightarrow"|"textmarker"|"forwardslash"|"reverseslash"|"verticalslash"|"box"|"star"|"xmark"|"triangle"|"mcircle"} seType + * @returns {SVGMarkerElement} + */ + const addMarker = (id, seType) => { + let marker = svgCanvas.getElem(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 S.BatchCommand(); + batchCmd.addSubCommand(new S.RemoveElementCommand(elem, elem.parentNode)); + batchCmd.addSubCommand(new S.InsertElementCommand(pline)); + + elem.insertAdjacentElement('afterend', pline); + elem.remove(); + svgCanvas.clearSelection(); + pline.id = id; + svgCanvas.addToSelection([ pline ]); + S.addCommandToHistory(batchCmd); + return pline; + }; + + /** + * + * @returns {void} + */ + const setMarker = (pos, markerType) => { + if (!selElems) 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) => { + mtypes.forEach((pos) => { + const id = 'mkr_' + pos + '_' + el.id; + 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 val = $id(pos + '_marker').getAttribute('value'); + addMarker(id, val); + svgCanvas.changeSelectedAttribute(markerName, 'url(#' + id + ')'); + if (el.tagName === 'line' && pos === 'mid') { el = convertline(el); } + 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"); + let innerHTML = '
'; + mtypes.forEach((pos) => { + innerHTML += ``; + Object.entries(markerTypes).forEach(([ marker, _mkr ]) => { + innerHTML += ``; + }); + innerHTML += ''; + }); + innerHTML += '
'; + // eslint-disable-next-line no-unsanitized/property + panelTemplate.innerHTML = innerHTML; + $id("tools_top").appendChild(panelTemplate.content.cloneNode(true)); + mtypes.forEach((pos) => { + $id(`${pos}_marker_list_opts`).addEventListener('change', (evt) => { + setMarker(pos, evt.detail.value); + }); + }); + // don't display the panels on start + showPanel(false); + }, + /* async */ addLangData ({ _importLocale, _lang }) { + return { data: strings.langList }; + }, + selectedChanged (opts) { + // Use this to update the current selected elements + selElems = opts.elems; + + const markerElems = [ 'line', 'path', 'polyline', 'polygon' ]; + + let i = selElems.length; + while (i--) { + const elem = selElems[i]; + if (elem && markerElems.includes(elem.tagName)) { + if (opts.selectedElement && !opts.multiselected) { + showPanel(true); + } 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); + } + } + }; + } +}; diff --git a/src/editor/extensions/ext-markers/locale/en.js b/src/editor/extensions/ext-markers/locale/en.js new file mode 100644 index 00000000..1bb5686b --- /dev/null +++ b/src/editor/extensions/ext-markers/locale/en.js @@ -0,0 +1,46 @@ +export default { + name: 'Markers', + langList: [ + { id: 'nomarker', title: 'No Marker' }, + { id: 'leftarrow', title: 'Left Arrow' }, + { id: 'rightarrow', title: 'Right Arrow' }, + { id: 'textmarker', title: 'Text Marker' }, + { id: 'forwardslash', title: 'Forward Slash' }, + { id: 'reverseslash', title: 'Reverse Slash' }, + { id: 'verticalslash', title: 'Vertical Slash' }, + { id: 'box', title: 'Box' }, + { id: 'star', title: 'Star' }, + { id: 'xmark', title: 'X' }, + { id: 'triangle', title: 'Triangle' }, + { id: 'mcircle', title: 'Circle' }, + { id: 'leftarrow_o', title: 'Open Left Arrow' }, + { id: 'rightarrow_o', title: 'Open Right Arrow' }, + { id: 'box_o', title: 'Open Box' }, + { id: 'star_o', title: 'Open Star' }, + { id: 'triangle_o', title: 'Open Triangle' }, + { id: 'mcircle_o', title: 'Open Circle' } + ], + contextTools: [ + { + title: 'Start marker', + label: 's' + }, + { + title: 'Select start marker type' + }, + { + title: 'Middle marker', + label: 'm' + }, + { + title: 'Select mid marker type' + }, + { + title: 'End marker', + label: 'e' + }, + { + title: 'Select end marker type' + } + ] +}; diff --git a/src/editor/extensions/ext-markers/locale/zh-CN.js b/src/editor/extensions/ext-markers/locale/zh-CN.js new file mode 100755 index 00000000..9b614ba5 --- /dev/null +++ b/src/editor/extensions/ext-markers/locale/zh-CN.js @@ -0,0 +1,46 @@ +export default { + name: '标记', + langList: [ + { id: 'nomarker', title: '无标记' }, + { id: 'leftarrow', title: '左箭头' }, + { id: 'rightarrow', title: '右箭头' }, + { id: 'textmarker', title: '文本' }, + { id: 'forwardslash', title: '斜杠' }, + { id: 'reverseslash', title: '反斜杠' }, + { id: 'verticalslash', title: '垂直线' }, + { id: 'box', title: '方块' }, + { id: 'star', title: '星形' }, + { id: 'xmark', title: 'X' }, + { id: 'triangle', title: '三角形' }, + { id: 'mcircle', title: '圆形' }, + { id: 'leftarrow_o', title: '左箭头(空心)' }, + { id: 'rightarrow_o', title: '右箭头(空心)' }, + { id: 'box_o', title: '方块(空心)' }, + { id: 'star_o', title: '星形(空心)' }, + { id: 'triangle_o', title: '三角形(空心)' }, + { id: 'mcircle_o', title: '圆形(空心)' } + ], + contextTools: [ + { + title: '起始标记', + label: 's' + }, + { + title: '选择起始标记类型' + }, + { + title: '中段标记', + label: 'm' + }, + { + title: '选择中段标记类型' + }, + { + title: '末端标记', + label: 'e' + }, + { + title: '选择末端标记类型' + } + ] +}; diff --git a/src/editor/extensions/ext-polystar/ext-polystar.js b/src/editor/extensions/ext-polystar/ext-polystar.js index b93f909e..cee59068 100644 --- a/src/editor/extensions/ext-polystar/ext-polystar.js +++ b/src/editor/extensions/ext-polystar/ext-polystar.js @@ -449,9 +449,6 @@ export default { showPanel(false, "polygon"); } } - }, - elementChanged(_opts) { - // const elem = opts.elems[0]; } }; } diff --git a/src/svgcanvas/sanitize.js b/src/svgcanvas/sanitize.js index 8544ca4f..b24402e1 100644 --- a/src/svgcanvas/sanitize.js +++ b/src/svgcanvas/sanitize.js @@ -42,7 +42,7 @@ const svgWhiteList_ = { image: [ 'clip-path', 'clip-rule', 'filter', 'height', 'mask', 'opacity', 'requiredFeatures', 'systemLanguage', 'width', 'x', 'xlink:href', 'xlink:title', 'y' ], line: [ 'clip-path', 'clip-rule', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'marker-end', 'marker-mid', 'marker-start', 'mask', 'opacity', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'x1', 'x2', 'y1', 'y2' ], linearGradient: [ 'gradientTransform', 'gradientUnits', 'requiredFeatures', 'spreadMethod', 'systemLanguage', 'x1', 'x2', 'xlink:href', 'y1', 'y2' ], - marker: [ 'markerHeight', 'markerUnits', 'markerWidth', 'orient', 'preserveAspectRatio', 'refX', 'refY', 'systemLanguage', 'viewBox' ], + marker: [ 'markerHeight', 'markerUnits', 'markerWidth', 'orient', 'preserveAspectRatio', 'refX', 'refY', 'se_type', 'systemLanguage', 'viewBox' ], mask: [ 'height', 'maskContentUnits', 'maskUnits', 'width', 'x', 'y' ], metadata: [ ], path: [ 'clip-path', 'clip-rule', 'd', 'enable-background', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'marker-end', 'marker-mid', 'marker-start', 'mask', 'opacity', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage' ],