mirror of
https://github.com/fastmail/Squire.git
synced 2024-12-21 23:03:11 -05:00
671319d316
This lets you reliably hook into the pathChange event to know when to recalculate button state for bold, italic, etc. Resolves #465
2795 lines
90 KiB
TypeScript
2795 lines
90 KiB
TypeScript
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, 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';
|
||
import { getTextContentsOfRange } from './range/Contents';
|
||
|
||
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<string, string>;
|
||
tagAttributes: TagAttributes;
|
||
classNames: {
|
||
color: string;
|
||
fontFamily: string;
|
||
fontSize: string;
|
||
highlight: string;
|
||
};
|
||
undo: {
|
||
documentSizeThreshold: number;
|
||
undoLimit: number;
|
||
};
|
||
addLinks: boolean;
|
||
willCutCopy: null | ((html: string) => string);
|
||
toPlainText: 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<string, Array<EventHandler>>;
|
||
|
||
_undoIndex: number;
|
||
_undoStack: Array<string>;
|
||
_undoStackLength: number;
|
||
_isInUndoState: boolean;
|
||
_ignoreChange: boolean;
|
||
_ignoreAllChanges: boolean;
|
||
|
||
_isShiftDown: boolean;
|
||
_keyHandlers: Record<string, KeyHandlerFunction>;
|
||
|
||
_mutation: MutationObserver;
|
||
|
||
constructor(root: HTMLElement, config?: Partial<SquireConfig>) {
|
||
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);
|
||
|
||
// On blur, cleanup any ZWS/empty inlines
|
||
this.addEventListener('blur', this._removeZWS);
|
||
|
||
// 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');
|
||
|
||
// 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,
|
||
toPlainText: 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: string, fn: KeyHandlerFunction) {
|
||
this._keyHandlers[key] = fn;
|
||
return this;
|
||
}
|
||
|
||
_beforeInput(event: InputEvent): void {
|
||
switch (event.inputType) {
|
||
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 <img>. 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 || anchor !== focus) {
|
||
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 {
|
||
const isInUndoState = this._isInUndoState;
|
||
if (!isInUndoState || replace) {
|
||
// Advance pointer to new position
|
||
let undoIndex = this._undoIndex + 1;
|
||
const undoStack = this._undoStack;
|
||
const undoConfig = this._config.undo;
|
||
const undoThreshold = undoConfig.documentSizeThreshold;
|
||
const undoLimit = undoConfig.undoLimit;
|
||
|
||
// Truncate stack if longer (i.e. if has been previously undone)
|
||
if (undoIndex < this._undoStackLength) {
|
||
undoStack.length = this._undoStackLength = undoIndex;
|
||
}
|
||
|
||
// Add bookmark
|
||
if (range) {
|
||
this._saveRangeToBookmark(range);
|
||
}
|
||
|
||
// Don't record if we're already in an undo state
|
||
if (isInUndoState) {
|
||
return this;
|
||
}
|
||
|
||
// Get data
|
||
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 (replace) {
|
||
undoIndex -= 1;
|
||
}
|
||
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.focus();
|
||
}
|
||
|
||
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.focus();
|
||
}
|
||
|
||
// --- 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', {
|
||
cancelable: true,
|
||
detail: {
|
||
html,
|
||
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 <a> element and if so if there is an equivalent
|
||
// cursor position after the <a> 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<string, string>,
|
||
): 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', {
|
||
cancelable: true,
|
||
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 = '</' + tag + '>';
|
||
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 <div></div>
|
||
if (i) {
|
||
line = openBlock + (line || '<BR>') + closeBlock;
|
||
}
|
||
lines[i] = line;
|
||
}
|
||
return this.insertHTML(lines.join(''), isPaste);
|
||
}
|
||
|
||
getSelectedText(range?: Range): string {
|
||
return getTextContentsOfRange(range || this.getSelection());
|
||
}
|
||
|
||
// --- 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<string, string | undefined> {
|
||
const fontInfo = {
|
||
color: undefined,
|
||
backgroundColor: undefined,
|
||
fontFamily: undefined,
|
||
fontSize: undefined,
|
||
} as Record<string, string | undefined>;
|
||
|
||
if (!range) {
|
||
range = this.getSelection();
|
||
}
|
||
moveRangeBoundariesDownTree(range);
|
||
|
||
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 <strong>
|
||
* instead of <b> etc.
|
||
*/
|
||
hasFormat(
|
||
tag: string,
|
||
attributes?: Record<string, string> | 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<Text>(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<string, string> } | null,
|
||
remove?: { tag: string; attributes?: Record<string, string> } | 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<string, string> | 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
|
||
// <br>. 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<Element | Text>(
|
||
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) {
|
||
// <br> 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<string, string>,
|
||
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 | null | 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));
|
||
});
|
||
|
||
if (cantFocusEmptyTextNodes && fixer) {
|
||
// Clean up any previous ZWS in this block. They are not needed,
|
||
// and this works around a Chrome bug where it doesn't render the
|
||
// text in some situations with multiple ZWS(!)
|
||
fixer = fixer.parentNode;
|
||
let block = fixer;
|
||
while (block && isInline(block)) {
|
||
block = block.parentNode;
|
||
}
|
||
if (block) {
|
||
removeZWS(block, fixer);
|
||
}
|
||
}
|
||
|
||
// 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<string, string>): 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<string, string>,
|
||
},
|
||
{
|
||
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<Text>(
|
||
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<string, string> = {
|
||
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 <br>.
|
||
if (!block || lineBreakOnly || /^T[HD]$/.test(block.nodeName)) {
|
||
// If inside an <a>, 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.replaceWithBlankLine(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<string, string> | 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 <b>/<i> 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<string, string> | 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 <ul>/<ol>?
|
||
const prev: ChildNode | null = node.previousSibling;
|
||
if (prev && prev.nodeName === type) {
|
||
prev.appendChild(newLi);
|
||
detach(node);
|
||
// Otherwise, replace this block with the <ul>/<ol>
|
||
} 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) => {
|
||
Array.from(frag.querySelectorAll('blockquote')).forEach(
|
||
(el: Node) => {
|
||
replaceWith(el, empty(el));
|
||
},
|
||
);
|
||
return frag;
|
||
}, range);
|
||
return this.focus();
|
||
}
|
||
|
||
replaceWithBlankLine(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 <br> with \n in content
|
||
let nodes = node.querySelectorAll('BR');
|
||
const brBreaksLine: boolean[] = [];
|
||
let l = nodes.length;
|
||
// Must calculate whether the <br> breaks a line first,
|
||
// because if we have two <br>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 <code>; its format clashes with <pre>
|
||
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<Text>(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<Text>(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.focus();
|
||
}
|
||
|
||
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.focus();
|
||
}
|
||
|
||
// 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 };
|
||
export type { SquireConfig };
|