This is an automated email from the ASF dual-hosted git repository. gerben pushed a commit to branch typescript in repository https://gitbox.apache.org/repos/asf/incubator-annotator.git
commit 18a7158f6c88375e0fa407c1443bdb85b22a02e6 Author: Gerben <[email protected]> AuthorDate: Fri Apr 17 18:38:30 2020 +0200 Convert code to typscript Adopt the word ‘matcher’ to distinguish the selector functions from selector objects. We could choose another word like ‘locator’ or whatever if desired. --- @types/cartesian/index.d.ts | 3 + @types/dom-node-iterator/index.d.ts | 4 + @types/dom-seek/index.d.ts | 3 + babel.config.js | 2 +- packages/dom/src/{cartesian.js => cartesian.ts} | 20 +++-- packages/dom/src/{css.js => css.ts} | 6 +- .../src/{highlight-range.js => highlight-range.ts} | 38 +++++---- packages/dom/src/{index.js => index.ts} | 0 packages/dom/src/{range.js => range.ts} | 26 ++++--- packages/dom/src/{scope.js => scope.ts} | 13 ++-- packages/dom/src/{text-quote.js => text-quote.ts} | 90 +++++++++++++--------- packages/dom/src/types.ts | 5 ++ packages/dom/test/cartesian.js | 2 +- packages/selector/src/{index.js => index.ts} | 30 ++++++-- packages/selector/src/types.ts | 25 ++++++ web/demo/index.js | 18 ++--- 16 files changed, 186 insertions(+), 99 deletions(-) diff --git a/@types/cartesian/index.d.ts b/@types/cartesian/index.d.ts new file mode 100644 index 0000000..9fc47e3 --- /dev/null +++ b/@types/cartesian/index.d.ts @@ -0,0 +1,3 @@ +declare module 'cartesian' { + export default function cartesian<T>(list: Array<Array<T>> | { [k: string]: Array<T> }): Array<Array<T>>; +} diff --git a/@types/dom-node-iterator/index.d.ts b/@types/dom-node-iterator/index.d.ts new file mode 100644 index 0000000..0e10887 --- /dev/null +++ b/@types/dom-node-iterator/index.d.ts @@ -0,0 +1,4 @@ +declare module 'dom-node-iterator' { + let createNodeIterator: Document['createNodeIterator']; + export default createNodeIterator; +} diff --git a/@types/dom-seek/index.d.ts b/@types/dom-seek/index.d.ts new file mode 100644 index 0000000..0ba0753 --- /dev/null +++ b/@types/dom-seek/index.d.ts @@ -0,0 +1,3 @@ +declare module 'dom-seek' { + export default function seek(iter: NodeIterator, where: number | Node): number; +} diff --git a/babel.config.js b/babel.config.js index dcd45ea..0c052cc 100644 --- a/babel.config.js +++ b/babel.config.js @@ -36,7 +36,7 @@ module.exports = api => { // Used for resolving source files during development. let resolverOptions = { alias: { - '^@annotator/(.+)$': '@annotator/\\1/src/index.js', + '^@annotator/(.+)$': '@annotator/\\1/src/index.ts', }, }; diff --git a/packages/dom/src/cartesian.js b/packages/dom/src/cartesian.ts similarity index 80% rename from packages/dom/src/cartesian.js rename to packages/dom/src/cartesian.ts index e800c2b..d2b44b7 100644 --- a/packages/dom/src/cartesian.js +++ b/packages/dom/src/cartesian.ts @@ -20,7 +20,7 @@ import cartesianArrays from 'cartesian'; -export async function* product(...iterables) { +export async function* product<T>(...iterables: AsyncGenerator<T>[]): AsyncGenerator<Array<T>, void, undefined> { // We listen to all iterators in parallel, while logging all the values they // produce. Whenever an iterator produces a value, we produce and yield all // combinations of that value with the logged values from other iterators. @@ -28,28 +28,27 @@ export async function* product(...iterables) { const iterators = iterables.map(iterable => iterable[Symbol.asyncIterator]()); // Initialise an empty log for each iterable. - const logs = iterables.map(() => []); + const logs: T[][] = iterables.map(() => []); const nextValuePromises = iterators.map((iterator, iterableNr) => iterator .next() - .then(async ({ value, done }) => ({ value: await value, done })) .then( // Label the result with iterableNr, to know which iterable produced // this value after Promise.race below. - ({ value, done }) => ({ value, done, iterableNr }), + nextResult => ({ nextResult, iterableNr }), ), ); // Keep listening as long as any of the iterables is not yet exhausted. while (nextValuePromises.some(p => p !== null)) { // Wait until any of the active iterators has produced a new value. - const { value, done, iterableNr } = await Promise.race( + const { nextResult, iterableNr } = await Promise.race( nextValuePromises.filter(p => p !== null), ); // If this iterable was exhausted, stop listening to it and move on. - if (done) { + if (nextResult.done === true) { nextValuePromises[iterableNr] = null; continue; } @@ -57,17 +56,16 @@ export async function* product(...iterables) { // Produce all combinations of the received value with the logged values // from the other iterables. const arrays = [...logs]; - arrays[iterableNr] = [value]; - const combinations = cartesianArrays(arrays); + arrays[iterableNr] = [nextResult.value]; + const combinations: T[][] = cartesianArrays(arrays); // Append the received value to the right log. - logs[iterableNr] = [...logs[iterableNr], value]; + logs[iterableNr] = [...logs[iterableNr], nextResult.value]; // Start listening for the next value of this iterable. nextValuePromises[iterableNr] = iterators[iterableNr] .next() - .then(async ({ value, done }) => ({ value: await value, done })) - .then(({ value, done }) => ({ value, done, iterableNr })); + .then(nextResult => ({ nextResult, iterableNr })); // Yield each of the produced combinations separately. yield* combinations; diff --git a/packages/dom/src/css.js b/packages/dom/src/css.ts similarity index 80% rename from packages/dom/src/css.js rename to packages/dom/src/css.ts index 3a7c6c1..004158c 100644 --- a/packages/dom/src/css.js +++ b/packages/dom/src/css.ts @@ -18,8 +18,10 @@ * under the License. */ -export function createCssSelector(selector) { - return async function* matchAll(scope) { +import { CssSelector, Matcher } from "../../selector/src"; + +export function createCssSelectorMatcher(selector: CssSelector): Matcher<Document, Element> { + return async function* matchAll(scope: Document) { yield* scope.querySelectorAll(selector.value); }; } diff --git a/packages/dom/src/highlight-range.js b/packages/dom/src/highlight-range.ts similarity index 84% rename from packages/dom/src/highlight-range.js rename to packages/dom/src/highlight-range.ts index 57f76ee..d46cead 100644 --- a/packages/dom/src/highlight-range.js +++ b/packages/dom/src/highlight-range.ts @@ -28,14 +28,18 @@ // unusable afterwards // - tagName: the element used to wrap text nodes. Defaults to 'mark'. // - attributes: an Object defining any attributes to be set on the wrapper elements. -export function highlightRange(range, tagName = 'mark', attributes = {}) { +export function highlightRange( + range: Range, + tagName: string = 'mark', + attributes: Record<string, string> = {} +): () => void { if (range.collapsed) return; // First put all nodes in an array (splits start and end nodes if needed) const nodes = textNodesInRange(range); // Highlight each node - const highlightElements = []; + const highlightElements: HTMLElement[] = []; for (const node of nodes) { const highlightElement = wrapNodeInHighlight(node, tagName, attributes); highlightElements.push(highlightElement); @@ -52,10 +56,10 @@ export function highlightRange(range, tagName = 'mark', attributes = {}) { } // Return an array of the text nodes in the range. Split the start and end nodes if required. -function textNodesInRange(range) { +function textNodesInRange(range: Range): Text[] { // If the start or end node is a text node and only partly in the range, split it. if ( - range.startContainer.nodeType === Node.TEXT_NODE && + isTextNode(range.startContainer) && range.startOffset > 0 ) { const endOffset = range.endOffset; // (this may get lost when the splitting the node) @@ -67,7 +71,7 @@ function textNodesInRange(range) { range.setStart(createdNode, 0); } if ( - range.endContainer.nodeType === Node.TEXT_NODE && + isTextNode(range.endContainer) && range.endOffset < range.endContainer.length ) { range.endContainer.splitText(range.endOffset); @@ -77,10 +81,12 @@ function textNodesInRange(range) { const walker = range.startContainer.ownerDocument.createTreeWalker( range.commonAncestorContainer, NodeFilter.SHOW_TEXT, - node => - range.intersectsNode(node) - ? NodeFilter.FILTER_ACCEPT - : NodeFilter.FILTER_REJECT, + { + acceptNode: node => + range.intersectsNode(node) + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT + }, ); walker.currentNode = range.startContainer; @@ -98,16 +104,16 @@ function textNodesInRange(range) { // } // } - const nodes = []; - if (walker.currentNode.nodeType === Node.TEXT_NODE) + const nodes: Text[] = []; + if (isTextNode(walker.currentNode)) nodes.push(walker.currentNode); while (walker.nextNode() && range.comparePoint(walker.currentNode, 0) !== 1) - nodes.push(walker.currentNode); + nodes.push(walker.currentNode as Text); return nodes; } // Replace [node] with <tagName ...attributes>[node]</tagName> -function wrapNodeInHighlight(node, tagName, attributes) { +function wrapNodeInHighlight(node: Node, tagName: string, attributes: Record<string, string>): HTMLElement { const highlightElement = node.ownerDocument.createElement(tagName); Object.keys(attributes).forEach(key => { highlightElement.setAttribute(key, attributes[key]); @@ -119,7 +125,7 @@ function wrapNodeInHighlight(node, tagName, attributes) { } // Remove a highlight element created with wrapNodeInHighlight. -function removeHighlight(highlightElement) { +function removeHighlight(highlightElement: HTMLElement) { // If it has somehow been removed already, there is nothing to be done. if (!highlightElement.parentNode) return; if (highlightElement.childNodes.length === 1) { @@ -138,3 +144,7 @@ function removeHighlight(highlightElement) { highlightElement.remove(); } } + +function isTextNode(node: Node): node is Text { + return node.nodeType === Node.TEXT_NODE +} diff --git a/packages/dom/src/index.js b/packages/dom/src/index.ts similarity index 100% rename from packages/dom/src/index.js rename to packages/dom/src/index.ts diff --git a/packages/dom/src/range.js b/packages/dom/src/range.ts similarity index 63% rename from packages/dom/src/range.js rename to packages/dom/src/range.ts index 51b1483..e465386 100644 --- a/packages/dom/src/range.js +++ b/packages/dom/src/range.ts @@ -18,19 +18,23 @@ * under the License. */ -import { ownerDocument } from './scope.js'; -import { product } from './cartesian.js'; - -export function createRangeSelectorCreator(createSelector) { - return function createRangeSelector(selector) { - const startSelector = createSelector(selector.startSelector); - const endSelector = createSelector(selector.endSelector); - - return async function* matchAll(scope) { +import { ownerDocument } from './scope'; +import { product } from './cartesian'; +import { RangeSelector, Selector } from '../../selector/src/types'; +import { DomMatcher, DomScope } from './types'; + +export function makeCreateRangeSelectorMatcher( + createMatcher: <T extends Selector>(selector: T) => DomMatcher +): (selector: RangeSelector) => DomMatcher { + return function createRangeSelectorMatcher(selector: RangeSelector) { + const startMatcher = createMatcher(selector.startSelector); + const endMatcher = createMatcher(selector.endSelector); + + return async function* matchAll(scope: DomScope) { const document = ownerDocument(scope); - const startMatches = startSelector(scope); - const endMatches = endSelector(scope); + const startMatches = startMatcher(scope); + const endMatches = endMatcher(scope); const pairs = product(startMatches, endMatches); diff --git a/packages/dom/src/scope.js b/packages/dom/src/scope.ts similarity index 81% rename from packages/dom/src/scope.js rename to packages/dom/src/scope.ts index 007618d..2b112ff 100644 --- a/packages/dom/src/scope.js +++ b/packages/dom/src/scope.ts @@ -18,15 +18,16 @@ * under the License. */ -export function ownerDocument(scope) { - if ('commonAncestorContainer' in scope) { - return scope.commonAncestorContainer.ownerDocument; - } +import { DomScope } from './types'; - return scope.ownerDocument; +export function ownerDocument(scope: DomScope): Document { + if ('commonAncestorContainer' in scope) + return scope.commonAncestorContainer.ownerDocument; + else + return scope.ownerDocument; } -export function rangeFromScope(scope) { +export function rangeFromScope(scope: DomScope | null): Range { if ('commonAncestorContainer' in scope) { return scope; } diff --git a/packages/dom/src/text-quote.js b/packages/dom/src/text-quote.ts similarity index 71% rename from packages/dom/src/text-quote.js rename to packages/dom/src/text-quote.ts index 21a530e..01a3989 100644 --- a/packages/dom/src/text-quote.js +++ b/packages/dom/src/text-quote.ts @@ -21,26 +21,22 @@ import createNodeIterator from 'dom-node-iterator'; import seek from 'dom-seek'; -import { ownerDocument, rangeFromScope } from './scope.js'; +import { TextQuoteSelector } from '../../selector/src'; +import { DomScope, DomMatcher } from './types'; +import { ownerDocument, rangeFromScope } from './scope'; -// Node constants -const TEXT_NODE = 3; - -// NodeFilter constants -const SHOW_TEXT = 4; - -function firstTextNodeInRange(range) { +function firstTextNodeInRange(range: Range): Text { const { startContainer } = range; - if (startContainer.nodeType === TEXT_NODE) return startContainer; + if (isTextNode(startContainer)) return startContainer; const root = range.commonAncestorContainer; - const iter = createNodeIterator(root, SHOW_TEXT); - return iter.nextNode(); + const iter = createNodeIterator(root, NodeFilter.SHOW_TEXT); + return iter.nextNode() as Text; } -export function createTextQuoteSelector(selector) { - return async function* matchAll(scope) { +export function createTextQuoteSelectorMatcher(selector: TextQuoteSelector): DomMatcher { + return async function* matchAll(scope: DomScope) { const document = ownerDocument(scope); const range = rangeFromScope(scope); const root = range.commonAncestorContainer; @@ -51,12 +47,12 @@ export function createTextQuoteSelector(selector) { const suffix = selector.suffix || ''; const pattern = prefix + exact + suffix; - const iter = createNodeIterator(root, SHOW_TEXT); + const iter = createNodeIterator(root, NodeFilter.SHOW_TEXT); let fromIndex = 0; let referenceNodeIndex = 0; - if (range.startContainer.nodeType === TEXT_NODE) { + if (isTextNode(range.startContainer)) { referenceNodeIndex -= range.startOffset; } @@ -122,32 +118,49 @@ export function createTextQuoteSelector(selector) { }; } -export async function describeTextQuote(range, scope = null) { - scope = rangeFromScope(scope || ownerDocument(range).documentElement); +export async function describeTextQuote( + range: Range, + scope: DomScope = null +): Promise<TextQuoteSelector> { + const exact = range.toString(); + + const result: TextQuoteSelector = { type: 'TextQuoteSelector', exact }; - const root = scope.commonAncestorContainer; - const text = scope.toString(); + const { prefix, suffix } = await calculateContextForDisambiguation(range, result, scope); + result.prefix = prefix; + result.suffix = suffix; - const exact = range.toString(); - const selector = createTextQuoteSelector({ exact }); + return result +} - const iter = createNodeIterator(root, SHOW_TEXT); +async function calculateContextForDisambiguation( + range: Range, + selector: TextQuoteSelector, + scope: DomScope +): Promise<{ prefix?: string, suffix?: string }> { + const scopeAsRange = rangeFromScope(scope || ownerDocument(range).documentElement); + const root = scopeAsRange.commonAncestorContainer; + const text = scopeAsRange.toString(); + + const matcher = createTextQuoteSelectorMatcher(selector); + + const iter = createNodeIterator(root, NodeFilter.SHOW_TEXT); const startNode = firstTextNodeInRange(range); const startIndex = - range.startContainer.nodeType === TEXT_NODE + isTextNode(range.startContainer) ? seek(iter, startNode) + range.startOffset : seek(iter, startNode); - const endIndex = startIndex + exact.length; + const endIndex = startIndex + selector.exact.length; - const affixLengthPairs = []; + const affixLengthPairs: Array<[number, number]> = []; - for await (const match of selector(scope)) { - const matchIter = createNodeIterator(root, SHOW_TEXT); + for await (const match of matcher(scopeAsRange)) { + const matchIter = createNodeIterator(root, NodeFilter.SHOW_TEXT); const matchStartNode = firstTextNodeInRange(match); const matchStartIndex = - match.startContainer.nodeType === TEXT_NODE + isTextNode(match.startContainer) ? seek(matchIter, matchStartNode) + match.startOffset : seek(matchIter, matchStartNode); const matchEndIndex = matchStartIndex + match.toString().length; @@ -174,24 +187,23 @@ export async function describeTextQuote(range, scope = null) { } // Construct and return an unambiguous selector. - const result = { type: 'TextQuoteSelector', exact }; - + let prefix, suffix; if (affixLengthPairs.length) { const [prefixLength, suffixLength] = minimalSolution(affixLengthPairs); if (prefixLength > 0 && startIndex > 0) { - result.prefix = text.substring(startIndex - prefixLength, startIndex); + prefix = text.substring(startIndex - prefixLength, startIndex); } if (suffixLength > 0 && endIndex < text.length) { - result.suffix = text.substring(endIndex, endIndex + suffixLength); + suffix = text.substring(endIndex, endIndex + suffixLength); } } - return result; + return { prefix, suffix }; } -function overlap(text1, text2) { +function overlap(text1: string, text2: string) { let count = 0; while (count < text1.length && count < text2.length) { @@ -204,7 +216,7 @@ function overlap(text1, text2) { return count; } -function overlapRight(text1, text2) { +function overlapRight(text1: string, text2: string) { let count = 0; while (count < text1.length && count < text2.length) { @@ -217,9 +229,9 @@ function overlapRight(text1, text2) { return count; } -function minimalSolution(requirements) { +function minimalSolution(requirements: Array<[number, number]>): [number, number] { // Build all the pairs and order them by their sums. - const pairs = requirements.flatMap(l => requirements.map(r => [l[0], r[1]])); + const pairs = requirements.flatMap(l => requirements.map<[number, number]>(r => [l[0], r[1]])); pairs.sort((a, b) => a[0] + a[1] - (b[0] + b[1])); // Find the first pair that satisfies every requirement. @@ -233,3 +245,7 @@ function minimalSolution(requirements) { // Return the largest pairing (unreachable). return pairs[pairs.length - 1]; } + +function isTextNode(node: Node): node is Text { + return node.nodeType === Node.TEXT_NODE +} diff --git a/packages/dom/src/types.ts b/packages/dom/src/types.ts new file mode 100644 index 0000000..db6d018 --- /dev/null +++ b/packages/dom/src/types.ts @@ -0,0 +1,5 @@ +import { Matcher } from '../../selector/src'; + +export type DomScope = Node | Range + +export type DomMatcher = Matcher<DomScope, Range> diff --git a/packages/dom/test/cartesian.js b/packages/dom/test/cartesian.js index a56f9c8..c5cfd23 100644 --- a/packages/dom/test/cartesian.js +++ b/packages/dom/test/cartesian.js @@ -18,7 +18,7 @@ * under the License. */ -import { product } from '../src/cartesian.js'; +import { product } from '../src/cartesian'; async function* gen1() { yield 1; diff --git a/packages/selector/src/index.js b/packages/selector/src/index.ts similarity index 50% rename from packages/selector/src/index.js rename to packages/selector/src/index.ts index c9c100d..8135f6e 100644 --- a/packages/selector/src/index.js +++ b/packages/selector/src/index.ts @@ -18,20 +18,36 @@ * under the License. */ -export function makeRefinable(selectorCreator) { - return function createSelector(source) { - const selector = selectorCreator(source); +import { Selector, Matcher } from './types'; - if (source.refinedBy) { - const refiningSelector = createSelector(source.refinedBy); +export * from './types'; + +export function makeRefinable< + // Any subtype of Selector can be made refinable; but note we limit the value + // of refinedBy because it must also be accepted by matcherCreator. + TSelector extends (Selector & { refinedBy: TSelector }), + TScope, + // To enable refinement, the implementation’s Match object must be usable as a + // Scope object itself. + TMatch extends TScope, +>( + matcherCreator: (selector: TSelector) => Matcher<TScope, TMatch>, +): (selector: TSelector) => Matcher<TScope, TMatch> { + return function createMatcherWithRefinement( + sourceSelector: TSelector + ): Matcher<TScope, TMatch> { + const matcher = matcherCreator(sourceSelector); + + if (sourceSelector.refinedBy) { + const refiningSelector = createMatcherWithRefinement(sourceSelector.refinedBy); return async function* matchAll(scope) { - for await (const match of selector(scope)) { + for await (const match of matcher(scope)) { yield* refiningSelector(match); } }; } - return selector; + return matcher; }; } diff --git a/packages/selector/src/types.ts b/packages/selector/src/types.ts new file mode 100644 index 0000000..4f620a8 --- /dev/null +++ b/packages/selector/src/types.ts @@ -0,0 +1,25 @@ +export interface Selector { + refinedBy?: Selector, +} + +export interface CssSelector extends Selector { + type: 'CssSelector', + value: string, +} + +export interface TextQuoteSelector extends Selector { + type: 'TextQuoteSelector', + exact: string, + prefix?: string, + suffix?: string, +} + +export interface RangeSelector extends Selector { + type: 'RangeSelector', + startSelector: Selector, + endSelector: Selector, +} + +export interface Matcher<TScope, TMatch> { + (scope: TScope): AsyncGenerator<TMatch, void, void>, +} diff --git a/web/demo/index.js b/web/demo/index.js index 47f7f30..b9e2533 100644 --- a/web/demo/index.js +++ b/web/demo/index.js @@ -21,8 +21,8 @@ /* global info, module, source, target */ import { - createRangeSelectorCreator, - createTextQuoteSelector, + makeCreateRangeSelectorMatcher, + createTextQuoteSelectorMatcher, describeTextQuote, highlightRange, } from '@annotator/dom'; @@ -91,21 +91,21 @@ function cleanup() { target.normalize(); } -const createSelector = makeRefinable(selector => { - const selectorCreator = { - TextQuoteSelector: createTextQuoteSelector, - RangeSelector: createRangeSelectorCreator(createSelector), +const createMatcher = makeRefinable(selector => { + const innerCreateMatcher = { + TextQuoteSelector: createTextQuoteSelectorMatcher, + RangeSelector: makeCreateRangeSelectorMatcher(createMatcher), }[selector.type]; - if (selectorCreator == null) { + if (!innerCreateMatcher) { throw new Error(`Unsupported selector type: ${selector.type}`); } - return selectorCreator(selector); + return innerCreateMatcher(selector); }); async function anchor(selector) { - const matchAll = createSelector(selector); + const matchAll = createMatcher(selector); const ranges = []; for await (const range of matchAll(target)) {
