import { TreeIterator, SHOW_TEXT, SHOW_ELEMENT_OR_TEXT, } from './node/TreeIterator'; import { createElement, detach, empty, getNearest, hasTagAttributes, replaceWith, } from './node/Node'; import { isLeaf, isInline, resetNodeCategoryCache, isContainer, isBlock, } from './node/Category'; import { isLineBreak, removeZWS } from './node/Whitespace'; import { moveRangeBoundariesDownTree, isNodeContainedInRange, moveRangeBoundaryOutOf, moveRangeBoundariesUpTree, } from './range/Boundaries'; import { createRange, deleteContentsOfRange, extractContentsOfRange, insertNodeInRange, insertTreeFragmentIntoRange, } from './range/InsertDelete'; import { fixContainer, fixCursor, mergeContainers, mergeInlines, split, } from './node/MergeSplit'; import { getBlockWalker, getNextBlock, isEmptyBlock } from './node/Block'; import { cleanTree, cleanupBRs, escapeHTML, removeEmptyInlines } from './Clean'; import { cantFocusEmptyTextNodes, isAndroid, ZWS } from './Constants'; import { expandRangeToBlockBoundaries, getEndBlockOfRange, getStartBlockOfRange, rangeDoesEndAtBlockBoundary, rangeDoesStartAtBlockBoundary, } from './range/Block'; import { _monitorShiftKey, _onCopy, _onCut, _onDrop, _onPaste, } from './Clipboard'; import { keyHandlers, _onKey } from './keyboard/KeyHandlers'; import { linkifyText } from './keyboard/KeyHelpers'; declare const DOMPurify: any; // --- type EventHandler = { handleEvent: (e: Event) => void } | ((e: Event) => void); type KeyHandlerFunction = (x: Squire, y: KeyboardEvent, z: Range) => void; type TagAttributes = { [key: string]: { [key: string]: string }; }; interface SquireConfig { blockTag: string; blockAttributes: null | Record; tagAttributes: TagAttributes; classNames: { color: string; fontFamily: string; fontSize: string; highlight: string; }; undo: { documentSizeThreshold: number; undoLimit: number; }; addLinks: boolean; willCutCopy: null | ((html: string) => string); sanitizeToDOMFragment: (html: string, editor: Squire) => DocumentFragment; didError: (x: any) => void; } // --- class Squire { _root: HTMLElement; _config: SquireConfig; _isFocused: boolean; _lastSelection: Range; _willRestoreSelection: boolean; _mayHaveZWS: boolean; _lastAnchorNode: Node | null; _lastFocusNode: Node | null; _path: string; _events: Map>; _undoIndex: number; _undoStack: Array; _undoStackLength: number; _isInUndoState: boolean; _ignoreChange: boolean; _ignoreAllChanges: boolean; _isShiftDown: boolean; _keyHandlers: Record; _mutation: MutationObserver; constructor(root: HTMLElement, config?: object) { this._root = root; this._config = this._makeConfig(config); this._isFocused = false; this._lastSelection = createRange(root, 0); this._willRestoreSelection = false; this._mayHaveZWS = false; this._lastAnchorNode = null; this._lastFocusNode = null; this._path = ''; this._events = new Map(); this._undoIndex = -1; this._undoStack = []; this._undoStackLength = 0; this._isInUndoState = false; this._ignoreChange = false; this._ignoreAllChanges = false; // Add event listeners this.addEventListener('selectionchange', this._updatePathOnEvent); // On blur, restore focus except if the user taps or clicks to focus a // specific point. Can't actually use click event because focus happens // before click, so use mousedown/touchstart this.addEventListener('blur', this._enableRestoreSelection); this.addEventListener('mousedown', this._disableRestoreSelection); this.addEventListener('touchstart', this._disableRestoreSelection); this.addEventListener('focus', this._restoreSelection); // Clipboard support this._isShiftDown = false; this.addEventListener('cut', _onCut as (e: Event) => void); this.addEventListener('copy', _onCopy as (e: Event) => void); this.addEventListener('paste', _onPaste as (e: Event) => void); this.addEventListener('drop', _onDrop as (e: Event) => void); this.addEventListener( 'keydown', _monitorShiftKey as (e: Event) => void, ); this.addEventListener('keyup', _monitorShiftKey as (e: Event) => void); // Keyboard support this.addEventListener('keydown', _onKey as (e: Event) => void); this._keyHandlers = Object.create(keyHandlers); const mutation = new MutationObserver(() => this._docWasChanged()); mutation.observe(root, { childList: true, attributes: true, characterData: true, subtree: true, }); this._mutation = mutation; // Make it editable root.setAttribute('contenteditable', 'true'); // Remove Firefox's built-in controls try { document.execCommand('enableObjectResizing', false, 'false'); document.execCommand('enableInlineTableEditing', false, 'false'); } catch (_) {} // Modern browsers let you override their default content editable // handling! this.addEventListener( 'beforeinput', this._beforeInput as (e: Event) => void, ); this.setHTML(''); } destroy(): void { this._events.forEach((_, type) => { this.removeEventListener(type); }); this._mutation.disconnect(); this._undoIndex = -1; this._undoStack = []; this._undoStackLength = 0; } _makeConfig(userConfig?: object): SquireConfig { const config = { blockTag: 'DIV', blockAttributes: null, tagAttributes: {}, classNames: { color: 'color', fontFamily: 'font', fontSize: 'size', highlight: 'highlight', }, undo: { documentSizeThreshold: -1, // -1 means no threshold undoLimit: -1, // -1 means no limit }, addLinks: true, willCutCopy: null, sanitizeToDOMFragment: ( html: string, /* editor: Squire, */ ): DocumentFragment => { const frag = DOMPurify.sanitize(html, { ALLOW_UNKNOWN_PROTOCOLS: true, WHOLE_DOCUMENT: false, RETURN_DOM: true, RETURN_DOM_FRAGMENT: true, FORCE_BODY: false, }); return frag ? document.importNode(frag, true) : document.createDocumentFragment(); }, didError: (error: any): void => console.log(error), }; if (userConfig) { Object.assign(config, userConfig); config.blockTag = config.blockTag.toUpperCase(); } return config; } setKeyHandler(key: number, fn: KeyHandlerFunction) { this._keyHandlers[key] = fn; return this; } _beforeInput(event: InputEvent): void { switch (event.inputType) { case 'insertText': // Generally we let the browser handle text insertion, as it // does so fine. However, the Samsung keyboard on Android with // the Grammerly extension goes batshit crazy for some reason // and will try to disastrously rewrite the whole data, without // the user even doing anything (it can happen on first load // before the user types anything). Fortunately we can detect // this by looking for a new line in the data and if we see it, // stop it by preventing default. if (isAndroid && event.data && event.data.includes('\n')) { event.preventDefault(); } break; case 'insertLineBreak': event.preventDefault(); this.splitBlock(true); break; case 'insertParagraph': event.preventDefault(); this.splitBlock(false); break; case 'insertOrderedList': event.preventDefault(); this.makeOrderedList(); break; case 'insertUnoderedList': event.preventDefault(); this.makeUnorderedList(); break; case 'historyUndo': event.preventDefault(); this.undo(); break; case 'historyRedo': event.preventDefault(); this.redo(); break; case 'formatBold': event.preventDefault(); this.bold(); break; case 'formaItalic': event.preventDefault(); this.italic(); break; case 'formatUnderline': event.preventDefault(); this.underline(); break; case 'formatStrikeThrough': event.preventDefault(); this.strikethrough(); break; case 'formatSuperscript': event.preventDefault(); this.superscript(); break; case 'formatSubscript': event.preventDefault(); this.subscript(); break; case 'formatJustifyFull': case 'formatJustifyCenter': case 'formatJustifyRight': case 'formatJustifyLeft': { event.preventDefault(); let alignment = event.inputType.slice(13).toLowerCase(); if (alignment === 'full') { alignment = 'justify'; } this.setTextAlignment(alignment); break; } case 'formatRemove': event.preventDefault(); this.removeAllFormatting(); break; case 'formatSetBlockTextDirection': { event.preventDefault(); let dir = event.data; if (dir === 'null') { dir = null; } this.setTextDirection(dir); break; } case 'formatBackColor': event.preventDefault(); this.setHighlightColor(event.data); break; case 'formatFontColor': event.preventDefault(); this.setTextColor(event.data); break; case 'formatFontName': event.preventDefault(); this.setFontFace(event.data); break; } } // --- Events handleEvent(event: Event): void { this.fireEvent(event.type, event); } fireEvent(type: string, detail?: Event | object): Squire { let handlers = this._events.get(type); // UI code, especially modal views, may be monitoring for focus events // and immediately removing focus. In certain conditions, this can // cause the focus event to fire after the blur event, which can cause // an infinite loop. So we detect whether we're actually // focused/blurred before firing. if (/^(?:focus|blur)/.test(type)) { const isFocused = this._root === document.activeElement; if (type === 'focus') { if (!isFocused || this._isFocused) { return this; } this._isFocused = true; } else { if (isFocused || !this._isFocused) { return this; } this._isFocused = false; } } if (handlers) { const event: Event = detail instanceof Event ? detail : new CustomEvent(type, { detail, }); // Clone handlers array, so any handlers added/removed do not // affect it. handlers = handlers.slice(); for (const handler of handlers) { try { if ('handleEvent' in handler) { handler.handleEvent(event); } else { handler.call(this, event); } } catch (error) { this._config.didError(error); } } } return this; } /** * Subscribing to these events won't automatically add a listener to the * document node, since these events are fired in a custom manner by the * editor code. */ customEvents = new Set([ 'pathChange', 'select', 'input', 'pasteImage', 'undoStateChange', ]); addEventListener(type: string, fn: EventHandler): Squire { let handlers = this._events.get(type); let target: Document | HTMLElement = this._root; if (!handlers) { handlers = []; this._events.set(type, handlers); if (!this.customEvents.has(type)) { if (type === 'selectionchange') { target = document; } target.addEventListener(type, this, true); } } handlers.push(fn); return this; } removeEventListener(type: string, fn?: EventHandler): Squire { const handlers = this._events.get(type); let target: Document | HTMLElement = this._root; if (handlers) { if (fn) { let l = handlers.length; while (l--) { if (handlers[l] === fn) { handlers.splice(l, 1); } } } else { handlers.length = 0; } if (!handlers.length) { this._events.delete(type); if (!this.customEvents.has(type)) { if (type === 'selectionchange') { target = document; } target.removeEventListener(type, this, true); } } } return this; } // --- Focus focus(): Squire { this._root.focus({ preventScroll: true }); return this; } blur(): Squire { this._root.blur(); return this; } // --- Selection and bookmarking _enableRestoreSelection(): void { this._willRestoreSelection = true; } _disableRestoreSelection(): void { this._willRestoreSelection = false; } _restoreSelection() { if (this._willRestoreSelection) { this.setSelection(this._lastSelection); } } // --- _removeZWS(): void { if (!this._mayHaveZWS) { return; } removeZWS(this._root); this._mayHaveZWS = false; } // --- startSelectionId = 'squire-selection-start'; endSelectionId = 'squire-selection-end'; _saveRangeToBookmark(range: Range): void { let startNode = createElement('INPUT', { id: this.startSelectionId, type: 'hidden', }); let endNode = createElement('INPUT', { id: this.endSelectionId, type: 'hidden', }); let temp: HTMLElement; insertNodeInRange(range, startNode); range.collapse(false); insertNodeInRange(range, endNode); // In a collapsed range, the start is sometimes inserted after the end! if ( startNode.compareDocumentPosition(endNode) & Node.DOCUMENT_POSITION_PRECEDING ) { startNode.id = this.endSelectionId; endNode.id = this.startSelectionId; temp = startNode; startNode = endNode; endNode = temp; } range.setStartAfter(startNode); range.setEndBefore(endNode); } _getRangeAndRemoveBookmark(range?: Range): Range | null { const root = this._root; const start = root.querySelector('#' + this.startSelectionId); const end = root.querySelector('#' + this.endSelectionId); if (start && end) { let startContainer: Node = start.parentNode!; let endContainer: Node = end.parentNode!; const startOffset = Array.from(startContainer.childNodes).indexOf( start, ); let endOffset = Array.from(endContainer.childNodes).indexOf(end); if (startContainer === endContainer) { endOffset -= 1; } start.remove(); end.remove(); if (!range) { range = document.createRange(); } range.setStart(startContainer, startOffset); range.setEnd(endContainer, endOffset); // Merge any text nodes we split mergeInlines(startContainer, range); if (startContainer !== endContainer) { mergeInlines(endContainer, range); } // If we didn't split a text node, we should move into any adjacent // text node to current selection point if (range.collapsed) { startContainer = range.startContainer; if (startContainer instanceof Text) { endContainer = startContainer.childNodes[range.startOffset]; if (!endContainer || !(endContainer instanceof Text)) { endContainer = startContainer.childNodes[range.startOffset - 1]; } if (endContainer && endContainer instanceof Text) { range.setStart(endContainer, 0); range.collapse(true); } } } } return range || null; } getSelection(): Range { const selection = window.getSelection(); const root = this._root; let range: Range | null = null; // If not focused, always rely on cached selection; another function may // have set it but the DOM is not modified until focus again if (this._isFocused && selection && selection.rangeCount) { range = selection.getRangeAt(0).cloneRange(); const startContainer = range.startContainer; const endContainer = range.endContainer; // FF can return the selection as being inside an . WTF? if (startContainer && isLeaf(startContainer)) { range.setStartBefore(startContainer); } if (endContainer && isLeaf(endContainer)) { range.setEndBefore(endContainer); } } if (range && root.contains(range.commonAncestorContainer)) { this._lastSelection = range; } else { range = this._lastSelection; // Check the editor is in the live document; if not, the range has // probably been rewritten by the browser and is bogus if (!document.contains(range.commonAncestorContainer)) { range = null; } } if (!range) { range = createRange(root.firstElementChild || root, 0); } return range; } setSelection(range: Range): Squire { this._lastSelection = range; // If we're setting selection, that automatically, and synchronously, // triggers a focus event. So just store the selection and mark it as // needing restore on focus. if (!this._isFocused) { this._enableRestoreSelection(); } else { const selection = window.getSelection(); if (selection) { if ('setBaseAndExtent' in Selection.prototype) { selection.setBaseAndExtent( range.startContainer, range.startOffset, range.endContainer, range.endOffset, ); } else { selection.removeAllRanges(); selection.addRange(range); } } } return this; } // --- _moveCursorTo(toStart: boolean): Squire { const root = this._root; const range = createRange(root, toStart ? 0 : root.childNodes.length); moveRangeBoundariesDownTree(range); this.setSelection(range); return this; } moveCursorToStart(): Squire { return this._moveCursorTo(true); } moveCursorToEnd(): Squire { return this._moveCursorTo(false); } // --- getCursorPosition(): DOMRect { const range = this.getSelection(); let rect = range.getBoundingClientRect(); // If the range is outside of the viewport, some browsers at least // will return 0 for all the values; need to get a DOM node to find // the position instead. if (rect && !rect.top) { this._ignoreChange = true; const node = createElement('SPAN'); node.textContent = ZWS; insertNodeInRange(range, node); rect = node.getBoundingClientRect(); const parent = node.parentNode!; parent.removeChild(node); mergeInlines(parent, range); } return rect; } // --- Path getPath(): string { return this._path; } _updatePathOnEvent(): void { if (this._isFocused) { this._updatePath(this.getSelection()); } } _updatePath(range: Range, force?: boolean): void { const anchor = range.startContainer; const focus = range.endContainer; let newPath: string; if ( force || anchor !== this._lastAnchorNode || focus !== this._lastFocusNode ) { this._lastAnchorNode = anchor; this._lastFocusNode = focus; newPath = anchor && focus ? anchor === focus ? this._getPath(focus) : '(selection)' : ''; if (this._path !== newPath) { this._path = newPath; this.fireEvent('pathChange', { path: newPath, }); } } this.fireEvent(range.collapsed ? 'cursor' : 'select', { range: range, }); } _getPath(node: Node) { const root = this._root; const config = this._config; let path = ''; if (node && node !== root) { const parent = node.parentNode; path = parent ? this._getPath(parent) : ''; if (node instanceof HTMLElement) { const id = node.id; const classList = node.classList; const classNames = Array.from(classList).sort(); const dir = node.dir; const styleNames = config.classNames; path += (path ? '>' : '') + node.nodeName; if (id) { path += '#' + id; } if (classNames.length) { path += '.'; path += classNames.join('.'); } if (dir) { path += '[dir=' + dir + ']'; } if (classList.contains(styleNames.highlight)) { path += '[backgroundColor=' + node.style.backgroundColor.replace(/ /g, '') + ']'; } if (classList.contains(styleNames.color)) { path += '[color=' + node.style.color.replace(/ /g, '') + ']'; } if (classList.contains(styleNames.fontFamily)) { path += '[fontFamily=' + node.style.fontFamily.replace(/ /g, '') + ']'; } if (classList.contains(styleNames.fontSize)) { path += '[fontSize=' + node.style.fontSize + ']'; } } } return path; } // --- History modifyDocument(modificationFn: () => void): Squire { const mutation = this._mutation; if (mutation) { if (mutation.takeRecords().length) { this._docWasChanged(); } mutation.disconnect(); } this._ignoreAllChanges = true; modificationFn(); this._ignoreAllChanges = false; if (mutation) { mutation.observe(this._root, { childList: true, attributes: true, characterData: true, subtree: true, }); this._ignoreChange = false; } return this; } _docWasChanged(): void { resetNodeCategoryCache(); this._mayHaveZWS = true; if (this._ignoreAllChanges) { return; } if (this._ignoreChange) { this._ignoreChange = false; return; } if (this._isInUndoState) { this._isInUndoState = false; this.fireEvent('undoStateChange', { canUndo: true, canRedo: false, }); } this.fireEvent('input'); } /** * Leaves bookmark. */ _recordUndoState(range: Range, replace?: boolean): Squire { // Don't record if we're already in an undo state if (!this._isInUndoState || replace) { // Advance pointer to new position let undoIndex = this._undoIndex; const undoStack = this._undoStack; const undoConfig = this._config.undo; const undoThreshold = undoConfig.documentSizeThreshold; const undoLimit = undoConfig.undoLimit; if (!replace) { undoIndex += 1; } // Truncate stack if longer (i.e. if has been previously undone) if (undoIndex < this._undoStackLength) { undoStack.length = this._undoStackLength = undoIndex; } // Get data if (range) { this._saveRangeToBookmark(range); } const html = this._getRawHTML(); // If this document is above the configured size threshold, // limit the number of saved undo states. // Threshold is in bytes, JS uses 2 bytes per character if (undoThreshold > -1 && html.length * 2 > undoThreshold) { if (undoLimit > -1 && undoIndex > undoLimit) { undoStack.splice(0, undoIndex - undoLimit); undoIndex = undoLimit; this._undoStackLength = undoLimit; } } // Save data undoStack[undoIndex] = html; this._undoIndex = undoIndex; this._undoStackLength += 1; this._isInUndoState = true; } return this; } saveUndoState(range?: Range): Squire { if (!range) { range = this.getSelection(); } this._recordUndoState(range, this._isInUndoState); this._getRangeAndRemoveBookmark(range); return this; } undo(): Squire { // Sanity check: must not be at beginning of the history stack if (this._undoIndex !== 0 || !this._isInUndoState) { // Make sure any changes since last checkpoint are saved. this._recordUndoState(this.getSelection(), false); this._undoIndex -= 1; this._setRawHTML(this._undoStack[this._undoIndex]); const range = this._getRangeAndRemoveBookmark(); if (range) { this.setSelection(range); } this._isInUndoState = true; this.fireEvent('undoStateChange', { canUndo: this._undoIndex !== 0, canRedo: true, }); this.fireEvent('input'); } return this; } redo(): Squire { // Sanity check: must not be at end of stack and must be in an undo // state. const undoIndex = this._undoIndex; const undoStackLength = this._undoStackLength; if (undoIndex + 1 < undoStackLength && this._isInUndoState) { this._undoIndex += 1; this._setRawHTML(this._undoStack[this._undoIndex]); const range = this._getRangeAndRemoveBookmark(); if (range) { this.setSelection(range); } this.fireEvent('undoStateChange', { canUndo: true, canRedo: undoIndex + 2 < undoStackLength, }); this.fireEvent('input'); } return this; } // --- Get and set data getRoot(): HTMLElement { return this._root; } _getRawHTML(): string { return this._root.innerHTML; } _setRawHTML(html: string): Squire { const root = this._root; root.innerHTML = html; let node: Element | null = root; const child = node.firstChild; if (!child || child.nodeName === 'BR') { const block = this.createDefaultBlock(); if (child) { node.replaceChild(block, child); } else { node.appendChild(block); } } else { while ((node = getNextBlock(node, root))) { fixCursor(node); } } this._ignoreChange = true; return this; } getHTML(withBookmark?: boolean): string { let range: Range | undefined; if (withBookmark) { range = this.getSelection(); this._saveRangeToBookmark(range); } const html = this._getRawHTML().replace(/\u200B/g, ''); if (withBookmark) { this._getRangeAndRemoveBookmark(range); } return html; } setHTML(html: string): Squire { // Parse HTML into DOM tree const frag = this._config.sanitizeToDOMFragment(html, this); const root = this._root; // Fixup DOM tree cleanTree(frag, this._config); cleanupBRs(frag, root, false); fixContainer(frag, root); // Fix cursor let node: DocumentFragment | HTMLElement | null = frag; let child = node.firstChild; if (!child || child.nodeName === 'BR') { const block = this.createDefaultBlock(); if (child) { node.replaceChild(block, child); } else { node.appendChild(block); } } else { while ((node = getNextBlock(node, root))) { fixCursor(node); } } // Don't fire an input event this._ignoreChange = true; // Remove existing root children and insert new content while ((child = root.lastChild)) { root.removeChild(child); } root.appendChild(frag); // Reset the undo stack this._undoIndex = -1; this._undoStack.length = 0; this._undoStackLength = 0; this._isInUndoState = false; // Record undo state const range = this._getRangeAndRemoveBookmark() || createRange(root.firstElementChild || root, 0); this.saveUndoState(range); // Set inital selection this.setSelection(range); this._updatePath(range, true); return this; } /** * Insert HTML at the cursor location. If the selection is not collapsed * insertTreeFragmentIntoRange will delete the selection so that it is * replaced by the html being inserted. */ insertHTML(html: string, isPaste: boolean): Squire { // Parse const config = this._config; let frag = config.sanitizeToDOMFragment(html, this); // Record undo checkpoint const range = this.getSelection(); this.saveUndoState(range); try { const root = this._root; if (config.addLinks) { this.addDetectedLinks(frag, frag); } cleanTree(frag, this._config); cleanupBRs(frag, root, false); removeEmptyInlines(frag); frag.normalize(); let node: HTMLElement | DocumentFragment | null = frag; while ((node = getNextBlock(node, frag))) { fixCursor(node); } let doInsert = true; if (isPaste) { const event = new CustomEvent('willPaste', { detail: { fragment: frag, }, }); this.fireEvent('willPaste', event); frag = event.detail.fragment; doInsert = !event.defaultPrevented; } if (doInsert) { insertTreeFragmentIntoRange(range, frag, root); range.collapse(false); // After inserting the fragment, check whether the cursor is // inside an element and if so if there is an equivalent // cursor position after the element. If there is, move it // there. moveRangeBoundaryOutOf(range, 'A', root); this._ensureBottomLine(); } this.setSelection(range); this._updatePath(range, true); // Safari sometimes loses focus after paste. Weird. if (isPaste) { this.focus(); } } catch (error) { this._config.didError(error); } return this; } insertElement(el: Element, range?: Range): Squire { if (!range) { range = this.getSelection(); } range.collapse(true); if (isInline(el)) { insertNodeInRange(range, el); range.setStartAfter(el); } else { // Get containing block node. const root = this._root; const startNode: HTMLElement | null = getStartBlockOfRange( range, root, ); let splitNode: Element | Node = startNode || root; let nodeAfterSplit: Node | null = null; // While at end of container node, move up DOM tree. while (splitNode !== root && !splitNode.nextSibling) { splitNode = splitNode.parentNode!; } // If in the middle of a container node, split up to root. if (splitNode !== root) { const parent = splitNode.parentNode!; nodeAfterSplit = split( parent, splitNode.nextSibling, root, root, ) as Node; } // If the startNode was empty remove it so that we don't end up // with two blank lines. if (startNode && isEmptyBlock(startNode)) { detach(startNode); } // Insert element and blank line. root.insertBefore(el, nodeAfterSplit); const blankLine = this.createDefaultBlock(); root.insertBefore(blankLine, nodeAfterSplit); // Move cursor to blank line after inserted element. range.setStart(blankLine, 0); range.setEnd(blankLine, 0); moveRangeBoundariesDownTree(range); } this.focus(); this.setSelection(range); this._updatePath(range); return this; } insertImage( src: string, attributes: Record, ): HTMLImageElement { const img = createElement( 'IMG', Object.assign( { src: src, }, attributes, ), ) as HTMLImageElement; this.insertElement(img); return img; } insertPlainText(plainText: string, isPaste: boolean): Squire { const range = this.getSelection(); if ( range.collapsed && getNearest(range.startContainer, this._root, 'PRE') ) { const startContainer: Node = range.startContainer; let offset = range.startOffset; let textNode: Text; if (!startContainer || !(startContainer instanceof Text)) { const text = document.createTextNode(''); startContainer.insertBefore( text, startContainer.childNodes[offset], ); textNode = text; offset = 0; } else { textNode = startContainer; } let doInsert = true; if (isPaste) { const event = new CustomEvent('willPaste', { detail: { text: plainText, }, }); this.fireEvent('willPaste', event); plainText = event.detail.text; doInsert = !event.defaultPrevented; } if (doInsert) { textNode.insertData(offset, plainText); range.setStart(textNode, offset + plainText.length); range.collapse(true); } this.setSelection(range); return this; } const lines = plainText.split('\n'); const config = this._config; const tag = config.blockTag; const attributes = config.blockAttributes; const closeBlock = ''; let openBlock = '<' + tag; for (const attr in attributes) { openBlock += ' ' + attr + '="' + escapeHTML(attributes[attr]) + '"'; } openBlock += '>'; for (let i = 0, l = lines.length; i < l; i += 1) { let line = lines[i]; line = escapeHTML(line).replace(/ (?=(?: |$))/g, ' '); // We don't wrap the first line in the block, so if it gets inserted // into a blank line it keeps that line's formatting. // Wrap each line in
if (i) { line = openBlock + (line || '
') + closeBlock; } lines[i] = line; } return this.insertHTML(lines.join(''), isPaste); } getSelectedText(): string { const range = this.getSelection(); if (range.collapsed) { return ''; } const startContainer = range.startContainer; const endContainer = range.endContainer; const walker = new TreeIterator( range.commonAncestorContainer, SHOW_ELEMENT_OR_TEXT, (node) => { return isNodeContainedInRange(range, node, true); }, ); walker.currentNode = startContainer; let node: Node | null = startContainer; let textContent = ''; let addedTextInBlock = false; let value: string; if ( (!(node instanceof Element) && !(node instanceof Text)) || !walker.filter(node) ) { node = walker.nextNode(); } while (node) { if (node instanceof Text) { value = node.data; if (value && /\S/.test(value)) { if (node === endContainer) { value = value.slice(0, range.endOffset); } if (node === startContainer) { value = value.slice(range.startOffset); } textContent += value; addedTextInBlock = true; } } else if ( node.nodeName === 'BR' || (addedTextInBlock && !isInline(node)) ) { textContent += '\n'; addedTextInBlock = false; } node = walker.nextNode(); } return textContent; } // --- Inline formatting /** * Extracts the font-family and font-size (if any) of the element * holding the cursor. If there's a selection, returns an empty object. */ getFontInfo(range?: Range): Record { const fontInfo = { color: undefined, backgroundColor: undefined, fontFamily: undefined, fontSize: undefined, } as Record; if (!range) { range = this.getSelection(); } let seenAttributes = 0; let element: Node | null = range.commonAncestorContainer; if (range.collapsed || element instanceof Text) { if (element instanceof Text) { element = element.parentNode!; } while (seenAttributes < 4 && element) { const style = (element as HTMLElement).style; if (style) { const color = style.color; if (!fontInfo.color && color) { fontInfo.color = color; seenAttributes += 1; } const backgroundColor = style.backgroundColor; if (!fontInfo.backgroundColor && backgroundColor) { fontInfo.backgroundColor = backgroundColor; seenAttributes += 1; } const fontFamily = style.fontFamily; if (!fontInfo.fontFamily && fontFamily) { fontInfo.fontFamily = fontFamily; seenAttributes += 1; } const fontSize = style.fontSize; if (!fontInfo.fontSize && fontSize) { fontInfo.fontSize = fontSize; seenAttributes += 1; } } element = element.parentNode; } } return fontInfo; } /** * Looks for matching tag and attributes, so won't work if * instead of etc. */ hasFormat( tag: string, attributes?: Record | null, range?: Range, ): boolean { // 1. Normalise the arguments and get selection tag = tag.toUpperCase(); if (!attributes) { attributes = {}; } if (!range) { range = this.getSelection(); } // Move range up one level in the DOM tree if at the edge of a text // node, so we don't consider it included when it's not really. if ( !range.collapsed && range.startContainer instanceof Text && range.startOffset === range.startContainer.length && range.startContainer.nextSibling ) { range.setStartBefore(range.startContainer.nextSibling); } if ( !range.collapsed && range.endContainer instanceof Text && range.endOffset === 0 && range.endContainer.previousSibling ) { range.setEndAfter(range.endContainer.previousSibling); } // If the common ancestor is inside the tag we require, we definitely // have the format. const root = this._root; const common = range.commonAncestorContainer; if (getNearest(common, root, tag, attributes)) { return true; } // If common ancestor is a text node and doesn't have the format, we // definitely don't have it. if (common instanceof Text) { return false; } // Otherwise, check each text node at least partially contained within // the selection and make sure all of them have the format we want. const walker = new TreeIterator(common, SHOW_TEXT, (node) => { return isNodeContainedInRange(range!, node, true); }); let seenNode = false; let node: Node | null; while ((node = walker.nextNode())) { if (!getNearest(node, root, tag, attributes)) { return false; } seenNode = true; } return seenNode; } changeFormat( add: { tag: string; attributes?: Record } | null, remove?: { tag: string; attributes?: Record } | null, range?: Range, partial?: boolean, ): Squire { // Normalise the arguments and get selection if (!range) { range = this.getSelection(); } // Save undo checkpoint this.saveUndoState(range); if (remove) { range = this._removeFormat( remove.tag.toUpperCase(), remove.attributes || {}, range, partial, ); } if (add) { range = this._addFormat( add.tag.toUpperCase(), add.attributes || {}, range, ); } this.setSelection(range); this._updatePath(range, true); return this.focus(); } _addFormat( tag: string, attributes: Record | null, range: Range, ): Range { // If the range is collapsed we simply insert the node by wrapping // it round the range and focus it. const root = this._root; if (range.collapsed) { const el = fixCursor(createElement(tag, attributes)); insertNodeInRange(range, el); const focusNode = el.firstChild || el; // Focus after the ZWS if present const focusOffset = focusNode instanceof Text ? focusNode.length : 0; range.setStart(focusNode, focusOffset); range.collapse(true); // Clean up any previous formats that may have been set on this // block that are unused. let block = el; while (isInline(block)) { block = block.parentNode!; } removeZWS(block, el); // Otherwise we find all the textnodes in the range (splitting // partially selected nodes) and if they're not already formatted // correctly we wrap them in the appropriate tag. } else { // Create an iterator to walk over all the text nodes under this // ancestor which are in the range and not already formatted // correctly. // // In Blink/WebKit, empty blocks may have no text nodes, just a //
. Therefore we wrap this in the tag as well, as this will // then cause it to apply when the user types something in the // block, which is presumably what was intended. // // IMG tags are included because we may want to create a link around // them, and adding other styles is harmless. const walker = new TreeIterator( range.commonAncestorContainer, SHOW_ELEMENT_OR_TEXT, (node: Node) => { return ( (node instanceof Text || node.nodeName === 'BR' || node.nodeName === 'IMG') && isNodeContainedInRange(range, node, true) ); }, ); // Start at the beginning node of the range and iterate through // all the nodes in the range that need formatting. let { startContainer, startOffset, endContainer, endOffset } = range; // Make sure we start with a valid node. walker.currentNode = startContainer; if ( (!(startContainer instanceof Element) && !(startContainer instanceof Text)) || !walker.filter(startContainer) ) { const next = walker.nextNode(); // If there are no interesting nodes in the selection, abort if (!next) { return range; } startContainer = next; startOffset = 0; } do { let node = walker.currentNode; const needsFormat = !getNearest(node, root, tag, attributes); if (needsFormat) { //
can never be a container node, so must have a text // node if node == (end|start)Container if ( node === endContainer && (node as Text).length > endOffset ) { (node as Text).splitText(endOffset); } if (node === startContainer && startOffset) { node = (node as Text).splitText(startOffset); if (endContainer === startContainer) { endContainer = node; endOffset -= startOffset; } else if (endContainer === startContainer.parentNode) { endOffset += 1; } startContainer = node; startOffset = 0; } const el = createElement(tag, attributes); replaceWith(node, el); el.appendChild(node); } } while (walker.nextNode()); // Now set the selection to as it was before range = createRange( startContainer, startOffset, endContainer, endOffset, ); } return range; } _removeFormat( tag: string, attributes: Record, range: Range, partial?: boolean, ): Range { // Add bookmark this._saveRangeToBookmark(range); // We need a node in the selection to break the surrounding // formatted text. let fixer: Node | Text | undefined; if (range.collapsed) { if (cantFocusEmptyTextNodes) { fixer = document.createTextNode(ZWS); } else { fixer = document.createTextNode(''); } insertNodeInRange(range, fixer!); } // Find block-level ancestor of selection let root = range.commonAncestorContainer; while (isInline(root)) { root = root.parentNode!; } // Find text nodes inside formatTags that are not in selection and // add an extra tag with the same formatting. const startContainer = range.startContainer; const startOffset = range.startOffset; const endContainer = range.endContainer; const endOffset = range.endOffset; const toWrap: [Node, Node][] = []; const examineNode = (node: Node, exemplar: Node) => { // If the node is completely contained by the range then // we're going to remove all formatting so ignore it. if (isNodeContainedInRange(range, node, false)) { return; } let child: Node; let next: Node; // If not at least partially contained, wrap entire contents // in a clone of the tag we're removing and we're done. if (!isNodeContainedInRange(range, node, true)) { // Ignore bookmarks and empty text nodes if ( !(node instanceof HTMLInputElement) && (!(node instanceof Text) || node.data) ) { toWrap.push([exemplar, node]); } return; } // Split any partially selected text nodes. if (node instanceof Text) { if (node === endContainer && endOffset !== node.length) { toWrap.push([exemplar, node.splitText(endOffset)]); } if (node === startContainer && startOffset) { node.splitText(startOffset); toWrap.push([exemplar, node]); } } else { // If not a text node, recurse onto all children. // Beware, the tree may be rewritten with each call // to examineNode, hence find the next sibling first. for (child = node.firstChild!; child; child = next) { next = child.nextSibling!; examineNode(child, exemplar); } } }; const formatTags = Array.from( (root as Element).getElementsByTagName(tag), ).filter((el: Node): boolean => { return ( isNodeContainedInRange(range, el, true) && hasTagAttributes(el, tag, attributes) ); }); if (!partial) { formatTags.forEach((node: Node) => { examineNode(node, node); }); } // Now wrap unselected nodes in the tag toWrap.forEach(([el, node]) => { el = el.cloneNode(false); replaceWith(node, el); el.appendChild(node); }); // and remove old formatting tags. formatTags.forEach((el: Element) => { replaceWith(el, empty(el)); }); // Merge adjacent inlines: this._getRangeAndRemoveBookmark(range); if (fixer) { range.collapse(false); } mergeInlines(root, range); return range; } // --- bold(): Squire { return this.changeFormat({ tag: 'B' }); } removeBold(): Squire { return this.changeFormat(null, { tag: 'B' }); } italic(): Squire { return this.changeFormat({ tag: 'I' }); } removeItalic(): Squire { return this.changeFormat(null, { tag: 'I' }); } underline(): Squire { return this.changeFormat({ tag: 'U' }); } removeUnderline(): Squire { return this.changeFormat(null, { tag: 'U' }); } strikethrough(): Squire { return this.changeFormat({ tag: 'S' }); } removeStrikethrough(): Squire { return this.changeFormat(null, { tag: 'S' }); } subscript(): Squire { return this.changeFormat({ tag: 'SUB' }, { tag: 'SUP' }); } removeSubscript(): Squire { return this.changeFormat(null, { tag: 'SUB' }); } superscript(): Squire { return this.changeFormat({ tag: 'SUP' }, { tag: 'SUB' }); } removeSuperscript(): Squire { return this.changeFormat(null, { tag: 'SUP' }); } // --- makeLink(url: string, attributes?: Record): Squire { const range = this.getSelection(); if (range.collapsed) { let protocolEnd = url.indexOf(':') + 1; if (protocolEnd) { while (url[protocolEnd] === '/') { protocolEnd += 1; } } insertNodeInRange( range, document.createTextNode(url.slice(protocolEnd)), ); } attributes = Object.assign( { href: url, }, this._config.tagAttributes.a, attributes, ); return this.changeFormat( { tag: 'A', attributes: attributes as Record, }, { tag: 'A', }, range, ); } removeLink(): Squire { return this.changeFormat( null, { tag: 'A', }, this.getSelection(), true, ); } /* linkRegExp = new RegExp( // Only look on boundaries '\\b(?:' + // Capture group 1: URLs '(' + // Add links to URLS // Starts with: '(?:' + // http(s):// or ftp:// '(?:ht|f)tps?:\\/\\/' + // or '|' + // www. 'www\\d{0,3}[.]' + // or '|' + // foo90.com/ '[a-z0-9][a-z0-9.\\-]*[.][a-z]{2,}\\/' + ')' + // Then we get one or more: '(?:' + // Run of non-spaces, non ()<> '[^\\s()<>]+' + // or '|' + // balanced parentheses (one level deep only) '\\([^\\s()<>]+\\)' + ')+' + // And we finish with '(?:' + // Not a space or punctuation character '[^\\s?&`!()\\[\\]{};:\'".,<>«»“”‘’]' + // or '|' + // Balanced parentheses. '\\([^\\s()<>]+\\)' + ')' + // Capture group 2: Emails ')|(' + // Add links to emails '[\\w\\-.%+]+@(?:[\\w\\-]+\\.)+[a-z]{2,}\\b' + // Allow query parameters in the mailto: style '(?:' + '[?][^&?\\s]+=[^\\s?&`!()\\[\\]{};:\'".,<>«»“”‘’]+' + '(?:&[^&?\\s]+=[^\\s?&`!()\\[\\]{};:\'".,<>«»“”‘’]+)*' + ')?' + '))', 'i' ); */ linkRegExp = /\b(?:((?:(?:ht|f)tps?:\/\/|www\d{0,3}[.]|[a-z0-9][a-z0-9.\-]*[.][a-z]{2,}\/)(?:[^\s()<>]+|\([^\s()<>]+\))+(?:[^\s?&`!()\[\]{};:'".,<>«»“”‘’]|\([^\s()<>]+\)))|([\w\-.%+]+@(?:[\w\-]+\.)+[a-z]{2,}\b(?:[?][^&?\s]+=[^\s?&`!()\[\]{};:'".,<>«»“”‘’]+(?:&[^&?\s]+=[^\s?&`!()\[\]{};:'".,<>«»“”‘’]+)*)?))/i; addDetectedLinks( searchInNode: DocumentFragment | Node, root?: DocumentFragment | HTMLElement, ): Squire { const walker = new TreeIterator( searchInNode, SHOW_TEXT, (node) => !getNearest(node, root || this._root, 'A'), ); const linkRegExp = this.linkRegExp; const defaultAttributes = this._config.tagAttributes.a; let node: Text | null; while ((node = walker.nextNode())) { const parent = node.parentNode!; let data = node.data; let match: RegExpExecArray | null; while ((match = linkRegExp.exec(data))) { const index = match.index; const endIndex = index + match[0].length; if (index) { parent.insertBefore( document.createTextNode(data.slice(0, index)), node, ); } const child = createElement( 'A', Object.assign( { href: match[1] ? /^(?:ht|f)tps?:/i.test(match[1]) ? match[1] : 'http://' + match[1] : 'mailto:' + match[0], }, defaultAttributes, ), ); child.textContent = data.slice(index, endIndex); parent.insertBefore(child, node); node.data = data = data.slice(endIndex); } } return this; } // --- setFontFace(name: string | null): Squire { const className = this._config.classNames.fontFamily; return this.changeFormat( name ? { tag: 'SPAN', attributes: { class: className, style: 'font-family: ' + name + ', sans-serif;', }, } : null, { tag: 'SPAN', attributes: { class: className }, }, ); } setFontSize(size: string | null): Squire { const className = this._config.classNames.fontSize; return this.changeFormat( size ? { tag: 'SPAN', attributes: { class: className, style: 'font-size: ' + (typeof size === 'number' ? size + 'px' : size), }, } : null, { tag: 'SPAN', attributes: { class: className }, }, ); } setTextColor(color: string | null): Squire { const className = this._config.classNames.color; return this.changeFormat( color ? { tag: 'SPAN', attributes: { class: className, style: 'color:' + color, }, } : null, { tag: 'SPAN', attributes: { class: className }, }, ); } setHighlightColor(color: string | null): Squire { const className = this._config.classNames.highlight; return this.changeFormat( color ? { tag: 'SPAN', attributes: { class: className, style: 'background-color:' + color, }, } : null, { tag: 'SPAN', attributes: { class: className }, }, ); } // --- Block formatting _ensureBottomLine(): void { const root = this._root; const last = root.lastElementChild; if ( !last || last.nodeName !== this._config.blockTag || !isBlock(last) ) { root.appendChild(this.createDefaultBlock()); } } createDefaultBlock(children?: Node[]): HTMLElement { const config = this._config; return fixCursor( createElement(config.blockTag, config.blockAttributes, children), ) as HTMLElement; } tagAfterSplit: Record = { DT: 'DD', DD: 'DT', LI: 'LI', PRE: 'PRE', }; splitBlock(lineBreakOnly: boolean, range?: Range): Squire { if (!range) { range = this.getSelection(); } const root = this._root; let block: Node | Element | null; let parent: Node | null; let node: Node; let nodeAfterSplit: Node; // Save undo checkpoint and remove any zws so we don't think there's // content in an empty block. this._recordUndoState(range); this._removeZWS(); this._getRangeAndRemoveBookmark(range); // Selected text is overwritten, therefore delete the contents // to collapse selection. if (!range.collapsed) { deleteContentsOfRange(range, root); } // Linkify text if (this._config.addLinks) { moveRangeBoundariesDownTree(range); const textNode = range.startContainer as Text; const offset = range.startOffset; setTimeout(() => { linkifyText(this, textNode, offset); }, 0); } block = getStartBlockOfRange(range, root); // Inside a PRE, insert literal newline, unless on blank line. if (block && (parent = getNearest(block, root, 'PRE'))) { moveRangeBoundariesDownTree(range); node = range.startContainer; const offset = range.startOffset; if (!(node instanceof Text)) { node = document.createTextNode(''); parent.insertBefore(node, parent.firstChild); } // If blank line: split and insert default block if ( !lineBreakOnly && node instanceof Text && (node.data.charAt(offset - 1) === '\n' || rangeDoesStartAtBlockBoundary(range, root)) && (node.data.charAt(offset) === '\n' || rangeDoesEndAtBlockBoundary(range, root)) ) { node.deleteData(offset && offset - 1, offset ? 2 : 1); nodeAfterSplit = split( node, offset && offset - 1, root, root, ) as Node; node = nodeAfterSplit.previousSibling!; if (!node.textContent) { detach(node); } node = this.createDefaultBlock(); nodeAfterSplit.parentNode!.insertBefore(node, nodeAfterSplit); if (!nodeAfterSplit.textContent) { detach(nodeAfterSplit); } range.setStart(node, 0); } else { (node as Text).insertData(offset, '\n'); fixCursor(parent); // Firefox bug: if you set the selection in the text node after // the new line, it draws the cursor before the line break still // but if you set the selection to the equivalent position // in the parent, it works. if ((node as Text).length === offset + 1) { range.setStartAfter(node); } else { range.setStart(node, offset + 1); } } range.collapse(true); this.setSelection(range); this._updatePath(range, true); this._docWasChanged(); return this; } // If this is a malformed bit of document or in a table; // just play it safe and insert a
. if (!block || lineBreakOnly || /^T[HD]$/.test(block.nodeName)) { // If inside an
, move focus out moveRangeBoundaryOutOf(range, 'A', root); insertNodeInRange(range, createElement('BR')); range.collapse(false); this.setSelection(range); this._updatePath(range, true); return this; } // If in a list, we'll split the LI instead. if ((parent = getNearest(block, root, 'LI'))) { block = parent; } if (isEmptyBlock(block as Element)) { if ( getNearest(block, root, 'UL') || getNearest(block, root, 'OL') ) { // Break list this.decreaseListLevel(range); return this; // Break blockquote } else if (getNearest(block, root, 'BLOCKQUOTE')) { this.removeQuote(range); return this; } } // Otherwise, split at cursor point. node = range.startContainer; const offset = range.startOffset; let splitTag = this.tagAfterSplit[block.nodeName]; nodeAfterSplit = split( node, offset, block.parentNode!, this._root, ) as Node; const config = this._config; let splitProperties: Record | null = null; if (!splitTag) { splitTag = config.blockTag; splitProperties = config.blockAttributes; } // Make sure the new node is the correct type. if (!hasTagAttributes(nodeAfterSplit, splitTag, splitProperties)) { block = createElement(splitTag, splitProperties); if ((nodeAfterSplit as HTMLElement).dir) { (block as HTMLElement).dir = ( nodeAfterSplit as HTMLElement ).dir; } replaceWith(nodeAfterSplit, block); block.appendChild(empty(nodeAfterSplit)); nodeAfterSplit = block; } // Clean up any empty inlines if we hit enter at the beginning of the // block removeZWS(block); removeEmptyInlines(block); fixCursor(block); // Focus cursor // If there's a / etc. at the beginning of the split // make sure we focus inside it. while (nodeAfterSplit instanceof Element) { let child = nodeAfterSplit.firstChild; let next; // Don't continue links over a block break; unlikely to be the // desired outcome. if ( nodeAfterSplit.nodeName === 'A' && (!nodeAfterSplit.textContent || nodeAfterSplit.textContent === ZWS) ) { child = document.createTextNode('') as Text; replaceWith(nodeAfterSplit, child); nodeAfterSplit = child; break; } while (child && child instanceof Text && !child.data) { next = child.nextSibling; if (!next || next.nodeName === 'BR') { break; } detach(child); child = next; } // 'BR's essentially don't count; they're a browser hack. // If you try to select the contents of a 'BR', FF will not let // you type anything! if (!child || child.nodeName === 'BR' || child instanceof Text) { break; } nodeAfterSplit = child; } range = createRange(nodeAfterSplit, 0); this.setSelection(range); this._updatePath(range, true); return this; } forEachBlock( fn: (el: HTMLElement) => any, mutates: boolean, range?: Range, ): Squire { if (!range) { range = this.getSelection(); } // Save undo checkpoint if (mutates) { this.saveUndoState(range); } const root = this._root; let start = getStartBlockOfRange(range, root); const end = getEndBlockOfRange(range, root); if (start && end) { do { if (fn(start) || start === end) { break; } } while ((start = getNextBlock(start, root))); } if (mutates) { this.setSelection(range); // Path may have changed this._updatePath(range, true); } return this; } modifyBlocks(modify: (x: DocumentFragment) => Node, range?: Range): Squire { if (!range) { range = this.getSelection(); } // 1. Save undo checkpoint and bookmark selection this._recordUndoState(range, this._isInUndoState); // 2. Expand range to block boundaries const root = this._root; expandRangeToBlockBoundaries(range, root); // 3. Remove range. moveRangeBoundariesUpTree(range, root, root, root); const frag = extractContentsOfRange(range, root, root); // 4. Modify tree of fragment and reinsert. if (!range.collapsed) { // After extracting contents, the range edges will still be at the // level we began the spilt. We want to insert directly in the // root, so move the range up there. let node = range.endContainer; if (node === root) { range.collapse(false); } else { while (node.parentNode !== root) { node = node.parentNode!; } range.setStartBefore(node); range.collapse(true); } } insertNodeInRange(range, modify.call(this, frag)); // 5. Merge containers at edges if (range.endOffset < range.endContainer.childNodes.length) { mergeContainers( range.endContainer.childNodes[range.endOffset], root, ); } mergeContainers( range.startContainer.childNodes[range.startOffset], root, ); // 6. Restore selection this._getRangeAndRemoveBookmark(range); this.setSelection(range); this._updatePath(range, true); return this; } // --- setTextAlignment(alignment: string): Squire { this.forEachBlock((block: HTMLElement) => { const className = block.className .split(/\s+/) .filter((klass) => { return !!klass && !/^align/.test(klass); }) .join(' '); if (alignment) { block.className = className + ' align-' + alignment; block.style.textAlign = alignment; } else { block.className = className; block.style.textAlign = ''; } }, true); return this.focus(); } setTextDirection(direction: string | null): Squire { this.forEachBlock((block: HTMLElement) => { if (direction) { block.dir = direction; } else { block.removeAttribute('dir'); } }, true); return this.focus(); } // --- _getListSelection( range: Range, root: Element, ): [Node, Node | null, Node | null] | null { let list: Node | null = range.commonAncestorContainer; let startLi: Node | null = range.startContainer; let endLi: Node | null = range.endContainer; while (list && list !== root && !/^[OU]L$/.test(list.nodeName)) { list = list.parentNode; } if (!list || list === root) { return null; } if (startLi === list) { startLi = startLi.childNodes[range.startOffset]; } if (endLi === list) { endLi = endLi.childNodes[range.endOffset]; } while (startLi && startLi.parentNode !== list) { startLi = startLi.parentNode; } while (endLi && endLi.parentNode !== list) { endLi = endLi.parentNode; } return [list, startLi, endLi]; } increaseListLevel(range?: Range) { if (!range) { range = this.getSelection(); } // Get start+end li in single common ancestor const root = this._root; const listSelection = this._getListSelection(range, root); if (!listSelection) { return this.focus(); } // eslint-disable-next-line prefer-const let [list, startLi, endLi] = listSelection; if (!startLi || startLi === list.firstChild) { return this.focus(); } // Save undo checkpoint and bookmark selection this._recordUndoState(range, this._isInUndoState); // Increase list depth const type = list.nodeName; let newParent = startLi.previousSibling!; let listAttrs: Record | null; let next: Node | null; if (newParent.nodeName !== type) { listAttrs = this._config.tagAttributes[type.toLowerCase()]; newParent = createElement(type, listAttrs); list.insertBefore(newParent, startLi); } do { next = startLi === endLi ? null : startLi.nextSibling; newParent.appendChild(startLi); } while ((startLi = next)); next = newParent.nextSibling; if (next) { mergeContainers(next, root); } // Restore selection this._getRangeAndRemoveBookmark(range); this.setSelection(range); this._updatePath(range, true); return this.focus(); } decreaseListLevel(range?: Range) { if (!range) { range = this.getSelection(); } const root = this._root; const listSelection = this._getListSelection(range, root); if (!listSelection) { return this.focus(); } // eslint-disable-next-line prefer-const let [list, startLi, endLi] = listSelection; if (!startLi) { startLi = list.firstChild; } if (!endLi) { endLi = list.lastChild!; } // Save undo checkpoint and bookmark selection this._recordUndoState(range, this._isInUndoState); let next: Node | null; let insertBefore: Node | null = null; if (startLi) { // Find the new parent list node let newParent = list.parentNode!; // Split list if necessary insertBefore = !endLi.nextSibling ? list.nextSibling : (split(list, endLi.nextSibling, newParent, root) as Node); if (newParent !== root && newParent.nodeName === 'LI') { newParent = newParent.parentNode!; while (insertBefore) { next = insertBefore.nextSibling; endLi.appendChild(insertBefore); insertBefore = next; } insertBefore = list.parentNode!.nextSibling; } const makeNotList = !/^[OU]L$/.test(newParent.nodeName); do { next = startLi === endLi ? null : startLi.nextSibling; list.removeChild(startLi); if (makeNotList && startLi.nodeName === 'LI') { startLi = this.createDefaultBlock([empty(startLi)]); } newParent.insertBefore(startLi!, insertBefore); } while ((startLi = next)); } if (!list.firstChild) { detach(list); } if (insertBefore) { mergeContainers(insertBefore, root); } // Restore selection this._getRangeAndRemoveBookmark(range); this.setSelection(range); this._updatePath(range, true); return this.focus(); } _makeList(frag: DocumentFragment, type: string): DocumentFragment { const walker = getBlockWalker(frag, this._root); const tagAttributes = this._config.tagAttributes; const listAttrs = tagAttributes[type.toLowerCase()]; const listItemAttrs = tagAttributes.li; let node: Node | null; while ((node = walker.nextNode())) { if (node.parentNode! instanceof HTMLLIElement) { node = node.parentNode!; walker.currentNode = node.lastChild!; } if (!(node instanceof HTMLLIElement)) { const newLi = createElement('LI', listItemAttrs); if ((node as HTMLElement).dir) { newLi.dir = (node as HTMLElement).dir; } // Have we replaced the previous block with a new
    /
      ? const prev: ChildNode | null = node.previousSibling; if (prev && prev.nodeName === type) { prev.appendChild(newLi); detach(node); // Otherwise, replace this block with the
        /
          } else { replaceWith(node, createElement(type, listAttrs, [newLi])); } newLi.appendChild(empty(node)); walker.currentNode = newLi; } else { node = node.parentNode; const tag = node!.nodeName; if (tag !== type && /^[OU]L$/.test(tag)) { replaceWith( node!, createElement(type, listAttrs, [empty(node!)]), ); } } } return frag; } makeUnorderedList(): Squire { this.modifyBlocks((frag) => this._makeList(frag, 'UL')); return this.focus(); } makeOrderedList(): Squire { this.modifyBlocks((frag) => this._makeList(frag, 'OL')); return this.focus(); } removeList(): Squire { this.modifyBlocks((frag) => { const lists = frag.querySelectorAll('UL, OL'); const items = frag.querySelectorAll('LI'); const root = this._root; for (let i = 0, l = lists.length; i < l; i += 1) { const list = lists[i]; const listFrag = empty(list); fixContainer(listFrag, root); replaceWith(list, listFrag); } for (let i = 0, l = items.length; i < l; i += 1) { const item = items[i]; if (isBlock(item)) { replaceWith(item, this.createDefaultBlock([empty(item)])); } else { fixContainer(item, root); replaceWith(item, empty(item)); } } return frag; }); return this.focus(); } // --- increaseQuoteLevel(range?: Range): Squire { this.modifyBlocks( (frag) => createElement( 'BLOCKQUOTE', this._config.tagAttributes.blockquote, [frag], ), range, ); return this.focus(); } decreaseQuoteLevel(range?: Range): Squire { this.modifyBlocks((frag) => { Array.from(frag.querySelectorAll('blockquote')) .filter((el: Node) => { return !getNearest(el.parentNode, frag, 'BLOCKQUOTE'); }) .forEach((el: Node) => { replaceWith(el, empty(el)); }); return frag; }, range); return this.focus(); } removeQuote(range?: Range): Squire { this.modifyBlocks( (/* frag */) => this.createDefaultBlock([ createElement('INPUT', { id: this.startSelectionId, type: 'hidden', }), createElement('INPUT', { id: this.endSelectionId, type: 'hidden', }), ]), range, ); return this.focus(); } // --- code(): Squire { const range = this.getSelection(); if (range.collapsed || isContainer(range.commonAncestorContainer)) { this.modifyBlocks((frag) => { const root = this._root; const output = document.createDocumentFragment(); const blockWalker = getBlockWalker(frag, root); let node: Element | Text | null; // 1. Extract inline content; drop all blocks and contains. while ((node = blockWalker.nextNode())) { // 2. Replace
          with \n in content let nodes = node.querySelectorAll('BR'); const brBreaksLine: boolean[] = []; let l = nodes.length; // Must calculate whether the
          breaks a line first, // because if we have two
          s next to each other, after // the first one is converted to a block split, the second // will be at the end of a block and therefore seem to not // be a line break. But in its original context it was, so // we should also convert it to a block split. for (let i = 0; i < l; i += 1) { brBreaksLine[i] = isLineBreak(nodes[i], false); } while (l--) { const br = nodes[l]; if (!brBreaksLine[l]) { detach(br); } else { replaceWith(br, document.createTextNode('\n')); } } // 3. Remove ; its format clashes with
                              nodes = node.querySelectorAll('CODE');
                              l = nodes.length;
                              while (l--) {
                                  replaceWith(nodes[l], empty(nodes[l]));
                              }
                              if (output.childNodes.length) {
                                  output.appendChild(document.createTextNode('\n'));
                              }
                              output.appendChild(empty(node));
                          }
                          // 4. Replace nbsp with regular sp
                          const textWalker = new TreeIterator(output, SHOW_TEXT);
                          while ((node = textWalker.nextNode())) {
                              // eslint-disable-next-line no-irregular-whitespace
                              node.data = node.data.replace(/ /g, ' '); // nbsp -> sp
                          }
                          output.normalize();
                          return fixCursor(
                              createElement('PRE', this._config.tagAttributes.pre, [
                                  output,
                              ]),
                          );
                      }, range);
                      this.focus();
                  } else {
                      this.changeFormat(
                          {
                              tag: 'CODE',
                              attributes: this._config.tagAttributes.code,
                          },
                          null,
                          range,
                      );
                  }
                  return this;
              }
          
              removeCode(): Squire {
                  const range = this.getSelection();
                  const ancestor = range.commonAncestorContainer;
                  const inPre = getNearest(ancestor, this._root, 'PRE');
                  if (inPre) {
                      this.modifyBlocks((frag) => {
                          const root = this._root;
                          const pres = frag.querySelectorAll('PRE');
                          let l = pres.length;
                          while (l--) {
                              const pre = pres[l];
                              const walker = new TreeIterator(pre, SHOW_TEXT);
                              let node: Text | null;
                              while ((node = walker.nextNode())) {
                                  let value = node.data;
                                  value = value.replace(/ (?= )/g, ' '); // sp -> nbsp
                                  const contents = document.createDocumentFragment();
                                  let index: number;
                                  while ((index = value.indexOf('\n')) > -1) {
                                      contents.appendChild(
                                          document.createTextNode(value.slice(0, index)),
                                      );
                                      contents.appendChild(createElement('BR'));
                                      value = value.slice(index + 1);
                                  }
                                  node.parentNode!.insertBefore(contents, node);
                                  node.data = value;
                              }
                              fixContainer(pre, root);
                              replaceWith(pre, empty(pre));
                          }
                          return frag;
                      }, range);
                      this.focus();
                  } else {
                      this.changeFormat(null, { tag: 'CODE' }, range);
                  }
                  return this;
              }
          
              toggleCode(): Squire {
                  if (this.hasFormat('PRE') || this.hasFormat('CODE')) {
                      this.removeCode();
                  } else {
                      this.code();
                  }
                  return this;
              }
          
              // ---
          
              _removeFormatting(
                  root: DocumentFragment | Element,
                  clean: DocumentFragment | Element,
              ): DocumentFragment | Element {
                  for (
                      let node = root.firstChild, next: ChildNode | null;
                      node;
                      node = next
                  ) {
                      next = node.nextSibling;
                      if (isInline(node)) {
                          if (
                              node instanceof Text ||
                              node.nodeName === 'BR' ||
                              node.nodeName === 'IMG'
                          ) {
                              clean.appendChild(node);
                              continue;
                          }
                      } else if (isBlock(node)) {
                          clean.appendChild(
                              this.createDefaultBlock([
                                  this._removeFormatting(
                                      node as Element,
                                      document.createDocumentFragment(),
                                  ),
                              ]),
                          );
                          continue;
                      }
                      this._removeFormatting(node as Element, clean);
                  }
                  return clean;
              }
          
              removeAllFormatting(range?: Range): Squire {
                  if (!range) {
                      range = this.getSelection();
                  }
                  if (range.collapsed) {
                      return this;
                  }
          
                  const root = this._root;
                  let stopNode = range.commonAncestorContainer;
                  while (stopNode && !isBlock(stopNode)) {
                      stopNode = stopNode.parentNode!;
                  }
                  if (!stopNode) {
                      expandRangeToBlockBoundaries(range, root);
                      stopNode = root;
                  }
                  if (stopNode instanceof Text) {
                      return this;
                  }
          
                  // Record undo point
                  this.saveUndoState(range);
          
                  // Avoid splitting where we're already at edges.
                  moveRangeBoundariesUpTree(range, stopNode, stopNode, root);
          
                  // Split the selection up to the block, or if whole selection in same
                  // block, expand range boundaries to ends of block and split up to root.
                  const startContainer = range.startContainer;
                  let startOffset = range.startOffset;
                  const endContainer = range.endContainer;
                  let endOffset = range.endOffset;
          
                  // Split end point first to avoid problems when end and start
                  // in same container.
                  const formattedNodes = document.createDocumentFragment();
                  const cleanNodes = document.createDocumentFragment();
                  const nodeAfterSplit = split(endContainer, endOffset, stopNode, root);
                  let nodeInSplit = split(startContainer, startOffset, stopNode, root);
                  let nextNode: ChildNode | null;
          
                  // Then replace contents in split with a cleaned version of the same:
                  // blocks become default blocks, text and leaf nodes survive, everything
                  // else is obliterated.
                  while (nodeInSplit !== nodeAfterSplit) {
                      nextNode = nodeInSplit!.nextSibling;
                      formattedNodes.appendChild(nodeInSplit!);
                      nodeInSplit = nextNode;
                  }
                  this._removeFormatting(formattedNodes, cleanNodes);
                  cleanNodes.normalize();
                  nodeInSplit = cleanNodes.firstChild;
                  nextNode = cleanNodes.lastChild;
          
                  // Restore selection
                  if (nodeInSplit) {
                      stopNode.insertBefore(cleanNodes, nodeAfterSplit);
                      const childNodes = Array.from(stopNode.childNodes) as Node[];
                      startOffset = childNodes.indexOf(nodeInSplit);
                      endOffset = nextNode ? childNodes.indexOf(nextNode) + 1 : 0;
                  } else if (nodeAfterSplit) {
                      const childNodes = Array.from(stopNode.childNodes) as Node[];
                      startOffset = childNodes.indexOf(nodeAfterSplit);
                      endOffset = startOffset;
                  }
          
                  // Merge text nodes at edges, if possible
                  range.setStart(stopNode, startOffset);
                  range.setEnd(stopNode, endOffset);
                  mergeInlines(stopNode, range);
          
                  // And move back down the tree
                  moveRangeBoundariesDownTree(range);
          
                  this.setSelection(range);
                  this._updatePath(range, true);
          
                  return this.focus();
              }
          }
          
          // ---
          
          export { Squire, SquireConfig };