| /** |
| * @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 { Chunk, Chunker, ChunkRange } from '@apache-annotator/selector'; |
| import { normalizeRange } from './normalize-range'; |
| import { ownerDocument } from './owner-document'; |
| import { toRange } from './range-node-conversion'; |
| |
| export interface PartialTextNode extends Chunk<string> { |
| readonly node: Text; |
| readonly startOffset: number; |
| readonly endOffset: number; |
| } |
| |
| export class EmptyScopeError extends TypeError { |
| constructor(message?: string) { |
| super(message || 'Scope contains no text nodes.'); |
| } |
| } |
| |
| export class OutOfScopeError extends TypeError { |
| constructor(message?: string) { |
| super( |
| message || |
| 'Cannot convert node to chunk, as it falls outside of chunker’s scope.', |
| ); |
| } |
| } |
| |
| export class TextNodeChunker implements Chunker<PartialTextNode> { |
| private scope: Range; |
| private iter: NodeIterator; |
| |
| get currentChunk(): PartialTextNode { |
| const node = this.iter.referenceNode; |
| |
| // This test should not actually be needed, but it keeps TypeScript happy. |
| if (!isText(node)) throw new EmptyScopeError(); |
| |
| return this.nodeToChunk(node); |
| } |
| |
| nodeToChunk(node: Text): PartialTextNode { |
| if (!this.scope.intersectsNode(node)) throw new OutOfScopeError(); |
| |
| const startOffset = |
| node === this.scope.startContainer ? this.scope.startOffset : 0; |
| const endOffset = |
| node === this.scope.endContainer ? this.scope.endOffset : node.length; |
| |
| return { |
| node, |
| startOffset, |
| endOffset, |
| data: node.data.substring(startOffset, endOffset), |
| equals(other) { |
| return ( |
| other.node === this.node && |
| other.startOffset === this.startOffset && |
| other.endOffset === this.endOffset |
| ); |
| }, |
| }; |
| } |
| |
| rangeToChunkRange(range: Range): ChunkRange<PartialTextNode> { |
| range = range.cloneRange(); |
| |
| // Take the part of the range that falls within the scope. |
| if (range.compareBoundaryPoints(Range.START_TO_START, this.scope) === -1) |
| range.setStart(this.scope.startContainer, this.scope.startOffset); |
| if (range.compareBoundaryPoints(Range.END_TO_END, this.scope) === 1) |
| range.setEnd(this.scope.endContainer, this.scope.endOffset); |
| |
| // Ensure it starts and ends at text nodes. |
| const textRange = normalizeRange(range, this.scope); |
| |
| const startChunk = this.nodeToChunk(textRange.startContainer); |
| const startIndex = textRange.startOffset - startChunk.startOffset; |
| const endChunk = this.nodeToChunk(textRange.endContainer); |
| const endIndex = textRange.endOffset - endChunk.startOffset; |
| |
| return { startChunk, startIndex, endChunk, endIndex }; |
| } |
| |
| chunkRangeToRange(chunkRange: ChunkRange<PartialTextNode>): Range { |
| const range = ownerDocument(this.scope).createRange(); |
| // The `+…startOffset` parts are only relevant for the first chunk, as it |
| // might start within a text node. |
| range.setStart( |
| chunkRange.startChunk.node, |
| chunkRange.startIndex + chunkRange.startChunk.startOffset, |
| ); |
| range.setEnd( |
| chunkRange.endChunk.node, |
| chunkRange.endIndex + chunkRange.endChunk.startOffset, |
| ); |
| return range; |
| } |
| |
| /** |
| * @param scope A Range that overlaps with at least one text node. |
| */ |
| constructor(scope: Node | Range) { |
| this.scope = toRange(scope); |
| this.iter = ownerDocument(scope).createNodeIterator( |
| this.scope.commonAncestorContainer, |
| NodeFilter.SHOW_TEXT, |
| { |
| acceptNode: (node: Text) => { |
| return this.scope.intersectsNode(node) |
| ? NodeFilter.FILTER_ACCEPT |
| : NodeFilter.FILTER_REJECT; |
| }, |
| }, |
| ); |
| |
| // Move the iterator to after the start (= root) node. |
| this.iter.nextNode(); |
| // If the start node is not a text node, move it to the first text node. |
| if (!isText(this.iter.referenceNode)) { |
| const nextNode = this.iter.nextNode(); |
| if (nextNode === null) throw new EmptyScopeError(); |
| } |
| } |
| |
| nextChunk(): PartialTextNode | null { |
| // Move the iterator to after the current node, so nextNode() will cause a jump. |
| if (this.iter.pointerBeforeReferenceNode) this.iter.nextNode(); |
| |
| if (this.iter.nextNode()) return this.currentChunk; |
| else return null; |
| } |
| |
| previousChunk(): PartialTextNode | null { |
| if (!this.iter.pointerBeforeReferenceNode) this.iter.previousNode(); |
| |
| if (this.iter.previousNode()) return this.currentChunk; |
| else return null; |
| } |
| |
| precedesCurrentChunk(chunk: PartialTextNode): boolean { |
| if (this.currentChunk === null) return false; |
| return !!( |
| this.currentChunk.node.compareDocumentPosition(chunk.node) & |
| Node.DOCUMENT_POSITION_PRECEDING |
| ); |
| } |
| } |
| |
| function isText(node: Node): node is Text { |
| return node.nodeType === Node.TEXT_NODE; |
| } |