blob: d5705a738e3993615148d7f48613a1159e9bcc7e [file] [log] [blame]
/**
* SPDX-FileCopyrightText: 2016-2020 The Apache Software Foundation
* SPDX-License-Identifier: Apache-2.0
* @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';
// TextRange is a Range that guarantees to always have Text nodes as its start
// and end nodes. To ensure the type remains correct, it also restricts usage
// of methods that would modify these nodes (note that a user can simply cast
// the TextRange back to a Range to remove these restrictions).
export interface TextRange extends Range {
readonly startContainer: Text;
readonly endContainer: Text;
cloneRange(): TextRange;
// Allow only Text nodes to be passed to these methods.
insertNode(node: Text): void;
selectNodeContents(node: Text): void;
setEnd(node: Text, offset: number): void;
setStart(node: Text, offset: number): void;
// Do not allow these methods to be used at all.
selectNode(node: never): void;
setEndAfter(node: never): void;
setEndBefore(node: never): void;
setStartAfter(node: never): void;
setStartBefore(node: never): void;
surroundContents(newParent: never): void;
}
// Normalise a range such that both its start and end are text nodes, and that
// if there are equivalent text selections it takes the narrowest option (i.e.
// it prefers the start not to be at the end of a text node, and vice versa).
//
// If there is no text between the start and end, they thus collapse onto one a
// single position; and if there are multiple equivalent positions, it takes the
// first one; or, if scope is passed, the first equivalent falling within scope.
//
// Note that if the given range does not contain non-empty text nodes, it will
// end up pointing at a text node outside of it (before it if possible, else
// after). If the document does not contain any text nodes, an error is thrown.
export function normalizeRange(range: Range, scope?: Range): TextRange {
const document = ownerDocument(range);
const walker = document.createTreeWalker(document, NodeFilter.SHOW_TEXT, {
acceptNode(node: Text) {
return !scope || scope.intersectsNode(node)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
});
let [startContainer, startOffset] = snapBoundaryPointToTextNode(
range.startContainer,
range.startOffset,
);
// If we point at the end of a text node, move to the start of the next one.
// The step is repeated to skip over empty text nodes.
walker.currentNode = startContainer;
while (startOffset === startContainer.length && walker.nextNode()) {
startContainer = walker.currentNode as Text;
startOffset = 0;
}
// Set the range’s start; note this might move its end too.
range.setStart(startContainer, startOffset);
let [endContainer, endOffset] = snapBoundaryPointToTextNode(
range.endContainer,
range.endOffset,
);
// If we point at the start of a text node, move to the end of the previous one.
// The step is repeated to skip over empty text nodes.
walker.currentNode = endContainer;
while (endOffset === 0 && walker.previousNode()) {
endContainer = walker.currentNode as Text;
endOffset = endContainer.length;
}
// Set the range’s end; note this might move its start too.
range.setEnd(endContainer, endOffset);
return range as TextRange;
}
// Given an arbitrary boundary point, this returns either:
// - that same boundary point, if its node is a text node;
// - otherwise the first boundary point after it whose node is a text node, if any;
// - otherwise, the last boundary point before it whose node is a text node.
// If the document has no text nodes, it throws an error.
function snapBoundaryPointToTextNode(
node: Node,
offset: number,
): [Text, number] {
if (isText(node)) return [node, offset];
// Find the node at or right after the boundary point.
let curNode: Node;
if (isCharacterData(node)) {
curNode = node;
} else if (offset < node.childNodes.length) {
curNode = node.childNodes[offset];
} else {
curNode = node;
while (curNode.nextSibling === null) {
if (curNode.parentNode === null)
// Boundary point is at end of document
throw new Error('not implemented'); // TODO
curNode = curNode.parentNode;
}
curNode = curNode.nextSibling;
}
if (isText(curNode)) return [curNode, 0];
// Walk to the next text node, or the last if there is none.
const document = node.ownerDocument ?? (node as Document);
const walker = document.createTreeWalker(document, NodeFilter.SHOW_TEXT);
walker.currentNode = curNode;
if (walker.nextNode() !== null) {
return [walker.currentNode as Text, 0];
} else if (walker.previousNode() !== null) {
return [walker.currentNode as Text, (walker.currentNode as Text).length];
} else {
throw new Error('Document contains no text nodes.');
}
}
function isText(node: Node): node is Text {
return node.nodeType === Node.TEXT_NODE;
}
function isCharacterData(node: Node): node is CharacterData {
return (
node.nodeType === Node.PROCESSING_INSTRUCTION_NODE ||
node.nodeType === Node.COMMENT_NODE ||
node.nodeType === Node.TEXT_NODE
);
}