import { Fun, Optional, Optionals, Type } from '@ephox/katamari'; import DOMUtils from '../api/dom/DOMUtils'; import EditorSelection from '../api/dom/Selection'; import Env from '../api/Env'; import Tools from '../api/util/Tools'; import * as CaretFinder from '../caret/CaretFinder'; import CaretPosition from '../caret/CaretPosition'; import * as NodeType from '../dom/NodeType'; import { getParentCaretContainer } from '../fmt/FormatContainer'; import * as Zwsp from '../text/Zwsp'; import { Bookmark, IdBookmark, IndexBookmark, isIdBookmark, isIndexBookmark, isPathBookmark, isRangeBookmark, isStringPathBookmark, PathBookmark, StringPathBookmark } from './BookmarkTypes'; import * as CaretBookmark from './CaretBookmark'; export interface BookmarkResolveResult { readonly range: Range; readonly forward: boolean; } const isForwardBookmark = (bookmark: Bookmark) => !isIndexBookmark(bookmark) && Type.isBoolean(bookmark.forward) ? bookmark.forward : true; const addBogus = (dom: DOMUtils, node: Node): Node => { // Adds a bogus BR element for empty block elements if (NodeType.isElement(node) && dom.isBlock(node) && !node.innerHTML) { node.innerHTML = '
'; } return node; }; const resolveCaretPositionBookmark = (dom: DOMUtils, bookmark: StringPathBookmark): Optional => { const startPos = Optional.from(CaretBookmark.resolve(dom.getRoot(), bookmark.start)); const endPos = Optional.from(CaretBookmark.resolve(dom.getRoot(), bookmark.end)); return Optionals.lift2(startPos, endPos, (start, end) => { const range = dom.createRng(); range.setStart(start.container(), start.offset()); range.setEnd(end.container(), end.offset()); return { range, forward: isForwardBookmark(bookmark) }; }); }; const insertZwsp = (node: Node, rng: Range) => { const doc = node.ownerDocument ?? document; const textNode = doc.createTextNode(Zwsp.ZWSP); node.appendChild(textNode); rng.setStart(textNode, 0); rng.setEnd(textNode, 0); }; const isEmpty = (node: Node) => !node.hasChildNodes(); const tryFindRangePosition = (node: Node, rng: Range): boolean => CaretFinder.lastPositionIn(node).fold( Fun.never, (pos) => { rng.setStart(pos.container(), pos.offset()); rng.setEnd(pos.container(), pos.offset()); return true; } ); // Since we trim zwsp from undo levels the caret format containers // may be empty if so pad them with a zwsp and move caret there const padEmptyCaretContainer = (root: HTMLElement, node: Node, rng: Range): boolean => { if (isEmpty(node) && getParentCaretContainer(root, node)) { insertZwsp(node, rng); return true; } else { return false; } }; const setEndPoint = (dom: DOMUtils, start: boolean, bookmark: PathBookmark, rng: Range) => { const point = bookmark[start ? 'start' : 'end']; const root = dom.getRoot(); if (point) { let node: Node | null = root; let offset = point[0]; // Find container node for (let i = point.length - 1; node && i >= 1; i--) { const children = node.childNodes as NodeListOf; if (padEmptyCaretContainer(root, node, rng)) { return true; } if (point[i] > children.length - 1) { if (padEmptyCaretContainer(root, node, rng)) { return true; } return tryFindRangePosition(node, rng); } node = children[point[i]]; } // Move text offset to best suitable location if (NodeType.isText(node)) { offset = Math.min(point[0], node.data.length); } // Move element offset to best suitable location if (NodeType.isElement(node)) { offset = Math.min(point[0], node.childNodes.length); } // Set offset within container node if (start) { rng.setStart(node, offset); } else { rng.setEnd(node, offset); } } return true; }; const isValidTextNode = (node: Node | null): node is Text => NodeType.isText(node) && node.data.length > 0; const restoreEndPoint = (dom: DOMUtils, suffix: string, bookmark: IdBookmark): Optional => { const marker = dom.get(bookmark.id + '_' + suffix); const markerParent = marker?.parentNode; const keep = bookmark.keep; if (marker && markerParent) { let container: Node; let offset: number; if (suffix === 'start') { if (!keep) { container = markerParent; offset = dom.nodeIndex(marker); } else { if (marker.hasChildNodes()) { container = marker.firstChild as Node; offset = 1; } else if (isValidTextNode(marker.nextSibling)) { container = marker.nextSibling; offset = 0; } else if (isValidTextNode(marker.previousSibling)) { container = marker.previousSibling; offset = marker.previousSibling.data.length; } else { container = markerParent; offset = dom.nodeIndex(marker) + 1; } } } else { if (!keep) { container = markerParent; offset = dom.nodeIndex(marker); } else { if (marker.hasChildNodes()) { container = marker.firstChild as Node; offset = 1; } else if (isValidTextNode(marker.previousSibling)) { container = marker.previousSibling; offset = marker.previousSibling.data.length; } else { container = markerParent; offset = dom.nodeIndex(marker); } } } if (!keep) { const prev = marker.previousSibling; const next = marker.nextSibling; // Remove all marker text nodes Tools.each(Tools.grep(marker.childNodes), (node) => { if (NodeType.isText(node)) { node.data = node.data.replace(/\uFEFF/g, ''); } }); // Remove marker but keep children if for example contents where inserted into the marker // Also remove duplicated instances of the marker for example by a // split operation or by WebKit auto split on paste feature let otherMarker: Node | null; while ((otherMarker = dom.get(bookmark.id + '_' + suffix))) { dom.remove(otherMarker, true); } // If siblings are text nodes then merge them unless it's Opera since it some how removes the node // and we are sniffing since adding a lot of detection code for a browser with 3% of the market // isn't worth the effort. Sorry, Opera but it's just a fact if (NodeType.isText(next) && NodeType.isText(prev) && !Env.browser.isOpera()) { const idx = prev.data.length; prev.appendData(next.data); dom.remove(next); container = prev; offset = idx; } } return Optional.some(CaretPosition(container, offset)); } else { return Optional.none(); } }; const resolvePaths = (dom: DOMUtils, bookmark: PathBookmark): Optional => { const range = dom.createRng(); if (setEndPoint(dom, true, bookmark, range) && setEndPoint(dom, false, bookmark, range)) { return Optional.some({ range, forward: isForwardBookmark(bookmark) }); } else { return Optional.none(); } }; const resolveId = (dom: DOMUtils, bookmark: IdBookmark): Optional => { const startPos = restoreEndPoint(dom, 'start', bookmark); const endPos = restoreEndPoint(dom, 'end', bookmark); return Optionals.lift2( startPos, endPos.or(startPos), (spos, epos) => { const range = dom.createRng(); range.setStart(addBogus(dom, spos.container()), spos.offset()); range.setEnd(addBogus(dom, epos.container()), epos.offset()); return { range, forward: isForwardBookmark(bookmark) }; } ); }; const resolveIndex = (dom: DOMUtils, bookmark: IndexBookmark): Optional => Optional.from(dom.select(bookmark.name)[bookmark.index]).map((elm) => { const range = dom.createRng(); range.selectNode(elm); return { range, forward: true }; }); const resolve = (selection: EditorSelection, bookmark: Bookmark): Optional => { const dom = selection.dom; if (bookmark) { if (isPathBookmark(bookmark)) { return resolvePaths(dom, bookmark); } else if (isStringPathBookmark(bookmark)) { return resolveCaretPositionBookmark(dom, bookmark); } else if (isIdBookmark(bookmark)) { return resolveId(dom, bookmark); } else if (isIndexBookmark(bookmark)) { return resolveIndex(dom, bookmark); } else if (isRangeBookmark(bookmark)) { return Optional.some({ range: bookmark.rng, forward: isForwardBookmark(bookmark) }); } } return Optional.none(); }; export { resolve };