0
Fork 0
mirror of https://github.com/fastmail/Squire.git synced 2024-12-22 15:23:29 -05:00
Squire/source/node/MergeSplit.ts
Neil Jenkins c0479f3fdf Fix missing assignment in fixCursor
The parent variable was introduced in the Typescript refactor to make
the type checker happy, however we missed assigning the result back to
the `node` variable at the end.

Fixes #449
2023-12-20 10:45:19 +11:00

303 lines
8.7 KiB
TypeScript

import { ZWS, cantFocusEmptyTextNodes } from '../Constants';
import {
createElement,
getNearest,
areAlike,
getLength,
detach,
empty,
} from './Node';
import { isInline, isContainer } from './Category';
// ---
const fixCursor = (node: Node): Node => {
// In Webkit and Gecko, block level elements are collapsed and
// unfocusable if they have no content. To remedy this, a <BR> must be
// inserted. In Opera and IE, we just need a textnode in order for the
// cursor to appear.
let fixer: Element | Text | null = null;
if (node instanceof Text) {
return node;
}
if (isInline(node)) {
let child = node.firstChild;
if (cantFocusEmptyTextNodes) {
while (child && child instanceof Text && !child.data) {
node.removeChild(child);
child = node.firstChild;
}
}
if (!child) {
if (cantFocusEmptyTextNodes) {
fixer = document.createTextNode(ZWS);
} else {
fixer = document.createTextNode('');
}
}
} else if (node instanceof Element && !node.querySelector('BR')) {
fixer = createElement('BR');
let parent: Element = node;
let child: Element | null;
while ((child = parent.lastElementChild) && !isInline(child)) {
parent = child;
}
node = parent;
}
if (fixer) {
try {
node.appendChild(fixer);
} catch (error) {}
}
return node;
};
// Recursively examine container nodes and wrap any inline children.
const fixContainer = (
container: Node,
root: Element | DocumentFragment,
): Node => {
let wrapper: HTMLElement | null = null;
Array.from(container.childNodes).forEach((child) => {
const isBR = child.nodeName === 'BR';
if (!isBR && isInline(child)) {
if (!wrapper) {
wrapper = createElement('DIV');
}
wrapper.appendChild(child);
} else if (isBR || wrapper) {
if (!wrapper) {
wrapper = createElement('DIV');
}
fixCursor(wrapper);
if (isBR) {
container.replaceChild(wrapper, child);
} else {
container.insertBefore(wrapper, child);
}
wrapper = null;
}
if (isContainer(child)) {
fixContainer(child, root);
}
});
if (wrapper) {
container.appendChild(fixCursor(wrapper));
}
return container;
};
const split = (
node: Node,
offset: number | Node | null,
stopNode: Node,
root: Element | DocumentFragment,
): Node | null => {
if (node instanceof Text && node !== stopNode) {
if (typeof offset !== 'number') {
throw new Error('Offset must be a number to split text node!');
}
if (!node.parentNode) {
throw new Error('Cannot split text node with no parent!');
}
return split(node.parentNode, node.splitText(offset), stopNode, root);
}
let nodeAfterSplit: Node | null =
typeof offset === 'number'
? offset < node.childNodes.length
? node.childNodes[offset]
: null
: offset;
const parent = node.parentNode;
if (!parent || node === stopNode || !(node instanceof Element)) {
return nodeAfterSplit;
}
// Clone node without children
const clone = node.cloneNode(false) as Element;
// Add right-hand siblings to the clone
while (nodeAfterSplit) {
const next = nodeAfterSplit.nextSibling;
clone.appendChild(nodeAfterSplit);
nodeAfterSplit = next;
}
// Maintain li numbering if inside a quote.
if (
node instanceof HTMLOListElement &&
getNearest(node, root, 'BLOCKQUOTE')
) {
(clone as HTMLOListElement).start =
(+node.start || 1) + node.childNodes.length - 1;
}
// DO NOT NORMALISE. This may undo the fixCursor() call
// of a node lower down the tree!
// We need something in the element in order for the cursor to appear.
fixCursor(node);
fixCursor(clone);
// Inject clone after original node
parent.insertBefore(clone, node.nextSibling);
// Keep on splitting up the tree
return split(parent, clone, stopNode, root);
};
const _mergeInlines = (
node: Node,
fakeRange: {
startContainer: Node;
startOffset: number;
endContainer: Node;
endOffset: number;
},
): void => {
const children = node.childNodes;
let l = children.length;
const frags: DocumentFragment[] = [];
while (l--) {
const child = children[l];
const prev = l ? children[l - 1] : null;
if (prev && isInline(child) && areAlike(child, prev)) {
if (fakeRange.startContainer === child) {
fakeRange.startContainer = prev;
fakeRange.startOffset += getLength(prev);
}
if (fakeRange.endContainer === child) {
fakeRange.endContainer = prev;
fakeRange.endOffset += getLength(prev);
}
if (fakeRange.startContainer === node) {
if (fakeRange.startOffset > l) {
fakeRange.startOffset -= 1;
} else if (fakeRange.startOffset === l) {
fakeRange.startContainer = prev;
fakeRange.startOffset = getLength(prev);
}
}
if (fakeRange.endContainer === node) {
if (fakeRange.endOffset > l) {
fakeRange.endOffset -= 1;
} else if (fakeRange.endOffset === l) {
fakeRange.endContainer = prev;
fakeRange.endOffset = getLength(prev);
}
}
detach(child);
if (child instanceof Text) {
(prev as Text).appendData(child.data);
} else {
frags.push(empty(child));
}
} else if (child instanceof Element) {
let frag: DocumentFragment | undefined;
while ((frag = frags.pop())) {
child.appendChild(frag);
}
_mergeInlines(child, fakeRange);
}
}
};
const mergeInlines = (node: Node, range: Range): void => {
const element = node instanceof Text ? node.parentNode : node;
if (element instanceof Element) {
const fakeRange = {
startContainer: range.startContainer,
startOffset: range.startOffset,
endContainer: range.endContainer,
endOffset: range.endOffset,
};
_mergeInlines(element, fakeRange);
range.setStart(fakeRange.startContainer, fakeRange.startOffset);
range.setEnd(fakeRange.endContainer, fakeRange.endOffset);
}
};
const mergeWithBlock = (
block: Node,
next: Node,
range: Range,
root: Element,
): void => {
let container = next;
let parent: Node | null;
let offset: number;
while (
(parent = container.parentNode) &&
parent !== root &&
parent instanceof Element &&
parent.childNodes.length === 1
) {
container = parent;
}
detach(container);
offset = block.childNodes.length;
// Remove extra <BR> fixer if present.
const last = block.lastChild;
if (last && last.nodeName === 'BR') {
block.removeChild(last);
offset -= 1;
}
block.appendChild(empty(next));
range.setStart(block, offset);
range.collapse(true);
mergeInlines(block, range);
};
const mergeContainers = (node: Node, root: Element): void => {
const prev = node.previousSibling;
const first = node.firstChild;
const isListItem = node.nodeName === 'LI';
// Do not merge LIs, unless it only contains a UL
if (isListItem && (!first || !/^[OU]L$/.test(first.nodeName))) {
return;
}
if (prev && areAlike(prev, node)) {
if (!isContainer(prev)) {
if (isListItem) {
const block = createElement('DIV');
block.appendChild(empty(prev));
prev.appendChild(block);
} else {
return;
}
}
detach(node);
const needsFix = !isContainer(node);
prev.appendChild(empty(node));
if (needsFix) {
fixContainer(prev, root);
}
if (first) {
mergeContainers(first, root);
}
} else if (isListItem) {
const block = createElement('DIV');
node.insertBefore(block, first);
fixCursor(block);
}
};
// ---
export {
fixContainer,
fixCursor,
mergeContainers,
mergeInlines,
mergeWithBlock,
split,
};