Merge branch 'allow-node-as-scope' (PR #110)
diff --git a/packages/dom/src/css.ts b/packages/dom/src/css.ts
index 1026004..2594374 100644
--- a/packages/dom/src/css.ts
+++ b/packages/dom/src/css.ts
@@ -21,6 +21,7 @@
import optimalSelect from 'optimal-select';
import type { CssSelector, Matcher } from '@apache-annotator/selector';
import { ownerDocument } from './owner-document';
+import { toRange } from './to-range';
/**
* Find the elements corresponding to the given {@link
@@ -45,21 +46,17 @@
* > “If […] the user agent discovers multiple matching text sequences, then the
* > selection SHOULD be treated as matching all of the matches.”
*
- * Each matching element is returned as a {@link https://developer.mozilla.org/en-US/docs/Web/API/Range
- * | Range} surrounding that element. This in order to make its output reusable
- * as the scope for any subsequents selectors that {@link
- * Selector.refinedBy | refine} this CssSelector.
- *
- * @param selector - The {@link CssSelector} to be anchored
- * @returns A {@link Matcher} function that applies `selector` to a given {@link https://developer.mozilla.org/en-US/docs/Web/API/Range
- * | Range}
+ * @param selector - The {@link CssSelector} to be anchored.
+ * @returns A {@link Matcher} function that applies `selector` to a given
+ * `scope`.
*
* @public
*/
export function createCssSelectorMatcher(
selector: CssSelector,
-): Matcher<Range, Range> {
+): Matcher<Node | Range, Element> {
return async function* matchAll(scope) {
+ scope = toRange(scope);
const document = ownerDocument(scope);
for (const element of document.querySelectorAll(selector.value)) {
const range = document.createRange();
@@ -69,7 +66,7 @@
scope.isPointInRange(range.startContainer, range.startOffset) &&
scope.isPointInRange(range.endContainer, range.endOffset)
) {
- yield range;
+ yield element;
}
}
};
diff --git a/packages/dom/src/owner-document.ts b/packages/dom/src/owner-document.ts
index 1c0621e..fe7818a 100644
--- a/packages/dom/src/owner-document.ts
+++ b/packages/dom/src/owner-document.ts
@@ -18,8 +18,19 @@
* under the License.
*/
-export function ownerDocument(range: Range): Document {
- const { startContainer } = range;
+/**
+ * Get the ownerDocument for either a range or a node.
+ *
+ * @param nodeOrRange the node or range for which to get the owner document.
+ */
+export function ownerDocument(nodeOrRange: Node | Range): Document {
+ const node = isRange(nodeOrRange)
+ ? nodeOrRange.startContainer
+ : nodeOrRange;
// node.ownerDocument is null iff node is itself a Document.
- return startContainer.ownerDocument ?? (startContainer as Document);
+ return node.ownerDocument ?? (node as Document);
+}
+
+function isRange(nodeOrRange: Node | Range): nodeOrRange is Range {
+ return ('startContainer' in nodeOrRange);
}
diff --git a/packages/dom/src/range/match.ts b/packages/dom/src/range/match.ts
index 061eb75..6ffece3 100644
--- a/packages/dom/src/range/match.ts
+++ b/packages/dom/src/range/match.ts
@@ -24,11 +24,11 @@
Selector,
} from '@apache-annotator/selector';
import { ownerDocument } from '../owner-document';
+import { toRange } from '../to-range';
import { cartesian } from './cartesian';
/**
- * Find the range(s) corresponding to the given {@link
- * RangeSelector}.
+ * 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.
@@ -36,13 +36,12 @@
* The function is curried, taking first the `createMatcher` function, then the
* selector, and then the scope.
*
- * As there may be multiple matches for a given selector, the matcher will
- * return an (async) generator that produces each match in the order they are
- * found in the text. If both its nested selectors produce multiple matches, the
- * RangeSelector matches each possible pair among those in which the order of
- * start and end are respected. *(Note this behaviour is a rather free
- * interpretation — the Web Annotation Data Model spec is silent about multiple
- * matches for RangeSelectors)*
+ * 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
@@ -88,15 +87,15 @@
* ```
*
* @param createMatcher - The function used to process nested selectors.
- * @returns A function that, given a RangeSelector, creates a {@link
- * Matcher} function that applies it to a given {@link https://developer.mozilla.org/en-US/docs/Web/API/Range
- * | Range}
+ * @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>(selector: T) => Matcher<Range, Range>,
-): (selector: RangeSelector) => Matcher<Range, Range> {
+ 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);
@@ -107,10 +106,14 @@
const pairs = cartesian(startMatches, endMatches);
- for await (const [start, end] of pairs) {
- const result = ownerDocument(scope).createRange();
+ 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;
diff --git a/packages/dom/src/text-node-chunker.ts b/packages/dom/src/text-node-chunker.ts
index dc916a6..e02ff71 100644
--- a/packages/dom/src/text-node-chunker.ts
+++ b/packages/dom/src/text-node-chunker.ts
@@ -21,6 +21,7 @@
import type { Chunk, Chunker, ChunkRange } from '@apache-annotator/selector';
import { normalizeRange } from './normalize-range';
import { ownerDocument } from './owner-document';
+import { toRange } from './to-range';
export interface PartialTextNode extends Chunk<string> {
readonly node: Text;
@@ -44,6 +45,7 @@
}
export class TextNodeChunker implements Chunker<PartialTextNode> {
+ private scope: Range;
private iter: NodeIterator;
get currentChunk(): PartialTextNode {
@@ -116,13 +118,14 @@
/**
* @param scope A Range that overlaps with at least one text node.
*/
- constructor(private scope: Range) {
+ constructor(scope: Node | Range) {
+ this.scope = toRange(scope);
this.iter = ownerDocument(scope).createNodeIterator(
- scope.commonAncestorContainer,
+ this.scope.commonAncestorContainer,
NodeFilter.SHOW_TEXT,
{
- acceptNode(node: Text) {
- return scope.intersectsNode(node)
+ acceptNode: (node: Text) => {
+ return this.scope.intersectsNode(node)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
diff --git a/packages/dom/src/text-position/describe.ts b/packages/dom/src/text-position/describe.ts
index bd2f42b..6e9566b 100644
--- a/packages/dom/src/text-position/describe.ts
+++ b/packages/dom/src/text-position/describe.ts
@@ -21,6 +21,7 @@
import type { TextPositionSelector } from '@apache-annotator/selector';
import { describeTextPosition as abstractDescribeTextPosition } from '@apache-annotator/selector';
import { ownerDocument } from '../owner-document';
+import { toRange } from '../to-range';
import { TextNodeChunker } from '../text-node-chunker';
/**
@@ -45,32 +46,24 @@
* // }
* ```
*
- * @param range - The range of characters that the selector should describe
- * @param maybeScope - A {@link https://developer.mozilla.org/en-US/docs/Web/API/Range
- * | Range} that serves as the ‘document’ for purposes of finding occurrences
- * and determining prefix and suffix. Defaults to span the full Document
- * containing the range.
+ * @param range - The {@link https://developer.mozilla.org/en-US/docs/Web/API/Range
+ * | Range} whose text content will be described.
+ * @param scope - A Node or Range that serves as the ‘document’ for purposes of
+ * finding occurrences and determining prefix and suffix. Defaults to span the
+ * full Document that contains the range.
* @returns The selector describing `range` within `scope`.
*
* @public
*/
export async function describeTextPosition(
range: Range,
- maybeScope?: Range,
+ scope?: Node | Range,
): Promise<TextPositionSelector> {
- // Default to search in the whole document.
- let scope: Range;
- if (maybeScope !== undefined) {
- scope = maybeScope;
- } else {
- const document = ownerDocument(range);
- scope = document.createRange();
- scope.selectNodeContents(document);
- }
+ scope = toRange(scope ?? ownerDocument(range))
const textChunks = new TextNodeChunker(scope);
if (textChunks.currentChunk === null)
- throw new RangeError('Range does not contain any Text nodes.');
+ throw new RangeError('Scope does not contain any Text nodes.');
return await abstractDescribeTextPosition(
textChunks.rangeToChunkRange(range),
diff --git a/packages/dom/src/text-position/match.ts b/packages/dom/src/text-position/match.ts
index 089def8..ac26caf 100644
--- a/packages/dom/src/text-position/match.ts
+++ b/packages/dom/src/text-position/match.ts
@@ -39,29 +39,22 @@
* @example
* ```
* const selector = { type: 'TextPositionSelector', start: 702, end: 736 };
- *
- * // Search in the whole document.
- * const scope = document.createRange();
- * scope.selectNodeContents(document);
- *
+ * const scope = document.body;
* const matches = textQuoteSelectorMatcher(selector)(scope);
* const match = (await matches.next()).value;
- *
* // ⇒ Range { startContainer: #text, startOffset: 64, endContainer: #text,
* // endOffset: 98, … }
* ```
*
- * @param selector - The {@link TextPositionSelector}
- * to be anchored
- * @returns A {@link Matcher} function that applies
- * `selector` to a given {@link https://developer.mozilla.org/en-US/docs/Web/API/Range
- * | Range}
+ * @param selector - The {@link TextPositionSelector} to be anchored.
+ * @returns A {@link Matcher} function that applies `selector` within a given
+ * `scope`.
*
* @public
*/
export function createTextPositionSelectorMatcher(
selector: TextPositionSelector,
-): Matcher<Range, Range> {
+): Matcher<Node | Range, Range> {
const abstractMatcher = abstractTextPositionSelectorMatcher(selector);
return async function* matchAll(scope) {
diff --git a/packages/dom/src/text-quote/describe.ts b/packages/dom/src/text-quote/describe.ts
index 8eca8bf..f5be4b9 100644
--- a/packages/dom/src/text-quote/describe.ts
+++ b/packages/dom/src/text-quote/describe.ts
@@ -24,6 +24,7 @@
} from '@apache-annotator/selector';
import { describeTextQuote as abstractDescribeTextQuote } from '@apache-annotator/selector';
import { ownerDocument } from '../owner-document';
+import { toRange } from '../to-range';
import { TextNodeChunker } from '../text-node-chunker';
/**
@@ -52,10 +53,9 @@
*
* @param range - The {@link https://developer.mozilla.org/en-US/docs/Web/API/Range
* | Range} whose text content will be described
- * @param maybeScope - A {@link https://developer.mozilla.org/en-US/docs/Web/API/Range
- * | Range} that serves as the ‘document’ for purposes of finding occurrences
- * and determining prefix and suffix. Defaults to span the full Document
- * containing the range.
+ * @param scope - A Node or Range that serves as the ‘document’ for purposes of
+ * finding occurrences and determining prefix and suffix. Defaults to span the
+ * full Document that contains the range.
* @param options - Options to fine-tune the function’s behaviour.
* @returns The selector unambiguously describing `range` within `scope`.
*
@@ -63,24 +63,16 @@
*/
export async function describeTextQuote(
range: Range,
- maybeScope?: Range,
+ scope?: Node | Range,
options: DescribeTextQuoteOptions = {},
): Promise<TextQuoteSelector> {
- // Default to search in the whole document.
- let scope: Range;
- if (maybeScope !== undefined) {
- scope = maybeScope;
- } else {
- const document = ownerDocument(range);
- scope = document.createRange();
- scope.selectNodeContents(document);
- }
+ const scopeAsRange = toRange(scope ?? ownerDocument(range));
- const chunker = new TextNodeChunker(scope);
+ const chunker = new TextNodeChunker(scopeAsRange);
return await abstractDescribeTextQuote(
chunker.rangeToChunkRange(range),
- () => new TextNodeChunker(scope),
+ () => new TextNodeChunker(scopeAsRange),
options,
);
}
diff --git a/packages/dom/src/text-quote/match.ts b/packages/dom/src/text-quote/match.ts
index a923586..2b29df6 100644
--- a/packages/dom/src/text-quote/match.ts
+++ b/packages/dom/src/text-quote/match.ts
@@ -44,10 +44,7 @@
* ```
* // Find the word ‘banana’.
* const selector = { type: 'TextQuoteSelector', exact: 'banana' };
- *
- * // Search in the document body.
- * const scope = document.createRange();
- * scope.selectNodeContents(document.body);
+ * const scope = document.body;
*
* // Read all matches.
* const matches = textQuoteSelectorMatcher(selector)(scope);
@@ -58,17 +55,15 @@
* // endOffset: 637, … }
* ```
*
- * @param selector - The {@link TextQuoteSelector}
- * to be anchored
- * @returns a {@link Matcher} function that applies
- * `selector` to a given {@link https://developer.mozilla.org/en-US/docs/Web/API/Range
- * | Range}
+ * @param selector - The {@link TextQuoteSelector} to be anchored.
+ * @returns A {@link Matcher} function that applies `selector` within a given
+ * `scope`.
*
* @public
*/
export function createTextQuoteSelectorMatcher(
selector: TextQuoteSelector,
-): Matcher<Range, Range> {
+): Matcher<Node | Range, Range> {
const abstractMatcher = abstractTextQuoteSelectorMatcher(selector);
return async function* matchAll(scope) {
diff --git a/packages/dom/src/to-range.ts b/packages/dom/src/to-range.ts
new file mode 100644
index 0000000..fd62543
--- /dev/null
+++ b/packages/dom/src/to-range.ts
@@ -0,0 +1,45 @@
+/**
+ * @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 { ownerDocument } from "./owner-document";
+
+/**
+ * Returns a range that exactly selects the contents of the given node.
+ *
+ * This function is idempotent: If the given argument is already a range, it
+ * simply returns that range.
+ *
+ * @param nodeOrRange The node/range to convert to a range if it is not already
+ * a range.
+ */
+export function toRange(nodeOrRange: Node | Range): Range {
+ if (isRange(nodeOrRange)) {
+ return nodeOrRange;
+ } else {
+ const node = nodeOrRange;
+ const range = ownerDocument(node).createRange();
+ range.selectNodeContents(node);
+ return range;
+ }
+}
+
+function isRange(nodeOrRange: Node | Range): nodeOrRange is Range {
+ return ('startContainer' in nodeOrRange);
+}
diff --git a/packages/dom/test/css/match.test.ts b/packages/dom/test/css/match.test.ts
index 9d4c18f..2c5d682 100644
--- a/packages/dom/test/css/match.test.ts
+++ b/packages/dom/test/css/match.test.ts
@@ -54,9 +54,6 @@
assert.equal(matches.length, expected.length, 'Unexpected number of matches');
matches.forEach((match, i) => {
const expectedElement = evaluateXPath(doc, expected[i]);
- // The match should be a Range that exactly contains the expected element.
- assert.equal(match.startContainer.childNodes[match.startOffset], expectedElement);
- assert.equal(match.endContainer, match.startContainer);
- assert.equal(match.endOffset, match.startOffset + 1);
+ assert.equal(match, expectedElement);
});
}