mirror of
https://github.com/fastmail/Squire.git
synced 2024-12-22 15:23:29 -05:00
fe0dfdf6c4
This is a massive refactor to port Squire to TypeScript, fix a bunch of small bugs and modernise our tooling. The development was done on an internal repository, so apologies to anyone following externally for the commit dump; updates from here should come as real commits again. Co-authored-by: Joe Woods <woods@fastmailteam.com>
442 lines
14 KiB
TypeScript
442 lines
14 KiB
TypeScript
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
|
||
if (startContainer instanceof Text && endContainer instanceof Text) {
|
||
startContainer.appendData(endContainer.data);
|
||
detach(endContainer);
|
||
endContainer = startContainer;
|
||
endOffset = startOffset;
|
||
}
|
||
|
||
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();
|
||
mergeWithBlock(block, blockContentsAfterSplit, tempRange, root);
|
||
range.setEnd(tempRange.endContainer, tempRange.endOffset);
|
||
}
|
||
moveRangeBoundariesDownTree(range);
|
||
};
|
||
|
||
// ---
|
||
|
||
export {
|
||
createRange,
|
||
deleteContentsOfRange,
|
||
extractContentsOfRange,
|
||
insertNodeInRange,
|
||
insertTreeFragmentIntoRange,
|
||
};
|