0
Fork 0
mirror of https://github.com/fastmail/Squire.git synced 2024-12-22 15:23:29 -05:00
Squire/source/range/InsertDelete.ts
Neil Jenkins 3def29e081 Fix merging text nodes after delete
startContainer is not necessarily a sibling to endContainer at this
point. endContainer is definitely at the right level of the hierarchy
so instead just get the previous sibling from this and merge with that
if it's also a text node.
2024-07-18 13:04:35 +10:00

444 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { cleanupBRs } from '../Clean';
import {
split,
fixCursor,
mergeWithBlock,
fixContainer,
mergeContainers,
} from '../node/MergeSplit';
import { detach, getNearest, getLength } from '../node/Node';
import { TreeIterator, SHOW_ELEMENT_OR_TEXT } from '../node/TreeIterator';
import { isInline, isContainer, isLeaf } from '../node/Category';
import { getNextBlock, isEmptyBlock, getPreviousBlock } from '../node/Block';
import {
getStartBlockOfRange,
getEndBlockOfRange,
rangeDoesEndAtBlockBoundary,
rangeDoesStartAtBlockBoundary,
} from './Block';
import {
moveRangeBoundariesDownTree,
moveRangeBoundariesUpTree,
} from './Boundaries';
// ---
function createRange(startContainer: Node, startOffset: number): Range;
function createRange(
startContainer: Node,
startOffset: number,
endContainer: Node,
endOffset: number,
): Range;
function createRange(
startContainer: Node,
startOffset: number,
endContainer?: Node,
endOffset?: number,
): Range {
const range = document.createRange();
range.setStart(startContainer, startOffset);
if (endContainer && typeof endOffset === 'number') {
range.setEnd(endContainer, endOffset);
} else {
range.setEnd(startContainer, startOffset);
}
return range;
}
const insertNodeInRange = (range: Range, node: Node): void => {
// Insert at start.
let { startContainer, startOffset, endContainer, endOffset } = range;
let children: NodeListOf<ChildNode>;
// If part way through a text node, split it.
if (startContainer instanceof Text) {
const parent = startContainer.parentNode!;
children = parent.childNodes;
if (startOffset === startContainer.length) {
startOffset = Array.from(children).indexOf(startContainer) + 1;
if (range.collapsed) {
endContainer = parent;
endOffset = startOffset;
}
} else {
if (startOffset) {
const afterSplit = startContainer.splitText(startOffset);
if (endContainer === startContainer) {
endOffset -= startOffset;
endContainer = afterSplit;
} else if (endContainer === parent) {
endOffset += 1;
}
startContainer = afterSplit;
}
startOffset = Array.from(children).indexOf(
startContainer as ChildNode,
);
}
startContainer = parent;
} else {
children = startContainer.childNodes;
}
const childCount = children.length;
if (startOffset === childCount) {
startContainer.appendChild(node);
} else {
startContainer.insertBefore(node, children[startOffset]);
}
if (startContainer === endContainer) {
endOffset += children.length - childCount;
}
range.setStart(startContainer, startOffset);
range.setEnd(endContainer, endOffset);
};
/**
* Removes the contents of the range and returns it as a DocumentFragment.
* The range at the end will be at the same position, with the edges just
* before/after the split. If the start/end have the same parents, it will
* be collapsed.
*/
const extractContentsOfRange = (
range: Range,
common: Node | null,
root: Element,
): DocumentFragment => {
const frag = document.createDocumentFragment();
if (range.collapsed) {
return frag;
}
if (!common) {
common = range.commonAncestorContainer;
}
if (common instanceof Text) {
common = common.parentNode!;
}
const startContainer = range.startContainer;
const startOffset = range.startOffset;
let endContainer = split(range.endContainer, range.endOffset, common, root);
let endOffset = 0;
let node = split(startContainer, startOffset, common, root);
while (node && node !== endContainer) {
const next = node.nextSibling;
frag.appendChild(node);
node = next;
}
// Merge text nodes if adjacent
node = endContainer.previousSibling;
if (node && node instanceof Text && endContainer instanceof Text) {
endOffset = node.length;
node.appendData(endContainer.data);
detach(endContainer);
endContainer = node;
}
range.setStart(startContainer, startOffset);
if (endContainer) {
range.setEnd(endContainer, endOffset);
} else {
// endContainer will be null if at end of parent's child nodes list.
range.setEnd(common, common.childNodes.length);
}
fixCursor(common);
return frag;
};
/**
* Returns the next/prev node that's part of the same inline content.
*/
const getAdjacentInlineNode = (
iterator: TreeIterator<Node>,
method: 'nextNode' | 'previousPONode',
node: Node,
): Node | null => {
iterator.currentNode = node;
let nextNode: Node | null;
while ((nextNode = iterator[method]())) {
if (nextNode instanceof Text || isLeaf(nextNode)) {
return nextNode;
}
if (!isInline(nextNode)) {
return null;
}
}
return null;
};
const deleteContentsOfRange = (
range: Range,
root: Element,
): DocumentFragment => {
const startBlock = getStartBlockOfRange(range, root);
let endBlock = getEndBlockOfRange(range, root);
const needsMerge = startBlock !== endBlock;
// Move boundaries up as much as possible without exiting block,
// to reduce need to split.
if (startBlock && endBlock) {
moveRangeBoundariesDownTree(range);
moveRangeBoundariesUpTree(range, startBlock, endBlock, root);
}
// Remove selected range
const frag = extractContentsOfRange(range, null, root);
// Move boundaries back down tree as far as possible.
moveRangeBoundariesDownTree(range);
// If we split into two different blocks, merge the blocks.
if (needsMerge) {
// endBlock will have been split, so need to refetch
endBlock = getEndBlockOfRange(range, root);
if (startBlock && endBlock && startBlock !== endBlock) {
mergeWithBlock(startBlock, endBlock, range, root);
}
}
// Ensure block has necessary children
if (startBlock) {
fixCursor(startBlock);
}
// Ensure root has a block-level element in it.
const child = root.firstChild;
if (!child || child.nodeName === 'BR') {
fixCursor(root);
if (root.firstChild) {
range.selectNodeContents(root.firstChild);
}
}
range.collapse(true);
// Now we may need to swap a space for a nbsp if the browser is going
// to swallow it due to HTML whitespace rules:
const startContainer = range.startContainer;
const startOffset = range.startOffset;
const iterator = new TreeIterator(root, SHOW_ELEMENT_OR_TEXT);
// Find the character after cursor point
let afterNode: Node | null = startContainer;
let afterOffset = startOffset;
if (!(afterNode instanceof Text) || afterOffset === afterNode.data.length) {
afterNode = getAdjacentInlineNode(iterator, 'nextNode', afterNode);
afterOffset = 0;
}
// Find the character before cursor point
let beforeNode: Node | null = startContainer;
let beforeOffset = startOffset - 1;
if (!(beforeNode instanceof Text) || beforeOffset === -1) {
beforeNode = getAdjacentInlineNode(
iterator,
'previousPONode',
afterNode ||
(startContainer instanceof Text
? startContainer
: startContainer.childNodes[startOffset] || startContainer),
);
if (beforeNode instanceof Text) {
beforeOffset = beforeNode.data.length;
}
}
// If range starts at block boundary and character after cursor point
// is a space, replace with nbsp
let node = null;
let offset = 0;
if (
afterNode instanceof Text &&
afterNode.data.charAt(afterOffset) === ' ' &&
rangeDoesStartAtBlockBoundary(range, root)
) {
node = afterNode;
offset = afterOffset;
} else if (
beforeNode instanceof Text &&
beforeNode.data.charAt(beforeOffset) === ' '
) {
// If character before cursor point is a space, replace with nbsp
// if either:
// a) There is a space after it; or
// b) The point after is the end of the block
if (
(afterNode instanceof Text &&
afterNode.data.charAt(afterOffset) === ' ') ||
rangeDoesEndAtBlockBoundary(range, root)
) {
node = beforeNode;
offset = beforeOffset;
}
}
if (node) {
node.replaceData(offset, 1, ' '); // nbsp
}
// Range needs to be put back in place
range.setStart(startContainer, startOffset);
range.collapse(true);
return frag;
};
// Contents of range will be deleted.
// After method, range will be around inserted content
const insertTreeFragmentIntoRange = (
range: Range,
frag: DocumentFragment,
root: Element,
): void => {
const firstInFragIsInline = frag.firstChild && isInline(frag.firstChild);
let node: Node | null;
// Fixup content: ensure no top-level inline, and add cursor fix elements.
fixContainer(frag, root);
node = frag;
while ((node = getNextBlock(node, root))) {
fixCursor(node);
}
// Delete any selected content.
if (!range.collapsed) {
deleteContentsOfRange(range, root);
}
// Move range down into text nodes.
moveRangeBoundariesDownTree(range);
range.collapse(false); // collapse to end
// Where will we split up to? First blockquote parent, otherwise root.
const stopPoint =
getNearest(range.endContainer, root, 'BLOCKQUOTE') || root;
// Merge the contents of the first block in the frag with the focused block.
// If there are contents in the block after the focus point, collect this
// up to insert in the last block later. This preserves the style that was
// present in this bit of the page.
//
// If the block being inserted into is empty though, replace it instead of
// merging if the fragment had block contents.
// e.g. <blockquote><p>Foo</p></blockquote>
// This seems a reasonable approximation of user intent.
let block = getStartBlockOfRange(range, root);
let blockContentsAfterSplit: DocumentFragment | null = null;
const firstBlockInFrag = getNextBlock(frag, frag);
const replaceBlock = !firstInFragIsInline && !!block && isEmptyBlock(block);
if (
block &&
firstBlockInFrag &&
!replaceBlock &&
// Don't merge table cells or PRE elements into block
!getNearest(firstBlockInFrag, frag, 'PRE') &&
!getNearest(firstBlockInFrag, frag, 'TABLE')
) {
moveRangeBoundariesUpTree(range, block, block, root);
range.collapse(true); // collapse to start
let container = range.endContainer;
let offset = range.endOffset;
// Remove trailing <br> we don't want this considered content to be
// inserted again later
cleanupBRs(block as HTMLElement, root, false);
if (isInline(container)) {
// Split up to block parent.
const nodeAfterSplit = split(
container,
offset,
getPreviousBlock(container, root) || root,
root,
) as Node;
container = nodeAfterSplit.parentNode!;
offset = Array.from(container.childNodes).indexOf(
nodeAfterSplit as ChildNode,
);
}
if (/*isBlock( container ) && */ offset !== getLength(container)) {
// Collect any inline contents of the block after the range point
blockContentsAfterSplit = document.createDocumentFragment();
while ((node = container.childNodes[offset])) {
blockContentsAfterSplit.appendChild(node);
}
}
// And merge the first block in.
mergeWithBlock(container, firstBlockInFrag, range, root);
// And where we will insert
offset =
Array.from(container.parentNode!.childNodes).indexOf(
container as ChildNode,
) + 1;
container = container.parentNode!;
range.setEnd(container, offset);
}
// Is there still any content in the fragment?
if (getLength(frag)) {
if (replaceBlock && block) {
range.setEndBefore(block);
range.collapse(false);
detach(block);
}
moveRangeBoundariesUpTree(range, stopPoint, stopPoint, root);
// Now split after block up to blockquote (if a parent) or root
let nodeAfterSplit = split(
range.endContainer,
range.endOffset,
stopPoint,
root,
) as Node | null;
const nodeBeforeSplit = nodeAfterSplit
? nodeAfterSplit.previousSibling
: stopPoint.lastChild;
stopPoint.insertBefore(frag, nodeAfterSplit);
if (nodeAfterSplit) {
range.setEndBefore(nodeAfterSplit);
} else {
range.setEnd(stopPoint, getLength(stopPoint));
}
block = getEndBlockOfRange(range, root);
// Get a reference that won't be invalidated if we merge containers.
moveRangeBoundariesDownTree(range);
const container = range.endContainer;
const offset = range.endOffset;
// Merge inserted containers with edges of split
if (nodeAfterSplit && isContainer(nodeAfterSplit)) {
mergeContainers(nodeAfterSplit, root);
}
nodeAfterSplit = nodeBeforeSplit && nodeBeforeSplit.nextSibling;
if (nodeAfterSplit && isContainer(nodeAfterSplit)) {
mergeContainers(nodeAfterSplit, root);
}
range.setEnd(container, offset);
}
// Insert inline content saved from before.
if (blockContentsAfterSplit && block) {
const tempRange = range.cloneRange();
fixCursor(blockContentsAfterSplit);
mergeWithBlock(block, blockContentsAfterSplit, tempRange, root);
range.setEnd(tempRange.endContainer, tempRange.endOffset);
}
moveRangeBoundariesDownTree(range);
};
// ---
export {
createRange,
deleteContentsOfRange,
extractContentsOfRange,
insertNodeInRange,
insertTreeFragmentIntoRange,
};