UNPKG

jodit

Version:

Jodit is awesome and usefully wysiwyg editor with filebrowser

1,312 lines (1,096 loc) 27.3 kB
/*! * Jodit Editor (https://xdsoft.net/jodit/) * Released under MIT see LICENSE.txt in the project root for license information. * Copyright (c) 2013-2020 Valeriy Chupurnov. All rights reserved. https://xdsoft.net */ import autobind from 'autobind-decorator'; import * as consts from '../constants'; import { INVISIBLE_SPACE, INVISIBLE_SPACE_REG_EXP_END as INV_END, INVISIBLE_SPACE_REG_EXP_START as INV_START } from '../constants'; import { CanUndef, HTMLTagNames, IDictionary, IJodit, markerInfo, Nullable } from '../../types'; import { Dom } from '../dom'; import { attr, error, isFunction, isString, $$, css, isArray, call } from '../helpers'; import { IStyle, Style } from './style/style'; type WindowSelection = Selection | null; export class Select { constructor(readonly jodit: IJodit) { jodit.e.on('removeMarkers', () => { this.removeMarkers(); }); } /** * Short alias for this.jodit */ get j(): this['jodit'] { return this.jodit; } /** * Throw Error exception if parameter is not Node * @param node */ private errorNode(node: unknown): void { if (!Dom.isNode(node, this.win)) { throw error('Parameter node must be instance of Node'); } } /** * Return current work place - for Jodit is Editor */ get area(): HTMLElement { return this.j.editor; } /** * Editor Window - it can be different for iframe mode */ get win(): Window { return this.j.ew; } /** * Current jodit editor doc */ get doc(): Document { return this.j.ed; } /** * Return current selection object */ get sel(): WindowSelection { if (this.j.o.shadowRoot) { return this.j.o.shadowRoot.getSelection(); } return this.win.getSelection(); } /** * Return first selected range or create new */ get range(): Range { const sel = this.sel; return sel && sel.rangeCount ? sel.getRangeAt(0) : this.createRange(); } /** * Return current selection object * @param select Immediately add in selection */ createRange(select: boolean = false): Range { const range = this.doc.createRange(); if (select) { this.selectRange(range); } return range; } /** * Remove all selected content */ remove(): void { const sel = this.sel, current = this.current(); if (sel && current) { for (let i = 0; i < sel.rangeCount; i += 1) { sel.getRangeAt(i).deleteContents(); sel.getRangeAt(i).collapse(true); } } } /** * Clear all selection */ clear(): void { if (this.sel?.rangeCount) { this.sel?.removeAllRanges(); } } /** * Remove node element from editor * @param node */ removeNode(node: Node): void { if (!Dom.isOrContains(this.j.editor, node, true)) { throw error( "Selection.removeNode can remove only editor's children" ); } Dom.safeRemove(node); this.j.e.fire('afterRemoveNode', node); } /** * Insert the cursor toWYSIWYG any point x, y * * @method insertAtPoint * @param {int} x Coordinate by horizontal * @param {int} y Coordinate by vertical * @return boolean Something went wrong */ insertCursorAtPoint(x: number, y: number): boolean { this.removeMarkers(); try { const rng = this.createRange(); (() => { if (this.doc.caretPositionFromPoint) { const caret = this.doc.caretPositionFromPoint(x, y); if (caret) { rng.setStart(caret.offsetNode, caret.offset); return; } } if (this.doc.caretRangeFromPoint) { const caret = this.doc.caretRangeFromPoint(x, y); rng.setStart(caret.startContainer, caret.startOffset); } })(); rng.collapse(true); this.selectRange(rng); return true; } catch {} return false; } /** * Define element is selection helper * @param elm */ isMarker = (elm: Node): boolean => Dom.isNode(elm, this.win) && Dom.isElement(elm) && Dom.isTag(elm, 'span') && elm.hasAttribute('data-' + consts.MARKER_CLASS); /** * Remove all markers */ removeMarkers(): void { $$('span[data-' + consts.MARKER_CLASS + ']', this.area).forEach( Dom.safeRemove ); } /** * Create marker element * * @param atStart * @param range */ marker(atStart = false, range?: Range): HTMLSpanElement { let newRange: Range | null = null; if (range) { newRange = range.cloneRange(); newRange.collapse(atStart); } const marker: HTMLSpanElement = this.j.createInside.span(); marker.id = consts.MARKER_CLASS + '_' + +new Date() + '_' + ('' + Math.random()).slice(2); marker.style.lineHeight = '0'; marker.style.display = 'none'; marker.setAttribute( 'data-' + consts.MARKER_CLASS, atStart ? 'start' : 'end' ); marker.appendChild(this.j.createInside.text(consts.INVISIBLE_SPACE)); if (newRange) { if ( Dom.isOrContains( this.area, atStart ? newRange.startContainer : newRange.endContainer ) ) { newRange.insertNode(marker); } } return marker; } /** * Restores user selections using marker invisible elements in the DOM. * * @param {markerInfo[]|null} selectionInfo */ restore(selectionInfo: markerInfo[] | null = []): void { if (isArray(selectionInfo)) { let range: Range | false = false; selectionInfo.forEach((selection: markerInfo) => { const end = this.area.querySelector( '#' + selection.endId ) as HTMLElement, start = this.area.querySelector( '#' + selection.startId ) as HTMLElement; if (!start) { return; } range = this.createRange(); if (selection.collapsed || !end) { const previousNode: Node | null = start.previousSibling; if (Dom.isText(previousNode)) { range.setStart( previousNode, previousNode.nodeValue ? previousNode.nodeValue.length : 0 ); } else { range.setStartBefore(start); } Dom.safeRemove(start); range.collapse(true); } else { range.setStartAfter(start); Dom.safeRemove(start); range.setEndBefore(end); Dom.safeRemove(end); } }); if (range) { this.selectRange(range); } } } /** * Saves selections using marker invisible elements in the DOM. * * @return markerInfo[] */ save(): markerInfo[] { const sel = this.sel; if (!sel || !sel.rangeCount) { return []; } const info: markerInfo[] = [], length: number = sel.rangeCount, ranges: Range[] = []; let i: number, start: HTMLSpanElement, end: HTMLSpanElement; for (i = 0; i < length; i += 1) { ranges[i] = sel.getRangeAt(i); if (ranges[i].collapsed) { start = this.marker(true, ranges[i]); info[i] = { startId: start.id, collapsed: true, startMarker: start.outerHTML }; } else { start = this.marker(true, ranges[i]); end = this.marker(false, ranges[i]); info[i] = { startId: start.id, endId: end.id, collapsed: false, startMarker: start.outerHTML, endMarker: end.outerHTML }; } } sel.removeAllRanges(); for (i = length - 1; i >= 0; --i) { const startElm = this.doc.getElementById(info[i].startId); if (startElm) { if (info[i].collapsed) { ranges[i].setStartAfter(startElm); ranges[i].collapse(true); } else { ranges[i].setStartBefore(startElm); if (info[i].endId) { const endElm = this.doc.getElementById( info[i].endId as string ); if (endElm) { ranges[i].setEndAfter(endElm); } } } } try { sel.addRange(ranges[i].cloneRange()); } catch {} } return info; } /** * Set focus in editor */ @autobind focus(): boolean { if (!this.isFocused()) { if (this.j.iframe) { if (this.doc.readyState === 'complete') { this.j.iframe.focus(); } } this.win.focus(); this.area.focus(); const sel = this.sel, range = sel?.rangeCount ? sel?.getRangeAt(0) : null; if (!range || !Dom.isOrContains(this.area, range.startContainer)) { const range = this.createRange(); range.setStart(this.area, 0); range.collapse(true); this.selectRange(range, false); } if (!this.j.editorIsActive) { this.j?.events?.fire('focus'); } return true; } return false; } /** * Checks whether the current selection is something or just set the cursor is * @return boolean true Selection does't have content */ isCollapsed(): boolean { const sel = this.sel; for (let r: number = 0; sel && r < sel.rangeCount; r += 1) { if (!sel.getRangeAt(r).collapsed) { return false; } } return true; } /** * Checks whether the editor currently in focus */ isFocused(): boolean { return ( this.doc.hasFocus && this.doc.hasFocus() && this.area === this.doc.activeElement ); } /** * Returns the current element under the cursor inside editor */ current(checkChild: boolean = true): null | Node { if (this.j.getRealMode() === consts.MODE_WYSIWYG) { const sel = this.sel; if (!sel || sel.rangeCount === 0) { return null; } const range = sel.getRangeAt(0); let node = range.startContainer, rightMode: boolean = false; const child = (nd: Node): Node | null => rightMode ? nd.lastChild : nd.firstChild; if (Dom.isTag(node, 'br') && sel.isCollapsed) { return node; } if (!Dom.isText(node)) { node = range.startContainer.childNodes[range.startOffset]; if (!node) { node = range.startContainer.childNodes[range.startOffset - 1]; rightMode = true; } if (node && sel.isCollapsed && !Dom.isText(node)) { // test Current method - Cursor in the left of some SPAN if (!rightMode && Dom.isText(node.previousSibling)) { node = node.previousSibling; } else if (checkChild) { let current: Node | null = child(node); while (current) { if (current && Dom.isText(current)) { node = current; break; } current = child(current); } } } if (node && !sel.isCollapsed && !Dom.isText(node)) { let leftChild: Node | null = node, rightChild: Node | null = node; do { leftChild = leftChild.firstChild; rightChild = rightChild.lastChild; } while (leftChild && rightChild && !Dom.isText(leftChild)); if ( leftChild === rightChild && leftChild && Dom.isText(leftChild) ) { node = leftChild; } } } // check - cursor inside editor if (node && Dom.isOrContains(this.area, node)) { return node; } } return null; } /** * Insert element in editor * * @param node * @param [insertCursorAfter] After insert, cursor will move after element * @param [fireChange] After insert, editor fire change event. You can prevent this behavior */ insertNode( node: Node, insertCursorAfter = true, fireChange: boolean = true ): void { this.errorNode(node); if (!this.isFocused() && this.j.isEditorMode()) { this.focus(); } const sel = this.sel; if (!this.isCollapsed()) { this.j.execCommand('Delete'); } if (sel && sel.rangeCount) { const range = sel.getRangeAt(0); if (Dom.isOrContains(this.area, range.commonAncestorContainer)) { if ( /^(BR|HR|IMG|VIDEO)$/i.test( range.startContainer.nodeName ) && range.collapsed ) { range.startContainer.parentNode?.insertBefore( node, range.startContainer ); } else { range.deleteContents(); range.insertNode(node); } } else { this.area.appendChild(node); } } else { this.area.appendChild(node); } if (insertCursorAfter) { if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { node.lastChild && this.setCursorAfter(node.lastChild); } else { this.setCursorAfter(node); } } if (fireChange && this.j.events) { this.j.e.fire('synchro'); } if (this.j.events) { this.j.e.fire('afterInsertNode', node); } } /** * Inserts in the current cursor position some HTML snippet * * @param {string} html HTML The text toWYSIWYG be inserted into the document * @example * ```javascript * parent.s.insertHTML('<img src="image.png"/>'); * ``` */ insertHTML(html: number | string | Node): void { if (html === '') { return; } const node = this.j.createInside.div(), fragment = this.j.createInside.fragment(); let lastChild: Node | null, lastEditorElement: Node | null; if (!this.isFocused() && this.j.isEditorMode()) { this.focus(); } if (!Dom.isNode(html, this.win)) { node.innerHTML = html.toString(); } else { node.appendChild(html); } if ( !this.j.isEditorMode() && this.j.e.fire('insertHTML', node.innerHTML) === false ) { return; } lastChild = node.lastChild; if (!lastChild) { return; } while (node.firstChild) { lastChild = node.firstChild; fragment.appendChild(node.firstChild); } this.insertNode(fragment, false); if (lastChild) { this.setCursorAfter(lastChild); } else { this.setCursorIn(fragment); } lastEditorElement = this.area.lastChild; while ( Dom.isText(lastEditorElement) && lastEditorElement.previousSibling && lastEditorElement.nodeValue && /^\s*$/.test(lastEditorElement.nodeValue) ) { lastEditorElement = lastEditorElement.previousSibling; } if (lastChild) { if ( lastEditorElement && lastChild === lastEditorElement && Dom.isElement(lastChild) ) { this.area.appendChild(this.j.createInside.element('br')); } this.setCursorAfter(lastChild); } } /** * Insert image in editor * * @param url URL for image, or HTMLImageElement * @param [styles] If specified, it will be applied <code>$(image).css(styles)</code> * @param defaultWidth * * @fired afterInsertImage */ insertImage( url: string | HTMLImageElement, styles: IDictionary<string> | null, defaultWidth: number | string | null ): void { const image = isString(url) ? this.j.createInside.element('img') : url; if (isString(url)) { image.setAttribute('src', url); } if (defaultWidth !== null) { let dw: string = defaultWidth.toString(); if ( dw && 'auto' !== dw && String(dw).indexOf('px') < 0 && String(dw).indexOf('%') < 0 ) { dw += 'px'; } css(image, 'width', dw); } if (styles && typeof styles === 'object') { css(image, styles); } const onload = () => { if ( image.naturalHeight < image.offsetHeight || image.naturalWidth < image.offsetWidth ) { image.style.width = ''; image.style.height = ''; } image.removeEventListener('load', onload); }; this.j.e.on(image, 'load', onload); if (image.complete) { onload(); } const result = this.insertNode(image); /** * Triggered after image was inserted {@link Selection~insertImage|insertImage}. This method can executed from * {@link FileBrowser|FileBrowser} or {@link Uploader|Uploader} * @event afterInsertImage * @param {HTMLImageElement} image * @example * ```javascript * var editor = new Jodit("#redactor"); * editor.e.on('afterInsertImage', function (image) { * image.className = 'bloghead4'; * }); * ``` */ this.j.e.fire('afterInsertImage', image); return result; } /** * Call callback for all selection node * @param callback */ eachSelection = (callback: (current: Node) => void): void => { const sel = this.sel; if (sel && sel.rangeCount) { const range = sel.getRangeAt(0); const nodes: Node[] = [], startOffset = range.startOffset, length = this.area.childNodes.length, elementOffset = startOffset < length ? startOffset : length - 1, start: Node = range.startContainer === this.area ? this.area.childNodes[elementOffset] : range.startContainer, end: Node = range.endContainer === this.area ? this.area.childNodes[range.endOffset - 1] : range.endContainer; Dom.find( start, (node: Node | null) => { if ( node && node !== this.area && !Dom.isEmptyTextNode(node) && !this.isMarker(node as HTMLElement) ) { nodes.push(node); } // checks parentElement as well because partial selections are not equal to entire element return ( node === end || (node && node.contains && node.contains(end)) ); }, this.area, true, 'nextSibling', false ); const forEvery = (current: Node): void => { if (!Dom.isOrContains(this.j.editor, current, true)) { return; } if (current.nodeName.match(/^(UL|OL)$/)) { return Array.from(current.childNodes).forEach(forEvery); } if (Dom.isTag(current, 'li')) { if (current.firstChild) { current = current.firstChild; } else { const currentB = this.j.createInside.text( INVISIBLE_SPACE ); current.appendChild(currentB); current = currentB; } } callback(current); }; if (nodes.length === 0 && Dom.isEmptyTextNode(start)) { nodes.push(start); } nodes.forEach(forEvery); } }; /** * Set cursor after the node * * @param {Node} node * @return {Node} fake invisible textnode. After insert it can be removed */ @autobind setCursorAfter( node: Node | HTMLElement | HTMLTableElement | HTMLTableCellElement ): Nullable<Text> { this.errorNode(node); if ( !Dom.up( node, elm => elm === this.area || (elm && elm.parentNode === this.area), this.area ) ) { throw error('Node element must be in editor'); } const range = this.createRange(); let fakeNode: Nullable<Text> = null; if (!Dom.isText(node)) { fakeNode = this.j.createInside.text(consts.INVISIBLE_SPACE); range.setStartAfter(node); range.insertNode(fakeNode); range.selectNode(fakeNode); } else { range.setEnd( node, node.nodeValue !== null ? node.nodeValue.length : 0 ); } range.collapse(false); this.selectRange(range); return fakeNode; } /** * Checks if the cursor is at the end(start) block * * @param {boolean} start=false true - check whether the cursor is at the start block * @param {HTMLElement} parentBlock - Find in this * * @return {boolean | null} true - the cursor is at the end(start) block, null - cursor somewhere outside */ cursorInTheEdge( start: boolean, parentBlock: HTMLElement ): Nullable<boolean> { const end = !start, range = this.sel?.getRangeAt(0), current = this.current(false); if ( !range || !current || !Dom.isOrContains(parentBlock, current, true) ) { return null; } const container = start ? range.startContainer : range.endContainer; const offset = start ? range.startOffset : range.endOffset; const check = (elm: Node | null) => elm && !Dom.isTag(elm, 'br') && !Dom.isEmptyTextNode(elm); // check right offset if (Dom.isText(container)) { const text = container.nodeValue?.length ? container.nodeValue : ''; if (end && text.replace(INV_END(), '').length > offset) { return false; } const inv = INV_START().exec(text); if ( start && ((inv && inv[0].length < offset) || (!inv && offset > 0)) ) { return false; } } else { const children = Array.from(container.childNodes); if (end) { if (children.slice(offset).some(check)) { return false; } } else { if (children.slice(0, offset).some(check)) { return false; } } } return !call(start ? Dom.prev : Dom.next, current, check, parentBlock); } /** * Wrapper for cursorInTheEdge * @param parentBlock */ cursorOnTheLeft(parentBlock: HTMLElement): Nullable<boolean> { return this.cursorInTheEdge(true, parentBlock); } /** * Wrapper for cursorInTheEdge * @param parentBlock */ cursorOnTheRight(parentBlock: HTMLElement): Nullable<boolean> { return this.cursorInTheEdge(false, parentBlock); } /** * Set cursor before the node * * @param node * @return fake invisible textnode. After insert it can be removed */ @autobind setCursorBefore(node: Node): Nullable<Text> { this.errorNode(node); if ( !Dom.up( node, (elm: Node | null) => elm === this.area || (elm && elm.parentNode === this.area), this.area ) ) { throw error('Node element must be in editor'); } const range = this.createRange(); let fakeNode: Nullable<Text> = null; if (!Dom.isText(node)) { fakeNode = this.j.createInside.text(consts.INVISIBLE_SPACE); range.setStartBefore(node); range.collapse(true); range.insertNode(fakeNode); range.selectNode(fakeNode); } else { range.setStart( node, node.nodeValue !== null ? node.nodeValue.length : 0 ); } range.collapse(true); this.selectRange(range); return fakeNode; } /** * Set cursor in the node * * @param node * @param [inStart] set cursor in start of element */ @autobind setCursorIn(node: Node, inStart: boolean = false): Node { this.errorNode(node); if ( !Dom.up( node, (elm: Node | null) => elm === this.area || (elm && elm.parentNode === this.area), this.area ) ) { throw error('Node element must be in editor'); } const range = this.createRange(); let start: Node | null = node, last: Node = node; do { if (Dom.isText(start)) { break; } last = start; start = inStart ? start.firstChild : start.lastChild; } while (start); if (!start) { const fakeNode = this.j.createInside.text(consts.INVISIBLE_SPACE); if (!/^(img|br|input)$/i.test(last.nodeName)) { last.appendChild(fakeNode); last = fakeNode; } else { start = last; } } range.selectNodeContents(start || last); range.collapse(inStart); this.selectRange(range); return last; } /** * Set range selection * * @param range * @param [focus] * * @fires changeSelection */ selectRange(range: Range, focus: boolean = true): void { const sel = this.sel; if (focus && !this.isFocused()) { this.focus(); } if (sel) { sel.removeAllRanges(); sel.addRange(range); } /** * Fired after change selection * * @event changeSelection */ this.j.e.fire('changeSelection'); } /** * Select node * * @param {Node} node * @param {boolean} [inward=false] select all inside */ select( node: Node | HTMLElement | HTMLTableElement | HTMLTableCellElement, inward = false ): void { this.errorNode(node); if ( !Dom.up( node, (elm: Node | null) => elm === this.area || (elm && elm.parentNode === this.area), this.area ) ) { throw error('Node element must be in editor'); } const range = this.createRange(); range[inward ? 'selectNodeContents' : 'selectNode'](node); this.selectRange(range); } /** * Return current selected HTML * @example * ```javascript * const editor = new jodit(); * console.log(editor.s.html); // html * console.log(Jodit.modules.Helpers.stripTags(editor.s.html)); // plain text * ``` */ get html(): string { const sel = this.sel; if (sel && sel.rangeCount > 0) { const range = sel.getRangeAt(0); const clonedSelection = range.cloneContents(); const div = this.j.createInside.div(); div.appendChild(clonedSelection); return div.innerHTML; } return ''; } /** * Wrap all selected fragments inside Tag or apply some callback * @param tagOrCallback */ wrapInTag( tagOrCallback: HTMLTagNames | ((font: HTMLElement) => any) ): HTMLElement[] { // fix issue https://github.com/xdan/jodit/issues/65 $$('*[style*=font-size]', this.area).forEach((elm: HTMLElement) => { elm.style && elm.style.fontSize && elm.setAttribute( 'data-font-size', elm.style.fontSize.toString() ); }); if (!this.isCollapsed()) { this.doc.execCommand('fontsize', false, '7'); } else { const font = this.j.createInside.element('font'); attr(font, 'size', 7); this.insertNode(font, false, false); } $$('*[data-font-size]', this.area).forEach((elm: HTMLElement) => { const fontSize = attr(elm, 'data-font-size'); if (elm.style && fontSize) { elm.style.fontSize = fontSize; elm.removeAttribute('data-font-size'); } }); const result: HTMLElement[] = []; $$('font[size="7"]', this.area).forEach((font: HTMLElement) => { try { if ( font.firstChild && font.firstChild === font.lastChild && this.isMarker(font.firstChild) ) { return; } if (isFunction(tagOrCallback)) { tagOrCallback(font); } else { result.push( Dom.replace(font, tagOrCallback, this.j.createInside) ); } } finally { if (font.parentNode) { Dom.unwrap(font); } } }); return result; } /** * Apply some css rules for all selections. It method wraps selections in nodeName tag. * * @param cssRules * @param options * @param options.element - tag - equal CSSRule (e.g. strong === font-weight: 700) * @param options.defaultTag - tag for wrapping and apply styles * @example * ```js * const editor = Jodit.make('#editor'); * editor.value = 'test'; * editor.execCommand('selectall'); * * editor.s.applyStyle({color: 'red'}) // will wrap `text` in `span` and add style `color:red` * editor.s.applyStyle({color: 'red'}) // will remove `color:red` from `span` * ``` */ applyStyle( style: CanUndef<IStyle>, options: { element?: HTMLTagNames; defaultTag?: HTMLTagNames; } = {} ): void { const styleElm = new Style({ style, element: options.element, defaultTag: options.defaultTag }); styleElm.apply(this.j); } /** * Split selection on two parts: left and right * @param currentBox * @return Left part */ splitSelection(currentBox: HTMLElement): Nullable<Element> { if (!this.isCollapsed()) { return null; } const leftRange = this.createRange(); const range = this.range; leftRange.setStartBefore(currentBox); const cursorOnTheRight = this.cursorOnTheRight(currentBox); const cursorOnTheLeft = this.cursorOnTheLeft(currentBox); let br: HTMLElement | null = null; if (cursorOnTheRight || cursorOnTheLeft) { br = this.j.createInside.element('br'); range.insertNode(br); const clearBR = ( start: Node, getNext: (node: Node) => Node | null ) => { let next = getNext(start); while (next) { const nextSib = getNext(next); if ( next && (Dom.isTag(next, 'br') || Dom.isEmptyTextNode(next)) ) { Dom.safeRemove(next); } else { break; } next = nextSib; } }; clearBR(br, (n: Node) => n.nextSibling); clearBR(br, (n: Node) => n.previousSibling); if (cursorOnTheRight) { leftRange.setEndBefore(br); range.setEndBefore(br); } else { leftRange.setEndAfter(br); range.setEndAfter(br); } } else { leftRange.setEnd(range.startContainer, range.startOffset); } const fragment = leftRange.extractContents(); if (currentBox.parentNode) { try { currentBox.parentNode.insertBefore(fragment, currentBox); if (cursorOnTheRight && br && br.parentNode) { const range = this.createRange(); range.setStartBefore(br); this.selectRange(range); } } catch (e) { if (!isProd) { throw e; } } } return currentBox.previousElementSibling; } }