mirror of
https://github.com/qmk/qmk_firmware.git
synced 2026-09-07 10:53:17 +02:00
5343 lines
193 KiB
JavaScript
5343 lines
193 KiB
JavaScript
var __defProp = Object.defineProperty;
|
|
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
import { V as __vitePreload, q as watch, am as tryOnScopeDispose, h as computed, an as toValue, ao as toArray, ap as unrefElement, aq as notNullish, G as shallowRef, d as defineComponent, ar as computedAsync, p as ref, as as useSessionStorage, at as useLocalStorage, s as watchEffect, au as watchDebounced, v as onMounted, P as nextTick, O as onKeyStroke, a6 as useRouter, av as useEventListener, W as useScrollLock, R as inBrowser, $ as onBeforeUnmount, o as openBlock, b as createBlock, j as createBaseVNode, a0 as withModifiers, k as unref, aw as withDirectives, ax as vModelText, ay as isRef, c as createElementBlock, n as normalizeClass, e as createCommentVNode, B as renderList, F as Fragment, a as createTextVNode, t as toDisplayString, az as Teleport, aA as markRaw, aB as createApp, aa as dataSymbol, af as pathToFile, aC as escapeRegExp, _ as _export_sfc } from "./framework.8zKZLKO7.js";
|
|
import { u as useData, c as createSearchTranslate } from "./theme.CCucVJkz.js";
|
|
const localSearchIndex = { "root": () => __vitePreload(() => import("./@localSearchIndexroot.cZDzDpCW.js"), true ? [] : void 0) };
|
|
/*!
|
|
* tabbable 6.4.0
|
|
* @license MIT, https://github.com/focus-trap/tabbable/blob/master/LICENSE
|
|
*/
|
|
var candidateSelectors = ["input:not([inert]):not([inert] *)", "select:not([inert]):not([inert] *)", "textarea:not([inert]):not([inert] *)", "a[href]:not([inert]):not([inert] *)", "button:not([inert]):not([inert] *)", "[tabindex]:not(slot):not([inert]):not([inert] *)", "audio[controls]:not([inert]):not([inert] *)", "video[controls]:not([inert]):not([inert] *)", '[contenteditable]:not([contenteditable="false"]):not([inert]):not([inert] *)', "details>summary:first-of-type:not([inert]):not([inert] *)", "details:not([inert]):not([inert] *)"];
|
|
var candidateSelector = /* @__PURE__ */ candidateSelectors.join(",");
|
|
var NoElement = typeof Element === "undefined";
|
|
var matches = NoElement ? function() {
|
|
} : Element.prototype.matches || Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector;
|
|
var getRootNode = !NoElement && Element.prototype.getRootNode ? function(element) {
|
|
var _element$getRootNode;
|
|
return element === null || element === void 0 ? void 0 : (_element$getRootNode = element.getRootNode) === null || _element$getRootNode === void 0 ? void 0 : _element$getRootNode.call(element);
|
|
} : function(element) {
|
|
return element === null || element === void 0 ? void 0 : element.ownerDocument;
|
|
};
|
|
var _isInert = function isInert(node, lookUp) {
|
|
var _node$getAttribute;
|
|
if (lookUp === void 0) {
|
|
lookUp = true;
|
|
}
|
|
var inertAtt = node === null || node === void 0 ? void 0 : (_node$getAttribute = node.getAttribute) === null || _node$getAttribute === void 0 ? void 0 : _node$getAttribute.call(node, "inert");
|
|
var inert = inertAtt === "" || inertAtt === "true";
|
|
var result = inert || lookUp && node && // closest does not exist on shadow roots, so we fall back to a manual
|
|
// lookup upward, in case it is not defined.
|
|
(typeof node.closest === "function" ? node.closest("[inert]") : _isInert(node.parentNode));
|
|
return result;
|
|
};
|
|
var isContentEditable = function isContentEditable2(node) {
|
|
var _node$getAttribute2;
|
|
var attValue = node === null || node === void 0 ? void 0 : (_node$getAttribute2 = node.getAttribute) === null || _node$getAttribute2 === void 0 ? void 0 : _node$getAttribute2.call(node, "contenteditable");
|
|
return attValue === "" || attValue === "true";
|
|
};
|
|
var getCandidates = function getCandidates2(el, includeContainer, filter) {
|
|
if (_isInert(el)) {
|
|
return [];
|
|
}
|
|
var candidates = Array.prototype.slice.apply(el.querySelectorAll(candidateSelector));
|
|
if (includeContainer && matches.call(el, candidateSelector)) {
|
|
candidates.unshift(el);
|
|
}
|
|
candidates = candidates.filter(filter);
|
|
return candidates;
|
|
};
|
|
var _getCandidatesIteratively = function getCandidatesIteratively(elements, includeContainer, options) {
|
|
var candidates = [];
|
|
var elementsToCheck = Array.from(elements);
|
|
while (elementsToCheck.length) {
|
|
var element = elementsToCheck.shift();
|
|
if (_isInert(element, false)) {
|
|
continue;
|
|
}
|
|
if (element.tagName === "SLOT") {
|
|
var assigned = element.assignedElements();
|
|
var content = assigned.length ? assigned : element.children;
|
|
var nestedCandidates = _getCandidatesIteratively(content, true, options);
|
|
if (options.flatten) {
|
|
candidates.push.apply(candidates, nestedCandidates);
|
|
} else {
|
|
candidates.push({
|
|
scopeParent: element,
|
|
candidates: nestedCandidates
|
|
});
|
|
}
|
|
} else {
|
|
var validCandidate = matches.call(element, candidateSelector);
|
|
if (validCandidate && options.filter(element) && (includeContainer || !elements.includes(element))) {
|
|
candidates.push(element);
|
|
}
|
|
var shadowRoot = element.shadowRoot || // check for an undisclosed shadow
|
|
typeof options.getShadowRoot === "function" && options.getShadowRoot(element);
|
|
var validShadowRoot = !_isInert(shadowRoot, false) && (!options.shadowRootFilter || options.shadowRootFilter(element));
|
|
if (shadowRoot && validShadowRoot) {
|
|
var _nestedCandidates = _getCandidatesIteratively(shadowRoot === true ? element.children : shadowRoot.children, true, options);
|
|
if (options.flatten) {
|
|
candidates.push.apply(candidates, _nestedCandidates);
|
|
} else {
|
|
candidates.push({
|
|
scopeParent: element,
|
|
candidates: _nestedCandidates
|
|
});
|
|
}
|
|
} else {
|
|
elementsToCheck.unshift.apply(elementsToCheck, element.children);
|
|
}
|
|
}
|
|
}
|
|
return candidates;
|
|
};
|
|
var hasTabIndex = function hasTabIndex2(node) {
|
|
return !isNaN(parseInt(node.getAttribute("tabindex"), 10));
|
|
};
|
|
var getTabIndex = function getTabIndex2(node) {
|
|
if (!node) {
|
|
throw new Error("No node provided");
|
|
}
|
|
if (node.tabIndex < 0) {
|
|
if ((/^(AUDIO|VIDEO|DETAILS)$/.test(node.tagName) || isContentEditable(node)) && !hasTabIndex(node)) {
|
|
return 0;
|
|
}
|
|
}
|
|
return node.tabIndex;
|
|
};
|
|
var getSortOrderTabIndex = function getSortOrderTabIndex2(node, isScope) {
|
|
var tabIndex = getTabIndex(node);
|
|
if (tabIndex < 0 && isScope && !hasTabIndex(node)) {
|
|
return 0;
|
|
}
|
|
return tabIndex;
|
|
};
|
|
var sortOrderedTabbables = function sortOrderedTabbables2(a, b) {
|
|
return a.tabIndex === b.tabIndex ? a.documentOrder - b.documentOrder : a.tabIndex - b.tabIndex;
|
|
};
|
|
var isInput = function isInput2(node) {
|
|
return node.tagName === "INPUT";
|
|
};
|
|
var isHiddenInput = function isHiddenInput2(node) {
|
|
return isInput(node) && node.type === "hidden";
|
|
};
|
|
var isDetailsWithSummary = function isDetailsWithSummary2(node) {
|
|
var r = node.tagName === "DETAILS" && Array.prototype.slice.apply(node.children).some(function(child) {
|
|
return child.tagName === "SUMMARY";
|
|
});
|
|
return r;
|
|
};
|
|
var getCheckedRadio = function getCheckedRadio2(nodes, form) {
|
|
for (var i = 0; i < nodes.length; i++) {
|
|
if (nodes[i].checked && nodes[i].form === form) {
|
|
return nodes[i];
|
|
}
|
|
}
|
|
};
|
|
var isTabbableRadio = function isTabbableRadio2(node) {
|
|
if (!node.name) {
|
|
return true;
|
|
}
|
|
var radioScope = node.form || getRootNode(node);
|
|
var queryRadios = function queryRadios2(name) {
|
|
return radioScope.querySelectorAll('input[type="radio"][name="' + name + '"]');
|
|
};
|
|
var radioSet;
|
|
if (typeof window !== "undefined" && typeof window.CSS !== "undefined" && typeof window.CSS.escape === "function") {
|
|
radioSet = queryRadios(window.CSS.escape(node.name));
|
|
} else {
|
|
try {
|
|
radioSet = queryRadios(node.name);
|
|
} catch (err) {
|
|
console.error("Looks like you have a radio button with a name attribute containing invalid CSS selector characters and need the CSS.escape polyfill: %s", err.message);
|
|
return false;
|
|
}
|
|
}
|
|
var checked = getCheckedRadio(radioSet, node.form);
|
|
return !checked || checked === node;
|
|
};
|
|
var isRadio = function isRadio2(node) {
|
|
return isInput(node) && node.type === "radio";
|
|
};
|
|
var isNonTabbableRadio = function isNonTabbableRadio2(node) {
|
|
return isRadio(node) && !isTabbableRadio(node);
|
|
};
|
|
var isNodeAttached = function isNodeAttached2(node) {
|
|
var _nodeRoot;
|
|
var nodeRoot = node && getRootNode(node);
|
|
var nodeRootHost = (_nodeRoot = nodeRoot) === null || _nodeRoot === void 0 ? void 0 : _nodeRoot.host;
|
|
var attached = false;
|
|
if (nodeRoot && nodeRoot !== node) {
|
|
var _nodeRootHost, _nodeRootHost$ownerDo, _node$ownerDocument;
|
|
attached = !!((_nodeRootHost = nodeRootHost) !== null && _nodeRootHost !== void 0 && (_nodeRootHost$ownerDo = _nodeRootHost.ownerDocument) !== null && _nodeRootHost$ownerDo !== void 0 && _nodeRootHost$ownerDo.contains(nodeRootHost) || node !== null && node !== void 0 && (_node$ownerDocument = node.ownerDocument) !== null && _node$ownerDocument !== void 0 && _node$ownerDocument.contains(node));
|
|
while (!attached && nodeRootHost) {
|
|
var _nodeRoot2, _nodeRootHost2, _nodeRootHost2$ownerD;
|
|
nodeRoot = getRootNode(nodeRootHost);
|
|
nodeRootHost = (_nodeRoot2 = nodeRoot) === null || _nodeRoot2 === void 0 ? void 0 : _nodeRoot2.host;
|
|
attached = !!((_nodeRootHost2 = nodeRootHost) !== null && _nodeRootHost2 !== void 0 && (_nodeRootHost2$ownerD = _nodeRootHost2.ownerDocument) !== null && _nodeRootHost2$ownerD !== void 0 && _nodeRootHost2$ownerD.contains(nodeRootHost));
|
|
}
|
|
}
|
|
return attached;
|
|
};
|
|
var isZeroArea = function isZeroArea2(node) {
|
|
var _node$getBoundingClie = node.getBoundingClientRect(), width = _node$getBoundingClie.width, height = _node$getBoundingClie.height;
|
|
return width === 0 && height === 0;
|
|
};
|
|
var isHidden = function isHidden2(node, _ref) {
|
|
var displayCheck = _ref.displayCheck, getShadowRoot = _ref.getShadowRoot;
|
|
if (displayCheck === "full-native") {
|
|
if ("checkVisibility" in node) {
|
|
var visible = node.checkVisibility({
|
|
// Checking opacity might be desirable for some use cases, but natively,
|
|
// opacity zero elements _are_ focusable and tabbable.
|
|
checkOpacity: false,
|
|
opacityProperty: false,
|
|
contentVisibilityAuto: true,
|
|
visibilityProperty: true,
|
|
// This is an alias for `visibilityProperty`. Contemporary browsers
|
|
// support both. However, this alias has wider browser support (Chrome
|
|
// >= 105 and Firefox >= 106, vs. Chrome >= 121 and Firefox >= 122), so
|
|
// we include it anyway.
|
|
checkVisibilityCSS: true
|
|
});
|
|
return !visible;
|
|
}
|
|
}
|
|
if (getComputedStyle(node).visibility === "hidden") {
|
|
return true;
|
|
}
|
|
var isDirectSummary = matches.call(node, "details>summary:first-of-type");
|
|
var nodeUnderDetails = isDirectSummary ? node.parentElement : node;
|
|
if (matches.call(nodeUnderDetails, "details:not([open]) *")) {
|
|
return true;
|
|
}
|
|
if (!displayCheck || displayCheck === "full" || // full-native can run this branch when it falls through in case
|
|
// Element#checkVisibility is unsupported
|
|
displayCheck === "full-native" || displayCheck === "legacy-full") {
|
|
if (typeof getShadowRoot === "function") {
|
|
var originalNode = node;
|
|
while (node) {
|
|
var parentElement = node.parentElement;
|
|
var rootNode = getRootNode(node);
|
|
if (parentElement && !parentElement.shadowRoot && getShadowRoot(parentElement) === true) {
|
|
return isZeroArea(node);
|
|
} else if (node.assignedSlot) {
|
|
node = node.assignedSlot;
|
|
} else if (!parentElement && rootNode !== node.ownerDocument) {
|
|
node = rootNode.host;
|
|
} else {
|
|
node = parentElement;
|
|
}
|
|
}
|
|
node = originalNode;
|
|
}
|
|
if (isNodeAttached(node)) {
|
|
return !node.getClientRects().length;
|
|
}
|
|
if (displayCheck !== "legacy-full") {
|
|
return true;
|
|
}
|
|
} else if (displayCheck === "non-zero-area") {
|
|
return isZeroArea(node);
|
|
}
|
|
return false;
|
|
};
|
|
var isDisabledFromFieldset = function isDisabledFromFieldset2(node) {
|
|
if (/^(INPUT|BUTTON|SELECT|TEXTAREA)$/.test(node.tagName)) {
|
|
var parentNode = node.parentElement;
|
|
while (parentNode) {
|
|
if (parentNode.tagName === "FIELDSET" && parentNode.disabled) {
|
|
for (var i = 0; i < parentNode.children.length; i++) {
|
|
var child = parentNode.children.item(i);
|
|
if (child.tagName === "LEGEND") {
|
|
return matches.call(parentNode, "fieldset[disabled] *") ? true : !child.contains(node);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
parentNode = parentNode.parentElement;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
var isNodeMatchingSelectorFocusable = function isNodeMatchingSelectorFocusable2(options, node) {
|
|
if (node.disabled || isHiddenInput(node) || isHidden(node, options) || // For a details element with a summary, the summary element gets the focus
|
|
isDetailsWithSummary(node) || isDisabledFromFieldset(node)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
var isNodeMatchingSelectorTabbable = function isNodeMatchingSelectorTabbable2(options, node) {
|
|
if (isNonTabbableRadio(node) || getTabIndex(node) < 0 || !isNodeMatchingSelectorFocusable(options, node)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
var isShadowRootTabbable = function isShadowRootTabbable2(shadowHostNode) {
|
|
var tabIndex = parseInt(shadowHostNode.getAttribute("tabindex"), 10);
|
|
if (isNaN(tabIndex) || tabIndex >= 0) {
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
var _sortByOrder = function sortByOrder(candidates) {
|
|
var regularTabbables = [];
|
|
var orderedTabbables = [];
|
|
candidates.forEach(function(item, i) {
|
|
var isScope = !!item.scopeParent;
|
|
var element = isScope ? item.scopeParent : item;
|
|
var candidateTabindex = getSortOrderTabIndex(element, isScope);
|
|
var elements = isScope ? _sortByOrder(item.candidates) : element;
|
|
if (candidateTabindex === 0) {
|
|
isScope ? regularTabbables.push.apply(regularTabbables, elements) : regularTabbables.push(element);
|
|
} else {
|
|
orderedTabbables.push({
|
|
documentOrder: i,
|
|
tabIndex: candidateTabindex,
|
|
item,
|
|
isScope,
|
|
content: elements
|
|
});
|
|
}
|
|
});
|
|
return orderedTabbables.sort(sortOrderedTabbables).reduce(function(acc, sortable) {
|
|
sortable.isScope ? acc.push.apply(acc, sortable.content) : acc.push(sortable.content);
|
|
return acc;
|
|
}, []).concat(regularTabbables);
|
|
};
|
|
var tabbable = function tabbable2(container, options) {
|
|
options = options || {};
|
|
var candidates;
|
|
if (options.getShadowRoot) {
|
|
candidates = _getCandidatesIteratively([container], options.includeContainer, {
|
|
filter: isNodeMatchingSelectorTabbable.bind(null, options),
|
|
flatten: false,
|
|
getShadowRoot: options.getShadowRoot,
|
|
shadowRootFilter: isShadowRootTabbable
|
|
});
|
|
} else {
|
|
candidates = getCandidates(container, options.includeContainer, isNodeMatchingSelectorTabbable.bind(null, options));
|
|
}
|
|
return _sortByOrder(candidates);
|
|
};
|
|
var focusable = function focusable2(container, options) {
|
|
options = options || {};
|
|
var candidates;
|
|
if (options.getShadowRoot) {
|
|
candidates = _getCandidatesIteratively([container], options.includeContainer, {
|
|
filter: isNodeMatchingSelectorFocusable.bind(null, options),
|
|
flatten: true,
|
|
getShadowRoot: options.getShadowRoot
|
|
});
|
|
} else {
|
|
candidates = getCandidates(container, options.includeContainer, isNodeMatchingSelectorFocusable.bind(null, options));
|
|
}
|
|
return candidates;
|
|
};
|
|
var isTabbable = function isTabbable2(node, options) {
|
|
options = options || {};
|
|
if (!node) {
|
|
throw new Error("No node provided");
|
|
}
|
|
if (matches.call(node, candidateSelector) === false) {
|
|
return false;
|
|
}
|
|
return isNodeMatchingSelectorTabbable(options, node);
|
|
};
|
|
var focusableCandidateSelector = /* @__PURE__ */ candidateSelectors.concat("iframe:not([inert]):not([inert] *)").join(",");
|
|
var isFocusable = function isFocusable2(node, options) {
|
|
options = options || {};
|
|
if (!node) {
|
|
throw new Error("No node provided");
|
|
}
|
|
if (matches.call(node, focusableCandidateSelector) === false) {
|
|
return false;
|
|
}
|
|
return isNodeMatchingSelectorFocusable(options, node);
|
|
};
|
|
/*!
|
|
* focus-trap 7.8.0
|
|
* @license MIT, https://github.com/focus-trap/focus-trap/blob/master/LICENSE
|
|
*/
|
|
function _arrayLikeToArray(r, a) {
|
|
(null == a || a > r.length) && (a = r.length);
|
|
for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e];
|
|
return n;
|
|
}
|
|
function _arrayWithoutHoles(r) {
|
|
if (Array.isArray(r)) return _arrayLikeToArray(r);
|
|
}
|
|
function _createForOfIteratorHelper(r, e) {
|
|
var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"];
|
|
if (!t) {
|
|
if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e) {
|
|
t && (r = t);
|
|
var n = 0, F = function() {
|
|
};
|
|
return {
|
|
s: F,
|
|
n: function() {
|
|
return n >= r.length ? {
|
|
done: true
|
|
} : {
|
|
done: false,
|
|
value: r[n++]
|
|
};
|
|
},
|
|
e: function(r2) {
|
|
throw r2;
|
|
},
|
|
f: F
|
|
};
|
|
}
|
|
throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");
|
|
}
|
|
var o, a = true, u = false;
|
|
return {
|
|
s: function() {
|
|
t = t.call(r);
|
|
},
|
|
n: function() {
|
|
var r2 = t.next();
|
|
return a = r2.done, r2;
|
|
},
|
|
e: function(r2) {
|
|
u = true, o = r2;
|
|
},
|
|
f: function() {
|
|
try {
|
|
a || null == t.return || t.return();
|
|
} finally {
|
|
if (u) throw o;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
function _defineProperty(e, r, t) {
|
|
return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
|
|
value: t,
|
|
enumerable: true,
|
|
configurable: true,
|
|
writable: true
|
|
}) : e[r] = t, e;
|
|
}
|
|
function _iterableToArray(r) {
|
|
if ("undefined" != typeof Symbol && null != r[Symbol.iterator] || null != r["@@iterator"]) return Array.from(r);
|
|
}
|
|
function _nonIterableSpread() {
|
|
throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");
|
|
}
|
|
function ownKeys(e, r) {
|
|
var t = Object.keys(e);
|
|
if (Object.getOwnPropertySymbols) {
|
|
var o = Object.getOwnPropertySymbols(e);
|
|
r && (o = o.filter(function(r2) {
|
|
return Object.getOwnPropertyDescriptor(e, r2).enumerable;
|
|
})), t.push.apply(t, o);
|
|
}
|
|
return t;
|
|
}
|
|
function _objectSpread2(e) {
|
|
for (var r = 1; r < arguments.length; r++) {
|
|
var t = null != arguments[r] ? arguments[r] : {};
|
|
r % 2 ? ownKeys(Object(t), true).forEach(function(r2) {
|
|
_defineProperty(e, r2, t[r2]);
|
|
}) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function(r2) {
|
|
Object.defineProperty(e, r2, Object.getOwnPropertyDescriptor(t, r2));
|
|
});
|
|
}
|
|
return e;
|
|
}
|
|
function _toConsumableArray(r) {
|
|
return _arrayWithoutHoles(r) || _iterableToArray(r) || _unsupportedIterableToArray(r) || _nonIterableSpread();
|
|
}
|
|
function _toPrimitive(t, r) {
|
|
if ("object" != typeof t || !t) return t;
|
|
var e = t[Symbol.toPrimitive];
|
|
if (void 0 !== e) {
|
|
var i = e.call(t, r);
|
|
if ("object" != typeof i) return i;
|
|
throw new TypeError("@@toPrimitive must return a primitive value.");
|
|
}
|
|
return ("string" === r ? String : Number)(t);
|
|
}
|
|
function _toPropertyKey(t) {
|
|
var i = _toPrimitive(t, "string");
|
|
return "symbol" == typeof i ? i : i + "";
|
|
}
|
|
function _unsupportedIterableToArray(r, a) {
|
|
if (r) {
|
|
if ("string" == typeof r) return _arrayLikeToArray(r, a);
|
|
var t = {}.toString.call(r).slice(8, -1);
|
|
return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0;
|
|
}
|
|
}
|
|
var activeFocusTraps = {
|
|
// Returns the trap from the top of the stack.
|
|
getActiveTrap: function getActiveTrap(trapStack) {
|
|
if ((trapStack === null || trapStack === void 0 ? void 0 : trapStack.length) > 0) {
|
|
return trapStack[trapStack.length - 1];
|
|
}
|
|
return null;
|
|
},
|
|
// Pauses the currently active trap, then adds a new trap to the stack.
|
|
activateTrap: function activateTrap(trapStack, trap) {
|
|
var activeTrap = activeFocusTraps.getActiveTrap(trapStack);
|
|
if (trap !== activeTrap) {
|
|
activeFocusTraps.pauseTrap(trapStack);
|
|
}
|
|
var trapIndex = trapStack.indexOf(trap);
|
|
if (trapIndex === -1) {
|
|
trapStack.push(trap);
|
|
} else {
|
|
trapStack.splice(trapIndex, 1);
|
|
trapStack.push(trap);
|
|
}
|
|
},
|
|
// Removes the trap from the top of the stack, then unpauses the next trap down.
|
|
deactivateTrap: function deactivateTrap(trapStack, trap) {
|
|
var trapIndex = trapStack.indexOf(trap);
|
|
if (trapIndex !== -1) {
|
|
trapStack.splice(trapIndex, 1);
|
|
}
|
|
activeFocusTraps.unpauseTrap(trapStack);
|
|
},
|
|
// Pauses the trap at the top of the stack.
|
|
pauseTrap: function pauseTrap(trapStack) {
|
|
var activeTrap = activeFocusTraps.getActiveTrap(trapStack);
|
|
activeTrap === null || activeTrap === void 0 || activeTrap._setPausedState(true);
|
|
},
|
|
// Unpauses the trap at the top of the stack.
|
|
unpauseTrap: function unpauseTrap(trapStack) {
|
|
var activeTrap = activeFocusTraps.getActiveTrap(trapStack);
|
|
if (activeTrap && !activeTrap._isManuallyPaused()) {
|
|
activeTrap._setPausedState(false);
|
|
}
|
|
}
|
|
};
|
|
var isSelectableInput = function isSelectableInput2(node) {
|
|
return node.tagName && node.tagName.toLowerCase() === "input" && typeof node.select === "function";
|
|
};
|
|
var isEscapeEvent = function isEscapeEvent2(e) {
|
|
return (e === null || e === void 0 ? void 0 : e.key) === "Escape" || (e === null || e === void 0 ? void 0 : e.key) === "Esc" || (e === null || e === void 0 ? void 0 : e.keyCode) === 27;
|
|
};
|
|
var isTabEvent = function isTabEvent2(e) {
|
|
return (e === null || e === void 0 ? void 0 : e.key) === "Tab" || (e === null || e === void 0 ? void 0 : e.keyCode) === 9;
|
|
};
|
|
var isKeyForward = function isKeyForward2(e) {
|
|
return isTabEvent(e) && !e.shiftKey;
|
|
};
|
|
var isKeyBackward = function isKeyBackward2(e) {
|
|
return isTabEvent(e) && e.shiftKey;
|
|
};
|
|
var delay = function delay2(fn) {
|
|
return setTimeout(fn, 0);
|
|
};
|
|
var valueOrHandler = function valueOrHandler2(value) {
|
|
for (var _len = arguments.length, params = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
|
|
params[_key - 1] = arguments[_key];
|
|
}
|
|
return typeof value === "function" ? value.apply(void 0, params) : value;
|
|
};
|
|
var getActualTarget = function getActualTarget2(event) {
|
|
return event.target.shadowRoot && typeof event.composedPath === "function" ? event.composedPath()[0] : event.target;
|
|
};
|
|
var internalTrapStack = [];
|
|
var createFocusTrap = function createFocusTrap2(elements, userOptions) {
|
|
var doc = (userOptions === null || userOptions === void 0 ? void 0 : userOptions.document) || document;
|
|
var trapStack = (userOptions === null || userOptions === void 0 ? void 0 : userOptions.trapStack) || internalTrapStack;
|
|
var config = _objectSpread2({
|
|
returnFocusOnDeactivate: true,
|
|
escapeDeactivates: true,
|
|
delayInitialFocus: true,
|
|
isolateSubtrees: false,
|
|
isKeyForward,
|
|
isKeyBackward
|
|
}, userOptions);
|
|
var state = {
|
|
// containers given to createFocusTrap()
|
|
/** @type {Array<HTMLElement>} */
|
|
containers: [],
|
|
// list of objects identifying tabbable nodes in `containers` in the trap
|
|
// NOTE: it's possible that a group has no tabbable nodes if nodes get removed while the trap
|
|
// is active, but the trap should never get to a state where there isn't at least one group
|
|
// with at least one tabbable node in it (that would lead to an error condition that would
|
|
// result in an error being thrown)
|
|
/** @type {Array<{
|
|
* container: HTMLElement,
|
|
* tabbableNodes: Array<HTMLElement>, // empty if none
|
|
* focusableNodes: Array<HTMLElement>, // empty if none
|
|
* posTabIndexesFound: boolean,
|
|
* firstTabbableNode: HTMLElement|undefined,
|
|
* lastTabbableNode: HTMLElement|undefined,
|
|
* firstDomTabbableNode: HTMLElement|undefined,
|
|
* lastDomTabbableNode: HTMLElement|undefined,
|
|
* nextTabbableNode: (node: HTMLElement, forward: boolean) => HTMLElement|undefined
|
|
* }>}
|
|
*/
|
|
containerGroups: [],
|
|
// same order/length as `containers` list
|
|
// references to objects in `containerGroups`, but only those that actually have
|
|
// tabbable nodes in them
|
|
// NOTE: same order as `containers` and `containerGroups`, but __not necessarily__
|
|
// the same length
|
|
tabbableGroups: [],
|
|
// references to nodes that are siblings to the ancestors of this trap's containers.
|
|
/** @type {Set<HTMLElement>} */
|
|
adjacentElements: /* @__PURE__ */ new Set(),
|
|
// references to nodes that were inert or aria-hidden before the trap was activated.
|
|
/** @type {Set<HTMLElement>} */
|
|
alreadySilent: /* @__PURE__ */ new Set(),
|
|
nodeFocusedBeforeActivation: null,
|
|
mostRecentlyFocusedNode: null,
|
|
active: false,
|
|
paused: false,
|
|
manuallyPaused: false,
|
|
// timer ID for when delayInitialFocus is true and initial focus in this trap
|
|
// has been delayed during activation
|
|
delayInitialFocusTimer: void 0,
|
|
// the most recent KeyboardEvent for the configured nav key (typically [SHIFT+]TAB), if any
|
|
recentNavEvent: void 0
|
|
};
|
|
var trap;
|
|
var getOption = function getOption2(configOverrideOptions, optionName, configOptionName) {
|
|
return configOverrideOptions && configOverrideOptions[optionName] !== void 0 ? configOverrideOptions[optionName] : config[configOptionName || optionName];
|
|
};
|
|
var findContainerIndex = function findContainerIndex2(element, event) {
|
|
var composedPath = typeof (event === null || event === void 0 ? void 0 : event.composedPath) === "function" ? event.composedPath() : void 0;
|
|
return state.containerGroups.findIndex(function(_ref) {
|
|
var container = _ref.container, tabbableNodes = _ref.tabbableNodes;
|
|
return container.contains(element) || // fall back to explicit tabbable search which will take into consideration any
|
|
// web components if the `tabbableOptions.getShadowRoot` option was used for
|
|
// the trap, enabling shadow DOM support in tabbable (`Node.contains()` doesn't
|
|
// look inside web components even if open)
|
|
(composedPath === null || composedPath === void 0 ? void 0 : composedPath.includes(container)) || tabbableNodes.find(function(node) {
|
|
return node === element;
|
|
});
|
|
});
|
|
};
|
|
var getNodeForOption = function getNodeForOption2(optionName) {
|
|
var _ref2 = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : {}, _ref2$hasFallback = _ref2.hasFallback, hasFallback = _ref2$hasFallback === void 0 ? false : _ref2$hasFallback, _ref2$params = _ref2.params, params = _ref2$params === void 0 ? [] : _ref2$params;
|
|
var optionValue = config[optionName];
|
|
if (typeof optionValue === "function") {
|
|
optionValue = optionValue.apply(void 0, _toConsumableArray(params));
|
|
}
|
|
if (optionValue === true) {
|
|
optionValue = void 0;
|
|
}
|
|
if (!optionValue) {
|
|
if (optionValue === void 0 || optionValue === false) {
|
|
return optionValue;
|
|
}
|
|
throw new Error("`".concat(optionName, "` was specified but was not a node, or did not return a node"));
|
|
}
|
|
var node = optionValue;
|
|
if (typeof optionValue === "string") {
|
|
try {
|
|
node = doc.querySelector(optionValue);
|
|
} catch (err) {
|
|
throw new Error("`".concat(optionName, '` appears to be an invalid selector; error="').concat(err.message, '"'));
|
|
}
|
|
if (!node) {
|
|
if (!hasFallback) {
|
|
throw new Error("`".concat(optionName, "` as selector refers to no known node"));
|
|
}
|
|
}
|
|
}
|
|
return node;
|
|
};
|
|
var getInitialFocusNode = function getInitialFocusNode2() {
|
|
var node = getNodeForOption("initialFocus", {
|
|
hasFallback: true
|
|
});
|
|
if (node === false) {
|
|
return false;
|
|
}
|
|
if (node === void 0 || node && !isFocusable(node, config.tabbableOptions)) {
|
|
if (findContainerIndex(doc.activeElement) >= 0) {
|
|
node = doc.activeElement;
|
|
} else {
|
|
var firstTabbableGroup = state.tabbableGroups[0];
|
|
var firstTabbableNode = firstTabbableGroup && firstTabbableGroup.firstTabbableNode;
|
|
node = firstTabbableNode || getNodeForOption("fallbackFocus");
|
|
}
|
|
} else if (node === null) {
|
|
node = getNodeForOption("fallbackFocus");
|
|
}
|
|
if (!node) {
|
|
throw new Error("Your focus-trap needs to have at least one focusable element");
|
|
}
|
|
return node;
|
|
};
|
|
var updateTabbableNodes = function updateTabbableNodes2() {
|
|
state.containerGroups = state.containers.map(function(container) {
|
|
var tabbableNodes = tabbable(container, config.tabbableOptions);
|
|
var focusableNodes = focusable(container, config.tabbableOptions);
|
|
var firstTabbableNode = tabbableNodes.length > 0 ? tabbableNodes[0] : void 0;
|
|
var lastTabbableNode = tabbableNodes.length > 0 ? tabbableNodes[tabbableNodes.length - 1] : void 0;
|
|
var firstDomTabbableNode = focusableNodes.find(function(node) {
|
|
return isTabbable(node);
|
|
});
|
|
var lastDomTabbableNode = focusableNodes.slice().reverse().find(function(node) {
|
|
return isTabbable(node);
|
|
});
|
|
var posTabIndexesFound = !!tabbableNodes.find(function(node) {
|
|
return getTabIndex(node) > 0;
|
|
});
|
|
return {
|
|
container,
|
|
tabbableNodes,
|
|
focusableNodes,
|
|
/** True if at least one node with positive `tabindex` was found in this container. */
|
|
posTabIndexesFound,
|
|
/** First tabbable node in container, __tabindex__ order; `undefined` if none. */
|
|
firstTabbableNode,
|
|
/** Last tabbable node in container, __tabindex__ order; `undefined` if none. */
|
|
lastTabbableNode,
|
|
// NOTE: DOM order is NOT NECESSARILY "document position" order, but figuring that out
|
|
// would require more than just https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition
|
|
// because that API doesn't work with Shadow DOM as well as it should (@see
|
|
// https://github.com/whatwg/dom/issues/320) and since this first/last is only needed, so far,
|
|
// to address an edge case related to positive tabindex support, this seems like a much easier,
|
|
// "close enough most of the time" alternative for positive tabindexes which should generally
|
|
// be avoided anyway...
|
|
/** First tabbable node in container, __DOM__ order; `undefined` if none. */
|
|
firstDomTabbableNode,
|
|
/** Last tabbable node in container, __DOM__ order; `undefined` if none. */
|
|
lastDomTabbableNode,
|
|
/**
|
|
* Finds the __tabbable__ node that follows the given node in the specified direction,
|
|
* in this container, if any.
|
|
* @param {HTMLElement} node
|
|
* @param {boolean} [forward] True if going in forward tab order; false if going
|
|
* in reverse.
|
|
* @returns {HTMLElement|undefined} The next tabbable node, if any.
|
|
*/
|
|
nextTabbableNode: function nextTabbableNode(node) {
|
|
var forward = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : true;
|
|
var nodeIdx = tabbableNodes.indexOf(node);
|
|
if (nodeIdx < 0) {
|
|
if (forward) {
|
|
return focusableNodes.slice(focusableNodes.indexOf(node) + 1).find(function(el) {
|
|
return isTabbable(el);
|
|
});
|
|
}
|
|
return focusableNodes.slice(0, focusableNodes.indexOf(node)).reverse().find(function(el) {
|
|
return isTabbable(el);
|
|
});
|
|
}
|
|
return tabbableNodes[nodeIdx + (forward ? 1 : -1)];
|
|
}
|
|
};
|
|
});
|
|
state.tabbableGroups = state.containerGroups.filter(function(group) {
|
|
return group.tabbableNodes.length > 0;
|
|
});
|
|
if (state.tabbableGroups.length <= 0 && !getNodeForOption("fallbackFocus")) {
|
|
throw new Error("Your focus-trap must have at least one container with at least one tabbable node in it at all times");
|
|
}
|
|
if (state.containerGroups.find(function(g) {
|
|
return g.posTabIndexesFound;
|
|
}) && state.containerGroups.length > 1) {
|
|
throw new Error("At least one node with a positive tabindex was found in one of your focus-trap's multiple containers. Positive tabindexes are only supported in single-container focus-traps.");
|
|
}
|
|
};
|
|
var _getActiveElement = function getActiveElement(el) {
|
|
var activeElement = el.activeElement;
|
|
if (!activeElement) {
|
|
return;
|
|
}
|
|
if (activeElement.shadowRoot && activeElement.shadowRoot.activeElement !== null) {
|
|
return _getActiveElement(activeElement.shadowRoot);
|
|
}
|
|
return activeElement;
|
|
};
|
|
var _tryFocus = function tryFocus(node) {
|
|
if (node === false) {
|
|
return;
|
|
}
|
|
if (node === _getActiveElement(document)) {
|
|
return;
|
|
}
|
|
if (!node || !node.focus) {
|
|
_tryFocus(getInitialFocusNode());
|
|
return;
|
|
}
|
|
node.focus({
|
|
preventScroll: !!config.preventScroll
|
|
});
|
|
state.mostRecentlyFocusedNode = node;
|
|
if (isSelectableInput(node)) {
|
|
node.select();
|
|
}
|
|
};
|
|
var getReturnFocusNode = function getReturnFocusNode2(previousActiveElement) {
|
|
var node = getNodeForOption("setReturnFocus", {
|
|
params: [previousActiveElement]
|
|
});
|
|
return node ? node : node === false ? false : previousActiveElement;
|
|
};
|
|
var findNextNavNode = function findNextNavNode2(_ref3) {
|
|
var target = _ref3.target, event = _ref3.event, _ref3$isBackward = _ref3.isBackward, isBackward = _ref3$isBackward === void 0 ? false : _ref3$isBackward;
|
|
target = target || getActualTarget(event);
|
|
updateTabbableNodes();
|
|
var destinationNode = null;
|
|
if (state.tabbableGroups.length > 0) {
|
|
var containerIndex = findContainerIndex(target, event);
|
|
var containerGroup = containerIndex >= 0 ? state.containerGroups[containerIndex] : void 0;
|
|
if (containerIndex < 0) {
|
|
if (isBackward) {
|
|
destinationNode = state.tabbableGroups[state.tabbableGroups.length - 1].lastTabbableNode;
|
|
} else {
|
|
destinationNode = state.tabbableGroups[0].firstTabbableNode;
|
|
}
|
|
} else if (isBackward) {
|
|
var startOfGroupIndex = state.tabbableGroups.findIndex(function(_ref4) {
|
|
var firstTabbableNode = _ref4.firstTabbableNode;
|
|
return target === firstTabbableNode;
|
|
});
|
|
if (startOfGroupIndex < 0 && (containerGroup.container === target || isFocusable(target, config.tabbableOptions) && !isTabbable(target, config.tabbableOptions) && !containerGroup.nextTabbableNode(target, false))) {
|
|
startOfGroupIndex = containerIndex;
|
|
}
|
|
if (startOfGroupIndex >= 0) {
|
|
var destinationGroupIndex = startOfGroupIndex === 0 ? state.tabbableGroups.length - 1 : startOfGroupIndex - 1;
|
|
var destinationGroup = state.tabbableGroups[destinationGroupIndex];
|
|
destinationNode = getTabIndex(target) >= 0 ? destinationGroup.lastTabbableNode : destinationGroup.lastDomTabbableNode;
|
|
} else if (!isTabEvent(event)) {
|
|
destinationNode = containerGroup.nextTabbableNode(target, false);
|
|
}
|
|
} else {
|
|
var lastOfGroupIndex = state.tabbableGroups.findIndex(function(_ref5) {
|
|
var lastTabbableNode = _ref5.lastTabbableNode;
|
|
return target === lastTabbableNode;
|
|
});
|
|
if (lastOfGroupIndex < 0 && (containerGroup.container === target || isFocusable(target, config.tabbableOptions) && !isTabbable(target, config.tabbableOptions) && !containerGroup.nextTabbableNode(target))) {
|
|
lastOfGroupIndex = containerIndex;
|
|
}
|
|
if (lastOfGroupIndex >= 0) {
|
|
var _destinationGroupIndex = lastOfGroupIndex === state.tabbableGroups.length - 1 ? 0 : lastOfGroupIndex + 1;
|
|
var _destinationGroup = state.tabbableGroups[_destinationGroupIndex];
|
|
destinationNode = getTabIndex(target) >= 0 ? _destinationGroup.firstTabbableNode : _destinationGroup.firstDomTabbableNode;
|
|
} else if (!isTabEvent(event)) {
|
|
destinationNode = containerGroup.nextTabbableNode(target);
|
|
}
|
|
}
|
|
} else {
|
|
destinationNode = getNodeForOption("fallbackFocus");
|
|
}
|
|
return destinationNode;
|
|
};
|
|
var checkPointerDown = function checkPointerDown2(e) {
|
|
var target = getActualTarget(e);
|
|
if (findContainerIndex(target, e) >= 0) {
|
|
return;
|
|
}
|
|
if (valueOrHandler(config.clickOutsideDeactivates, e)) {
|
|
trap.deactivate({
|
|
// NOTE: by setting `returnFocus: false`, deactivate() will do nothing,
|
|
// which will result in the outside click setting focus to the node
|
|
// that was clicked (and if not focusable, to "nothing"); by setting
|
|
// `returnFocus: true`, we'll attempt to re-focus the node originally-focused
|
|
// on activation (or the configured `setReturnFocus` node), whether the
|
|
// outside click was on a focusable node or not
|
|
returnFocus: config.returnFocusOnDeactivate
|
|
});
|
|
return;
|
|
}
|
|
if (valueOrHandler(config.allowOutsideClick, e)) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
};
|
|
var checkFocusIn = function checkFocusIn2(event) {
|
|
var target = getActualTarget(event);
|
|
var targetContained = findContainerIndex(target, event) >= 0;
|
|
if (targetContained || target instanceof Document) {
|
|
if (targetContained) {
|
|
state.mostRecentlyFocusedNode = target;
|
|
}
|
|
} else {
|
|
event.stopImmediatePropagation();
|
|
var nextNode;
|
|
var navAcrossContainers = true;
|
|
if (state.mostRecentlyFocusedNode) {
|
|
if (getTabIndex(state.mostRecentlyFocusedNode) > 0) {
|
|
var mruContainerIdx = findContainerIndex(state.mostRecentlyFocusedNode);
|
|
var tabbableNodes = state.containerGroups[mruContainerIdx].tabbableNodes;
|
|
if (tabbableNodes.length > 0) {
|
|
var mruTabIdx = tabbableNodes.findIndex(function(node) {
|
|
return node === state.mostRecentlyFocusedNode;
|
|
});
|
|
if (mruTabIdx >= 0) {
|
|
if (config.isKeyForward(state.recentNavEvent)) {
|
|
if (mruTabIdx + 1 < tabbableNodes.length) {
|
|
nextNode = tabbableNodes[mruTabIdx + 1];
|
|
navAcrossContainers = false;
|
|
}
|
|
} else {
|
|
if (mruTabIdx - 1 >= 0) {
|
|
nextNode = tabbableNodes[mruTabIdx - 1];
|
|
navAcrossContainers = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if (!state.containerGroups.some(function(g) {
|
|
return g.tabbableNodes.some(function(n) {
|
|
return getTabIndex(n) > 0;
|
|
});
|
|
})) {
|
|
navAcrossContainers = false;
|
|
}
|
|
}
|
|
} else {
|
|
navAcrossContainers = false;
|
|
}
|
|
if (navAcrossContainers) {
|
|
nextNode = findNextNavNode({
|
|
// move FROM the MRU node, not event-related node (which will be the node that is
|
|
// outside the trap causing the focus escape we're trying to fix)
|
|
target: state.mostRecentlyFocusedNode,
|
|
isBackward: config.isKeyBackward(state.recentNavEvent)
|
|
});
|
|
}
|
|
if (nextNode) {
|
|
_tryFocus(nextNode);
|
|
} else {
|
|
_tryFocus(state.mostRecentlyFocusedNode || getInitialFocusNode());
|
|
}
|
|
}
|
|
state.recentNavEvent = void 0;
|
|
};
|
|
var checkKeyNav = function checkKeyNav2(event) {
|
|
var isBackward = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : false;
|
|
state.recentNavEvent = event;
|
|
var destinationNode = findNextNavNode({
|
|
event,
|
|
isBackward
|
|
});
|
|
if (destinationNode) {
|
|
if (isTabEvent(event)) {
|
|
event.preventDefault();
|
|
}
|
|
_tryFocus(destinationNode);
|
|
}
|
|
};
|
|
var checkTabKey = function checkTabKey2(event) {
|
|
if (config.isKeyForward(event) || config.isKeyBackward(event)) {
|
|
checkKeyNav(event, config.isKeyBackward(event));
|
|
}
|
|
};
|
|
var checkEscapeKey = function checkEscapeKey2(event) {
|
|
if (isEscapeEvent(event) && valueOrHandler(config.escapeDeactivates, event) !== false) {
|
|
event.preventDefault();
|
|
trap.deactivate();
|
|
}
|
|
};
|
|
var checkClick = function checkClick2(e) {
|
|
var target = getActualTarget(e);
|
|
if (findContainerIndex(target, e) >= 0) {
|
|
return;
|
|
}
|
|
if (valueOrHandler(config.clickOutsideDeactivates, e)) {
|
|
return;
|
|
}
|
|
if (valueOrHandler(config.allowOutsideClick, e)) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
};
|
|
var addListeners = function addListeners2() {
|
|
if (!state.active) {
|
|
return;
|
|
}
|
|
activeFocusTraps.activateTrap(trapStack, trap);
|
|
state.delayInitialFocusTimer = config.delayInitialFocus ? delay(function() {
|
|
_tryFocus(getInitialFocusNode());
|
|
}) : _tryFocus(getInitialFocusNode());
|
|
doc.addEventListener("focusin", checkFocusIn, true);
|
|
doc.addEventListener("mousedown", checkPointerDown, {
|
|
capture: true,
|
|
passive: false
|
|
});
|
|
doc.addEventListener("touchstart", checkPointerDown, {
|
|
capture: true,
|
|
passive: false
|
|
});
|
|
doc.addEventListener("click", checkClick, {
|
|
capture: true,
|
|
passive: false
|
|
});
|
|
doc.addEventListener("keydown", checkTabKey, {
|
|
capture: true,
|
|
passive: false
|
|
});
|
|
doc.addEventListener("keydown", checkEscapeKey);
|
|
return trap;
|
|
};
|
|
var collectAdjacentElements = function collectAdjacentElements2(containers) {
|
|
if (state.active && !state.paused) {
|
|
trap._setSubtreeIsolation(false);
|
|
}
|
|
state.adjacentElements.clear();
|
|
state.alreadySilent.clear();
|
|
var containerAncestors = /* @__PURE__ */ new Set();
|
|
var adjacentElements = /* @__PURE__ */ new Set();
|
|
var _iterator = _createForOfIteratorHelper(containers), _step;
|
|
try {
|
|
for (_iterator.s(); !(_step = _iterator.n()).done; ) {
|
|
var container = _step.value;
|
|
containerAncestors.add(container);
|
|
var insideShadowRoot = typeof ShadowRoot !== "undefined" && container.getRootNode() instanceof ShadowRoot;
|
|
var current = container;
|
|
while (current) {
|
|
containerAncestors.add(current);
|
|
var parent = current.parentElement;
|
|
var siblings = [];
|
|
if (parent) {
|
|
siblings = parent.children;
|
|
} else if (!parent && insideShadowRoot) {
|
|
siblings = current.getRootNode().children;
|
|
parent = current.getRootNode().host;
|
|
insideShadowRoot = typeof ShadowRoot !== "undefined" && parent.getRootNode() instanceof ShadowRoot;
|
|
}
|
|
var _iterator2 = _createForOfIteratorHelper(siblings), _step2;
|
|
try {
|
|
for (_iterator2.s(); !(_step2 = _iterator2.n()).done; ) {
|
|
var child = _step2.value;
|
|
adjacentElements.add(child);
|
|
}
|
|
} catch (err) {
|
|
_iterator2.e(err);
|
|
} finally {
|
|
_iterator2.f();
|
|
}
|
|
current = parent;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
_iterator.e(err);
|
|
} finally {
|
|
_iterator.f();
|
|
}
|
|
containerAncestors.forEach(function(el) {
|
|
adjacentElements["delete"](el);
|
|
});
|
|
state.adjacentElements = adjacentElements;
|
|
};
|
|
var removeListeners = function removeListeners2() {
|
|
if (!state.active) {
|
|
return;
|
|
}
|
|
doc.removeEventListener("focusin", checkFocusIn, true);
|
|
doc.removeEventListener("mousedown", checkPointerDown, true);
|
|
doc.removeEventListener("touchstart", checkPointerDown, true);
|
|
doc.removeEventListener("click", checkClick, true);
|
|
doc.removeEventListener("keydown", checkTabKey, true);
|
|
doc.removeEventListener("keydown", checkEscapeKey);
|
|
return trap;
|
|
};
|
|
var checkDomRemoval = function checkDomRemoval2(mutations) {
|
|
var isFocusedNodeRemoved = mutations.some(function(mutation) {
|
|
var removedNodes = Array.from(mutation.removedNodes);
|
|
return removedNodes.some(function(node) {
|
|
return node === state.mostRecentlyFocusedNode;
|
|
});
|
|
});
|
|
if (isFocusedNodeRemoved) {
|
|
_tryFocus(getInitialFocusNode());
|
|
}
|
|
};
|
|
var mutationObserver = typeof window !== "undefined" && "MutationObserver" in window ? new MutationObserver(checkDomRemoval) : void 0;
|
|
var updateObservedNodes = function updateObservedNodes2() {
|
|
if (!mutationObserver) {
|
|
return;
|
|
}
|
|
mutationObserver.disconnect();
|
|
if (state.active && !state.paused) {
|
|
state.containers.map(function(container) {
|
|
mutationObserver.observe(container, {
|
|
subtree: true,
|
|
childList: true
|
|
});
|
|
});
|
|
}
|
|
};
|
|
trap = {
|
|
get active() {
|
|
return state.active;
|
|
},
|
|
get paused() {
|
|
return state.paused;
|
|
},
|
|
activate: function activate(activateOptions) {
|
|
if (state.active) {
|
|
return this;
|
|
}
|
|
var onActivate = getOption(activateOptions, "onActivate");
|
|
var onPostActivate = getOption(activateOptions, "onPostActivate");
|
|
var checkCanFocusTrap = getOption(activateOptions, "checkCanFocusTrap");
|
|
var preexistingTrap = activeFocusTraps.getActiveTrap(trapStack);
|
|
var revertState = false;
|
|
if (preexistingTrap && !preexistingTrap.paused) {
|
|
var _preexistingTrap$_set;
|
|
(_preexistingTrap$_set = preexistingTrap._setSubtreeIsolation) === null || _preexistingTrap$_set === void 0 || _preexistingTrap$_set.call(preexistingTrap, false);
|
|
revertState = true;
|
|
}
|
|
try {
|
|
if (!checkCanFocusTrap) {
|
|
updateTabbableNodes();
|
|
}
|
|
state.active = true;
|
|
state.paused = false;
|
|
state.nodeFocusedBeforeActivation = _getActiveElement(doc);
|
|
onActivate === null || onActivate === void 0 || onActivate();
|
|
var finishActivation = function finishActivation2() {
|
|
if (checkCanFocusTrap) {
|
|
updateTabbableNodes();
|
|
}
|
|
addListeners();
|
|
updateObservedNodes();
|
|
if (config.isolateSubtrees) {
|
|
trap._setSubtreeIsolation(true);
|
|
}
|
|
onPostActivate === null || onPostActivate === void 0 || onPostActivate();
|
|
};
|
|
if (checkCanFocusTrap) {
|
|
checkCanFocusTrap(state.containers.concat()).then(finishActivation, finishActivation);
|
|
return this;
|
|
}
|
|
finishActivation();
|
|
} catch (error) {
|
|
if (preexistingTrap === activeFocusTraps.getActiveTrap(trapStack) && revertState) {
|
|
var _preexistingTrap$_set2;
|
|
(_preexistingTrap$_set2 = preexistingTrap._setSubtreeIsolation) === null || _preexistingTrap$_set2 === void 0 || _preexistingTrap$_set2.call(preexistingTrap, true);
|
|
}
|
|
throw error;
|
|
}
|
|
return this;
|
|
},
|
|
deactivate: function deactivate(deactivateOptions) {
|
|
if (!state.active) {
|
|
return this;
|
|
}
|
|
var options = _objectSpread2({
|
|
onDeactivate: config.onDeactivate,
|
|
onPostDeactivate: config.onPostDeactivate,
|
|
checkCanReturnFocus: config.checkCanReturnFocus
|
|
}, deactivateOptions);
|
|
clearTimeout(state.delayInitialFocusTimer);
|
|
state.delayInitialFocusTimer = void 0;
|
|
if (!state.paused) {
|
|
trap._setSubtreeIsolation(false);
|
|
}
|
|
state.alreadySilent.clear();
|
|
removeListeners();
|
|
state.active = false;
|
|
state.paused = false;
|
|
updateObservedNodes();
|
|
activeFocusTraps.deactivateTrap(trapStack, trap);
|
|
var onDeactivate = getOption(options, "onDeactivate");
|
|
var onPostDeactivate = getOption(options, "onPostDeactivate");
|
|
var checkCanReturnFocus = getOption(options, "checkCanReturnFocus");
|
|
var returnFocus = getOption(options, "returnFocus", "returnFocusOnDeactivate");
|
|
onDeactivate === null || onDeactivate === void 0 || onDeactivate();
|
|
var finishDeactivation = function finishDeactivation2() {
|
|
delay(function() {
|
|
if (returnFocus) {
|
|
_tryFocus(getReturnFocusNode(state.nodeFocusedBeforeActivation));
|
|
}
|
|
onPostDeactivate === null || onPostDeactivate === void 0 || onPostDeactivate();
|
|
});
|
|
};
|
|
if (returnFocus && checkCanReturnFocus) {
|
|
checkCanReturnFocus(getReturnFocusNode(state.nodeFocusedBeforeActivation)).then(finishDeactivation, finishDeactivation);
|
|
return this;
|
|
}
|
|
finishDeactivation();
|
|
return this;
|
|
},
|
|
pause: function pause(pauseOptions) {
|
|
if (!state.active) {
|
|
return this;
|
|
}
|
|
state.manuallyPaused = true;
|
|
return this._setPausedState(true, pauseOptions);
|
|
},
|
|
unpause: function unpause(unpauseOptions) {
|
|
if (!state.active) {
|
|
return this;
|
|
}
|
|
state.manuallyPaused = false;
|
|
if (trapStack[trapStack.length - 1] !== this) {
|
|
return this;
|
|
}
|
|
return this._setPausedState(false, unpauseOptions);
|
|
},
|
|
updateContainerElements: function updateContainerElements(containerElements) {
|
|
var elementsAsArray = [].concat(containerElements).filter(Boolean);
|
|
state.containers = elementsAsArray.map(function(element) {
|
|
return typeof element === "string" ? doc.querySelector(element) : element;
|
|
});
|
|
if (config.isolateSubtrees) {
|
|
collectAdjacentElements(state.containers);
|
|
}
|
|
if (state.active) {
|
|
updateTabbableNodes();
|
|
if (config.isolateSubtrees && !state.paused) {
|
|
trap._setSubtreeIsolation(true);
|
|
}
|
|
}
|
|
updateObservedNodes();
|
|
return this;
|
|
}
|
|
};
|
|
Object.defineProperties(trap, {
|
|
_isManuallyPaused: {
|
|
value: function value() {
|
|
return state.manuallyPaused;
|
|
}
|
|
},
|
|
_setPausedState: {
|
|
value: function value(paused, options) {
|
|
if (state.paused === paused) {
|
|
return this;
|
|
}
|
|
state.paused = paused;
|
|
if (paused) {
|
|
var onPause = getOption(options, "onPause");
|
|
var onPostPause = getOption(options, "onPostPause");
|
|
onPause === null || onPause === void 0 || onPause();
|
|
removeListeners();
|
|
updateObservedNodes();
|
|
trap._setSubtreeIsolation(false);
|
|
onPostPause === null || onPostPause === void 0 || onPostPause();
|
|
} else {
|
|
var onUnpause = getOption(options, "onUnpause");
|
|
var onPostUnpause = getOption(options, "onPostUnpause");
|
|
onUnpause === null || onUnpause === void 0 || onUnpause();
|
|
trap._setSubtreeIsolation(true);
|
|
updateTabbableNodes();
|
|
addListeners();
|
|
updateObservedNodes();
|
|
onPostUnpause === null || onPostUnpause === void 0 || onPostUnpause();
|
|
}
|
|
return this;
|
|
}
|
|
},
|
|
_setSubtreeIsolation: {
|
|
value: function value(isEnabled) {
|
|
if (config.isolateSubtrees) {
|
|
state.adjacentElements.forEach(function(el) {
|
|
var _el$getAttribute;
|
|
if (isEnabled) {
|
|
switch (config.isolateSubtrees) {
|
|
case "aria-hidden":
|
|
if (el.ariaHidden === "true" || ((_el$getAttribute = el.getAttribute("aria-hidden")) === null || _el$getAttribute === void 0 ? void 0 : _el$getAttribute.toLowerCase()) === "true") {
|
|
state.alreadySilent.add(el);
|
|
}
|
|
el.setAttribute("aria-hidden", "true");
|
|
break;
|
|
default:
|
|
if (el.inert || el.hasAttribute("inert")) {
|
|
state.alreadySilent.add(el);
|
|
}
|
|
el.setAttribute("inert", true);
|
|
break;
|
|
}
|
|
} else {
|
|
if (state.alreadySilent.has(el)) ;
|
|
else {
|
|
switch (config.isolateSubtrees) {
|
|
case "aria-hidden":
|
|
el.removeAttribute("aria-hidden");
|
|
break;
|
|
default:
|
|
el.removeAttribute("inert");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
trap.updateContainerElements(elements);
|
|
return trap;
|
|
};
|
|
function useFocusTrap(target, options = {}) {
|
|
let trap;
|
|
const { immediate, ...focusTrapOptions } = options;
|
|
const hasFocus = shallowRef(false);
|
|
const isPaused = shallowRef(false);
|
|
const activate = (opts) => trap && trap.activate(opts);
|
|
const deactivate = (opts) => trap && trap.deactivate(opts);
|
|
const pause = () => {
|
|
if (trap) {
|
|
trap.pause();
|
|
isPaused.value = true;
|
|
}
|
|
};
|
|
const unpause = () => {
|
|
if (trap) {
|
|
trap.unpause();
|
|
isPaused.value = false;
|
|
}
|
|
};
|
|
const targets = computed(() => {
|
|
const _targets = toValue(target);
|
|
return toArray(_targets).map((el) => {
|
|
const _el = toValue(el);
|
|
return typeof _el === "string" ? _el : unrefElement(_el);
|
|
}).filter(notNullish);
|
|
});
|
|
watch(
|
|
targets,
|
|
(els) => {
|
|
if (!els.length)
|
|
return;
|
|
trap = createFocusTrap(els, {
|
|
...focusTrapOptions,
|
|
onActivate() {
|
|
hasFocus.value = true;
|
|
if (options.onActivate)
|
|
options.onActivate();
|
|
},
|
|
onDeactivate() {
|
|
hasFocus.value = false;
|
|
if (options.onDeactivate)
|
|
options.onDeactivate();
|
|
}
|
|
});
|
|
if (immediate)
|
|
activate();
|
|
},
|
|
{ flush: "post" }
|
|
);
|
|
tryOnScopeDispose(() => deactivate());
|
|
return {
|
|
hasFocus,
|
|
isPaused,
|
|
activate,
|
|
deactivate,
|
|
pause,
|
|
unpause
|
|
};
|
|
}
|
|
class DOMIterator {
|
|
/**
|
|
* @param {HTMLElement|HTMLElement[]|NodeList|string} ctx - The context DOM
|
|
* element, an array of DOM elements, a NodeList or a selector
|
|
* @param {boolean} [iframes=true] - A boolean indicating if iframes should
|
|
* be handled
|
|
* @param {string[]} [exclude=[]] - An array containing exclusion selectors
|
|
* for iframes
|
|
* @param {number} [iframesTimeout=5000] - A number indicating the ms to
|
|
* wait before an iframe should be skipped, in case the load event isn't
|
|
* fired. This also applies if the user is offline and the resource of the
|
|
* iframe is online (either by the browsers "offline" mode or because
|
|
* there's no internet connection)
|
|
*/
|
|
constructor(ctx, iframes = true, exclude = [], iframesTimeout = 5e3) {
|
|
this.ctx = ctx;
|
|
this.iframes = iframes;
|
|
this.exclude = exclude;
|
|
this.iframesTimeout = iframesTimeout;
|
|
}
|
|
/**
|
|
* Checks if the specified DOM element matches the selector
|
|
* @param {HTMLElement} element - The DOM element
|
|
* @param {string|string[]} selector - The selector or an array with
|
|
* selectors
|
|
* @return {boolean}
|
|
* @access public
|
|
*/
|
|
static matches(element, selector) {
|
|
const selectors = typeof selector === "string" ? [selector] : selector, fn = element.matches || element.matchesSelector || element.msMatchesSelector || element.mozMatchesSelector || element.oMatchesSelector || element.webkitMatchesSelector;
|
|
if (fn) {
|
|
let match = false;
|
|
selectors.every((sel) => {
|
|
if (fn.call(element, sel)) {
|
|
match = true;
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
return match;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
/**
|
|
* Returns all contexts filtered by duplicates (even nested)
|
|
* @return {HTMLElement[]} - An array containing DOM contexts
|
|
* @access protected
|
|
*/
|
|
getContexts() {
|
|
let ctx, filteredCtx = [];
|
|
if (typeof this.ctx === "undefined" || !this.ctx) {
|
|
ctx = [];
|
|
} else if (NodeList.prototype.isPrototypeOf(this.ctx)) {
|
|
ctx = Array.prototype.slice.call(this.ctx);
|
|
} else if (Array.isArray(this.ctx)) {
|
|
ctx = this.ctx;
|
|
} else if (typeof this.ctx === "string") {
|
|
ctx = Array.prototype.slice.call(
|
|
document.querySelectorAll(this.ctx)
|
|
);
|
|
} else {
|
|
ctx = [this.ctx];
|
|
}
|
|
ctx.forEach((ctx2) => {
|
|
const isDescendant = filteredCtx.filter((contexts) => {
|
|
return contexts.contains(ctx2);
|
|
}).length > 0;
|
|
if (filteredCtx.indexOf(ctx2) === -1 && !isDescendant) {
|
|
filteredCtx.push(ctx2);
|
|
}
|
|
});
|
|
return filteredCtx;
|
|
}
|
|
/**
|
|
* @callback DOMIterator~getIframeContentsSuccessCallback
|
|
* @param {HTMLDocument} contents - The contentDocument of the iframe
|
|
*/
|
|
/**
|
|
* Calls the success callback function with the iframe document. If it can't
|
|
* be accessed it calls the error callback function
|
|
* @param {HTMLElement} ifr - The iframe DOM element
|
|
* @param {DOMIterator~getIframeContentsSuccessCallback} successFn
|
|
* @param {function} [errorFn]
|
|
* @access protected
|
|
*/
|
|
getIframeContents(ifr, successFn, errorFn = () => {
|
|
}) {
|
|
let doc;
|
|
try {
|
|
const ifrWin = ifr.contentWindow;
|
|
doc = ifrWin.document;
|
|
if (!ifrWin || !doc) {
|
|
throw new Error("iframe inaccessible");
|
|
}
|
|
} catch (e) {
|
|
errorFn();
|
|
}
|
|
if (doc) {
|
|
successFn(doc);
|
|
}
|
|
}
|
|
/**
|
|
* Checks if an iframe is empty (if about:blank is the shown page)
|
|
* @param {HTMLElement} ifr - The iframe DOM element
|
|
* @return {boolean}
|
|
* @access protected
|
|
*/
|
|
isIframeBlank(ifr) {
|
|
const bl = "about:blank", src = ifr.getAttribute("src").trim(), href = ifr.contentWindow.location.href;
|
|
return href === bl && src !== bl && src;
|
|
}
|
|
/**
|
|
* Observes the onload event of an iframe and calls the success callback or
|
|
* the error callback if the iframe is inaccessible. If the event isn't
|
|
* fired within the specified {@link DOMIterator#iframesTimeout}, then it'll
|
|
* call the error callback too
|
|
* @param {HTMLElement} ifr - The iframe DOM element
|
|
* @param {DOMIterator~getIframeContentsSuccessCallback} successFn
|
|
* @param {function} errorFn
|
|
* @access protected
|
|
*/
|
|
observeIframeLoad(ifr, successFn, errorFn) {
|
|
let called = false, tout = null;
|
|
const listener = () => {
|
|
if (called) {
|
|
return;
|
|
}
|
|
called = true;
|
|
clearTimeout(tout);
|
|
try {
|
|
if (!this.isIframeBlank(ifr)) {
|
|
ifr.removeEventListener("load", listener);
|
|
this.getIframeContents(ifr, successFn, errorFn);
|
|
}
|
|
} catch (e) {
|
|
errorFn();
|
|
}
|
|
};
|
|
ifr.addEventListener("load", listener);
|
|
tout = setTimeout(listener, this.iframesTimeout);
|
|
}
|
|
/**
|
|
* Callback when the iframe is ready
|
|
* @callback DOMIterator~onIframeReadySuccessCallback
|
|
* @param {HTMLDocument} contents - The contentDocument of the iframe
|
|
*/
|
|
/**
|
|
* Callback if the iframe can't be accessed
|
|
* @callback DOMIterator~onIframeReadyErrorCallback
|
|
*/
|
|
/**
|
|
* Calls the callback if the specified iframe is ready for DOM access
|
|
* @param {HTMLElement} ifr - The iframe DOM element
|
|
* @param {DOMIterator~onIframeReadySuccessCallback} successFn - Success
|
|
* callback
|
|
* @param {DOMIterator~onIframeReadyErrorCallback} errorFn - Error callback
|
|
* @see {@link http://stackoverflow.com/a/36155560/3894981} for
|
|
* background information
|
|
* @access protected
|
|
*/
|
|
onIframeReady(ifr, successFn, errorFn) {
|
|
try {
|
|
if (ifr.contentWindow.document.readyState === "complete") {
|
|
if (this.isIframeBlank(ifr)) {
|
|
this.observeIframeLoad(ifr, successFn, errorFn);
|
|
} else {
|
|
this.getIframeContents(ifr, successFn, errorFn);
|
|
}
|
|
} else {
|
|
this.observeIframeLoad(ifr, successFn, errorFn);
|
|
}
|
|
} catch (e) {
|
|
errorFn();
|
|
}
|
|
}
|
|
/**
|
|
* Callback when all iframes are ready for DOM access
|
|
* @callback DOMIterator~waitForIframesDoneCallback
|
|
*/
|
|
/**
|
|
* Iterates over all iframes and calls the done callback when all of them
|
|
* are ready for DOM access (including nested ones)
|
|
* @param {HTMLElement} ctx - The context DOM element
|
|
* @param {DOMIterator~waitForIframesDoneCallback} done - Done callback
|
|
*/
|
|
waitForIframes(ctx, done) {
|
|
let eachCalled = 0;
|
|
this.forEachIframe(ctx, () => true, (ifr) => {
|
|
eachCalled++;
|
|
this.waitForIframes(ifr.querySelector("html"), () => {
|
|
if (!--eachCalled) {
|
|
done();
|
|
}
|
|
});
|
|
}, (handled) => {
|
|
if (!handled) {
|
|
done();
|
|
}
|
|
});
|
|
}
|
|
/**
|
|
* Callback allowing to filter an iframe. Must return true when the element
|
|
* should remain, otherwise false
|
|
* @callback DOMIterator~forEachIframeFilterCallback
|
|
* @param {HTMLElement} iframe - The iframe DOM element
|
|
*/
|
|
/**
|
|
* Callback for each iframe content
|
|
* @callback DOMIterator~forEachIframeEachCallback
|
|
* @param {HTMLElement} content - The iframe document
|
|
*/
|
|
/**
|
|
* Callback if all iframes inside the context were handled
|
|
* @callback DOMIterator~forEachIframeEndCallback
|
|
* @param {number} handled - The number of handled iframes (those who
|
|
* wheren't filtered)
|
|
*/
|
|
/**
|
|
* Iterates over all iframes inside the specified context and calls the
|
|
* callbacks when they're ready. Filters iframes based on the instance
|
|
* exclusion selectors
|
|
* @param {HTMLElement} ctx - The context DOM element
|
|
* @param {DOMIterator~forEachIframeFilterCallback} filter - Filter callback
|
|
* @param {DOMIterator~forEachIframeEachCallback} each - Each callback
|
|
* @param {DOMIterator~forEachIframeEndCallback} [end] - End callback
|
|
* @access protected
|
|
*/
|
|
forEachIframe(ctx, filter, each, end = () => {
|
|
}) {
|
|
let ifr = ctx.querySelectorAll("iframe"), open = ifr.length, handled = 0;
|
|
ifr = Array.prototype.slice.call(ifr);
|
|
const checkEnd = () => {
|
|
if (--open <= 0) {
|
|
end(handled);
|
|
}
|
|
};
|
|
if (!open) {
|
|
checkEnd();
|
|
}
|
|
ifr.forEach((ifr2) => {
|
|
if (DOMIterator.matches(ifr2, this.exclude)) {
|
|
checkEnd();
|
|
} else {
|
|
this.onIframeReady(ifr2, (con) => {
|
|
if (filter(ifr2)) {
|
|
handled++;
|
|
each(con);
|
|
}
|
|
checkEnd();
|
|
}, checkEnd);
|
|
}
|
|
});
|
|
}
|
|
/**
|
|
* Creates a NodeIterator on the specified context
|
|
* @see {@link https://developer.mozilla.org/en/docs/Web/API/NodeIterator}
|
|
* @param {HTMLElement} ctx - The context DOM element
|
|
* @param {DOMIterator~whatToShow} whatToShow
|
|
* @param {DOMIterator~filterCb} filter
|
|
* @return {NodeIterator}
|
|
* @access protected
|
|
*/
|
|
createIterator(ctx, whatToShow, filter) {
|
|
return document.createNodeIterator(ctx, whatToShow, filter, false);
|
|
}
|
|
/**
|
|
* Creates an instance of DOMIterator in an iframe
|
|
* @param {HTMLDocument} contents - Iframe document
|
|
* @return {DOMIterator}
|
|
* @access protected
|
|
*/
|
|
createInstanceOnIframe(contents) {
|
|
return new DOMIterator(contents.querySelector("html"), this.iframes);
|
|
}
|
|
/**
|
|
* Checks if an iframe occurs between two nodes, more specifically if an
|
|
* iframe occurs before the specified node and after the specified prevNode
|
|
* @param {HTMLElement} node - The node that should occur after the iframe
|
|
* @param {HTMLElement} prevNode - The node that should occur before the
|
|
* iframe
|
|
* @param {HTMLElement} ifr - The iframe to check against
|
|
* @return {boolean}
|
|
* @access protected
|
|
*/
|
|
compareNodeIframe(node, prevNode, ifr) {
|
|
const compCurr = node.compareDocumentPosition(ifr), prev = Node.DOCUMENT_POSITION_PRECEDING;
|
|
if (compCurr & prev) {
|
|
if (prevNode !== null) {
|
|
const compPrev = prevNode.compareDocumentPosition(ifr), after = Node.DOCUMENT_POSITION_FOLLOWING;
|
|
if (compPrev & after) {
|
|
return true;
|
|
}
|
|
} else {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
/**
|
|
* @typedef {DOMIterator~getIteratorNodeReturn}
|
|
* @type {object.<string>}
|
|
* @property {HTMLElement} prevNode - The previous node or null if there is
|
|
* no
|
|
* @property {HTMLElement} node - The current node
|
|
*/
|
|
/**
|
|
* Returns the previous and current node of the specified iterator
|
|
* @param {NodeIterator} itr - The iterator
|
|
* @return {DOMIterator~getIteratorNodeReturn}
|
|
* @access protected
|
|
*/
|
|
getIteratorNode(itr) {
|
|
const prevNode = itr.previousNode();
|
|
let node;
|
|
if (prevNode === null) {
|
|
node = itr.nextNode();
|
|
} else {
|
|
node = itr.nextNode() && itr.nextNode();
|
|
}
|
|
return {
|
|
prevNode,
|
|
node
|
|
};
|
|
}
|
|
/**
|
|
* An array containing objects. The object key "val" contains an iframe
|
|
* DOM element. The object key "handled" contains a boolean indicating if
|
|
* the iframe was handled already.
|
|
* It wouldn't be enough to save all open or all already handled iframes.
|
|
* The information of open iframes is necessary because they may occur after
|
|
* all other text nodes (and compareNodeIframe would never be true). The
|
|
* information of already handled iframes is necessary as otherwise they may
|
|
* be handled multiple times
|
|
* @typedef DOMIterator~checkIframeFilterIfr
|
|
* @type {object[]}
|
|
*/
|
|
/**
|
|
* Checks if an iframe wasn't handled already and if so, calls
|
|
* {@link DOMIterator#compareNodeIframe} to check if it should be handled.
|
|
* Information wheter an iframe was or wasn't handled is given within the
|
|
* <code>ifr</code> dictionary
|
|
* @param {HTMLElement} node - The node that should occur after the iframe
|
|
* @param {HTMLElement} prevNode - The node that should occur before the
|
|
* iframe
|
|
* @param {HTMLElement} currIfr - The iframe to check
|
|
* @param {DOMIterator~checkIframeFilterIfr} ifr - The iframe dictionary.
|
|
* Will be manipulated (by reference)
|
|
* @return {boolean} Returns true when it should be handled, otherwise false
|
|
* @access protected
|
|
*/
|
|
checkIframeFilter(node, prevNode, currIfr, ifr) {
|
|
let key = false, handled = false;
|
|
ifr.forEach((ifrDict, i) => {
|
|
if (ifrDict.val === currIfr) {
|
|
key = i;
|
|
handled = ifrDict.handled;
|
|
}
|
|
});
|
|
if (this.compareNodeIframe(node, prevNode, currIfr)) {
|
|
if (key === false && !handled) {
|
|
ifr.push({
|
|
val: currIfr,
|
|
handled: true
|
|
});
|
|
} else if (key !== false && !handled) {
|
|
ifr[key].handled = true;
|
|
}
|
|
return true;
|
|
}
|
|
if (key === false) {
|
|
ifr.push({
|
|
val: currIfr,
|
|
handled: false
|
|
});
|
|
}
|
|
return false;
|
|
}
|
|
/**
|
|
* Creates an iterator on all open iframes in the specified array and calls
|
|
* the end callback when finished
|
|
* @param {DOMIterator~checkIframeFilterIfr} ifr
|
|
* @param {DOMIterator~whatToShow} whatToShow
|
|
* @param {DOMIterator~forEachNodeCallback} eCb - Each callback
|
|
* @param {DOMIterator~filterCb} fCb
|
|
* @access protected
|
|
*/
|
|
handleOpenIframes(ifr, whatToShow, eCb, fCb) {
|
|
ifr.forEach((ifrDict) => {
|
|
if (!ifrDict.handled) {
|
|
this.getIframeContents(ifrDict.val, (con) => {
|
|
this.createInstanceOnIframe(con).forEachNode(
|
|
whatToShow,
|
|
eCb,
|
|
fCb
|
|
);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
/**
|
|
* Iterates through all nodes in the specified context and handles iframe
|
|
* nodes at the correct position
|
|
* @param {DOMIterator~whatToShow} whatToShow
|
|
* @param {HTMLElement} ctx - The context
|
|
* @param {DOMIterator~forEachNodeCallback} eachCb - Each callback
|
|
* @param {DOMIterator~filterCb} filterCb - Filter callback
|
|
* @param {DOMIterator~forEachNodeEndCallback} doneCb - End callback
|
|
* @access protected
|
|
*/
|
|
iterateThroughNodes(whatToShow, ctx, eachCb, filterCb, doneCb) {
|
|
const itr = this.createIterator(ctx, whatToShow, filterCb);
|
|
let ifr = [], elements = [], node, prevNode, retrieveNodes = () => {
|
|
({
|
|
prevNode,
|
|
node
|
|
} = this.getIteratorNode(itr));
|
|
return node;
|
|
};
|
|
while (retrieveNodes()) {
|
|
if (this.iframes) {
|
|
this.forEachIframe(ctx, (currIfr) => {
|
|
return this.checkIframeFilter(node, prevNode, currIfr, ifr);
|
|
}, (con) => {
|
|
this.createInstanceOnIframe(con).forEachNode(
|
|
whatToShow,
|
|
(ifrNode) => elements.push(ifrNode),
|
|
filterCb
|
|
);
|
|
});
|
|
}
|
|
elements.push(node);
|
|
}
|
|
elements.forEach((node2) => {
|
|
eachCb(node2);
|
|
});
|
|
if (this.iframes) {
|
|
this.handleOpenIframes(ifr, whatToShow, eachCb, filterCb);
|
|
}
|
|
doneCb();
|
|
}
|
|
/**
|
|
* Callback for each node
|
|
* @callback DOMIterator~forEachNodeCallback
|
|
* @param {HTMLElement} node - The DOM text node element
|
|
*/
|
|
/**
|
|
* Callback if all contexts were handled
|
|
* @callback DOMIterator~forEachNodeEndCallback
|
|
*/
|
|
/**
|
|
* Iterates over all contexts and initializes
|
|
* {@link DOMIterator#iterateThroughNodes iterateThroughNodes} on them
|
|
* @param {DOMIterator~whatToShow} whatToShow
|
|
* @param {DOMIterator~forEachNodeCallback} each - Each callback
|
|
* @param {DOMIterator~filterCb} filter - Filter callback
|
|
* @param {DOMIterator~forEachNodeEndCallback} done - End callback
|
|
* @access public
|
|
*/
|
|
forEachNode(whatToShow, each, filter, done = () => {
|
|
}) {
|
|
const contexts = this.getContexts();
|
|
let open = contexts.length;
|
|
if (!open) {
|
|
done();
|
|
}
|
|
contexts.forEach((ctx) => {
|
|
const ready = () => {
|
|
this.iterateThroughNodes(whatToShow, ctx, each, filter, () => {
|
|
if (--open <= 0) {
|
|
done();
|
|
}
|
|
});
|
|
};
|
|
if (this.iframes) {
|
|
this.waitForIframes(ctx, ready);
|
|
} else {
|
|
ready();
|
|
}
|
|
});
|
|
}
|
|
/**
|
|
* Callback to filter nodes. Can return e.g. NodeFilter.FILTER_ACCEPT or
|
|
* NodeFilter.FILTER_REJECT
|
|
* @see {@link http://tinyurl.com/zdczmm2}
|
|
* @callback DOMIterator~filterCb
|
|
* @param {HTMLElement} node - The node to filter
|
|
*/
|
|
/**
|
|
* @typedef DOMIterator~whatToShow
|
|
* @see {@link http://tinyurl.com/zfqqkx2}
|
|
* @type {number}
|
|
*/
|
|
}
|
|
let Mark$1 = class Mark {
|
|
// eslint-disable-line no-unused-vars
|
|
/**
|
|
* @param {HTMLElement|HTMLElement[]|NodeList|string} ctx - The context DOM
|
|
* element, an array of DOM elements, a NodeList or a selector
|
|
*/
|
|
constructor(ctx) {
|
|
this.ctx = ctx;
|
|
this.ie = false;
|
|
const ua = window.navigator.userAgent;
|
|
if (ua.indexOf("MSIE") > -1 || ua.indexOf("Trident") > -1) {
|
|
this.ie = true;
|
|
}
|
|
}
|
|
/**
|
|
* Options defined by the user. They will be initialized from one of the
|
|
* public methods. See {@link Mark#mark}, {@link Mark#markRegExp},
|
|
* {@link Mark#markRanges} and {@link Mark#unmark} for option properties.
|
|
* @type {object}
|
|
* @param {object} [val] - An object that will be merged with defaults
|
|
* @access protected
|
|
*/
|
|
set opt(val) {
|
|
this._opt = Object.assign({}, {
|
|
"element": "",
|
|
"className": "",
|
|
"exclude": [],
|
|
"iframes": false,
|
|
"iframesTimeout": 5e3,
|
|
"separateWordSearch": true,
|
|
"diacritics": true,
|
|
"synonyms": {},
|
|
"accuracy": "partially",
|
|
"acrossElements": false,
|
|
"caseSensitive": false,
|
|
"ignoreJoiners": false,
|
|
"ignoreGroups": 0,
|
|
"ignorePunctuation": [],
|
|
"wildcards": "disabled",
|
|
"each": () => {
|
|
},
|
|
"noMatch": () => {
|
|
},
|
|
"filter": () => true,
|
|
"done": () => {
|
|
},
|
|
"debug": false,
|
|
"log": window.console
|
|
}, val);
|
|
}
|
|
get opt() {
|
|
return this._opt;
|
|
}
|
|
/**
|
|
* An instance of DOMIterator
|
|
* @type {DOMIterator}
|
|
* @access protected
|
|
*/
|
|
get iterator() {
|
|
return new DOMIterator(
|
|
this.ctx,
|
|
this.opt.iframes,
|
|
this.opt.exclude,
|
|
this.opt.iframesTimeout
|
|
);
|
|
}
|
|
/**
|
|
* Logs a message if log is enabled
|
|
* @param {string} msg - The message to log
|
|
* @param {string} [level="debug"] - The log level, e.g. <code>warn</code>
|
|
* <code>error</code>, <code>debug</code>
|
|
* @access protected
|
|
*/
|
|
log(msg, level = "debug") {
|
|
const log = this.opt.log;
|
|
if (!this.opt.debug) {
|
|
return;
|
|
}
|
|
if (typeof log === "object" && typeof log[level] === "function") {
|
|
log[level](`mark.js: ${msg}`);
|
|
}
|
|
}
|
|
/**
|
|
* Escapes a string for usage within a regular expression
|
|
* @param {string} str - The string to escape
|
|
* @return {string}
|
|
* @access protected
|
|
*/
|
|
escapeStr(str) {
|
|
return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
|
|
}
|
|
/**
|
|
* Creates a regular expression string to match the specified search
|
|
* term including synonyms, diacritics and accuracy if defined
|
|
* @param {string} str - The search term to be used
|
|
* @return {string}
|
|
* @access protected
|
|
*/
|
|
createRegExp(str) {
|
|
if (this.opt.wildcards !== "disabled") {
|
|
str = this.setupWildcardsRegExp(str);
|
|
}
|
|
str = this.escapeStr(str);
|
|
if (Object.keys(this.opt.synonyms).length) {
|
|
str = this.createSynonymsRegExp(str);
|
|
}
|
|
if (this.opt.ignoreJoiners || this.opt.ignorePunctuation.length) {
|
|
str = this.setupIgnoreJoinersRegExp(str);
|
|
}
|
|
if (this.opt.diacritics) {
|
|
str = this.createDiacriticsRegExp(str);
|
|
}
|
|
str = this.createMergedBlanksRegExp(str);
|
|
if (this.opt.ignoreJoiners || this.opt.ignorePunctuation.length) {
|
|
str = this.createJoinersRegExp(str);
|
|
}
|
|
if (this.opt.wildcards !== "disabled") {
|
|
str = this.createWildcardsRegExp(str);
|
|
}
|
|
str = this.createAccuracyRegExp(str);
|
|
return str;
|
|
}
|
|
/**
|
|
* Creates a regular expression string to match the defined synonyms
|
|
* @param {string} str - The search term to be used
|
|
* @return {string}
|
|
* @access protected
|
|
*/
|
|
createSynonymsRegExp(str) {
|
|
const syn = this.opt.synonyms, sens = this.opt.caseSensitive ? "" : "i", joinerPlaceholder = this.opt.ignoreJoiners || this.opt.ignorePunctuation.length ? "\0" : "";
|
|
for (let index in syn) {
|
|
if (syn.hasOwnProperty(index)) {
|
|
const value = syn[index], k1 = this.opt.wildcards !== "disabled" ? this.setupWildcardsRegExp(index) : this.escapeStr(index), k2 = this.opt.wildcards !== "disabled" ? this.setupWildcardsRegExp(value) : this.escapeStr(value);
|
|
if (k1 !== "" && k2 !== "") {
|
|
str = str.replace(
|
|
new RegExp(
|
|
`(${this.escapeStr(k1)}|${this.escapeStr(k2)})`,
|
|
`gm${sens}`
|
|
),
|
|
joinerPlaceholder + `(${this.processSynomyms(k1)}|${this.processSynomyms(k2)})` + joinerPlaceholder
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return str;
|
|
}
|
|
/**
|
|
* Setup synonyms to work with ignoreJoiners and or ignorePunctuation
|
|
* @param {string} str - synonym key or value to process
|
|
* @return {string} - processed synonym string
|
|
*/
|
|
processSynomyms(str) {
|
|
if (this.opt.ignoreJoiners || this.opt.ignorePunctuation.length) {
|
|
str = this.setupIgnoreJoinersRegExp(str);
|
|
}
|
|
return str;
|
|
}
|
|
/**
|
|
* Sets up the regular expression string to allow later insertion of
|
|
* wildcard regular expression matches
|
|
* @param {string} str - The search term to be used
|
|
* @return {string}
|
|
* @access protected
|
|
*/
|
|
setupWildcardsRegExp(str) {
|
|
str = str.replace(/(?:\\)*\?/g, (val) => {
|
|
return val.charAt(0) === "\\" ? "?" : "";
|
|
});
|
|
return str.replace(/(?:\\)*\*/g, (val) => {
|
|
return val.charAt(0) === "\\" ? "*" : "";
|
|
});
|
|
}
|
|
/**
|
|
* Sets up the regular expression string to allow later insertion of
|
|
* wildcard regular expression matches
|
|
* @param {string} str - The search term to be used
|
|
* @return {string}
|
|
* @access protected
|
|
*/
|
|
createWildcardsRegExp(str) {
|
|
let spaces = this.opt.wildcards === "withSpaces";
|
|
return str.replace(/\u0001/g, spaces ? "[\\S\\s]?" : "\\S?").replace(/\u0002/g, spaces ? "[\\S\\s]*?" : "\\S*");
|
|
}
|
|
/**
|
|
* Sets up the regular expression string to allow later insertion of
|
|
* designated characters (soft hyphens & zero width characters)
|
|
* @param {string} str - The search term to be used
|
|
* @return {string}
|
|
* @access protected
|
|
*/
|
|
setupIgnoreJoinersRegExp(str) {
|
|
return str.replace(/[^(|)\\]/g, (val, indx, original) => {
|
|
let nextChar = original.charAt(indx + 1);
|
|
if (/[(|)\\]/.test(nextChar) || nextChar === "") {
|
|
return val;
|
|
} else {
|
|
return val + "\0";
|
|
}
|
|
});
|
|
}
|
|
/**
|
|
* Creates a regular expression string to allow ignoring of designated
|
|
* characters (soft hyphens, zero width characters & punctuation) based on
|
|
* the specified option values of <code>ignorePunctuation</code> and
|
|
* <code>ignoreJoiners</code>
|
|
* @param {string} str - The search term to be used
|
|
* @return {string}
|
|
* @access protected
|
|
*/
|
|
createJoinersRegExp(str) {
|
|
let joiner = [];
|
|
const ignorePunctuation = this.opt.ignorePunctuation;
|
|
if (Array.isArray(ignorePunctuation) && ignorePunctuation.length) {
|
|
joiner.push(this.escapeStr(ignorePunctuation.join("")));
|
|
}
|
|
if (this.opt.ignoreJoiners) {
|
|
joiner.push("\\u00ad\\u200b\\u200c\\u200d");
|
|
}
|
|
return joiner.length ? str.split(/\u0000+/).join(`[${joiner.join("")}]*`) : str;
|
|
}
|
|
/**
|
|
* Creates a regular expression string to match diacritics
|
|
* @param {string} str - The search term to be used
|
|
* @return {string}
|
|
* @access protected
|
|
*/
|
|
createDiacriticsRegExp(str) {
|
|
const sens = this.opt.caseSensitive ? "" : "i", dct = this.opt.caseSensitive ? [
|
|
"aàáảãạăằắẳẵặâầấẩẫậäåāą",
|
|
"AÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬÄÅĀĄ",
|
|
"cçćč",
|
|
"CÇĆČ",
|
|
"dđď",
|
|
"DĐĎ",
|
|
"eèéẻẽẹêềếểễệëěēę",
|
|
"EÈÉẺẼẸÊỀẾỂỄỆËĚĒĘ",
|
|
"iìíỉĩịîïī",
|
|
"IÌÍỈĨỊÎÏĪ",
|
|
"lł",
|
|
"LŁ",
|
|
"nñňń",
|
|
"NÑŇŃ",
|
|
"oòóỏõọôồốổỗộơởỡớờợöøō",
|
|
"OÒÓỎÕỌÔỒỐỔỖỘƠỞỠỚỜỢÖØŌ",
|
|
"rř",
|
|
"RŘ",
|
|
"sšśșş",
|
|
"SŠŚȘŞ",
|
|
"tťțţ",
|
|
"TŤȚŢ",
|
|
"uùúủũụưừứửữựûüůū",
|
|
"UÙÚỦŨỤƯỪỨỬỮỰÛÜŮŪ",
|
|
"yýỳỷỹỵÿ",
|
|
"YÝỲỶỸỴŸ",
|
|
"zžżź",
|
|
"ZŽŻŹ"
|
|
] : [
|
|
"aàáảãạăằắẳẵặâầấẩẫậäåāąAÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬÄÅĀĄ",
|
|
"cçćčCÇĆČ",
|
|
"dđďDĐĎ",
|
|
"eèéẻẽẹêềếểễệëěēęEÈÉẺẼẸÊỀẾỂỄỆËĚĒĘ",
|
|
"iìíỉĩịîïīIÌÍỈĨỊÎÏĪ",
|
|
"lłLŁ",
|
|
"nñňńNÑŇŃ",
|
|
"oòóỏõọôồốổỗộơởỡớờợöøōOÒÓỎÕỌÔỒỐỔỖỘƠỞỠỚỜỢÖØŌ",
|
|
"rřRŘ",
|
|
"sšśșşSŠŚȘŞ",
|
|
"tťțţTŤȚŢ",
|
|
"uùúủũụưừứửữựûüůūUÙÚỦŨỤƯỪỨỬỮỰÛÜŮŪ",
|
|
"yýỳỷỹỵÿYÝỲỶỸỴŸ",
|
|
"zžżźZŽŻŹ"
|
|
];
|
|
let handled = [];
|
|
str.split("").forEach((ch) => {
|
|
dct.every((dct2) => {
|
|
if (dct2.indexOf(ch) !== -1) {
|
|
if (handled.indexOf(dct2) > -1) {
|
|
return false;
|
|
}
|
|
str = str.replace(
|
|
new RegExp(`[${dct2}]`, `gm${sens}`),
|
|
`[${dct2}]`
|
|
);
|
|
handled.push(dct2);
|
|
}
|
|
return true;
|
|
});
|
|
});
|
|
return str;
|
|
}
|
|
/**
|
|
* Creates a regular expression string that merges whitespace characters
|
|
* including subsequent ones into a single pattern, one or multiple
|
|
* whitespaces
|
|
* @param {string} str - The search term to be used
|
|
* @return {string}
|
|
* @access protected
|
|
*/
|
|
createMergedBlanksRegExp(str) {
|
|
return str.replace(/[\s]+/gmi, "[\\s]+");
|
|
}
|
|
/**
|
|
* Creates a regular expression string to match the specified string with
|
|
* the defined accuracy. As in the regular expression of "exactly" can be
|
|
* a group containing a blank at the beginning, all regular expressions will
|
|
* be created with two groups. The first group can be ignored (may contain
|
|
* the said blank), the second contains the actual match
|
|
* @param {string} str - The searm term to be used
|
|
* @return {str}
|
|
* @access protected
|
|
*/
|
|
createAccuracyRegExp(str) {
|
|
const chars = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~¡¿";
|
|
let acc = this.opt.accuracy, val = typeof acc === "string" ? acc : acc.value, ls = typeof acc === "string" ? [] : acc.limiters, lsJoin = "";
|
|
ls.forEach((limiter) => {
|
|
lsJoin += `|${this.escapeStr(limiter)}`;
|
|
});
|
|
switch (val) {
|
|
case "partially":
|
|
default:
|
|
return `()(${str})`;
|
|
case "complementary":
|
|
lsJoin = "\\s" + (lsJoin ? lsJoin : this.escapeStr(chars));
|
|
return `()([^${lsJoin}]*${str}[^${lsJoin}]*)`;
|
|
case "exactly":
|
|
return `(^|\\s${lsJoin})(${str})(?=$|\\s${lsJoin})`;
|
|
}
|
|
}
|
|
/**
|
|
* @typedef Mark~separatedKeywords
|
|
* @type {object.<string>}
|
|
* @property {array.<string>} keywords - The list of keywords
|
|
* @property {number} length - The length
|
|
*/
|
|
/**
|
|
* Returns a list of keywords dependent on whether separate word search
|
|
* was defined. Also it filters empty keywords
|
|
* @param {array} sv - The array of keywords
|
|
* @return {Mark~separatedKeywords}
|
|
* @access protected
|
|
*/
|
|
getSeparatedKeywords(sv) {
|
|
let stack = [];
|
|
sv.forEach((kw) => {
|
|
if (!this.opt.separateWordSearch) {
|
|
if (kw.trim() && stack.indexOf(kw) === -1) {
|
|
stack.push(kw);
|
|
}
|
|
} else {
|
|
kw.split(" ").forEach((kwSplitted) => {
|
|
if (kwSplitted.trim() && stack.indexOf(kwSplitted) === -1) {
|
|
stack.push(kwSplitted);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
return {
|
|
// sort because of https://git.io/v6USg
|
|
"keywords": stack.sort((a, b) => {
|
|
return b.length - a.length;
|
|
}),
|
|
"length": stack.length
|
|
};
|
|
}
|
|
/**
|
|
* Check if a value is a number
|
|
* @param {number|string} value - the value to check;
|
|
* numeric strings allowed
|
|
* @return {boolean}
|
|
* @access protected
|
|
*/
|
|
isNumeric(value) {
|
|
return Number(parseFloat(value)) == value;
|
|
}
|
|
/**
|
|
* @typedef Mark~rangeObject
|
|
* @type {object}
|
|
* @property {number} start - The start position within the composite value
|
|
* @property {number} length - The length of the string to mark within the
|
|
* composite value.
|
|
*/
|
|
/**
|
|
* @typedef Mark~setOfRanges
|
|
* @type {object[]}
|
|
* @property {Mark~rangeObject}
|
|
*/
|
|
/**
|
|
* Returns a processed list of integer offset indexes that do not overlap
|
|
* each other, and remove any string values or additional elements
|
|
* @param {Mark~setOfRanges} array - unprocessed raw array
|
|
* @return {Mark~setOfRanges} - processed array with any invalid entries
|
|
* removed
|
|
* @throws Will throw an error if an array of objects is not passed
|
|
* @access protected
|
|
*/
|
|
checkRanges(array) {
|
|
if (!Array.isArray(array) || Object.prototype.toString.call(array[0]) !== "[object Object]") {
|
|
this.log("markRanges() will only accept an array of objects");
|
|
this.opt.noMatch(array);
|
|
return [];
|
|
}
|
|
const stack = [];
|
|
let last2 = 0;
|
|
array.sort((a, b) => {
|
|
return a.start - b.start;
|
|
}).forEach((item) => {
|
|
let { start, end, valid } = this.callNoMatchOnInvalidRanges(item, last2);
|
|
if (valid) {
|
|
item.start = start;
|
|
item.length = end - start;
|
|
stack.push(item);
|
|
last2 = end;
|
|
}
|
|
});
|
|
return stack;
|
|
}
|
|
/**
|
|
* @typedef Mark~validObject
|
|
* @type {object}
|
|
* @property {number} start - The start position within the composite value
|
|
* @property {number} end - The calculated end position within the composite
|
|
* value.
|
|
* @property {boolean} valid - boolean value indicating that the start and
|
|
* calculated end range is valid
|
|
*/
|
|
/**
|
|
* Initial validation of ranges for markRanges. Preliminary checks are done
|
|
* to ensure the start and length values exist and are not zero or non-
|
|
* numeric
|
|
* @param {Mark~rangeObject} range - the current range object
|
|
* @param {number} last - last index of range
|
|
* @return {Mark~validObject}
|
|
* @access protected
|
|
*/
|
|
callNoMatchOnInvalidRanges(range, last2) {
|
|
let start, end, valid = false;
|
|
if (range && typeof range.start !== "undefined") {
|
|
start = parseInt(range.start, 10);
|
|
end = start + parseInt(range.length, 10);
|
|
if (this.isNumeric(range.start) && this.isNumeric(range.length) && end - last2 > 0 && end - start > 0) {
|
|
valid = true;
|
|
} else {
|
|
this.log(
|
|
`Ignoring invalid or overlapping range: ${JSON.stringify(range)}`
|
|
);
|
|
this.opt.noMatch(range);
|
|
}
|
|
} else {
|
|
this.log(`Ignoring invalid range: ${JSON.stringify(range)}`);
|
|
this.opt.noMatch(range);
|
|
}
|
|
return {
|
|
start,
|
|
end,
|
|
valid
|
|
};
|
|
}
|
|
/**
|
|
* Check valid range for markRanges. Check ranges with access to the context
|
|
* string. Range values are double checked, lengths that extend the mark
|
|
* beyond the string length are limitied and ranges containing only
|
|
* whitespace are ignored
|
|
* @param {Mark~rangeObject} range - the current range object
|
|
* @param {number} originalLength - original length of the context string
|
|
* @param {string} string - current content string
|
|
* @return {Mark~validObject}
|
|
* @access protected
|
|
*/
|
|
checkWhitespaceRanges(range, originalLength, string) {
|
|
let end, valid = true, max = string.length, offset = originalLength - max, start = parseInt(range.start, 10) - offset;
|
|
start = start > max ? max : start;
|
|
end = start + parseInt(range.length, 10);
|
|
if (end > max) {
|
|
end = max;
|
|
this.log(`End range automatically set to the max value of ${max}`);
|
|
}
|
|
if (start < 0 || end - start < 0 || start > max || end > max) {
|
|
valid = false;
|
|
this.log(`Invalid range: ${JSON.stringify(range)}`);
|
|
this.opt.noMatch(range);
|
|
} else if (string.substring(start, end).replace(/\s+/g, "") === "") {
|
|
valid = false;
|
|
this.log("Skipping whitespace only range: " + JSON.stringify(range));
|
|
this.opt.noMatch(range);
|
|
}
|
|
return {
|
|
start,
|
|
end,
|
|
valid
|
|
};
|
|
}
|
|
/**
|
|
* @typedef Mark~getTextNodesDict
|
|
* @type {object.<string>}
|
|
* @property {string} value - The composite value of all text nodes
|
|
* @property {object[]} nodes - An array of objects
|
|
* @property {number} nodes.start - The start position within the composite
|
|
* value
|
|
* @property {number} nodes.end - The end position within the composite
|
|
* value
|
|
* @property {HTMLElement} nodes.node - The DOM text node element
|
|
*/
|
|
/**
|
|
* Callback
|
|
* @callback Mark~getTextNodesCallback
|
|
* @param {Mark~getTextNodesDict}
|
|
*/
|
|
/**
|
|
* Calls the callback with an object containing all text nodes (including
|
|
* iframe text nodes) with start and end positions and the composite value
|
|
* of them (string)
|
|
* @param {Mark~getTextNodesCallback} cb - Callback
|
|
* @access protected
|
|
*/
|
|
getTextNodes(cb) {
|
|
let val = "", nodes = [];
|
|
this.iterator.forEachNode(NodeFilter.SHOW_TEXT, (node) => {
|
|
nodes.push({
|
|
start: val.length,
|
|
end: (val += node.textContent).length,
|
|
node
|
|
});
|
|
}, (node) => {
|
|
if (this.matchesExclude(node.parentNode)) {
|
|
return NodeFilter.FILTER_REJECT;
|
|
} else {
|
|
return NodeFilter.FILTER_ACCEPT;
|
|
}
|
|
}, () => {
|
|
cb({
|
|
value: val,
|
|
nodes
|
|
});
|
|
});
|
|
}
|
|
/**
|
|
* Checks if an element matches any of the specified exclude selectors. Also
|
|
* it checks for elements in which no marks should be performed (e.g.
|
|
* script and style tags) and optionally already marked elements
|
|
* @param {HTMLElement} el - The element to check
|
|
* @return {boolean}
|
|
* @access protected
|
|
*/
|
|
matchesExclude(el) {
|
|
return DOMIterator.matches(el, this.opt.exclude.concat([
|
|
// ignores the elements itself, not their childrens (selector *)
|
|
"script",
|
|
"style",
|
|
"title",
|
|
"head",
|
|
"html"
|
|
]));
|
|
}
|
|
/**
|
|
* Wraps the instance element and class around matches that fit the start
|
|
* and end positions within the node
|
|
* @param {HTMLElement} node - The DOM text node
|
|
* @param {number} start - The position where to start wrapping
|
|
* @param {number} end - The position where to end wrapping
|
|
* @return {HTMLElement} Returns the splitted text node that will appear
|
|
* after the wrapped text node
|
|
* @access protected
|
|
*/
|
|
wrapRangeInTextNode(node, start, end) {
|
|
const hEl = !this.opt.element ? "mark" : this.opt.element, startNode = node.splitText(start), ret = startNode.splitText(end - start);
|
|
let repl = document.createElement(hEl);
|
|
repl.setAttribute("data-markjs", "true");
|
|
if (this.opt.className) {
|
|
repl.setAttribute("class", this.opt.className);
|
|
}
|
|
repl.textContent = startNode.textContent;
|
|
startNode.parentNode.replaceChild(repl, startNode);
|
|
return ret;
|
|
}
|
|
/**
|
|
* @typedef Mark~wrapRangeInMappedTextNodeDict
|
|
* @type {object.<string>}
|
|
* @property {string} value - The composite value of all text nodes
|
|
* @property {object[]} nodes - An array of objects
|
|
* @property {number} nodes.start - The start position within the composite
|
|
* value
|
|
* @property {number} nodes.end - The end position within the composite
|
|
* value
|
|
* @property {HTMLElement} nodes.node - The DOM text node element
|
|
*/
|
|
/**
|
|
* Each callback
|
|
* @callback Mark~wrapMatchesEachCallback
|
|
* @param {HTMLElement} node - The wrapped DOM element
|
|
* @param {number} lastIndex - The last matching position within the
|
|
* composite value of text nodes
|
|
*/
|
|
/**
|
|
* Filter callback
|
|
* @callback Mark~wrapMatchesFilterCallback
|
|
* @param {HTMLElement} node - The matching text node DOM element
|
|
*/
|
|
/**
|
|
* Determines matches by start and end positions using the text node
|
|
* dictionary even across text nodes and calls
|
|
* {@link Mark#wrapRangeInTextNode} to wrap them
|
|
* @param {Mark~wrapRangeInMappedTextNodeDict} dict - The dictionary
|
|
* @param {number} start - The start position of the match
|
|
* @param {number} end - The end position of the match
|
|
* @param {Mark~wrapMatchesFilterCallback} filterCb - Filter callback
|
|
* @param {Mark~wrapMatchesEachCallback} eachCb - Each callback
|
|
* @access protected
|
|
*/
|
|
wrapRangeInMappedTextNode(dict, start, end, filterCb, eachCb) {
|
|
dict.nodes.every((n, i) => {
|
|
const sibl = dict.nodes[i + 1];
|
|
if (typeof sibl === "undefined" || sibl.start > start) {
|
|
if (!filterCb(n.node)) {
|
|
return false;
|
|
}
|
|
const s = start - n.start, e = (end > n.end ? n.end : end) - n.start, startStr = dict.value.substr(0, n.start), endStr = dict.value.substr(e + n.start);
|
|
n.node = this.wrapRangeInTextNode(n.node, s, e);
|
|
dict.value = startStr + endStr;
|
|
dict.nodes.forEach((k, j) => {
|
|
if (j >= i) {
|
|
if (dict.nodes[j].start > 0 && j !== i) {
|
|
dict.nodes[j].start -= e;
|
|
}
|
|
dict.nodes[j].end -= e;
|
|
}
|
|
});
|
|
end -= e;
|
|
eachCb(n.node.previousSibling, n.start);
|
|
if (end > n.end) {
|
|
start = n.end;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
/**
|
|
* Filter callback before each wrapping
|
|
* @callback Mark~wrapMatchesFilterCallback
|
|
* @param {string} match - The matching string
|
|
* @param {HTMLElement} node - The text node where the match occurs
|
|
*/
|
|
/**
|
|
* Callback for each wrapped element
|
|
* @callback Mark~wrapMatchesEachCallback
|
|
* @param {HTMLElement} element - The marked DOM element
|
|
*/
|
|
/**
|
|
* Callback on end
|
|
* @callback Mark~wrapMatchesEndCallback
|
|
*/
|
|
/**
|
|
* Wraps the instance element and class around matches within single HTML
|
|
* elements in all contexts
|
|
* @param {RegExp} regex - The regular expression to be searched for
|
|
* @param {number} ignoreGroups - A number indicating the amount of RegExp
|
|
* matching groups to ignore
|
|
* @param {Mark~wrapMatchesFilterCallback} filterCb
|
|
* @param {Mark~wrapMatchesEachCallback} eachCb
|
|
* @param {Mark~wrapMatchesEndCallback} endCb
|
|
* @access protected
|
|
*/
|
|
wrapMatches(regex, ignoreGroups, filterCb, eachCb, endCb) {
|
|
const matchIdx = ignoreGroups === 0 ? 0 : ignoreGroups + 1;
|
|
this.getTextNodes((dict) => {
|
|
dict.nodes.forEach((node) => {
|
|
node = node.node;
|
|
let match;
|
|
while ((match = regex.exec(node.textContent)) !== null && match[matchIdx] !== "") {
|
|
if (!filterCb(match[matchIdx], node)) {
|
|
continue;
|
|
}
|
|
let pos = match.index;
|
|
if (matchIdx !== 0) {
|
|
for (let i = 1; i < matchIdx; i++) {
|
|
pos += match[i].length;
|
|
}
|
|
}
|
|
node = this.wrapRangeInTextNode(
|
|
node,
|
|
pos,
|
|
pos + match[matchIdx].length
|
|
);
|
|
eachCb(node.previousSibling);
|
|
regex.lastIndex = 0;
|
|
}
|
|
});
|
|
endCb();
|
|
});
|
|
}
|
|
/**
|
|
* Callback for each wrapped element
|
|
* @callback Mark~wrapMatchesAcrossElementsEachCallback
|
|
* @param {HTMLElement} element - The marked DOM element
|
|
*/
|
|
/**
|
|
* Filter callback before each wrapping
|
|
* @callback Mark~wrapMatchesAcrossElementsFilterCallback
|
|
* @param {string} match - The matching string
|
|
* @param {HTMLElement} node - The text node where the match occurs
|
|
*/
|
|
/**
|
|
* Callback on end
|
|
* @callback Mark~wrapMatchesAcrossElementsEndCallback
|
|
*/
|
|
/**
|
|
* Wraps the instance element and class around matches across all HTML
|
|
* elements in all contexts
|
|
* @param {RegExp} regex - The regular expression to be searched for
|
|
* @param {number} ignoreGroups - A number indicating the amount of RegExp
|
|
* matching groups to ignore
|
|
* @param {Mark~wrapMatchesAcrossElementsFilterCallback} filterCb
|
|
* @param {Mark~wrapMatchesAcrossElementsEachCallback} eachCb
|
|
* @param {Mark~wrapMatchesAcrossElementsEndCallback} endCb
|
|
* @access protected
|
|
*/
|
|
wrapMatchesAcrossElements(regex, ignoreGroups, filterCb, eachCb, endCb) {
|
|
const matchIdx = ignoreGroups === 0 ? 0 : ignoreGroups + 1;
|
|
this.getTextNodes((dict) => {
|
|
let match;
|
|
while ((match = regex.exec(dict.value)) !== null && match[matchIdx] !== "") {
|
|
let start = match.index;
|
|
if (matchIdx !== 0) {
|
|
for (let i = 1; i < matchIdx; i++) {
|
|
start += match[i].length;
|
|
}
|
|
}
|
|
const end = start + match[matchIdx].length;
|
|
this.wrapRangeInMappedTextNode(dict, start, end, (node) => {
|
|
return filterCb(match[matchIdx], node);
|
|
}, (node, lastIndex) => {
|
|
regex.lastIndex = lastIndex;
|
|
eachCb(node);
|
|
});
|
|
}
|
|
endCb();
|
|
});
|
|
}
|
|
/**
|
|
* Callback for each wrapped element
|
|
* @callback Mark~wrapRangeFromIndexEachCallback
|
|
* @param {HTMLElement} element - The marked DOM element
|
|
* @param {Mark~rangeObject} range - the current range object; provided
|
|
* start and length values will be numeric integers modified from the
|
|
* provided original ranges.
|
|
*/
|
|
/**
|
|
* Filter callback before each wrapping
|
|
* @callback Mark~wrapRangeFromIndexFilterCallback
|
|
* @param {HTMLElement} node - The text node which includes the range
|
|
* @param {Mark~rangeObject} range - the current range object
|
|
* @param {string} match - string extracted from the matching range
|
|
* @param {number} counter - A counter indicating the number of all marks
|
|
*/
|
|
/**
|
|
* Callback on end
|
|
* @callback Mark~wrapRangeFromIndexEndCallback
|
|
*/
|
|
/**
|
|
* Wraps the indicated ranges across all HTML elements in all contexts
|
|
* @param {Mark~setOfRanges} ranges
|
|
* @param {Mark~wrapRangeFromIndexFilterCallback} filterCb
|
|
* @param {Mark~wrapRangeFromIndexEachCallback} eachCb
|
|
* @param {Mark~wrapRangeFromIndexEndCallback} endCb
|
|
* @access protected
|
|
*/
|
|
wrapRangeFromIndex(ranges, filterCb, eachCb, endCb) {
|
|
this.getTextNodes((dict) => {
|
|
const originalLength = dict.value.length;
|
|
ranges.forEach((range, counter) => {
|
|
let { start, end, valid } = this.checkWhitespaceRanges(
|
|
range,
|
|
originalLength,
|
|
dict.value
|
|
);
|
|
if (valid) {
|
|
this.wrapRangeInMappedTextNode(dict, start, end, (node) => {
|
|
return filterCb(
|
|
node,
|
|
range,
|
|
dict.value.substring(start, end),
|
|
counter
|
|
);
|
|
}, (node) => {
|
|
eachCb(node, range);
|
|
});
|
|
}
|
|
});
|
|
endCb();
|
|
});
|
|
}
|
|
/**
|
|
* Unwraps the specified DOM node with its content (text nodes or HTML)
|
|
* without destroying possibly present events (using innerHTML) and
|
|
* normalizes the parent at the end (merge splitted text nodes)
|
|
* @param {HTMLElement} node - The DOM node to unwrap
|
|
* @access protected
|
|
*/
|
|
unwrapMatches(node) {
|
|
const parent = node.parentNode;
|
|
let docFrag = document.createDocumentFragment();
|
|
while (node.firstChild) {
|
|
docFrag.appendChild(node.removeChild(node.firstChild));
|
|
}
|
|
parent.replaceChild(docFrag, node);
|
|
if (!this.ie) {
|
|
parent.normalize();
|
|
} else {
|
|
this.normalizeTextNode(parent);
|
|
}
|
|
}
|
|
/**
|
|
* Normalizes text nodes. It's a workaround for the native normalize method
|
|
* that has a bug in IE (see attached link). Should only be used in IE
|
|
* browsers as it's slower than the native method.
|
|
* @see {@link http://tinyurl.com/z5asa8c}
|
|
* @param {HTMLElement} node - The DOM node to normalize
|
|
* @access protected
|
|
*/
|
|
normalizeTextNode(node) {
|
|
if (!node) {
|
|
return;
|
|
}
|
|
if (node.nodeType === 3) {
|
|
while (node.nextSibling && node.nextSibling.nodeType === 3) {
|
|
node.nodeValue += node.nextSibling.nodeValue;
|
|
node.parentNode.removeChild(node.nextSibling);
|
|
}
|
|
} else {
|
|
this.normalizeTextNode(node.firstChild);
|
|
}
|
|
this.normalizeTextNode(node.nextSibling);
|
|
}
|
|
/**
|
|
* Callback when finished
|
|
* @callback Mark~commonDoneCallback
|
|
* @param {number} totalMatches - The number of marked elements
|
|
*/
|
|
/**
|
|
* @typedef Mark~commonOptions
|
|
* @type {object.<string>}
|
|
* @property {string} [element="mark"] - HTML element tag name
|
|
* @property {string} [className] - An optional class name
|
|
* @property {string[]} [exclude] - An array with exclusion selectors.
|
|
* Elements matching those selectors will be ignored
|
|
* @property {boolean} [iframes=false] - Whether to search inside iframes
|
|
* @property {Mark~commonDoneCallback} [done]
|
|
* @property {boolean} [debug=false] - Wheter to log messages
|
|
* @property {object} [log=window.console] - Where to log messages (only if
|
|
* debug is true)
|
|
*/
|
|
/**
|
|
* Callback for each marked element
|
|
* @callback Mark~markRegExpEachCallback
|
|
* @param {HTMLElement} element - The marked DOM element
|
|
*/
|
|
/**
|
|
* Callback if there were no matches
|
|
* @callback Mark~markRegExpNoMatchCallback
|
|
* @param {RegExp} regexp - The regular expression
|
|
*/
|
|
/**
|
|
* Callback to filter matches
|
|
* @callback Mark~markRegExpFilterCallback
|
|
* @param {HTMLElement} textNode - The text node which includes the match
|
|
* @param {string} match - The matching string for the RegExp
|
|
* @param {number} counter - A counter indicating the number of all marks
|
|
*/
|
|
/**
|
|
* These options also include the common options from
|
|
* {@link Mark~commonOptions}
|
|
* @typedef Mark~markRegExpOptions
|
|
* @type {object.<string>}
|
|
* @property {Mark~markRegExpEachCallback} [each]
|
|
* @property {Mark~markRegExpNoMatchCallback} [noMatch]
|
|
* @property {Mark~markRegExpFilterCallback} [filter]
|
|
*/
|
|
/**
|
|
* Marks a custom regular expression
|
|
* @param {RegExp} regexp - The regular expression
|
|
* @param {Mark~markRegExpOptions} [opt] - Optional options object
|
|
* @access public
|
|
*/
|
|
markRegExp(regexp, opt) {
|
|
this.opt = opt;
|
|
this.log(`Searching with expression "${regexp}"`);
|
|
let totalMatches = 0, fn = "wrapMatches";
|
|
const eachCb = (element) => {
|
|
totalMatches++;
|
|
this.opt.each(element);
|
|
};
|
|
if (this.opt.acrossElements) {
|
|
fn = "wrapMatchesAcrossElements";
|
|
}
|
|
this[fn](regexp, this.opt.ignoreGroups, (match, node) => {
|
|
return this.opt.filter(node, match, totalMatches);
|
|
}, eachCb, () => {
|
|
if (totalMatches === 0) {
|
|
this.opt.noMatch(regexp);
|
|
}
|
|
this.opt.done(totalMatches);
|
|
});
|
|
}
|
|
/**
|
|
* Callback for each marked element
|
|
* @callback Mark~markEachCallback
|
|
* @param {HTMLElement} element - The marked DOM element
|
|
*/
|
|
/**
|
|
* Callback if there were no matches
|
|
* @callback Mark~markNoMatchCallback
|
|
* @param {RegExp} term - The search term that was not found
|
|
*/
|
|
/**
|
|
* Callback to filter matches
|
|
* @callback Mark~markFilterCallback
|
|
* @param {HTMLElement} textNode - The text node which includes the match
|
|
* @param {string} match - The matching term
|
|
* @param {number} totalCounter - A counter indicating the number of all
|
|
* marks
|
|
* @param {number} termCounter - A counter indicating the number of marks
|
|
* for the specific match
|
|
*/
|
|
/**
|
|
* @typedef Mark~markAccuracyObject
|
|
* @type {object.<string>}
|
|
* @property {string} value - A accuracy string value
|
|
* @property {string[]} limiters - A custom array of limiters. For example
|
|
* <code>["-", ","]</code>
|
|
*/
|
|
/**
|
|
* @typedef Mark~markAccuracySetting
|
|
* @type {string}
|
|
* @property {"partially"|"complementary"|"exactly"|Mark~markAccuracyObject}
|
|
* [accuracy="partially"] - Either one of the following string values:
|
|
* <ul>
|
|
* <li><i>partially</i>: When searching for "lor" only "lor" inside
|
|
* "lorem" will be marked</li>
|
|
* <li><i>complementary</i>: When searching for "lor" the whole word
|
|
* "lorem" will be marked</li>
|
|
* <li><i>exactly</i>: When searching for "lor" only those exact words
|
|
* will be marked. In this example nothing inside "lorem". This value
|
|
* is equivalent to the previous option <i>wordBoundary</i></li>
|
|
* </ul>
|
|
* Or an object containing two properties:
|
|
* <ul>
|
|
* <li><i>value</i>: One of the above named string values</li>
|
|
* <li><i>limiters</i>: A custom array of string limiters for accuracy
|
|
* "exactly" or "complementary"</li>
|
|
* </ul>
|
|
*/
|
|
/**
|
|
* @typedef Mark~markWildcardsSetting
|
|
* @type {string}
|
|
* @property {"disabled"|"enabled"|"withSpaces"}
|
|
* [wildcards="disabled"] - Set to any of the following string values:
|
|
* <ul>
|
|
* <li><i>disabled</i>: Disable wildcard usage</li>
|
|
* <li><i>enabled</i>: When searching for "lor?m", the "?" will match zero
|
|
* or one non-space character (e.g. "lorm", "loram", "lor3m", etc). When
|
|
* searching for "lor*m", the "*" will match zero or more non-space
|
|
* characters (e.g. "lorm", "loram", "lor123m", etc).</li>
|
|
* <li><i>withSpaces</i>: When searching for "lor?m", the "?" will
|
|
* match zero or one space or non-space character (e.g. "lor m", "loram",
|
|
* etc). When searching for "lor*m", the "*" will match zero or more space
|
|
* or non-space characters (e.g. "lorm", "lore et dolor ipsum", "lor: m",
|
|
* etc).</li>
|
|
* </ul>
|
|
*/
|
|
/**
|
|
* @typedef Mark~markIgnorePunctuationSetting
|
|
* @type {string[]}
|
|
* @property {string} The strings in this setting will contain punctuation
|
|
* marks that will be ignored:
|
|
* <ul>
|
|
* <li>These punctuation marks can be between any characters, e.g. setting
|
|
* this option to <code>["'"]</code> would match "Worlds", "World's" and
|
|
* "Wo'rlds"</li>
|
|
* <li>One or more apostrophes between the letters would still produce a
|
|
* match (e.g. "W'o''r'l'd's").</li>
|
|
* <li>A typical setting for this option could be as follows:
|
|
* <pre>ignorePunctuation: ":;.,-–—‒_(){}[]!'\"+=".split(""),</pre> This
|
|
* setting includes common punctuation as well as a minus, en-dash,
|
|
* em-dash and figure-dash
|
|
* ({@link https://en.wikipedia.org/wiki/Dash#Figure_dash ref}), as well
|
|
* as an underscore.</li>
|
|
* </ul>
|
|
*/
|
|
/**
|
|
* These options also include the common options from
|
|
* {@link Mark~commonOptions}
|
|
* @typedef Mark~markOptions
|
|
* @type {object.<string>}
|
|
* @property {boolean} [separateWordSearch=true] - Whether to search for
|
|
* each word separated by a blank instead of the complete term
|
|
* @property {boolean} [diacritics=true] - If diacritic characters should be
|
|
* matched. ({@link https://en.wikipedia.org/wiki/Diacritic Diacritics})
|
|
* @property {object} [synonyms] - An object with synonyms. The key will be
|
|
* a synonym for the value and the value for the key
|
|
* @property {Mark~markAccuracySetting} [accuracy]
|
|
* @property {Mark~markWildcardsSetting} [wildcards]
|
|
* @property {boolean} [acrossElements=false] - Whether to find matches
|
|
* across HTML elements. By default, only matches within single HTML
|
|
* elements will be found
|
|
* @property {boolean} [ignoreJoiners=false] - Whether to ignore word
|
|
* joiners inside of key words. These include soft-hyphens, zero-width
|
|
* space, zero-width non-joiners and zero-width joiners.
|
|
* @property {Mark~markIgnorePunctuationSetting} [ignorePunctuation]
|
|
* @property {Mark~markEachCallback} [each]
|
|
* @property {Mark~markNoMatchCallback} [noMatch]
|
|
* @property {Mark~markFilterCallback} [filter]
|
|
*/
|
|
/**
|
|
* Marks the specified search terms
|
|
* @param {string|string[]} [sv] - Search value, either a search string or
|
|
* an array containing multiple search strings
|
|
* @param {Mark~markOptions} [opt] - Optional options object
|
|
* @access public
|
|
*/
|
|
mark(sv, opt) {
|
|
this.opt = opt;
|
|
let totalMatches = 0, fn = "wrapMatches";
|
|
const {
|
|
keywords: kwArr,
|
|
length: kwArrLen
|
|
} = this.getSeparatedKeywords(typeof sv === "string" ? [sv] : sv), sens = this.opt.caseSensitive ? "" : "i", handler = (kw) => {
|
|
let regex = new RegExp(this.createRegExp(kw), `gm${sens}`), matches2 = 0;
|
|
this.log(`Searching with expression "${regex}"`);
|
|
this[fn](regex, 1, (term, node) => {
|
|
return this.opt.filter(node, kw, totalMatches, matches2);
|
|
}, (element) => {
|
|
matches2++;
|
|
totalMatches++;
|
|
this.opt.each(element);
|
|
}, () => {
|
|
if (matches2 === 0) {
|
|
this.opt.noMatch(kw);
|
|
}
|
|
if (kwArr[kwArrLen - 1] === kw) {
|
|
this.opt.done(totalMatches);
|
|
} else {
|
|
handler(kwArr[kwArr.indexOf(kw) + 1]);
|
|
}
|
|
});
|
|
};
|
|
if (this.opt.acrossElements) {
|
|
fn = "wrapMatchesAcrossElements";
|
|
}
|
|
if (kwArrLen === 0) {
|
|
this.opt.done(totalMatches);
|
|
} else {
|
|
handler(kwArr[0]);
|
|
}
|
|
}
|
|
/**
|
|
* Callback for each marked element
|
|
* @callback Mark~markRangesEachCallback
|
|
* @param {HTMLElement} element - The marked DOM element
|
|
* @param {array} range - array of range start and end points
|
|
*/
|
|
/**
|
|
* Callback if a processed range is invalid, out-of-bounds, overlaps another
|
|
* range, or only matches whitespace
|
|
* @callback Mark~markRangesNoMatchCallback
|
|
* @param {Mark~rangeObject} range - a range object
|
|
*/
|
|
/**
|
|
* Callback to filter matches
|
|
* @callback Mark~markRangesFilterCallback
|
|
* @param {HTMLElement} node - The text node which includes the range
|
|
* @param {array} range - array of range start and end points
|
|
* @param {string} match - string extracted from the matching range
|
|
* @param {number} counter - A counter indicating the number of all marks
|
|
*/
|
|
/**
|
|
* These options also include the common options from
|
|
* {@link Mark~commonOptions}
|
|
* @typedef Mark~markRangesOptions
|
|
* @type {object.<string>}
|
|
* @property {Mark~markRangesEachCallback} [each]
|
|
* @property {Mark~markRangesNoMatchCallback} [noMatch]
|
|
* @property {Mark~markRangesFilterCallback} [filter]
|
|
*/
|
|
/**
|
|
* Marks an array of objects containing a start with an end or length of the
|
|
* string to mark
|
|
* @param {Mark~setOfRanges} rawRanges - The original (preprocessed)
|
|
* array of objects
|
|
* @param {Mark~markRangesOptions} [opt] - Optional options object
|
|
* @access public
|
|
*/
|
|
markRanges(rawRanges, opt) {
|
|
this.opt = opt;
|
|
let totalMatches = 0, ranges = this.checkRanges(rawRanges);
|
|
if (ranges && ranges.length) {
|
|
this.log(
|
|
"Starting to mark with the following ranges: " + JSON.stringify(ranges)
|
|
);
|
|
this.wrapRangeFromIndex(
|
|
ranges,
|
|
(node, range, match, counter) => {
|
|
return this.opt.filter(node, range, match, counter);
|
|
},
|
|
(element, range) => {
|
|
totalMatches++;
|
|
this.opt.each(element, range);
|
|
},
|
|
() => {
|
|
this.opt.done(totalMatches);
|
|
}
|
|
);
|
|
} else {
|
|
this.opt.done(totalMatches);
|
|
}
|
|
}
|
|
/**
|
|
* Removes all marked elements inside the context with their HTML and
|
|
* normalizes the parent at the end
|
|
* @param {Mark~commonOptions} [opt] - Optional options object
|
|
* @access public
|
|
*/
|
|
unmark(opt) {
|
|
this.opt = opt;
|
|
let sel = this.opt.element ? this.opt.element : "*";
|
|
sel += "[data-markjs]";
|
|
if (this.opt.className) {
|
|
sel += `.${this.opt.className}`;
|
|
}
|
|
this.log(`Removal selector "${sel}"`);
|
|
this.iterator.forEachNode(NodeFilter.SHOW_ELEMENT, (node) => {
|
|
this.unwrapMatches(node);
|
|
}, (node) => {
|
|
const matchesSel = DOMIterator.matches(node, sel), matchesExclude = this.matchesExclude(node);
|
|
if (!matchesSel || matchesExclude) {
|
|
return NodeFilter.FILTER_REJECT;
|
|
} else {
|
|
return NodeFilter.FILTER_ACCEPT;
|
|
}
|
|
}, this.opt.done);
|
|
}
|
|
};
|
|
function Mark2(ctx) {
|
|
const instance = new Mark$1(ctx);
|
|
this.mark = (sv, opt) => {
|
|
instance.mark(sv, opt);
|
|
return this;
|
|
};
|
|
this.markRegExp = (sv, opt) => {
|
|
instance.markRegExp(sv, opt);
|
|
return this;
|
|
};
|
|
this.markRanges = (sv, opt) => {
|
|
instance.markRanges(sv, opt);
|
|
return this;
|
|
};
|
|
this.unmark = (opt) => {
|
|
instance.unmark(opt);
|
|
return this;
|
|
};
|
|
return this;
|
|
}
|
|
const ENTRIES = "ENTRIES";
|
|
const KEYS = "KEYS";
|
|
const VALUES = "VALUES";
|
|
const LEAF = "";
|
|
class TreeIterator {
|
|
constructor(set, type) {
|
|
const node = set._tree;
|
|
const keys = Array.from(node.keys());
|
|
this.set = set;
|
|
this._type = type;
|
|
this._path = keys.length > 0 ? [{ node, keys }] : [];
|
|
}
|
|
next() {
|
|
const value = this.dive();
|
|
this.backtrack();
|
|
return value;
|
|
}
|
|
dive() {
|
|
if (this._path.length === 0) {
|
|
return { done: true, value: void 0 };
|
|
}
|
|
const { node, keys } = last$1(this._path);
|
|
if (last$1(keys) === LEAF) {
|
|
return { done: false, value: this.result() };
|
|
}
|
|
const child = node.get(last$1(keys));
|
|
this._path.push({ node: child, keys: Array.from(child.keys()) });
|
|
return this.dive();
|
|
}
|
|
backtrack() {
|
|
if (this._path.length === 0) {
|
|
return;
|
|
}
|
|
const keys = last$1(this._path).keys;
|
|
keys.pop();
|
|
if (keys.length > 0) {
|
|
return;
|
|
}
|
|
this._path.pop();
|
|
this.backtrack();
|
|
}
|
|
key() {
|
|
return this.set._prefix + this._path.map(({ keys }) => last$1(keys)).filter((key) => key !== LEAF).join("");
|
|
}
|
|
value() {
|
|
return last$1(this._path).node.get(LEAF);
|
|
}
|
|
result() {
|
|
switch (this._type) {
|
|
case VALUES:
|
|
return this.value();
|
|
case KEYS:
|
|
return this.key();
|
|
default:
|
|
return [this.key(), this.value()];
|
|
}
|
|
}
|
|
[Symbol.iterator]() {
|
|
return this;
|
|
}
|
|
}
|
|
const last$1 = (array) => {
|
|
return array[array.length - 1];
|
|
};
|
|
const fuzzySearch = (node, query, maxDistance) => {
|
|
const results = /* @__PURE__ */ new Map();
|
|
if (query === void 0)
|
|
return results;
|
|
const n = query.length + 1;
|
|
const m = n + maxDistance;
|
|
const matrix = new Uint8Array(m * n).fill(maxDistance + 1);
|
|
for (let j = 0; j < n; ++j)
|
|
matrix[j] = j;
|
|
for (let i = 1; i < m; ++i)
|
|
matrix[i * n] = i;
|
|
recurse(node, query, maxDistance, results, matrix, 1, n, "");
|
|
return results;
|
|
};
|
|
const recurse = (node, query, maxDistance, results, matrix, m, n, prefix) => {
|
|
const offset = m * n;
|
|
key: for (const key of node.keys()) {
|
|
if (key === LEAF) {
|
|
const distance = matrix[offset - 1];
|
|
if (distance <= maxDistance) {
|
|
results.set(prefix, [node.get(key), distance]);
|
|
}
|
|
} else {
|
|
let i = m;
|
|
for (let pos = 0; pos < key.length; ++pos, ++i) {
|
|
const char = key[pos];
|
|
const thisRowOffset = n * i;
|
|
const prevRowOffset = thisRowOffset - n;
|
|
let minDistance = matrix[thisRowOffset];
|
|
const jmin = Math.max(0, i - maxDistance - 1);
|
|
const jmax = Math.min(n - 1, i + maxDistance);
|
|
for (let j = jmin; j < jmax; ++j) {
|
|
const different = char !== query[j];
|
|
const rpl = matrix[prevRowOffset + j] + +different;
|
|
const del = matrix[prevRowOffset + j + 1] + 1;
|
|
const ins = matrix[thisRowOffset + j] + 1;
|
|
const dist = matrix[thisRowOffset + j + 1] = Math.min(rpl, del, ins);
|
|
if (dist < minDistance)
|
|
minDistance = dist;
|
|
}
|
|
if (minDistance > maxDistance) {
|
|
continue key;
|
|
}
|
|
}
|
|
recurse(node.get(key), query, maxDistance, results, matrix, i, n, prefix + key);
|
|
}
|
|
}
|
|
};
|
|
class SearchableMap {
|
|
/**
|
|
* The constructor is normally called without arguments, creating an empty
|
|
* map. In order to create a {@link SearchableMap} from an iterable or from an
|
|
* object, check {@link SearchableMap.from} and {@link
|
|
* SearchableMap.fromObject}.
|
|
*
|
|
* The constructor arguments are for internal use, when creating derived
|
|
* mutable views of a map at a prefix.
|
|
*/
|
|
constructor(tree = /* @__PURE__ */ new Map(), prefix = "") {
|
|
this._size = void 0;
|
|
this._tree = tree;
|
|
this._prefix = prefix;
|
|
}
|
|
/**
|
|
* Creates and returns a mutable view of this {@link SearchableMap},
|
|
* containing only entries that share the given prefix.
|
|
*
|
|
* ### Usage:
|
|
*
|
|
* ```javascript
|
|
* let map = new SearchableMap()
|
|
* map.set("unicorn", 1)
|
|
* map.set("universe", 2)
|
|
* map.set("university", 3)
|
|
* map.set("unique", 4)
|
|
* map.set("hello", 5)
|
|
*
|
|
* let uni = map.atPrefix("uni")
|
|
* uni.get("unique") // => 4
|
|
* uni.get("unicorn") // => 1
|
|
* uni.get("hello") // => undefined
|
|
*
|
|
* let univer = map.atPrefix("univer")
|
|
* univer.get("unique") // => undefined
|
|
* univer.get("universe") // => 2
|
|
* univer.get("university") // => 3
|
|
* ```
|
|
*
|
|
* @param prefix The prefix
|
|
* @return A {@link SearchableMap} representing a mutable view of the original
|
|
* Map at the given prefix
|
|
*/
|
|
atPrefix(prefix) {
|
|
if (!prefix.startsWith(this._prefix)) {
|
|
throw new Error("Mismatched prefix");
|
|
}
|
|
const [node, path] = trackDown(this._tree, prefix.slice(this._prefix.length));
|
|
if (node === void 0) {
|
|
const [parentNode, key] = last(path);
|
|
for (const k of parentNode.keys()) {
|
|
if (k !== LEAF && k.startsWith(key)) {
|
|
const node2 = /* @__PURE__ */ new Map();
|
|
node2.set(k.slice(key.length), parentNode.get(k));
|
|
return new SearchableMap(node2, prefix);
|
|
}
|
|
}
|
|
}
|
|
return new SearchableMap(node, prefix);
|
|
}
|
|
/**
|
|
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/clear
|
|
*/
|
|
clear() {
|
|
this._size = void 0;
|
|
this._tree.clear();
|
|
}
|
|
/**
|
|
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/delete
|
|
* @param key Key to delete
|
|
*/
|
|
delete(key) {
|
|
this._size = void 0;
|
|
return remove(this._tree, key);
|
|
}
|
|
/**
|
|
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/entries
|
|
* @return An iterator iterating through `[key, value]` entries.
|
|
*/
|
|
entries() {
|
|
return new TreeIterator(this, ENTRIES);
|
|
}
|
|
/**
|
|
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/forEach
|
|
* @param fn Iteration function
|
|
*/
|
|
forEach(fn) {
|
|
for (const [key, value] of this) {
|
|
fn(key, value, this);
|
|
}
|
|
}
|
|
/**
|
|
* Returns a Map of all the entries that have a key within the given edit
|
|
* distance from the search key. The keys of the returned Map are the matching
|
|
* keys, while the values are two-element arrays where the first element is
|
|
* the value associated to the key, and the second is the edit distance of the
|
|
* key to the search key.
|
|
*
|
|
* ### Usage:
|
|
*
|
|
* ```javascript
|
|
* let map = new SearchableMap()
|
|
* map.set('hello', 'world')
|
|
* map.set('hell', 'yeah')
|
|
* map.set('ciao', 'mondo')
|
|
*
|
|
* // Get all entries that match the key 'hallo' with a maximum edit distance of 2
|
|
* map.fuzzyGet('hallo', 2)
|
|
* // => Map(2) { 'hello' => ['world', 1], 'hell' => ['yeah', 2] }
|
|
*
|
|
* // In the example, the "hello" key has value "world" and edit distance of 1
|
|
* // (change "e" to "a"), the key "hell" has value "yeah" and edit distance of 2
|
|
* // (change "e" to "a", delete "o")
|
|
* ```
|
|
*
|
|
* @param key The search key
|
|
* @param maxEditDistance The maximum edit distance (Levenshtein)
|
|
* @return A Map of the matching keys to their value and edit distance
|
|
*/
|
|
fuzzyGet(key, maxEditDistance) {
|
|
return fuzzySearch(this._tree, key, maxEditDistance);
|
|
}
|
|
/**
|
|
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/get
|
|
* @param key Key to get
|
|
* @return Value associated to the key, or `undefined` if the key is not
|
|
* found.
|
|
*/
|
|
get(key) {
|
|
const node = lookup(this._tree, key);
|
|
return node !== void 0 ? node.get(LEAF) : void 0;
|
|
}
|
|
/**
|
|
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/has
|
|
* @param key Key
|
|
* @return True if the key is in the map, false otherwise
|
|
*/
|
|
has(key) {
|
|
const node = lookup(this._tree, key);
|
|
return node !== void 0 && node.has(LEAF);
|
|
}
|
|
/**
|
|
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/keys
|
|
* @return An `Iterable` iterating through keys
|
|
*/
|
|
keys() {
|
|
return new TreeIterator(this, KEYS);
|
|
}
|
|
/**
|
|
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/set
|
|
* @param key Key to set
|
|
* @param value Value to associate to the key
|
|
* @return The {@link SearchableMap} itself, to allow chaining
|
|
*/
|
|
set(key, value) {
|
|
if (typeof key !== "string") {
|
|
throw new Error("key must be a string");
|
|
}
|
|
this._size = void 0;
|
|
const node = createPath(this._tree, key);
|
|
node.set(LEAF, value);
|
|
return this;
|
|
}
|
|
/**
|
|
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/size
|
|
*/
|
|
get size() {
|
|
if (this._size) {
|
|
return this._size;
|
|
}
|
|
this._size = 0;
|
|
const iter = this.entries();
|
|
while (!iter.next().done)
|
|
this._size += 1;
|
|
return this._size;
|
|
}
|
|
/**
|
|
* Updates the value at the given key using the provided function. The function
|
|
* is called with the current value at the key, and its return value is used as
|
|
* the new value to be set.
|
|
*
|
|
* ### Example:
|
|
*
|
|
* ```javascript
|
|
* // Increment the current value by one
|
|
* searchableMap.update('somekey', (currentValue) => currentValue == null ? 0 : currentValue + 1)
|
|
* ```
|
|
*
|
|
* If the value at the given key is or will be an object, it might not require
|
|
* re-assignment. In that case it is better to use `fetch()`, because it is
|
|
* faster.
|
|
*
|
|
* @param key The key to update
|
|
* @param fn The function used to compute the new value from the current one
|
|
* @return The {@link SearchableMap} itself, to allow chaining
|
|
*/
|
|
update(key, fn) {
|
|
if (typeof key !== "string") {
|
|
throw new Error("key must be a string");
|
|
}
|
|
this._size = void 0;
|
|
const node = createPath(this._tree, key);
|
|
node.set(LEAF, fn(node.get(LEAF)));
|
|
return this;
|
|
}
|
|
/**
|
|
* Fetches the value of the given key. If the value does not exist, calls the
|
|
* given function to create a new value, which is inserted at the given key
|
|
* and subsequently returned.
|
|
*
|
|
* ### Example:
|
|
*
|
|
* ```javascript
|
|
* const map = searchableMap.fetch('somekey', () => new Map())
|
|
* map.set('foo', 'bar')
|
|
* ```
|
|
*
|
|
* @param key The key to update
|
|
* @param initial A function that creates a new value if the key does not exist
|
|
* @return The existing or new value at the given key
|
|
*/
|
|
fetch(key, initial) {
|
|
if (typeof key !== "string") {
|
|
throw new Error("key must be a string");
|
|
}
|
|
this._size = void 0;
|
|
const node = createPath(this._tree, key);
|
|
let value = node.get(LEAF);
|
|
if (value === void 0) {
|
|
node.set(LEAF, value = initial());
|
|
}
|
|
return value;
|
|
}
|
|
/**
|
|
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/values
|
|
* @return An `Iterable` iterating through values.
|
|
*/
|
|
values() {
|
|
return new TreeIterator(this, VALUES);
|
|
}
|
|
/**
|
|
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/@@iterator
|
|
*/
|
|
[Symbol.iterator]() {
|
|
return this.entries();
|
|
}
|
|
/**
|
|
* Creates a {@link SearchableMap} from an `Iterable` of entries
|
|
*
|
|
* @param entries Entries to be inserted in the {@link SearchableMap}
|
|
* @return A new {@link SearchableMap} with the given entries
|
|
*/
|
|
static from(entries) {
|
|
const tree = new SearchableMap();
|
|
for (const [key, value] of entries) {
|
|
tree.set(key, value);
|
|
}
|
|
return tree;
|
|
}
|
|
/**
|
|
* Creates a {@link SearchableMap} from the iterable properties of a JavaScript object
|
|
*
|
|
* @param object Object of entries for the {@link SearchableMap}
|
|
* @return A new {@link SearchableMap} with the given entries
|
|
*/
|
|
static fromObject(object) {
|
|
return SearchableMap.from(Object.entries(object));
|
|
}
|
|
}
|
|
const trackDown = (tree, key, path = []) => {
|
|
if (key.length === 0 || tree == null) {
|
|
return [tree, path];
|
|
}
|
|
for (const k of tree.keys()) {
|
|
if (k !== LEAF && key.startsWith(k)) {
|
|
path.push([tree, k]);
|
|
return trackDown(tree.get(k), key.slice(k.length), path);
|
|
}
|
|
}
|
|
path.push([tree, key]);
|
|
return trackDown(void 0, "", path);
|
|
};
|
|
const lookup = (tree, key) => {
|
|
if (key.length === 0 || tree == null) {
|
|
return tree;
|
|
}
|
|
for (const k of tree.keys()) {
|
|
if (k !== LEAF && key.startsWith(k)) {
|
|
return lookup(tree.get(k), key.slice(k.length));
|
|
}
|
|
}
|
|
};
|
|
const createPath = (node, key) => {
|
|
const keyLength = key.length;
|
|
outer: for (let pos = 0; node && pos < keyLength; ) {
|
|
for (const k of node.keys()) {
|
|
if (k !== LEAF && key[pos] === k[0]) {
|
|
const len = Math.min(keyLength - pos, k.length);
|
|
let offset = 1;
|
|
while (offset < len && key[pos + offset] === k[offset])
|
|
++offset;
|
|
const child2 = node.get(k);
|
|
if (offset === k.length) {
|
|
node = child2;
|
|
} else {
|
|
const intermediate = /* @__PURE__ */ new Map();
|
|
intermediate.set(k.slice(offset), child2);
|
|
node.set(key.slice(pos, pos + offset), intermediate);
|
|
node.delete(k);
|
|
node = intermediate;
|
|
}
|
|
pos += offset;
|
|
continue outer;
|
|
}
|
|
}
|
|
const child = /* @__PURE__ */ new Map();
|
|
node.set(key.slice(pos), child);
|
|
return child;
|
|
}
|
|
return node;
|
|
};
|
|
const remove = (tree, key) => {
|
|
const [node, path] = trackDown(tree, key);
|
|
if (node === void 0) {
|
|
return;
|
|
}
|
|
node.delete(LEAF);
|
|
if (node.size === 0) {
|
|
cleanup(path);
|
|
} else if (node.size === 1) {
|
|
const [key2, value] = node.entries().next().value;
|
|
merge(path, key2, value);
|
|
}
|
|
};
|
|
const cleanup = (path) => {
|
|
if (path.length === 0) {
|
|
return;
|
|
}
|
|
const [node, key] = last(path);
|
|
node.delete(key);
|
|
if (node.size === 0) {
|
|
cleanup(path.slice(0, -1));
|
|
} else if (node.size === 1) {
|
|
const [key2, value] = node.entries().next().value;
|
|
if (key2 !== LEAF) {
|
|
merge(path.slice(0, -1), key2, value);
|
|
}
|
|
}
|
|
};
|
|
const merge = (path, key, value) => {
|
|
if (path.length === 0) {
|
|
return;
|
|
}
|
|
const [node, nodeKey] = last(path);
|
|
node.set(nodeKey + key, value);
|
|
node.delete(nodeKey);
|
|
};
|
|
const last = (array) => {
|
|
return array[array.length - 1];
|
|
};
|
|
const OR = "or";
|
|
const AND = "and";
|
|
const AND_NOT = "and_not";
|
|
class MiniSearch {
|
|
/**
|
|
* @param options Configuration options
|
|
*
|
|
* ### Examples:
|
|
*
|
|
* ```javascript
|
|
* // Create a search engine that indexes the 'title' and 'text' fields of your
|
|
* // documents:
|
|
* const miniSearch = new MiniSearch({ fields: ['title', 'text'] })
|
|
* ```
|
|
*
|
|
* ### ID Field:
|
|
*
|
|
* ```javascript
|
|
* // Your documents are assumed to include a unique 'id' field, but if you want
|
|
* // to use a different field for document identification, you can set the
|
|
* // 'idField' option:
|
|
* const miniSearch = new MiniSearch({ idField: 'key', fields: ['title', 'text'] })
|
|
* ```
|
|
*
|
|
* ### Options and defaults:
|
|
*
|
|
* ```javascript
|
|
* // The full set of options (here with their default value) is:
|
|
* const miniSearch = new MiniSearch({
|
|
* // idField: field that uniquely identifies a document
|
|
* idField: 'id',
|
|
*
|
|
* // extractField: function used to get the value of a field in a document.
|
|
* // By default, it assumes the document is a flat object with field names as
|
|
* // property keys and field values as string property values, but custom logic
|
|
* // can be implemented by setting this option to a custom extractor function.
|
|
* extractField: (document, fieldName) => document[fieldName],
|
|
*
|
|
* // tokenize: function used to split fields into individual terms. By
|
|
* // default, it is also used to tokenize search queries, unless a specific
|
|
* // `tokenize` search option is supplied. When tokenizing an indexed field,
|
|
* // the field name is passed as the second argument.
|
|
* tokenize: (string, _fieldName) => string.split(SPACE_OR_PUNCTUATION),
|
|
*
|
|
* // processTerm: function used to process each tokenized term before
|
|
* // indexing. It can be used for stemming and normalization. Return a falsy
|
|
* // value in order to discard a term. By default, it is also used to process
|
|
* // search queries, unless a specific `processTerm` option is supplied as a
|
|
* // search option. When processing a term from a indexed field, the field
|
|
* // name is passed as the second argument.
|
|
* processTerm: (term, _fieldName) => term.toLowerCase(),
|
|
*
|
|
* // searchOptions: default search options, see the `search` method for
|
|
* // details
|
|
* searchOptions: undefined,
|
|
*
|
|
* // fields: document fields to be indexed. Mandatory, but not set by default
|
|
* fields: undefined
|
|
*
|
|
* // storeFields: document fields to be stored and returned as part of the
|
|
* // search results.
|
|
* storeFields: []
|
|
* })
|
|
* ```
|
|
*/
|
|
constructor(options) {
|
|
if ((options === null || options === void 0 ? void 0 : options.fields) == null) {
|
|
throw new Error('MiniSearch: option "fields" must be provided');
|
|
}
|
|
const autoVacuum = options.autoVacuum == null || options.autoVacuum === true ? defaultAutoVacuumOptions : options.autoVacuum;
|
|
this._options = {
|
|
...defaultOptions,
|
|
...options,
|
|
autoVacuum,
|
|
searchOptions: { ...defaultSearchOptions, ...options.searchOptions || {} },
|
|
autoSuggestOptions: { ...defaultAutoSuggestOptions, ...options.autoSuggestOptions || {} }
|
|
};
|
|
this._index = new SearchableMap();
|
|
this._documentCount = 0;
|
|
this._documentIds = /* @__PURE__ */ new Map();
|
|
this._idToShortId = /* @__PURE__ */ new Map();
|
|
this._fieldIds = {};
|
|
this._fieldLength = /* @__PURE__ */ new Map();
|
|
this._avgFieldLength = [];
|
|
this._nextId = 0;
|
|
this._storedFields = /* @__PURE__ */ new Map();
|
|
this._dirtCount = 0;
|
|
this._currentVacuum = null;
|
|
this._enqueuedVacuum = null;
|
|
this._enqueuedVacuumConditions = defaultVacuumConditions;
|
|
this.addFields(this._options.fields);
|
|
}
|
|
/**
|
|
* Adds a document to the index
|
|
*
|
|
* @param document The document to be indexed
|
|
*/
|
|
add(document2) {
|
|
const { extractField, stringifyField, tokenize, processTerm, fields, idField } = this._options;
|
|
const id = extractField(document2, idField);
|
|
if (id == null) {
|
|
throw new Error(`MiniSearch: document does not have ID field "${idField}"`);
|
|
}
|
|
if (this._idToShortId.has(id)) {
|
|
throw new Error(`MiniSearch: duplicate ID ${id}`);
|
|
}
|
|
const shortDocumentId = this.addDocumentId(id);
|
|
this.saveStoredFields(shortDocumentId, document2);
|
|
for (const field of fields) {
|
|
const fieldValue = extractField(document2, field);
|
|
if (fieldValue == null)
|
|
continue;
|
|
const tokens = tokenize(stringifyField(fieldValue, field), field);
|
|
const fieldId = this._fieldIds[field];
|
|
const uniqueTerms = new Set(tokens).size;
|
|
this.addFieldLength(shortDocumentId, fieldId, this._documentCount - 1, uniqueTerms);
|
|
for (const term of tokens) {
|
|
const processedTerm = processTerm(term, field);
|
|
if (Array.isArray(processedTerm)) {
|
|
for (const t of processedTerm) {
|
|
this.addTerm(fieldId, shortDocumentId, t);
|
|
}
|
|
} else if (processedTerm) {
|
|
this.addTerm(fieldId, shortDocumentId, processedTerm);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Adds all the given documents to the index
|
|
*
|
|
* @param documents An array of documents to be indexed
|
|
*/
|
|
addAll(documents) {
|
|
for (const document2 of documents)
|
|
this.add(document2);
|
|
}
|
|
/**
|
|
* Adds all the given documents to the index asynchronously.
|
|
*
|
|
* Returns a promise that resolves (to `undefined`) when the indexing is done.
|
|
* This method is useful when index many documents, to avoid blocking the main
|
|
* thread. The indexing is performed asynchronously and in chunks.
|
|
*
|
|
* @param documents An array of documents to be indexed
|
|
* @param options Configuration options
|
|
* @return A promise resolving to `undefined` when the indexing is done
|
|
*/
|
|
addAllAsync(documents, options = {}) {
|
|
const { chunkSize = 10 } = options;
|
|
const acc = { chunk: [], promise: Promise.resolve() };
|
|
const { chunk, promise } = documents.reduce(({ chunk: chunk2, promise: promise2 }, document2, i) => {
|
|
chunk2.push(document2);
|
|
if ((i + 1) % chunkSize === 0) {
|
|
return {
|
|
chunk: [],
|
|
promise: promise2.then(() => new Promise((resolve) => setTimeout(resolve, 0))).then(() => this.addAll(chunk2))
|
|
};
|
|
} else {
|
|
return { chunk: chunk2, promise: promise2 };
|
|
}
|
|
}, acc);
|
|
return promise.then(() => this.addAll(chunk));
|
|
}
|
|
/**
|
|
* Removes the given document from the index.
|
|
*
|
|
* The document to remove must NOT have changed between indexing and removal,
|
|
* otherwise the index will be corrupted.
|
|
*
|
|
* This method requires passing the full document to be removed (not just the
|
|
* ID), and immediately removes the document from the inverted index, allowing
|
|
* memory to be released. A convenient alternative is {@link
|
|
* MiniSearch#discard}, which needs only the document ID, and has the same
|
|
* visible effect, but delays cleaning up the index until the next vacuuming.
|
|
*
|
|
* @param document The document to be removed
|
|
*/
|
|
remove(document2) {
|
|
const { tokenize, processTerm, extractField, stringifyField, fields, idField } = this._options;
|
|
const id = extractField(document2, idField);
|
|
if (id == null) {
|
|
throw new Error(`MiniSearch: document does not have ID field "${idField}"`);
|
|
}
|
|
const shortId = this._idToShortId.get(id);
|
|
if (shortId == null) {
|
|
throw new Error(`MiniSearch: cannot remove document with ID ${id}: it is not in the index`);
|
|
}
|
|
for (const field of fields) {
|
|
const fieldValue = extractField(document2, field);
|
|
if (fieldValue == null)
|
|
continue;
|
|
const tokens = tokenize(stringifyField(fieldValue, field), field);
|
|
const fieldId = this._fieldIds[field];
|
|
const uniqueTerms = new Set(tokens).size;
|
|
this.removeFieldLength(shortId, fieldId, this._documentCount, uniqueTerms);
|
|
for (const term of tokens) {
|
|
const processedTerm = processTerm(term, field);
|
|
if (Array.isArray(processedTerm)) {
|
|
for (const t of processedTerm) {
|
|
this.removeTerm(fieldId, shortId, t);
|
|
}
|
|
} else if (processedTerm) {
|
|
this.removeTerm(fieldId, shortId, processedTerm);
|
|
}
|
|
}
|
|
}
|
|
this._storedFields.delete(shortId);
|
|
this._documentIds.delete(shortId);
|
|
this._idToShortId.delete(id);
|
|
this._fieldLength.delete(shortId);
|
|
this._documentCount -= 1;
|
|
}
|
|
/**
|
|
* Removes all the given documents from the index. If called with no arguments,
|
|
* it removes _all_ documents from the index.
|
|
*
|
|
* @param documents The documents to be removed. If this argument is omitted,
|
|
* all documents are removed. Note that, for removing all documents, it is
|
|
* more efficient to call this method with no arguments than to pass all
|
|
* documents.
|
|
*/
|
|
removeAll(documents) {
|
|
if (documents) {
|
|
for (const document2 of documents)
|
|
this.remove(document2);
|
|
} else if (arguments.length > 0) {
|
|
throw new Error("Expected documents to be present. Omit the argument to remove all documents.");
|
|
} else {
|
|
this._index = new SearchableMap();
|
|
this._documentCount = 0;
|
|
this._documentIds = /* @__PURE__ */ new Map();
|
|
this._idToShortId = /* @__PURE__ */ new Map();
|
|
this._fieldLength = /* @__PURE__ */ new Map();
|
|
this._avgFieldLength = [];
|
|
this._storedFields = /* @__PURE__ */ new Map();
|
|
this._nextId = 0;
|
|
}
|
|
}
|
|
/**
|
|
* Discards the document with the given ID, so it won't appear in search results
|
|
*
|
|
* It has the same visible effect of {@link MiniSearch.remove} (both cause the
|
|
* document to stop appearing in searches), but a different effect on the
|
|
* internal data structures:
|
|
*
|
|
* - {@link MiniSearch#remove} requires passing the full document to be
|
|
* removed as argument, and removes it from the inverted index immediately.
|
|
*
|
|
* - {@link MiniSearch#discard} instead only needs the document ID, and
|
|
* works by marking the current version of the document as discarded, so it
|
|
* is immediately ignored by searches. This is faster and more convenient
|
|
* than {@link MiniSearch#remove}, but the index is not immediately
|
|
* modified. To take care of that, vacuuming is performed after a certain
|
|
* number of documents are discarded, cleaning up the index and allowing
|
|
* memory to be released.
|
|
*
|
|
* After discarding a document, it is possible to re-add a new version, and
|
|
* only the new version will appear in searches. In other words, discarding
|
|
* and re-adding a document works exactly like removing and re-adding it. The
|
|
* {@link MiniSearch.replace} method can also be used to replace a document
|
|
* with a new version.
|
|
*
|
|
* #### Details about vacuuming
|
|
*
|
|
* Repetite calls to this method would leave obsolete document references in
|
|
* the index, invisible to searches. Two mechanisms take care of cleaning up:
|
|
* clean up during search, and vacuuming.
|
|
*
|
|
* - Upon search, whenever a discarded ID is found (and ignored for the
|
|
* results), references to the discarded document are removed from the
|
|
* inverted index entries for the search terms. This ensures that subsequent
|
|
* searches for the same terms do not need to skip these obsolete references
|
|
* again.
|
|
*
|
|
* - In addition, vacuuming is performed automatically by default (see the
|
|
* `autoVacuum` field in {@link Options}) after a certain number of
|
|
* documents are discarded. Vacuuming traverses all terms in the index,
|
|
* cleaning up all references to discarded documents. Vacuuming can also be
|
|
* triggered manually by calling {@link MiniSearch#vacuum}.
|
|
*
|
|
* @param id The ID of the document to be discarded
|
|
*/
|
|
discard(id) {
|
|
const shortId = this._idToShortId.get(id);
|
|
if (shortId == null) {
|
|
throw new Error(`MiniSearch: cannot discard document with ID ${id}: it is not in the index`);
|
|
}
|
|
this._idToShortId.delete(id);
|
|
this._documentIds.delete(shortId);
|
|
this._storedFields.delete(shortId);
|
|
(this._fieldLength.get(shortId) || []).forEach((fieldLength, fieldId) => {
|
|
this.removeFieldLength(shortId, fieldId, this._documentCount, fieldLength);
|
|
});
|
|
this._fieldLength.delete(shortId);
|
|
this._documentCount -= 1;
|
|
this._dirtCount += 1;
|
|
this.maybeAutoVacuum();
|
|
}
|
|
maybeAutoVacuum() {
|
|
if (this._options.autoVacuum === false) {
|
|
return;
|
|
}
|
|
const { minDirtFactor, minDirtCount, batchSize, batchWait } = this._options.autoVacuum;
|
|
this.conditionalVacuum({ batchSize, batchWait }, { minDirtCount, minDirtFactor });
|
|
}
|
|
/**
|
|
* Discards the documents with the given IDs, so they won't appear in search
|
|
* results
|
|
*
|
|
* It is equivalent to calling {@link MiniSearch#discard} for all the given
|
|
* IDs, but with the optimization of triggering at most one automatic
|
|
* vacuuming at the end.
|
|
*
|
|
* Note: to remove all documents from the index, it is faster and more
|
|
* convenient to call {@link MiniSearch.removeAll} with no argument, instead
|
|
* of passing all IDs to this method.
|
|
*/
|
|
discardAll(ids) {
|
|
const autoVacuum = this._options.autoVacuum;
|
|
try {
|
|
this._options.autoVacuum = false;
|
|
for (const id of ids) {
|
|
this.discard(id);
|
|
}
|
|
} finally {
|
|
this._options.autoVacuum = autoVacuum;
|
|
}
|
|
this.maybeAutoVacuum();
|
|
}
|
|
/**
|
|
* It replaces an existing document with the given updated version
|
|
*
|
|
* It works by discarding the current version and adding the updated one, so
|
|
* it is functionally equivalent to calling {@link MiniSearch#discard}
|
|
* followed by {@link MiniSearch#add}. The ID of the updated document should
|
|
* be the same as the original one.
|
|
*
|
|
* Since it uses {@link MiniSearch#discard} internally, this method relies on
|
|
* vacuuming to clean up obsolete document references from the index, allowing
|
|
* memory to be released (see {@link MiniSearch#discard}).
|
|
*
|
|
* @param updatedDocument The updated document to replace the old version
|
|
* with
|
|
*/
|
|
replace(updatedDocument) {
|
|
const { idField, extractField } = this._options;
|
|
const id = extractField(updatedDocument, idField);
|
|
this.discard(id);
|
|
this.add(updatedDocument);
|
|
}
|
|
/**
|
|
* Triggers a manual vacuuming, cleaning up references to discarded documents
|
|
* from the inverted index
|
|
*
|
|
* Vacuuming is only useful for applications that use the {@link
|
|
* MiniSearch#discard} or {@link MiniSearch#replace} methods.
|
|
*
|
|
* By default, vacuuming is performed automatically when needed (controlled by
|
|
* the `autoVacuum` field in {@link Options}), so there is usually no need to
|
|
* call this method, unless one wants to make sure to perform vacuuming at a
|
|
* specific moment.
|
|
*
|
|
* Vacuuming traverses all terms in the inverted index in batches, and cleans
|
|
* up references to discarded documents from the posting list, allowing memory
|
|
* to be released.
|
|
*
|
|
* The method takes an optional object as argument with the following keys:
|
|
*
|
|
* - `batchSize`: the size of each batch (1000 by default)
|
|
*
|
|
* - `batchWait`: the number of milliseconds to wait between batches (10 by
|
|
* default)
|
|
*
|
|
* On large indexes, vacuuming could have a non-negligible cost: batching
|
|
* avoids blocking the thread for long, diluting this cost so that it is not
|
|
* negatively affecting the application. Nonetheless, this method should only
|
|
* be called when necessary, and relying on automatic vacuuming is usually
|
|
* better.
|
|
*
|
|
* It returns a promise that resolves (to undefined) when the clean up is
|
|
* completed. If vacuuming is already ongoing at the time this method is
|
|
* called, a new one is enqueued immediately after the ongoing one, and a
|
|
* corresponding promise is returned. However, no more than one vacuuming is
|
|
* enqueued on top of the ongoing one, even if this method is called more
|
|
* times (enqueuing multiple ones would be useless).
|
|
*
|
|
* @param options Configuration options for the batch size and delay. See
|
|
* {@link VacuumOptions}.
|
|
*/
|
|
vacuum(options = {}) {
|
|
return this.conditionalVacuum(options);
|
|
}
|
|
conditionalVacuum(options, conditions) {
|
|
if (this._currentVacuum) {
|
|
this._enqueuedVacuumConditions = this._enqueuedVacuumConditions && conditions;
|
|
if (this._enqueuedVacuum != null) {
|
|
return this._enqueuedVacuum;
|
|
}
|
|
this._enqueuedVacuum = this._currentVacuum.then(() => {
|
|
const conditions2 = this._enqueuedVacuumConditions;
|
|
this._enqueuedVacuumConditions = defaultVacuumConditions;
|
|
return this.performVacuuming(options, conditions2);
|
|
});
|
|
return this._enqueuedVacuum;
|
|
}
|
|
if (this.vacuumConditionsMet(conditions) === false) {
|
|
return Promise.resolve();
|
|
}
|
|
this._currentVacuum = this.performVacuuming(options);
|
|
return this._currentVacuum;
|
|
}
|
|
async performVacuuming(options, conditions) {
|
|
const initialDirtCount = this._dirtCount;
|
|
if (this.vacuumConditionsMet(conditions)) {
|
|
const batchSize = options.batchSize || defaultVacuumOptions.batchSize;
|
|
const batchWait = options.batchWait || defaultVacuumOptions.batchWait;
|
|
let i = 1;
|
|
for (const [term, fieldsData] of this._index) {
|
|
for (const [fieldId, fieldIndex] of fieldsData) {
|
|
for (const [shortId] of fieldIndex) {
|
|
if (this._documentIds.has(shortId)) {
|
|
continue;
|
|
}
|
|
if (fieldIndex.size <= 1) {
|
|
fieldsData.delete(fieldId);
|
|
} else {
|
|
fieldIndex.delete(shortId);
|
|
}
|
|
}
|
|
}
|
|
if (this._index.get(term).size === 0) {
|
|
this._index.delete(term);
|
|
}
|
|
if (i % batchSize === 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, batchWait));
|
|
}
|
|
i += 1;
|
|
}
|
|
this._dirtCount -= initialDirtCount;
|
|
}
|
|
await null;
|
|
this._currentVacuum = this._enqueuedVacuum;
|
|
this._enqueuedVacuum = null;
|
|
}
|
|
vacuumConditionsMet(conditions) {
|
|
if (conditions == null) {
|
|
return true;
|
|
}
|
|
let { minDirtCount, minDirtFactor } = conditions;
|
|
minDirtCount = minDirtCount || defaultAutoVacuumOptions.minDirtCount;
|
|
minDirtFactor = minDirtFactor || defaultAutoVacuumOptions.minDirtFactor;
|
|
return this.dirtCount >= minDirtCount && this.dirtFactor >= minDirtFactor;
|
|
}
|
|
/**
|
|
* Is `true` if a vacuuming operation is ongoing, `false` otherwise
|
|
*/
|
|
get isVacuuming() {
|
|
return this._currentVacuum != null;
|
|
}
|
|
/**
|
|
* The number of documents discarded since the most recent vacuuming
|
|
*/
|
|
get dirtCount() {
|
|
return this._dirtCount;
|
|
}
|
|
/**
|
|
* A number between 0 and 1 giving an indication about the proportion of
|
|
* documents that are discarded, and can therefore be cleaned up by vacuuming.
|
|
* A value close to 0 means that the index is relatively clean, while a higher
|
|
* value means that the index is relatively dirty, and vacuuming could release
|
|
* memory.
|
|
*/
|
|
get dirtFactor() {
|
|
return this._dirtCount / (1 + this._documentCount + this._dirtCount);
|
|
}
|
|
/**
|
|
* Returns `true` if a document with the given ID is present in the index and
|
|
* available for search, `false` otherwise
|
|
*
|
|
* @param id The document ID
|
|
*/
|
|
has(id) {
|
|
return this._idToShortId.has(id);
|
|
}
|
|
/**
|
|
* Returns the stored fields (as configured in the `storeFields` constructor
|
|
* option) for the given document ID. Returns `undefined` if the document is
|
|
* not present in the index.
|
|
*
|
|
* @param id The document ID
|
|
*/
|
|
getStoredFields(id) {
|
|
const shortId = this._idToShortId.get(id);
|
|
if (shortId == null) {
|
|
return void 0;
|
|
}
|
|
return this._storedFields.get(shortId);
|
|
}
|
|
/**
|
|
* Search for documents matching the given search query.
|
|
*
|
|
* The result is a list of scored document IDs matching the query, sorted by
|
|
* descending score, and each including data about which terms were matched and
|
|
* in which fields.
|
|
*
|
|
* ### Basic usage:
|
|
*
|
|
* ```javascript
|
|
* // Search for "zen art motorcycle" with default options: terms have to match
|
|
* // exactly, and individual terms are joined with OR
|
|
* miniSearch.search('zen art motorcycle')
|
|
* // => [ { id: 2, score: 2.77258, match: { ... } }, { id: 4, score: 1.38629, match: { ... } } ]
|
|
* ```
|
|
*
|
|
* ### Restrict search to specific fields:
|
|
*
|
|
* ```javascript
|
|
* // Search only in the 'title' field
|
|
* miniSearch.search('zen', { fields: ['title'] })
|
|
* ```
|
|
*
|
|
* ### Field boosting:
|
|
*
|
|
* ```javascript
|
|
* // Boost a field
|
|
* miniSearch.search('zen', { boost: { title: 2 } })
|
|
* ```
|
|
*
|
|
* ### Prefix search:
|
|
*
|
|
* ```javascript
|
|
* // Search for "moto" with prefix search (it will match documents
|
|
* // containing terms that start with "moto" or "neuro")
|
|
* miniSearch.search('moto neuro', { prefix: true })
|
|
* ```
|
|
*
|
|
* ### Fuzzy search:
|
|
*
|
|
* ```javascript
|
|
* // Search for "ismael" with fuzzy search (it will match documents containing
|
|
* // terms similar to "ismael", with a maximum edit distance of 0.2 term.length
|
|
* // (rounded to nearest integer)
|
|
* miniSearch.search('ismael', { fuzzy: 0.2 })
|
|
* ```
|
|
*
|
|
* ### Combining strategies:
|
|
*
|
|
* ```javascript
|
|
* // Mix of exact match, prefix search, and fuzzy search
|
|
* miniSearch.search('ismael mob', {
|
|
* prefix: true,
|
|
* fuzzy: 0.2
|
|
* })
|
|
* ```
|
|
*
|
|
* ### Advanced prefix and fuzzy search:
|
|
*
|
|
* ```javascript
|
|
* // Perform fuzzy and prefix search depending on the search term. Here
|
|
* // performing prefix and fuzzy search only on terms longer than 3 characters
|
|
* miniSearch.search('ismael mob', {
|
|
* prefix: term => term.length > 3
|
|
* fuzzy: term => term.length > 3 ? 0.2 : null
|
|
* })
|
|
* ```
|
|
*
|
|
* ### Combine with AND:
|
|
*
|
|
* ```javascript
|
|
* // Combine search terms with AND (to match only documents that contain both
|
|
* // "motorcycle" and "art")
|
|
* miniSearch.search('motorcycle art', { combineWith: 'AND' })
|
|
* ```
|
|
*
|
|
* ### Combine with AND_NOT:
|
|
*
|
|
* There is also an AND_NOT combinator, that finds documents that match the
|
|
* first term, but do not match any of the other terms. This combinator is
|
|
* rarely useful with simple queries, and is meant to be used with advanced
|
|
* query combinations (see later for more details).
|
|
*
|
|
* ### Filtering results:
|
|
*
|
|
* ```javascript
|
|
* // Filter only results in the 'fiction' category (assuming that 'category'
|
|
* // is a stored field)
|
|
* miniSearch.search('motorcycle art', {
|
|
* filter: (result) => result.category === 'fiction'
|
|
* })
|
|
* ```
|
|
*
|
|
* ### Wildcard query
|
|
*
|
|
* Searching for an empty string (assuming the default tokenizer) returns no
|
|
* results. Sometimes though, one needs to match all documents, like in a
|
|
* "wildcard" search. This is possible by passing the special value
|
|
* {@link MiniSearch.wildcard} as the query:
|
|
*
|
|
* ```javascript
|
|
* // Return search results for all documents
|
|
* miniSearch.search(MiniSearch.wildcard)
|
|
* ```
|
|
*
|
|
* Note that search options such as `filter` and `boostDocument` are still
|
|
* applied, influencing which results are returned, and their order:
|
|
*
|
|
* ```javascript
|
|
* // Return search results for all documents in the 'fiction' category
|
|
* miniSearch.search(MiniSearch.wildcard, {
|
|
* filter: (result) => result.category === 'fiction'
|
|
* })
|
|
* ```
|
|
*
|
|
* ### Advanced combination of queries:
|
|
*
|
|
* It is possible to combine different subqueries with OR, AND, and AND_NOT,
|
|
* and even with different search options, by passing a query expression
|
|
* tree object as the first argument, instead of a string.
|
|
*
|
|
* ```javascript
|
|
* // Search for documents that contain "zen" and ("motorcycle" or "archery")
|
|
* miniSearch.search({
|
|
* combineWith: 'AND',
|
|
* queries: [
|
|
* 'zen',
|
|
* {
|
|
* combineWith: 'OR',
|
|
* queries: ['motorcycle', 'archery']
|
|
* }
|
|
* ]
|
|
* })
|
|
*
|
|
* // Search for documents that contain ("apple" or "pear") but not "juice" and
|
|
* // not "tree"
|
|
* miniSearch.search({
|
|
* combineWith: 'AND_NOT',
|
|
* queries: [
|
|
* {
|
|
* combineWith: 'OR',
|
|
* queries: ['apple', 'pear']
|
|
* },
|
|
* 'juice',
|
|
* 'tree'
|
|
* ]
|
|
* })
|
|
* ```
|
|
*
|
|
* Each node in the expression tree can be either a string, or an object that
|
|
* supports all {@link SearchOptions} fields, plus a `queries` array field for
|
|
* subqueries.
|
|
*
|
|
* Note that, while this can become complicated to do by hand for complex or
|
|
* deeply nested queries, it provides a formalized expression tree API for
|
|
* external libraries that implement a parser for custom query languages.
|
|
*
|
|
* @param query Search query
|
|
* @param searchOptions Search options. Each option, if not given, defaults to the corresponding value of `searchOptions` given to the constructor, or to the library default.
|
|
*/
|
|
search(query, searchOptions = {}) {
|
|
const { searchOptions: globalSearchOptions } = this._options;
|
|
const searchOptionsWithDefaults = { ...globalSearchOptions, ...searchOptions };
|
|
const rawResults = this.executeQuery(query, searchOptions);
|
|
const results = [];
|
|
for (const [docId, { score, terms, match }] of rawResults) {
|
|
const quality = terms.length || 1;
|
|
const result = {
|
|
id: this._documentIds.get(docId),
|
|
score: score * quality,
|
|
terms: Object.keys(match),
|
|
queryTerms: terms,
|
|
match
|
|
};
|
|
Object.assign(result, this._storedFields.get(docId));
|
|
if (searchOptionsWithDefaults.filter == null || searchOptionsWithDefaults.filter(result)) {
|
|
results.push(result);
|
|
}
|
|
}
|
|
if (query === MiniSearch.wildcard && searchOptionsWithDefaults.boostDocument == null) {
|
|
return results;
|
|
}
|
|
results.sort(byScore);
|
|
return results;
|
|
}
|
|
/**
|
|
* Provide suggestions for the given search query
|
|
*
|
|
* The result is a list of suggested modified search queries, derived from the
|
|
* given search query, each with a relevance score, sorted by descending score.
|
|
*
|
|
* By default, it uses the same options used for search, except that by
|
|
* default it performs prefix search on the last term of the query, and
|
|
* combine terms with `'AND'` (requiring all query terms to match). Custom
|
|
* options can be passed as a second argument. Defaults can be changed upon
|
|
* calling the {@link MiniSearch} constructor, by passing a
|
|
* `autoSuggestOptions` option.
|
|
*
|
|
* ### Basic usage:
|
|
*
|
|
* ```javascript
|
|
* // Get suggestions for 'neuro':
|
|
* miniSearch.autoSuggest('neuro')
|
|
* // => [ { suggestion: 'neuromancer', terms: [ 'neuromancer' ], score: 0.46240 } ]
|
|
* ```
|
|
*
|
|
* ### Multiple words:
|
|
*
|
|
* ```javascript
|
|
* // Get suggestions for 'zen ar':
|
|
* miniSearch.autoSuggest('zen ar')
|
|
* // => [
|
|
* // { suggestion: 'zen archery art', terms: [ 'zen', 'archery', 'art' ], score: 1.73332 },
|
|
* // { suggestion: 'zen art', terms: [ 'zen', 'art' ], score: 1.21313 }
|
|
* // ]
|
|
* ```
|
|
*
|
|
* ### Fuzzy suggestions:
|
|
*
|
|
* ```javascript
|
|
* // Correct spelling mistakes using fuzzy search:
|
|
* miniSearch.autoSuggest('neromancer', { fuzzy: 0.2 })
|
|
* // => [ { suggestion: 'neuromancer', terms: [ 'neuromancer' ], score: 1.03998 } ]
|
|
* ```
|
|
*
|
|
* ### Filtering:
|
|
*
|
|
* ```javascript
|
|
* // Get suggestions for 'zen ar', but only within the 'fiction' category
|
|
* // (assuming that 'category' is a stored field):
|
|
* miniSearch.autoSuggest('zen ar', {
|
|
* filter: (result) => result.category === 'fiction'
|
|
* })
|
|
* // => [
|
|
* // { suggestion: 'zen archery art', terms: [ 'zen', 'archery', 'art' ], score: 1.73332 },
|
|
* // { suggestion: 'zen art', terms: [ 'zen', 'art' ], score: 1.21313 }
|
|
* // ]
|
|
* ```
|
|
*
|
|
* @param queryString Query string to be expanded into suggestions
|
|
* @param options Search options. The supported options and default values
|
|
* are the same as for the {@link MiniSearch#search} method, except that by
|
|
* default prefix search is performed on the last term in the query, and terms
|
|
* are combined with `'AND'`.
|
|
* @return A sorted array of suggestions sorted by relevance score.
|
|
*/
|
|
autoSuggest(queryString, options = {}) {
|
|
options = { ...this._options.autoSuggestOptions, ...options };
|
|
const suggestions = /* @__PURE__ */ new Map();
|
|
for (const { score, terms } of this.search(queryString, options)) {
|
|
const phrase = terms.join(" ");
|
|
const suggestion = suggestions.get(phrase);
|
|
if (suggestion != null) {
|
|
suggestion.score += score;
|
|
suggestion.count += 1;
|
|
} else {
|
|
suggestions.set(phrase, { score, terms, count: 1 });
|
|
}
|
|
}
|
|
const results = [];
|
|
for (const [suggestion, { score, terms, count }] of suggestions) {
|
|
results.push({ suggestion, terms, score: score / count });
|
|
}
|
|
results.sort(byScore);
|
|
return results;
|
|
}
|
|
/**
|
|
* Total number of documents available to search
|
|
*/
|
|
get documentCount() {
|
|
return this._documentCount;
|
|
}
|
|
/**
|
|
* Number of terms in the index
|
|
*/
|
|
get termCount() {
|
|
return this._index.size;
|
|
}
|
|
/**
|
|
* Deserializes a JSON index (serialized with `JSON.stringify(miniSearch)`)
|
|
* and instantiates a MiniSearch instance. It should be given the same options
|
|
* originally used when serializing the index.
|
|
*
|
|
* ### Usage:
|
|
*
|
|
* ```javascript
|
|
* // If the index was serialized with:
|
|
* let miniSearch = new MiniSearch({ fields: ['title', 'text'] })
|
|
* miniSearch.addAll(documents)
|
|
*
|
|
* const json = JSON.stringify(miniSearch)
|
|
* // It can later be deserialized like this:
|
|
* miniSearch = MiniSearch.loadJSON(json, { fields: ['title', 'text'] })
|
|
* ```
|
|
*
|
|
* @param json JSON-serialized index
|
|
* @param options configuration options, same as the constructor
|
|
* @return An instance of MiniSearch deserialized from the given JSON.
|
|
*/
|
|
static loadJSON(json, options) {
|
|
if (options == null) {
|
|
throw new Error("MiniSearch: loadJSON should be given the same options used when serializing the index");
|
|
}
|
|
return this.loadJS(JSON.parse(json), options);
|
|
}
|
|
/**
|
|
* Async equivalent of {@link MiniSearch.loadJSON}
|
|
*
|
|
* This function is an alternative to {@link MiniSearch.loadJSON} that returns
|
|
* a promise, and loads the index in batches, leaving pauses between them to avoid
|
|
* blocking the main thread. It tends to be slower than the synchronous
|
|
* version, but does not block the main thread, so it can be a better choice
|
|
* when deserializing very large indexes.
|
|
*
|
|
* @param json JSON-serialized index
|
|
* @param options configuration options, same as the constructor
|
|
* @return A Promise that will resolve to an instance of MiniSearch deserialized from the given JSON.
|
|
*/
|
|
static async loadJSONAsync(json, options) {
|
|
if (options == null) {
|
|
throw new Error("MiniSearch: loadJSON should be given the same options used when serializing the index");
|
|
}
|
|
return this.loadJSAsync(JSON.parse(json), options);
|
|
}
|
|
/**
|
|
* Returns the default value of an option. It will throw an error if no option
|
|
* with the given name exists.
|
|
*
|
|
* @param optionName Name of the option
|
|
* @return The default value of the given option
|
|
*
|
|
* ### Usage:
|
|
*
|
|
* ```javascript
|
|
* // Get default tokenizer
|
|
* MiniSearch.getDefault('tokenize')
|
|
*
|
|
* // Get default term processor
|
|
* MiniSearch.getDefault('processTerm')
|
|
*
|
|
* // Unknown options will throw an error
|
|
* MiniSearch.getDefault('notExisting')
|
|
* // => throws 'MiniSearch: unknown option "notExisting"'
|
|
* ```
|
|
*/
|
|
static getDefault(optionName) {
|
|
if (defaultOptions.hasOwnProperty(optionName)) {
|
|
return getOwnProperty(defaultOptions, optionName);
|
|
} else {
|
|
throw new Error(`MiniSearch: unknown option "${optionName}"`);
|
|
}
|
|
}
|
|
/**
|
|
* @ignore
|
|
*/
|
|
static loadJS(js, options) {
|
|
const { index, documentIds, fieldLength, storedFields, serializationVersion } = js;
|
|
const miniSearch = this.instantiateMiniSearch(js, options);
|
|
miniSearch._documentIds = objectToNumericMap(documentIds);
|
|
miniSearch._fieldLength = objectToNumericMap(fieldLength);
|
|
miniSearch._storedFields = objectToNumericMap(storedFields);
|
|
for (const [shortId, id] of miniSearch._documentIds) {
|
|
miniSearch._idToShortId.set(id, shortId);
|
|
}
|
|
for (const [term, data] of index) {
|
|
const dataMap = /* @__PURE__ */ new Map();
|
|
for (const fieldId of Object.keys(data)) {
|
|
let indexEntry = data[fieldId];
|
|
if (serializationVersion === 1) {
|
|
indexEntry = indexEntry.ds;
|
|
}
|
|
dataMap.set(parseInt(fieldId, 10), objectToNumericMap(indexEntry));
|
|
}
|
|
miniSearch._index.set(term, dataMap);
|
|
}
|
|
return miniSearch;
|
|
}
|
|
/**
|
|
* @ignore
|
|
*/
|
|
static async loadJSAsync(js, options) {
|
|
const { index, documentIds, fieldLength, storedFields, serializationVersion } = js;
|
|
const miniSearch = this.instantiateMiniSearch(js, options);
|
|
miniSearch._documentIds = await objectToNumericMapAsync(documentIds);
|
|
miniSearch._fieldLength = await objectToNumericMapAsync(fieldLength);
|
|
miniSearch._storedFields = await objectToNumericMapAsync(storedFields);
|
|
for (const [shortId, id] of miniSearch._documentIds) {
|
|
miniSearch._idToShortId.set(id, shortId);
|
|
}
|
|
let count = 0;
|
|
for (const [term, data] of index) {
|
|
const dataMap = /* @__PURE__ */ new Map();
|
|
for (const fieldId of Object.keys(data)) {
|
|
let indexEntry = data[fieldId];
|
|
if (serializationVersion === 1) {
|
|
indexEntry = indexEntry.ds;
|
|
}
|
|
dataMap.set(parseInt(fieldId, 10), await objectToNumericMapAsync(indexEntry));
|
|
}
|
|
if (++count % 1e3 === 0)
|
|
await wait(0);
|
|
miniSearch._index.set(term, dataMap);
|
|
}
|
|
return miniSearch;
|
|
}
|
|
/**
|
|
* @ignore
|
|
*/
|
|
static instantiateMiniSearch(js, options) {
|
|
const { documentCount, nextId, fieldIds, averageFieldLength, dirtCount, serializationVersion } = js;
|
|
if (serializationVersion !== 1 && serializationVersion !== 2) {
|
|
throw new Error("MiniSearch: cannot deserialize an index created with an incompatible version");
|
|
}
|
|
const miniSearch = new MiniSearch(options);
|
|
miniSearch._documentCount = documentCount;
|
|
miniSearch._nextId = nextId;
|
|
miniSearch._idToShortId = /* @__PURE__ */ new Map();
|
|
miniSearch._fieldIds = fieldIds;
|
|
miniSearch._avgFieldLength = averageFieldLength;
|
|
miniSearch._dirtCount = dirtCount || 0;
|
|
miniSearch._index = new SearchableMap();
|
|
return miniSearch;
|
|
}
|
|
/**
|
|
* @ignore
|
|
*/
|
|
executeQuery(query, searchOptions = {}) {
|
|
if (query === MiniSearch.wildcard) {
|
|
return this.executeWildcardQuery(searchOptions);
|
|
}
|
|
if (typeof query !== "string") {
|
|
const options2 = { ...searchOptions, ...query, queries: void 0 };
|
|
const results2 = query.queries.map((subquery) => this.executeQuery(subquery, options2));
|
|
return this.combineResults(results2, options2.combineWith);
|
|
}
|
|
const { tokenize, processTerm, searchOptions: globalSearchOptions } = this._options;
|
|
const options = { tokenize, processTerm, ...globalSearchOptions, ...searchOptions };
|
|
const { tokenize: searchTokenize, processTerm: searchProcessTerm } = options;
|
|
const terms = searchTokenize(query).flatMap((term) => searchProcessTerm(term)).filter((term) => !!term);
|
|
const queries = terms.map(termToQuerySpec(options));
|
|
const results = queries.map((query2) => this.executeQuerySpec(query2, options));
|
|
return this.combineResults(results, options.combineWith);
|
|
}
|
|
/**
|
|
* @ignore
|
|
*/
|
|
executeQuerySpec(query, searchOptions) {
|
|
const options = { ...this._options.searchOptions, ...searchOptions };
|
|
const boosts = (options.fields || this._options.fields).reduce((boosts2, field) => ({ ...boosts2, [field]: getOwnProperty(options.boost, field) || 1 }), {});
|
|
const { boostDocument, weights, maxFuzzy, bm25: bm25params } = options;
|
|
const { fuzzy: fuzzyWeight, prefix: prefixWeight } = { ...defaultSearchOptions.weights, ...weights };
|
|
const data = this._index.get(query.term);
|
|
const results = this.termResults(query.term, query.term, 1, query.termBoost, data, boosts, boostDocument, bm25params);
|
|
let prefixMatches;
|
|
let fuzzyMatches;
|
|
if (query.prefix) {
|
|
prefixMatches = this._index.atPrefix(query.term);
|
|
}
|
|
if (query.fuzzy) {
|
|
const fuzzy = query.fuzzy === true ? 0.2 : query.fuzzy;
|
|
const maxDistance = fuzzy < 1 ? Math.min(maxFuzzy, Math.round(query.term.length * fuzzy)) : fuzzy;
|
|
if (maxDistance)
|
|
fuzzyMatches = this._index.fuzzyGet(query.term, maxDistance);
|
|
}
|
|
if (prefixMatches) {
|
|
for (const [term, data2] of prefixMatches) {
|
|
const distance = term.length - query.term.length;
|
|
if (!distance) {
|
|
continue;
|
|
}
|
|
fuzzyMatches === null || fuzzyMatches === void 0 ? void 0 : fuzzyMatches.delete(term);
|
|
const weight = prefixWeight * term.length / (term.length + 0.3 * distance);
|
|
this.termResults(query.term, term, weight, query.termBoost, data2, boosts, boostDocument, bm25params, results);
|
|
}
|
|
}
|
|
if (fuzzyMatches) {
|
|
for (const term of fuzzyMatches.keys()) {
|
|
const [data2, distance] = fuzzyMatches.get(term);
|
|
if (!distance) {
|
|
continue;
|
|
}
|
|
const weight = fuzzyWeight * term.length / (term.length + distance);
|
|
this.termResults(query.term, term, weight, query.termBoost, data2, boosts, boostDocument, bm25params, results);
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
/**
|
|
* @ignore
|
|
*/
|
|
executeWildcardQuery(searchOptions) {
|
|
const results = /* @__PURE__ */ new Map();
|
|
const options = { ...this._options.searchOptions, ...searchOptions };
|
|
for (const [shortId, id] of this._documentIds) {
|
|
const score = options.boostDocument ? options.boostDocument(id, "", this._storedFields.get(shortId)) : 1;
|
|
results.set(shortId, {
|
|
score,
|
|
terms: [],
|
|
match: {}
|
|
});
|
|
}
|
|
return results;
|
|
}
|
|
/**
|
|
* @ignore
|
|
*/
|
|
combineResults(results, combineWith = OR) {
|
|
if (results.length === 0) {
|
|
return /* @__PURE__ */ new Map();
|
|
}
|
|
const operator = combineWith.toLowerCase();
|
|
const combinator = combinators[operator];
|
|
if (!combinator) {
|
|
throw new Error(`Invalid combination operator: ${combineWith}`);
|
|
}
|
|
return results.reduce(combinator) || /* @__PURE__ */ new Map();
|
|
}
|
|
/**
|
|
* Allows serialization of the index to JSON, to possibly store it and later
|
|
* deserialize it with {@link MiniSearch.loadJSON}.
|
|
*
|
|
* Normally one does not directly call this method, but rather call the
|
|
* standard JavaScript `JSON.stringify()` passing the {@link MiniSearch}
|
|
* instance, and JavaScript will internally call this method. Upon
|
|
* deserialization, one must pass to {@link MiniSearch.loadJSON} the same
|
|
* options used to create the original instance that was serialized.
|
|
*
|
|
* ### Usage:
|
|
*
|
|
* ```javascript
|
|
* // Serialize the index:
|
|
* let miniSearch = new MiniSearch({ fields: ['title', 'text'] })
|
|
* miniSearch.addAll(documents)
|
|
* const json = JSON.stringify(miniSearch)
|
|
*
|
|
* // Later, to deserialize it:
|
|
* miniSearch = MiniSearch.loadJSON(json, { fields: ['title', 'text'] })
|
|
* ```
|
|
*
|
|
* @return A plain-object serializable representation of the search index.
|
|
*/
|
|
toJSON() {
|
|
const index = [];
|
|
for (const [term, fieldIndex] of this._index) {
|
|
const data = {};
|
|
for (const [fieldId, freqs] of fieldIndex) {
|
|
data[fieldId] = Object.fromEntries(freqs);
|
|
}
|
|
index.push([term, data]);
|
|
}
|
|
return {
|
|
documentCount: this._documentCount,
|
|
nextId: this._nextId,
|
|
documentIds: Object.fromEntries(this._documentIds),
|
|
fieldIds: this._fieldIds,
|
|
fieldLength: Object.fromEntries(this._fieldLength),
|
|
averageFieldLength: this._avgFieldLength,
|
|
storedFields: Object.fromEntries(this._storedFields),
|
|
dirtCount: this._dirtCount,
|
|
index,
|
|
serializationVersion: 2
|
|
};
|
|
}
|
|
/**
|
|
* @ignore
|
|
*/
|
|
termResults(sourceTerm, derivedTerm, termWeight, termBoost, fieldTermData, fieldBoosts, boostDocumentFn, bm25params, results = /* @__PURE__ */ new Map()) {
|
|
if (fieldTermData == null)
|
|
return results;
|
|
for (const field of Object.keys(fieldBoosts)) {
|
|
const fieldBoost = fieldBoosts[field];
|
|
const fieldId = this._fieldIds[field];
|
|
const fieldTermFreqs = fieldTermData.get(fieldId);
|
|
if (fieldTermFreqs == null)
|
|
continue;
|
|
let matchingFields = fieldTermFreqs.size;
|
|
const avgFieldLength = this._avgFieldLength[fieldId];
|
|
for (const docId of fieldTermFreqs.keys()) {
|
|
if (!this._documentIds.has(docId)) {
|
|
this.removeTerm(fieldId, docId, derivedTerm);
|
|
matchingFields -= 1;
|
|
continue;
|
|
}
|
|
const docBoost = boostDocumentFn ? boostDocumentFn(this._documentIds.get(docId), derivedTerm, this._storedFields.get(docId)) : 1;
|
|
if (!docBoost)
|
|
continue;
|
|
const termFreq = fieldTermFreqs.get(docId);
|
|
const fieldLength = this._fieldLength.get(docId)[fieldId];
|
|
const rawScore = calcBM25Score(termFreq, matchingFields, this._documentCount, fieldLength, avgFieldLength, bm25params);
|
|
const weightedScore = termWeight * termBoost * fieldBoost * docBoost * rawScore;
|
|
const result = results.get(docId);
|
|
if (result) {
|
|
result.score += weightedScore;
|
|
assignUniqueTerm(result.terms, sourceTerm);
|
|
const match = getOwnProperty(result.match, derivedTerm);
|
|
if (match) {
|
|
match.push(field);
|
|
} else {
|
|
result.match[derivedTerm] = [field];
|
|
}
|
|
} else {
|
|
results.set(docId, {
|
|
score: weightedScore,
|
|
terms: [sourceTerm],
|
|
match: { [derivedTerm]: [field] }
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
/**
|
|
* @ignore
|
|
*/
|
|
addTerm(fieldId, documentId, term) {
|
|
const indexData = this._index.fetch(term, createMap);
|
|
let fieldIndex = indexData.get(fieldId);
|
|
if (fieldIndex == null) {
|
|
fieldIndex = /* @__PURE__ */ new Map();
|
|
fieldIndex.set(documentId, 1);
|
|
indexData.set(fieldId, fieldIndex);
|
|
} else {
|
|
const docs = fieldIndex.get(documentId);
|
|
fieldIndex.set(documentId, (docs || 0) + 1);
|
|
}
|
|
}
|
|
/**
|
|
* @ignore
|
|
*/
|
|
removeTerm(fieldId, documentId, term) {
|
|
if (!this._index.has(term)) {
|
|
this.warnDocumentChanged(documentId, fieldId, term);
|
|
return;
|
|
}
|
|
const indexData = this._index.fetch(term, createMap);
|
|
const fieldIndex = indexData.get(fieldId);
|
|
if (fieldIndex == null || fieldIndex.get(documentId) == null) {
|
|
this.warnDocumentChanged(documentId, fieldId, term);
|
|
} else if (fieldIndex.get(documentId) <= 1) {
|
|
if (fieldIndex.size <= 1) {
|
|
indexData.delete(fieldId);
|
|
} else {
|
|
fieldIndex.delete(documentId);
|
|
}
|
|
} else {
|
|
fieldIndex.set(documentId, fieldIndex.get(documentId) - 1);
|
|
}
|
|
if (this._index.get(term).size === 0) {
|
|
this._index.delete(term);
|
|
}
|
|
}
|
|
/**
|
|
* @ignore
|
|
*/
|
|
warnDocumentChanged(shortDocumentId, fieldId, term) {
|
|
for (const fieldName of Object.keys(this._fieldIds)) {
|
|
if (this._fieldIds[fieldName] === fieldId) {
|
|
this._options.logger("warn", `MiniSearch: document with ID ${this._documentIds.get(shortDocumentId)} has changed before removal: term "${term}" was not present in field "${fieldName}". Removing a document after it has changed can corrupt the index!`, "version_conflict");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* @ignore
|
|
*/
|
|
addDocumentId(documentId) {
|
|
const shortDocumentId = this._nextId;
|
|
this._idToShortId.set(documentId, shortDocumentId);
|
|
this._documentIds.set(shortDocumentId, documentId);
|
|
this._documentCount += 1;
|
|
this._nextId += 1;
|
|
return shortDocumentId;
|
|
}
|
|
/**
|
|
* @ignore
|
|
*/
|
|
addFields(fields) {
|
|
for (let i = 0; i < fields.length; i++) {
|
|
this._fieldIds[fields[i]] = i;
|
|
}
|
|
}
|
|
/**
|
|
* @ignore
|
|
*/
|
|
addFieldLength(documentId, fieldId, count, length) {
|
|
let fieldLengths = this._fieldLength.get(documentId);
|
|
if (fieldLengths == null)
|
|
this._fieldLength.set(documentId, fieldLengths = []);
|
|
fieldLengths[fieldId] = length;
|
|
const averageFieldLength = this._avgFieldLength[fieldId] || 0;
|
|
const totalFieldLength = averageFieldLength * count + length;
|
|
this._avgFieldLength[fieldId] = totalFieldLength / (count + 1);
|
|
}
|
|
/**
|
|
* @ignore
|
|
*/
|
|
removeFieldLength(documentId, fieldId, count, length) {
|
|
if (count === 1) {
|
|
this._avgFieldLength[fieldId] = 0;
|
|
return;
|
|
}
|
|
const totalFieldLength = this._avgFieldLength[fieldId] * count - length;
|
|
this._avgFieldLength[fieldId] = totalFieldLength / (count - 1);
|
|
}
|
|
/**
|
|
* @ignore
|
|
*/
|
|
saveStoredFields(documentId, doc) {
|
|
const { storeFields, extractField } = this._options;
|
|
if (storeFields == null || storeFields.length === 0) {
|
|
return;
|
|
}
|
|
let documentFields = this._storedFields.get(documentId);
|
|
if (documentFields == null)
|
|
this._storedFields.set(documentId, documentFields = {});
|
|
for (const fieldName of storeFields) {
|
|
const fieldValue = extractField(doc, fieldName);
|
|
if (fieldValue !== void 0)
|
|
documentFields[fieldName] = fieldValue;
|
|
}
|
|
}
|
|
}
|
|
MiniSearch.wildcard = Symbol("*");
|
|
const getOwnProperty = (object, property) => Object.prototype.hasOwnProperty.call(object, property) ? object[property] : void 0;
|
|
const combinators = {
|
|
[OR]: (a, b) => {
|
|
for (const docId of b.keys()) {
|
|
const existing = a.get(docId);
|
|
if (existing == null) {
|
|
a.set(docId, b.get(docId));
|
|
} else {
|
|
const { score, terms, match } = b.get(docId);
|
|
existing.score = existing.score + score;
|
|
existing.match = Object.assign(existing.match, match);
|
|
assignUniqueTerms(existing.terms, terms);
|
|
}
|
|
}
|
|
return a;
|
|
},
|
|
[AND]: (a, b) => {
|
|
const combined = /* @__PURE__ */ new Map();
|
|
for (const docId of b.keys()) {
|
|
const existing = a.get(docId);
|
|
if (existing == null)
|
|
continue;
|
|
const { score, terms, match } = b.get(docId);
|
|
assignUniqueTerms(existing.terms, terms);
|
|
combined.set(docId, {
|
|
score: existing.score + score,
|
|
terms: existing.terms,
|
|
match: Object.assign(existing.match, match)
|
|
});
|
|
}
|
|
return combined;
|
|
},
|
|
[AND_NOT]: (a, b) => {
|
|
for (const docId of b.keys())
|
|
a.delete(docId);
|
|
return a;
|
|
}
|
|
};
|
|
const defaultBM25params = { k: 1.2, b: 0.7, d: 0.5 };
|
|
const calcBM25Score = (termFreq, matchingCount, totalCount, fieldLength, avgFieldLength, bm25params) => {
|
|
const { k, b, d } = bm25params;
|
|
const invDocFreq = Math.log(1 + (totalCount - matchingCount + 0.5) / (matchingCount + 0.5));
|
|
return invDocFreq * (d + termFreq * (k + 1) / (termFreq + k * (1 - b + b * fieldLength / avgFieldLength)));
|
|
};
|
|
const termToQuerySpec = (options) => (term, i, terms) => {
|
|
const fuzzy = typeof options.fuzzy === "function" ? options.fuzzy(term, i, terms) : options.fuzzy || false;
|
|
const prefix = typeof options.prefix === "function" ? options.prefix(term, i, terms) : options.prefix === true;
|
|
const termBoost = typeof options.boostTerm === "function" ? options.boostTerm(term, i, terms) : 1;
|
|
return { term, fuzzy, prefix, termBoost };
|
|
};
|
|
const defaultOptions = {
|
|
idField: "id",
|
|
extractField: (document2, fieldName) => document2[fieldName],
|
|
stringifyField: (fieldValue, fieldName) => fieldValue.toString(),
|
|
tokenize: (text) => text.split(SPACE_OR_PUNCTUATION),
|
|
processTerm: (term) => term.toLowerCase(),
|
|
fields: void 0,
|
|
searchOptions: void 0,
|
|
storeFields: [],
|
|
logger: (level, message) => {
|
|
if (typeof (console === null || console === void 0 ? void 0 : console[level]) === "function")
|
|
console[level](message);
|
|
},
|
|
autoVacuum: true
|
|
};
|
|
const defaultSearchOptions = {
|
|
combineWith: OR,
|
|
prefix: false,
|
|
fuzzy: false,
|
|
maxFuzzy: 6,
|
|
boost: {},
|
|
weights: { fuzzy: 0.45, prefix: 0.375 },
|
|
bm25: defaultBM25params
|
|
};
|
|
const defaultAutoSuggestOptions = {
|
|
combineWith: AND,
|
|
prefix: (term, i, terms) => i === terms.length - 1
|
|
};
|
|
const defaultVacuumOptions = { batchSize: 1e3, batchWait: 10 };
|
|
const defaultVacuumConditions = { minDirtFactor: 0.1, minDirtCount: 20 };
|
|
const defaultAutoVacuumOptions = { ...defaultVacuumOptions, ...defaultVacuumConditions };
|
|
const assignUniqueTerm = (target, term) => {
|
|
if (!target.includes(term))
|
|
target.push(term);
|
|
};
|
|
const assignUniqueTerms = (target, source) => {
|
|
for (const term of source) {
|
|
if (!target.includes(term))
|
|
target.push(term);
|
|
}
|
|
};
|
|
const byScore = ({ score: a }, { score: b }) => b - a;
|
|
const createMap = () => /* @__PURE__ */ new Map();
|
|
const objectToNumericMap = (object) => {
|
|
const map = /* @__PURE__ */ new Map();
|
|
for (const key of Object.keys(object)) {
|
|
map.set(parseInt(key, 10), object[key]);
|
|
}
|
|
return map;
|
|
};
|
|
const objectToNumericMapAsync = async (object) => {
|
|
const map = /* @__PURE__ */ new Map();
|
|
let count = 0;
|
|
for (const key of Object.keys(object)) {
|
|
map.set(parseInt(key, 10), object[key]);
|
|
if (++count % 1e3 === 0) {
|
|
await wait(0);
|
|
}
|
|
}
|
|
return map;
|
|
};
|
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
const SPACE_OR_PUNCTUATION = /[\n\r\p{Z}\p{P}]+/u;
|
|
class LRUCache {
|
|
constructor(max = 10) {
|
|
__publicField(this, "max");
|
|
__publicField(this, "cache");
|
|
this.max = max;
|
|
this.cache = /* @__PURE__ */ new Map();
|
|
}
|
|
get(key) {
|
|
let item = this.cache.get(key);
|
|
if (item !== void 0) {
|
|
this.cache.delete(key);
|
|
this.cache.set(key, item);
|
|
}
|
|
return item;
|
|
}
|
|
set(key, val) {
|
|
if (this.cache.has(key))
|
|
this.cache.delete(key);
|
|
else if (this.cache.size === this.max)
|
|
this.cache.delete(this.first());
|
|
this.cache.set(key, val);
|
|
}
|
|
first() {
|
|
return this.cache.keys().next().value;
|
|
}
|
|
clear() {
|
|
this.cache.clear();
|
|
}
|
|
}
|
|
const _hoisted_1 = ["aria-owns"];
|
|
const _hoisted_2 = { class: "shell" };
|
|
const _hoisted_3 = ["title"];
|
|
const _hoisted_4 = { class: "search-actions before" };
|
|
const _hoisted_5 = ["title"];
|
|
const _hoisted_6 = ["aria-activedescendant", "aria-controls", "placeholder"];
|
|
const _hoisted_7 = { class: "search-actions" };
|
|
const _hoisted_8 = ["title"];
|
|
const _hoisted_9 = ["disabled", "title"];
|
|
const _hoisted_10 = ["id", "role", "aria-labelledby"];
|
|
const _hoisted_11 = ["id", "aria-selected"];
|
|
const _hoisted_12 = ["href", "aria-label", "onMouseenter", "onFocusin", "data-index"];
|
|
const _hoisted_13 = { class: "titles" };
|
|
const _hoisted_14 = ["innerHTML"];
|
|
const _hoisted_15 = { class: "title main" };
|
|
const _hoisted_16 = ["innerHTML"];
|
|
const _hoisted_17 = {
|
|
key: 0,
|
|
class: "excerpt-wrapper"
|
|
};
|
|
const _hoisted_18 = {
|
|
key: 0,
|
|
class: "excerpt",
|
|
inert: ""
|
|
};
|
|
const _hoisted_19 = ["innerHTML"];
|
|
const _hoisted_20 = {
|
|
key: 0,
|
|
class: "no-results"
|
|
};
|
|
const _hoisted_21 = { class: "search-keyboard-shortcuts" };
|
|
const _hoisted_22 = ["aria-label"];
|
|
const _hoisted_23 = ["aria-label"];
|
|
const _hoisted_24 = ["aria-label"];
|
|
const _hoisted_25 = ["aria-label"];
|
|
const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
__name: "VPLocalSearchBox",
|
|
emits: ["close"],
|
|
setup(__props, { emit: __emit }) {
|
|
var _a, _b;
|
|
const emit = __emit;
|
|
const el = shallowRef();
|
|
const resultsEl = shallowRef();
|
|
const searchIndexData = shallowRef(localSearchIndex);
|
|
const vitePressData = useData();
|
|
const { activate } = useFocusTrap(el, {
|
|
immediate: true,
|
|
allowOutsideClick: true,
|
|
clickOutsideDeactivates: true,
|
|
escapeDeactivates: true
|
|
});
|
|
const { localeIndex, theme } = vitePressData;
|
|
const searchIndex = computedAsync(
|
|
async () => {
|
|
var _a2, _b2, _c, _d, _e, _f, _g, _h, _i;
|
|
return markRaw(
|
|
MiniSearch.loadJSON(
|
|
(_c = await ((_b2 = (_a2 = searchIndexData.value)[localeIndex.value]) == null ? void 0 : _b2.call(_a2))) == null ? void 0 : _c.default,
|
|
{
|
|
fields: ["title", "titles", "text"],
|
|
storeFields: ["title", "titles"],
|
|
searchOptions: {
|
|
fuzzy: 0.2,
|
|
prefix: true,
|
|
boost: { title: 4, text: 2, titles: 1 },
|
|
...((_d = theme.value.search) == null ? void 0 : _d.provider) === "local" && ((_f = (_e = theme.value.search.options) == null ? void 0 : _e.miniSearch) == null ? void 0 : _f.searchOptions)
|
|
},
|
|
...((_g = theme.value.search) == null ? void 0 : _g.provider) === "local" && ((_i = (_h = theme.value.search.options) == null ? void 0 : _h.miniSearch) == null ? void 0 : _i.options)
|
|
}
|
|
)
|
|
);
|
|
}
|
|
);
|
|
const disableQueryPersistence = computed(() => {
|
|
var _a2, _b2;
|
|
return ((_a2 = theme.value.search) == null ? void 0 : _a2.provider) === "local" && ((_b2 = theme.value.search.options) == null ? void 0 : _b2.disableQueryPersistence) === true;
|
|
});
|
|
const filterText = disableQueryPersistence.value ? ref("") : useSessionStorage("vitepress:local-search-filter", "");
|
|
const showDetailedList = useLocalStorage(
|
|
"vitepress:local-search-detailed-list",
|
|
((_a = theme.value.search) == null ? void 0 : _a.provider) === "local" && ((_b = theme.value.search.options) == null ? void 0 : _b.detailedView) === true
|
|
);
|
|
const disableDetailedView = computed(() => {
|
|
var _a2, _b2, _c;
|
|
return ((_a2 = theme.value.search) == null ? void 0 : _a2.provider) === "local" && (((_b2 = theme.value.search.options) == null ? void 0 : _b2.disableDetailedView) === true || ((_c = theme.value.search.options) == null ? void 0 : _c.detailedView) === false);
|
|
});
|
|
const buttonText = computed(() => {
|
|
var _a2, _b2, _c, _d, _e, _f, _g;
|
|
const options = ((_a2 = theme.value.search) == null ? void 0 : _a2.options) ?? theme.value.algolia;
|
|
return ((_e = (_d = (_c = (_b2 = options == null ? void 0 : options.locales) == null ? void 0 : _b2[localeIndex.value]) == null ? void 0 : _c.translations) == null ? void 0 : _d.button) == null ? void 0 : _e.buttonText) || ((_g = (_f = options == null ? void 0 : options.translations) == null ? void 0 : _f.button) == null ? void 0 : _g.buttonText) || "Search";
|
|
});
|
|
watchEffect(() => {
|
|
if (disableDetailedView.value) {
|
|
showDetailedList.value = false;
|
|
}
|
|
});
|
|
const results = shallowRef([]);
|
|
const enableNoResults = ref(false);
|
|
watch(filterText, () => {
|
|
enableNoResults.value = false;
|
|
});
|
|
const mark = computedAsync(async () => {
|
|
if (!resultsEl.value) return;
|
|
return markRaw(new Mark2(resultsEl.value));
|
|
}, null);
|
|
const cache = new LRUCache(16);
|
|
watchDebounced(
|
|
() => [searchIndex.value, filterText.value, showDetailedList.value],
|
|
async ([index, filterTextValue, showDetailedListValue], old, onCleanup) => {
|
|
var _a2, _b2, _c, _d;
|
|
if ((old == null ? void 0 : old[0]) !== index) {
|
|
cache.clear();
|
|
}
|
|
let canceled = false;
|
|
onCleanup(() => {
|
|
canceled = true;
|
|
});
|
|
if (!index) return;
|
|
results.value = index.search(filterTextValue).slice(0, 16);
|
|
enableNoResults.value = true;
|
|
const mods = showDetailedListValue ? await Promise.all(results.value.map((r) => fetchExcerpt(r.id))) : [];
|
|
if (canceled) return;
|
|
for (const { id, mod } of mods) {
|
|
const mapId = id.slice(0, id.indexOf("#"));
|
|
let map = cache.get(mapId);
|
|
if (map) continue;
|
|
map = /* @__PURE__ */ new Map();
|
|
cache.set(mapId, map);
|
|
const comp = mod.default ?? mod;
|
|
if ((comp == null ? void 0 : comp.render) || (comp == null ? void 0 : comp.setup)) {
|
|
const app = createApp(comp);
|
|
app.config.warnHandler = () => {
|
|
};
|
|
app.provide(dataSymbol, vitePressData);
|
|
Object.defineProperties(app.config.globalProperties, {
|
|
$frontmatter: {
|
|
get() {
|
|
return vitePressData.frontmatter.value;
|
|
}
|
|
},
|
|
$params: {
|
|
get() {
|
|
return vitePressData.page.value.params;
|
|
}
|
|
}
|
|
});
|
|
const div = document.createElement("div");
|
|
app.mount(div);
|
|
const headings = div.querySelectorAll("h1, h2, h3, h4, h5, h6");
|
|
headings.forEach((el2) => {
|
|
var _a3;
|
|
const href = (_a3 = el2.querySelector("a")) == null ? void 0 : _a3.getAttribute("href");
|
|
const anchor = (href == null ? void 0 : href.startsWith("#")) && href.slice(1);
|
|
if (!anchor) return;
|
|
let html = "";
|
|
while ((el2 = el2.nextElementSibling) && !/^h[1-6]$/i.test(el2.tagName))
|
|
html += el2.outerHTML;
|
|
map.set(anchor, html);
|
|
});
|
|
app.unmount();
|
|
}
|
|
if (canceled) return;
|
|
}
|
|
const terms = /* @__PURE__ */ new Set();
|
|
results.value = results.value.map((r) => {
|
|
const [id, anchor] = r.id.split("#");
|
|
const map = cache.get(id);
|
|
const text = (map == null ? void 0 : map.get(anchor)) ?? "";
|
|
for (const term in r.match) {
|
|
terms.add(term);
|
|
}
|
|
return { ...r, text };
|
|
});
|
|
await nextTick();
|
|
if (canceled) return;
|
|
await new Promise((r) => {
|
|
var _a3;
|
|
(_a3 = mark.value) == null ? void 0 : _a3.unmark({
|
|
done: () => {
|
|
var _a4;
|
|
(_a4 = mark.value) == null ? void 0 : _a4.markRegExp(formMarkRegex(terms), { done: r });
|
|
}
|
|
});
|
|
});
|
|
const excerpts = ((_a2 = el.value) == null ? void 0 : _a2.querySelectorAll(".result .excerpt")) ?? [];
|
|
for (const excerpt of excerpts) {
|
|
(_b2 = excerpt.querySelector('mark[data-markjs="true"]')) == null ? void 0 : _b2.scrollIntoView({ block: "center" });
|
|
}
|
|
(_d = (_c = resultsEl.value) == null ? void 0 : _c.firstElementChild) == null ? void 0 : _d.scrollIntoView({ block: "start" });
|
|
},
|
|
{ debounce: 200, immediate: true }
|
|
);
|
|
async function fetchExcerpt(id) {
|
|
const file = pathToFile(id.slice(0, id.indexOf("#")));
|
|
try {
|
|
if (!file) throw new Error(`Cannot find file for id: ${id}`);
|
|
return { id, mod: await import(
|
|
/*@vite-ignore*/
|
|
file
|
|
) };
|
|
} catch (e) {
|
|
console.error(e);
|
|
return { id, mod: {} };
|
|
}
|
|
}
|
|
const searchInput = ref();
|
|
const disableReset = computed(() => {
|
|
var _a2;
|
|
return ((_a2 = filterText.value) == null ? void 0 : _a2.length) <= 0;
|
|
});
|
|
function focusSearchInput(select = true) {
|
|
var _a2, _b2;
|
|
(_a2 = searchInput.value) == null ? void 0 : _a2.focus();
|
|
select && ((_b2 = searchInput.value) == null ? void 0 : _b2.select());
|
|
}
|
|
onMounted(() => {
|
|
focusSearchInput();
|
|
});
|
|
function onSearchBarClick(event) {
|
|
if (event.pointerType === "mouse") {
|
|
focusSearchInput();
|
|
}
|
|
}
|
|
const selectedIndex = ref(-1);
|
|
const disableMouseOver = ref(true);
|
|
watch(results, (r) => {
|
|
selectedIndex.value = r.length ? 0 : -1;
|
|
scrollToSelectedResult();
|
|
});
|
|
function scrollToSelectedResult() {
|
|
nextTick(() => {
|
|
const selectedEl = document.querySelector(".result.selected");
|
|
selectedEl == null ? void 0 : selectedEl.scrollIntoView({ block: "nearest" });
|
|
});
|
|
}
|
|
onKeyStroke("ArrowUp", (event) => {
|
|
event.preventDefault();
|
|
selectedIndex.value--;
|
|
if (selectedIndex.value < 0) {
|
|
selectedIndex.value = results.value.length - 1;
|
|
}
|
|
disableMouseOver.value = true;
|
|
scrollToSelectedResult();
|
|
});
|
|
onKeyStroke("ArrowDown", (event) => {
|
|
event.preventDefault();
|
|
selectedIndex.value++;
|
|
if (selectedIndex.value >= results.value.length) {
|
|
selectedIndex.value = 0;
|
|
}
|
|
disableMouseOver.value = true;
|
|
scrollToSelectedResult();
|
|
});
|
|
const router = useRouter();
|
|
onKeyStroke("Enter", (e) => {
|
|
if (e.isComposing) return;
|
|
if (e.target instanceof HTMLButtonElement && e.target.type !== "submit")
|
|
return;
|
|
const selectedPackage = results.value[selectedIndex.value];
|
|
if (e.target instanceof HTMLInputElement && !selectedPackage) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
if (selectedPackage) {
|
|
router.go(selectedPackage.id);
|
|
emit("close");
|
|
}
|
|
});
|
|
onKeyStroke("Escape", () => {
|
|
emit("close");
|
|
});
|
|
const defaultTranslations = {
|
|
modal: {
|
|
displayDetails: "Display detailed list",
|
|
resetButtonTitle: "Reset search",
|
|
backButtonTitle: "Close search",
|
|
noResultsText: "No results for",
|
|
footer: {
|
|
selectText: "to select",
|
|
selectKeyAriaLabel: "enter",
|
|
navigateText: "to navigate",
|
|
navigateUpKeyAriaLabel: "up arrow",
|
|
navigateDownKeyAriaLabel: "down arrow",
|
|
closeText: "to close",
|
|
closeKeyAriaLabel: "escape"
|
|
}
|
|
}
|
|
};
|
|
const translate = createSearchTranslate(defaultTranslations);
|
|
onMounted(() => {
|
|
window.history.pushState(null, "", null);
|
|
});
|
|
useEventListener("popstate", (event) => {
|
|
event.preventDefault();
|
|
emit("close");
|
|
});
|
|
const isLocked = useScrollLock(inBrowser ? document.body : null);
|
|
onMounted(() => {
|
|
nextTick(() => {
|
|
isLocked.value = true;
|
|
nextTick().then(() => activate());
|
|
});
|
|
});
|
|
onBeforeUnmount(() => {
|
|
isLocked.value = false;
|
|
});
|
|
function resetSearch() {
|
|
filterText.value = "";
|
|
nextTick().then(() => focusSearchInput(false));
|
|
}
|
|
function formMarkRegex(terms) {
|
|
return new RegExp(
|
|
[...terms].sort((a, b) => b.length - a.length).map((term) => `(${escapeRegExp(term)})`).join("|"),
|
|
"gi"
|
|
);
|
|
}
|
|
function onMouseMove(e) {
|
|
var _a2;
|
|
if (!disableMouseOver.value) return;
|
|
const el2 = (_a2 = e.target) == null ? void 0 : _a2.closest(".result");
|
|
const index = Number.parseInt(el2 == null ? void 0 : el2.dataset.index);
|
|
if (index >= 0 && index !== selectedIndex.value) {
|
|
selectedIndex.value = index;
|
|
}
|
|
disableMouseOver.value = false;
|
|
}
|
|
return (_ctx, _cache) => {
|
|
var _a2, _b2, _c, _d, _e;
|
|
return openBlock(), createBlock(Teleport, { to: "body" }, [
|
|
createBaseVNode("div", {
|
|
ref_key: "el",
|
|
ref: el,
|
|
role: "button",
|
|
"aria-owns": ((_a2 = results.value) == null ? void 0 : _a2.length) ? "localsearch-list" : void 0,
|
|
"aria-expanded": "true",
|
|
"aria-haspopup": "listbox",
|
|
"aria-labelledby": "localsearch-label",
|
|
class: "VPLocalSearchBox"
|
|
}, [
|
|
createBaseVNode("div", {
|
|
class: "backdrop",
|
|
onClick: _cache[0] || (_cache[0] = ($event) => _ctx.$emit("close"))
|
|
}),
|
|
createBaseVNode("div", _hoisted_2, [
|
|
createBaseVNode("form", {
|
|
class: "search-bar",
|
|
onPointerup: _cache[4] || (_cache[4] = ($event) => onSearchBarClick($event)),
|
|
onSubmit: _cache[5] || (_cache[5] = withModifiers(() => {
|
|
}, ["prevent"]))
|
|
}, [
|
|
createBaseVNode("label", {
|
|
title: buttonText.value,
|
|
id: "localsearch-label",
|
|
for: "localsearch-input"
|
|
}, [..._cache[7] || (_cache[7] = [
|
|
createBaseVNode("span", {
|
|
"aria-hidden": "true",
|
|
class: "vpi-search search-icon local-search-icon"
|
|
}, null, -1)
|
|
])], 8, _hoisted_3),
|
|
createBaseVNode("div", _hoisted_4, [
|
|
createBaseVNode("button", {
|
|
class: "back-button",
|
|
title: unref(translate)("modal.backButtonTitle"),
|
|
onClick: _cache[1] || (_cache[1] = ($event) => _ctx.$emit("close"))
|
|
}, [..._cache[8] || (_cache[8] = [
|
|
createBaseVNode("span", { class: "vpi-arrow-left local-search-icon" }, null, -1)
|
|
])], 8, _hoisted_5)
|
|
]),
|
|
withDirectives(createBaseVNode("input", {
|
|
ref_key: "searchInput",
|
|
ref: searchInput,
|
|
"onUpdate:modelValue": _cache[2] || (_cache[2] = ($event) => isRef(filterText) ? filterText.value = $event : null),
|
|
"aria-activedescendant": selectedIndex.value > -1 ? "localsearch-item-" + selectedIndex.value : void 0,
|
|
"aria-autocomplete": "both",
|
|
"aria-controls": ((_b2 = results.value) == null ? void 0 : _b2.length) ? "localsearch-list" : void 0,
|
|
"aria-labelledby": "localsearch-label",
|
|
autocapitalize: "off",
|
|
autocomplete: "off",
|
|
autocorrect: "off",
|
|
class: "search-input",
|
|
id: "localsearch-input",
|
|
enterkeyhint: "go",
|
|
maxlength: "64",
|
|
placeholder: buttonText.value,
|
|
spellcheck: "false",
|
|
type: "search"
|
|
}, null, 8, _hoisted_6), [
|
|
[vModelText, unref(filterText)]
|
|
]),
|
|
createBaseVNode("div", _hoisted_7, [
|
|
!disableDetailedView.value ? (openBlock(), createElementBlock("button", {
|
|
key: 0,
|
|
class: normalizeClass(["toggle-layout-button", { "detailed-list": unref(showDetailedList) }]),
|
|
type: "button",
|
|
title: unref(translate)("modal.displayDetails"),
|
|
onClick: _cache[3] || (_cache[3] = ($event) => selectedIndex.value > -1 && (showDetailedList.value = !unref(showDetailedList)))
|
|
}, [..._cache[9] || (_cache[9] = [
|
|
createBaseVNode("span", { class: "vpi-layout-list local-search-icon" }, null, -1)
|
|
])], 10, _hoisted_8)) : createCommentVNode("", true),
|
|
createBaseVNode("button", {
|
|
class: "clear-button",
|
|
type: "reset",
|
|
disabled: disableReset.value,
|
|
title: unref(translate)("modal.resetButtonTitle"),
|
|
onClick: resetSearch
|
|
}, [..._cache[10] || (_cache[10] = [
|
|
createBaseVNode("span", { class: "vpi-delete local-search-icon" }, null, -1)
|
|
])], 8, _hoisted_9)
|
|
])
|
|
], 32),
|
|
createBaseVNode("ul", {
|
|
ref_key: "resultsEl",
|
|
ref: resultsEl,
|
|
id: ((_c = results.value) == null ? void 0 : _c.length) ? "localsearch-list" : void 0,
|
|
role: ((_d = results.value) == null ? void 0 : _d.length) ? "listbox" : void 0,
|
|
"aria-labelledby": ((_e = results.value) == null ? void 0 : _e.length) ? "localsearch-label" : void 0,
|
|
class: "results",
|
|
onMousemove: onMouseMove
|
|
}, [
|
|
(openBlock(true), createElementBlock(Fragment, null, renderList(results.value, (p, index) => {
|
|
return openBlock(), createElementBlock("li", {
|
|
key: p.id,
|
|
id: "localsearch-item-" + index,
|
|
"aria-selected": selectedIndex.value === index ? "true" : "false",
|
|
role: "option"
|
|
}, [
|
|
createBaseVNode("a", {
|
|
href: p.id,
|
|
class: normalizeClass(["result", {
|
|
selected: selectedIndex.value === index
|
|
}]),
|
|
"aria-label": [...p.titles, p.title].join(" > "),
|
|
onMouseenter: ($event) => !disableMouseOver.value && (selectedIndex.value = index),
|
|
onFocusin: ($event) => selectedIndex.value = index,
|
|
onClick: _cache[6] || (_cache[6] = ($event) => _ctx.$emit("close")),
|
|
"data-index": index
|
|
}, [
|
|
createBaseVNode("div", null, [
|
|
createBaseVNode("div", _hoisted_13, [
|
|
_cache[12] || (_cache[12] = createBaseVNode("span", { class: "title-icon" }, "#", -1)),
|
|
(openBlock(true), createElementBlock(Fragment, null, renderList(p.titles, (t, index2) => {
|
|
return openBlock(), createElementBlock("span", {
|
|
key: index2,
|
|
class: "title"
|
|
}, [
|
|
createBaseVNode("span", {
|
|
class: "text",
|
|
innerHTML: t
|
|
}, null, 8, _hoisted_14),
|
|
_cache[11] || (_cache[11] = createBaseVNode("span", { class: "vpi-chevron-right local-search-icon" }, null, -1))
|
|
]);
|
|
}), 128)),
|
|
createBaseVNode("span", _hoisted_15, [
|
|
createBaseVNode("span", {
|
|
class: "text",
|
|
innerHTML: p.title
|
|
}, null, 8, _hoisted_16)
|
|
])
|
|
]),
|
|
unref(showDetailedList) ? (openBlock(), createElementBlock("div", _hoisted_17, [
|
|
p.text ? (openBlock(), createElementBlock("div", _hoisted_18, [
|
|
createBaseVNode("div", {
|
|
class: "vp-doc",
|
|
innerHTML: p.text
|
|
}, null, 8, _hoisted_19)
|
|
])) : createCommentVNode("", true),
|
|
_cache[13] || (_cache[13] = createBaseVNode("div", { class: "excerpt-gradient-bottom" }, null, -1)),
|
|
_cache[14] || (_cache[14] = createBaseVNode("div", { class: "excerpt-gradient-top" }, null, -1))
|
|
])) : createCommentVNode("", true)
|
|
])
|
|
], 42, _hoisted_12)
|
|
], 8, _hoisted_11);
|
|
}), 128)),
|
|
unref(filterText) && !results.value.length && enableNoResults.value ? (openBlock(), createElementBlock("li", _hoisted_20, [
|
|
createTextVNode(toDisplayString(unref(translate)("modal.noResultsText")) + ' "', 1),
|
|
createBaseVNode("strong", null, toDisplayString(unref(filterText)), 1),
|
|
_cache[15] || (_cache[15] = createTextVNode('" ', -1))
|
|
])) : createCommentVNode("", true)
|
|
], 40, _hoisted_10),
|
|
createBaseVNode("div", _hoisted_21, [
|
|
createBaseVNode("span", null, [
|
|
createBaseVNode("kbd", {
|
|
"aria-label": unref(translate)("modal.footer.navigateUpKeyAriaLabel")
|
|
}, [..._cache[16] || (_cache[16] = [
|
|
createBaseVNode("span", { class: "vpi-arrow-up navigate-icon" }, null, -1)
|
|
])], 8, _hoisted_22),
|
|
createBaseVNode("kbd", {
|
|
"aria-label": unref(translate)("modal.footer.navigateDownKeyAriaLabel")
|
|
}, [..._cache[17] || (_cache[17] = [
|
|
createBaseVNode("span", { class: "vpi-arrow-down navigate-icon" }, null, -1)
|
|
])], 8, _hoisted_23),
|
|
createTextVNode(" " + toDisplayString(unref(translate)("modal.footer.navigateText")), 1)
|
|
]),
|
|
createBaseVNode("span", null, [
|
|
createBaseVNode("kbd", {
|
|
"aria-label": unref(translate)("modal.footer.selectKeyAriaLabel")
|
|
}, [..._cache[18] || (_cache[18] = [
|
|
createBaseVNode("span", { class: "vpi-corner-down-left navigate-icon" }, null, -1)
|
|
])], 8, _hoisted_24),
|
|
createTextVNode(" " + toDisplayString(unref(translate)("modal.footer.selectText")), 1)
|
|
]),
|
|
createBaseVNode("span", null, [
|
|
createBaseVNode("kbd", {
|
|
"aria-label": unref(translate)("modal.footer.closeKeyAriaLabel")
|
|
}, "esc", 8, _hoisted_25),
|
|
createTextVNode(" " + toDisplayString(unref(translate)("modal.footer.closeText")), 1)
|
|
])
|
|
])
|
|
])
|
|
], 8, _hoisted_1)
|
|
]);
|
|
};
|
|
}
|
|
});
|
|
const VPLocalSearchBox = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-ce626c7c"]]);
|
|
export {
|
|
VPLocalSearchBox as default
|
|
};
|