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; // 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, 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.

Foo

// 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
– 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, };