diagram-js
Version:
A toolbox for displaying and modifying diagrams on the web
556 lines (430 loc) • 13.3 kB
JavaScript
import {
clear as domClear,
delegate as domDelegate,
query as domQuery,
classes as domClasses,
attr as domAttr,
domify as domify
} from 'min-dom';
import {
escapeHTML
} from '../../util/EscapeUtil';
import { isKey } from '../keyboard/KeyboardUtil';
/**
* @typedef {import('../../core/Canvas').default} Canvas
* @typedef {import('../../core/EventBus').default} EventBus
* @typedef {import('../selection/Selection').default} Selection
* @typedef {import('../../i18n/translate/translate.js').default} Translate
*
* @typedef {import('../../util/Types').Dimensions} Dimensions
*
* @typedef {import('./SearchPadProvider').default} SearchPadProvider
* @typedef {import('./SearchPadProvider').SearchResult} SearchResult
* @typedef {import('./SearchPadProvider').Token} Token
*/
var SCROLL_TO_ELEMENT_PADDING = 300;
/**
* Provides searching infrastructure.
*
* @param {Canvas} canvas
* @param {EventBus} eventBus
* @param {Selection} selection
* @param {Translate} translate
*/
export default function SearchPad(canvas, eventBus, selection, translate) {
this._open = false;
this._results = {};
this._eventMaps = [];
this._cachedRootElement = null;
this._cachedSelection = null;
this._cachedViewbox = null;
this._canvas = canvas;
this._eventBus = eventBus;
this._selection = selection;
this._translate = translate;
// setup elements
this._container = this._getBoxHtml();
this._searchInput = domQuery(SearchPad.INPUT_SELECTOR, this._container);
this._resultsContainer = domQuery(SearchPad.RESULTS_CONTAINER_SELECTOR, this._container);
// attach search pad
this._canvas.getContainer().appendChild(this._container);
// cleanup whenever appropriate
eventBus.on([
'canvas.destroy',
'diagram.destroy',
'drag.init',
'elements.changed'
], this.close, this);
}
SearchPad.$inject = [
'canvas',
'eventBus',
'selection',
'translate'
];
/**
* Binds and keeps track of all event listereners
*/
SearchPad.prototype._bindEvents = function() {
var self = this;
function listen(el, selector, type, fn) {
self._eventMaps.push({
el: el,
type: type,
listener: domDelegate.bind(el, selector, type, fn)
});
}
// close search on clicking anywhere outside
listen(document, 'html', 'click', function(e) {
self.close(false);
});
// stop event from propagating and closing search
// focus on input
listen(this._container, SearchPad.INPUT_SELECTOR, 'click', function(e) {
e.stopPropagation();
e.delegateTarget.focus();
});
// preselect result on hover
listen(this._container, SearchPad.RESULT_SELECTOR, 'mouseover', function(e) {
e.stopPropagation();
self._scrollToNode(e.delegateTarget);
self._preselect(e.delegateTarget);
});
// selects desired result on mouse click
listen(this._container, SearchPad.RESULT_SELECTOR, 'click', function(e) {
e.stopPropagation();
self._select(e.delegateTarget);
});
// prevent cursor in input from going left and right when using up/down to
// navigate results
listen(this._container, SearchPad.INPUT_SELECTOR, 'keydown', function(e) {
if (isKey('ArrowUp', e)) {
e.preventDefault();
}
if (isKey('ArrowDown', e)) {
e.preventDefault();
}
});
// handle keyboard input
listen(this._container, SearchPad.INPUT_SELECTOR, 'keyup', function(e) {
if (isKey('Escape', e)) {
return self.close();
}
if (isKey('Enter', e)) {
var selected = self._getCurrentResult();
return selected ? self._select(selected) : self.close(false);
}
if (isKey('ArrowUp', e)) {
return self._scrollToDirection(true);
}
if (isKey('ArrowDown', e)) {
return self._scrollToDirection();
}
// do not search while navigating text input
if (isKey([ 'ArrowLeft', 'ArrowRight' ], e)) {
return;
}
// anything else
self._search(e.delegateTarget.value);
});
};
/**
* Unbinds all previously established listeners
*/
SearchPad.prototype._unbindEvents = function() {
this._eventMaps.forEach(function(m) {
domDelegate.unbind(m.el, m.type, m.listener);
});
};
/**
* Performs a search for the given pattern.
*
* @param {string} pattern
*/
SearchPad.prototype._search = function(pattern) {
var self = this;
this._clearResults();
// do not search on empty query
if (!pattern.trim()) {
return;
}
var searchResults = this._searchProvider.find(pattern);
searchResults = searchResults.filter(function(searchResult) {
return !self._canvas.getRootElements().includes(searchResult.element);
});
if (!searchResults.length) {
this._selection.select(null);
return;
}
// append new results
searchResults.forEach(function(result) {
var id = result.element.id;
var node = self._createResultNode(result, id);
self._results[id] = {
element: result.element,
node: node
};
});
// preselect first result
var node = domQuery(SearchPad.RESULT_SELECTOR, this._resultsContainer);
this._scrollToNode(node);
this._preselect(node);
};
/**
* Navigate to the previous/next result. Defaults to next result.
*
* @param {boolean} previous
*/
SearchPad.prototype._scrollToDirection = function(previous) {
var selected = this._getCurrentResult();
if (!selected) {
return;
}
var node = previous ? selected.previousElementSibling : selected.nextElementSibling;
if (node) {
this._scrollToNode(node);
this._preselect(node);
}
};
/**
* Scroll to the node if it is not visible.
*
* @param {HTMLElement} node
*/
SearchPad.prototype._scrollToNode = function(node) {
if (!node || node === this._getCurrentResult()) {
return;
}
var nodeOffset = node.offsetTop;
var containerScroll = this._resultsContainer.scrollTop;
var bottomScroll = nodeOffset - this._resultsContainer.clientHeight + node.clientHeight;
if (nodeOffset < containerScroll) {
this._resultsContainer.scrollTop = nodeOffset;
} else if (containerScroll < bottomScroll) {
this._resultsContainer.scrollTop = bottomScroll;
}
};
/**
* Clears all results data.
*/
SearchPad.prototype._clearResults = function() {
domClear(this._resultsContainer);
this._results = {};
this._eventBus.fire('searchPad.cleared');
};
/**
* Get currently selected result.
*
* @return {HTMLElement}
*/
SearchPad.prototype._getCurrentResult = function() {
return domQuery(SearchPad.RESULT_SELECTED_SELECTOR, this._resultsContainer);
};
/**
* Create result DOM element within results container
* that corresponds to a search result.
*
* 'result' : one of the elements returned by Pad
* 'id' : id attribute value to assign to the new DOM node
* return : created DOM element
*
* @param {SearchResult} result
* @param {string} id
*
* @return {HTMLElement}
*/
SearchPad.prototype._createResultNode = function(result, id) {
var node = domify(SearchPad.RESULT_HTML);
// create only if available
if (result.primaryTokens.length > 0) {
createInnerTextNode(node, result.primaryTokens, SearchPad.RESULT_PRIMARY_HTML);
}
// secondary tokens (represent element ID) are allways available
createInnerTextNode(node, result.secondaryTokens, SearchPad.RESULT_SECONDARY_HTML);
domAttr(node, SearchPad.RESULT_ID_ATTRIBUTE, id);
this._resultsContainer.appendChild(node);
return node;
};
/**
* Register search element provider.
*
* @param {SearchPadProvider} provider
*/
SearchPad.prototype.registerProvider = function(provider) {
this._searchProvider = provider;
};
/**
* Open search pad.
*/
SearchPad.prototype.open = function() {
if (!this._searchProvider) {
throw new Error('no search provider registered');
}
if (this.isOpen()) {
return;
}
this._cachedRootElement = this._canvas.getRootElement();
this._cachedSelection = this._selection.get();
this._cachedViewbox = this._canvas.viewbox();
this._selection.select(null);
this._bindEvents();
this._open = true;
domClasses(this._canvas.getContainer()).add('djs-search-open');
domClasses(this._container).add('open');
this._searchInput.focus();
this._eventBus.fire('searchPad.opened');
};
/**
* Close search pad.
*/
SearchPad.prototype.close = function(restoreCached = true) {
if (!this.isOpen()) {
return;
}
if (restoreCached) {
if (this._cachedRootElement) {
this._canvas.setRootElement(this._cachedRootElement);
}
if (this._cachedSelection) {
this._selection.select(this._cachedSelection);
}
if (this._cachedViewbox) {
this._canvas.viewbox(this._cachedViewbox);
}
this._eventBus.fire('searchPad.restored');
}
this._cachedRootElement = null;
this._cachedSelection = null;
this._cachedViewbox = null;
this._unbindEvents();
this._open = false;
domClasses(this._canvas.getContainer()).remove('djs-search-open');
domClasses(this._container).remove('open');
this._clearResults();
this._searchInput.value = '';
this._searchInput.blur();
this._eventBus.fire('searchPad.closed');
this._canvas.restoreFocus();
};
/**
* Toggles search pad on/off.
*/
SearchPad.prototype.toggle = function() {
this.isOpen() ? this.close() : this.open();
};
/**
* Report state of search pad.
*/
SearchPad.prototype.isOpen = function() {
return this._open;
};
/**
* Preselect result entry.
*
* @param {HTMLElement} element
*/
SearchPad.prototype._preselect = function(node) {
var selectedNode = this._getCurrentResult();
// already selected
if (node === selectedNode) {
return;
}
// removing preselection from current node
if (selectedNode) {
domClasses(selectedNode).remove(SearchPad.RESULT_SELECTED_CLASS);
}
var id = domAttr(node, SearchPad.RESULT_ID_ATTRIBUTE);
var element = this._results[id].element;
domClasses(node).add(SearchPad.RESULT_SELECTED_CLASS);
this._canvas.scrollToElement(element, {
top: SCROLL_TO_ELEMENT_PADDING
});
this._selection.select(element);
this._eventBus.fire('searchPad.preselected', element);
};
/**
* Select result node.
*
* @param {HTMLElement} element
*/
SearchPad.prototype._select = function(node) {
var id = domAttr(node, SearchPad.RESULT_ID_ATTRIBUTE);
var element = this._results[id].element;
this._cachedSelection = null;
this._cachedViewbox = null;
this.close(false);
this._canvas.scrollToElement(element, {
top: SCROLL_TO_ELEMENT_PADDING
});
this._selection.select(element);
this._eventBus.fire('searchPad.selected', element);
};
SearchPad.prototype._getBoxHtml = function() {
const box = domify(SearchPad.BOX_HTML);
const input = domQuery(SearchPad.INPUT_SELECTOR, box);
if (input) {
input.setAttribute('aria-label', this._translate('Search in diagram'));
}
return box;
};
/**
* Creates and appends child node from result tokens and HTML template.
*
* @param {HTMLElement} node
* @param {Token[]} tokens
* @param {string} template
*/
function createInnerTextNode(parentNode, tokens, template) {
var text = createHtmlText(tokens);
var childNode = domify(template);
childNode.innerHTML = text;
parentNode.appendChild(childNode);
}
/**
* Create internal HTML markup from result tokens.
* Caters for highlighting pattern matched tokens.
*
* @param {Token[]} tokens
*
* @return {string|null}
*/
function createHtmlText(tokens) {
var htmlText = '';
tokens.forEach(function(t) {
var text = escapeHTML(t.value || t.matched || t.normal);
var match = t.match || t.matched;
if (match) {
htmlText += '<b class="' + SearchPad.RESULT_HIGHLIGHT_CLASS + '">' + text + '</b>';
} else {
htmlText += text;
}
});
return htmlText !== '' ? htmlText : null;
}
/**
* CONSTANTS
*/
SearchPad.CONTAINER_SELECTOR = '.djs-search-container';
SearchPad.INPUT_SELECTOR = '.djs-search-input input';
SearchPad.RESULTS_CONTAINER_SELECTOR = '.djs-search-results';
SearchPad.RESULT_SELECTOR = '.djs-search-result';
SearchPad.RESULT_SELECTED_CLASS = 'djs-search-result-selected';
SearchPad.RESULT_SELECTED_SELECTOR = '.' + SearchPad.RESULT_SELECTED_CLASS;
SearchPad.RESULT_ID_ATTRIBUTE = 'data-result-id';
SearchPad.RESULT_HIGHLIGHT_CLASS = 'djs-search-highlight';
SearchPad.BOX_HTML =
`<div class="djs-search-container djs-scrollable">
<div class="djs-search-input">
<svg class="djs-search-icon" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.0325 8.5H9.625L13.3675 12.25L12.25 13.3675L8.5 9.625V9.0325L8.2975 8.8225C7.4425 9.5575 6.3325 10 5.125 10C2.4325 10 0.25 7.8175 0.25 5.125C0.25 2.4325 2.4325 0.25 5.125 0.25C7.8175 0.25 10 2.4325 10 5.125C10 6.3325 9.5575 7.4425 8.8225 8.2975L9.0325 8.5ZM1.75 5.125C1.75 6.9925 3.2575 8.5 5.125 8.5C6.9925 8.5 8.5 6.9925 8.5 5.125C8.5 3.2575 6.9925 1.75 5.125 1.75C3.2575 1.75 1.75 3.2575 1.75 5.125Z" fill="#22242A"/>
</svg>
<input type="text" spellcheck="false" />
</div>
<div class="djs-search-results" />
</div>`;
SearchPad.RESULT_HTML =
'<div class="djs-search-result"></div>';
SearchPad.RESULT_PRIMARY_HTML =
'<div class="djs-search-result-primary"></div>';
SearchPad.RESULT_SECONDARY_HTML =
'<p class="djs-search-result-secondary"></p>';