Source: editor/extensions/ext-markers/ext-markers.js

/**
 * @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 (S) {
    const svgEditor = this;
    const { svgCanvas } = svgEditor;
    const { $id, addSVGElementFromJson: 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.getElem(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.getSelectedElems();
      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) => {
      const selElems = svgCanvas.getSelectedElems();
      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.getSelectedElems();
      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>';
        // eslint-disable-next-line no-unsanitized/property
        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);
        }
      }
    };
  }
};