blob: 98cf2502ea6376875dcf6ff6d65799bfc669a5b0 [file] [log] [blame]
/**
* @license
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type {
Matcher,
RangeSelector,
Selector,
} from '@apache-annotator/selector';
import { ownerDocument } from '../owner-document';
import { toRange } from '../range-node-conversion';
import { cartesian } from './cartesian';
/**
* Find the range(s) corresponding to the given {@link RangeSelector}.
*
* As a RangeSelector itself nests two further selectors, one needs to pass a
* `createMatcher` function that will be used to process those nested selectors.
*
* The function is curried, taking first the `createMatcher` function, then the
* selector, and then the scope.
*
* As there may be multiple matches for the start & end selectors, the resulting
* matcher will return an (async) iterable, that produces a match for each
* possible pair of matches of the nested selectors (except those where its end
* would precede its start). *(Note that this behaviour is a rather free
* interpretation of the Web Annotation Data Model spec, which is silent about
* the possibility of multiple matches for RangeSelectors)*
*
* @example
* By using a matcher for {@link TextQuoteSelector}s, one
* could create a matcher for text quotes with ellipsis to select a phrase
* “ipsum … amet,”:
* ```
* const selector = {
* type: 'RangeSelector',
* startSelector: {
* type: 'TextQuoteSelector',
* exact: 'ipsum ',
* },
* endSelector: {
* type: 'TextQuoteSelector',
* // Because the end of a RangeSelector is *exclusive*, we will present the
* // latter part of the quote as the *prefix* so it will be part of the
* // match.
* exact: '',
* prefix: ' amet,',
* }
* };
* const createRangeSelectorMatcher =
* makeCreateRangeSelectorMatcher(createTextQuoteMatcher);
* const match = createRangeSelectorMatcher(selector)(document.body);
* console.log(match)
* // ⇒ Range { startContainer: #text, startOffset: 6, endContainer: #text,
* // endOffset: 27, … }
* ```
*
* @example
* To support RangeSelectors that might themselves contain RangeSelectors,
* recursion can be created by supplying the resulting matcher creator function
* as the `createMatcher` parameter:
* ```
* const createWhicheverMatcher = (selector) => {
* const innerCreateMatcher = {
* TextQuoteSelector: createTextQuoteSelectorMatcher,
* TextPositionSelector: createTextPositionSelectorMatcher,
* RangeSelector: makeCreateRangeSelectorMatcher(createWhicheverMatcher),
* }[selector.type];
* return innerCreateMatcher(selector);
* });
* ```
*
* @param createMatcher - The function used to process nested selectors.
* @returns A function that, given a RangeSelector `selector`, creates a {@link
* Matcher} function that can apply it to a given `scope`.
*
* @public
*/
export function makeCreateRangeSelectorMatcher(
createMatcher: <T extends Selector, TMatch extends Node | Range>(selector: T)
=> Matcher<Node | Range, TMatch>,
): (selector: RangeSelector) => Matcher<Node | Range, Range> {
return function createRangeSelectorMatcher(selector) {
const startMatcher = createMatcher(selector.startSelector);
const endMatcher = createMatcher(selector.endSelector);
return async function* matchAll(scope) {
const startMatches = startMatcher(scope);
const endMatches = endMatcher(scope);
const pairs = cartesian(startMatches, endMatches);
for await (let [start, end] of pairs) {
start = toRange(start);
end = toRange(end);
const result = ownerDocument(scope).createRange();
result.setStart(start.startContainer, start.startOffset);
// Note that a RangeSelector’s match *excludes* the endSelector’s match,
// hence we take the end’s startContainer & startOffset.
result.setEnd(end.startContainer, end.startOffset);
if (!result.collapsed) yield result;
}
};
};
}