UNPKG

jodit

Version:

Jodit is awesome and usefully wysiwyg editor with filebrowser

811 lines (706 loc) 19.7 kB
/*! * Jodit Editor (https://xdsoft.net/jodit/) * Licensed under GNU General Public License version 2 or later or a commercial license or MIT; * For GPL see LICENSE-GPL.txt in the project root for license information. * For MIT see LICENSE-MIT.txt in the project root for license information. * For commercial licenses see https://xdsoft.net/jodit/commercial/ * Copyright (c) 2013-2019 Valeriy Chupurnov. All rights reserved. https://xdsoft.net */ import { Config } from '../Config'; import * as consts from '../constants'; import { MODE_SOURCE } from '../constants'; import { Plugin } from '../modules/Plugin'; import { IJodit, markerInfo } from '../types'; import { IControlType } from '../types/toolbar'; import { appendScript, CallbackAndElement } from '../modules/helpers/appendScript'; import { debounce } from '../modules/helpers/async'; import { $$ } from '../modules/helpers/selector'; import { css } from '../modules/helpers/css'; import { Dom } from '../modules/Dom'; declare module '../Config' { interface Config { /** * Use ACE editor instead of usual textarea */ useAceEditor: boolean; /** * Options for [ace](https://ace.c9.io/#config) editor */ sourceEditorNativeOptions: { showGutter: boolean; theme: string; mode: string; wrap: string | boolean | number; highlightActiveLine: boolean; }; /** * Beautify HTML then it possible */ beautifyHTML: boolean; /** * CDN URLs for HTML Beautifier */ beautifyHTMLCDNUrlsJS: string[]; /** * CDN URLs for ACE editor */ sourceEditorCDNUrlsJS: string[]; } } Config.prototype.beautifyHTML = true; Config.prototype.useAceEditor = true; Config.prototype.sourceEditorNativeOptions = { /** * Show gutter */ showGutter: true, /** * Default theme */ theme: 'ace/theme/idle_fingers', /** * Default mode */ mode: 'ace/mode/html', /** * Wrap lines. Possible values - "off", 80-100..., true, "free" */ wrap: true, /** * Highlight active line */ highlightActiveLine: true }; Config.prototype.sourceEditorCDNUrlsJS = [ 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.5/ace.js' ]; Config.prototype.beautifyHTMLCDNUrlsJS = [ 'https://cdnjs.cloudflare.com/ajax/libs/js-beautify/1.10.0/beautify.min.js', 'https://cdnjs.cloudflare.com/ajax/libs/js-beautify/1.10.0/beautify-html.min.js' ]; Config.prototype.controls.source = { mode: consts.MODE_SPLIT, exec: (editor: IJodit) => { editor.toggleMode(); }, isActive: (editor: IJodit) => { return editor.getRealMode() === consts.MODE_SOURCE; }, tooltip: 'Change mode' } as IControlType; /** * Plug-in change simple textarea on CodeMirror editor in Source code mode * * @module source */ export class source extends Plugin { private className = 'jodit_ace_editor'; private mirrorContainer: HTMLDivElement; private __lock = false; private __oldMirrorValue = ''; private autosize = debounce(() => { this.mirror.style.height = 'auto'; this.mirror.style.height = this.mirror.scrollHeight + 'px'; }, this.jodit.defaultTimeout); private tempMarkerStart = '{start-jodit-selection}'; private tempMarkerStartReg = /{start-jodit-selection}/g; private tempMarkerEnd = '{end-jodit-selection}'; private tempMarkerEndReg = /{end-jodit-selection}/g; private selInfo: markerInfo[] = []; private lastTuple: null | CallbackAndElement = null; private loadNext = ( i: number, urls: string[], eventOnFinalize: false | string = 'aceReady', className: string = this.className ) => { if (eventOnFinalize && urls[i] === undefined && !this.isDestructed) { this.jodit && this.jodit.events && this.jodit.events.fire(eventOnFinalize); this.jodit && this.jodit.events && this.jodit.events.fire(this.jodit.ownerWindow, eventOnFinalize); return; } if (urls[i] !== undefined) { if (this.lastTuple) { this.lastTuple.element.removeEventListener( 'load', this.lastTuple.callback ); } this.lastTuple = appendScript( urls[i], () => { if (!this.isDestructed) { this.loadNext(i + 1, urls, eventOnFinalize, className); } }, className, this.jodit.ownerDocument ); } }; private insertHTML = (html: string) => { if (this.mirror.selectionStart || this.mirror.selectionStart === 0) { const startPos: number = this.mirror.selectionStart, endPos: number = this.mirror.selectionEnd; this.mirror.value = this.mirror.value.substring(0, startPos) + html + this.mirror.value.substring(endPos, this.mirror.value.length); } else { this.mirror.value += this.mirror; } this.toWYSIWYG(); }; private fromWYSIWYG = (force: boolean | string = false) => { if (!this.__lock || force === true) { this.__lock = true; const new_value = this.jodit.getEditorValue(false); if (new_value !== this.getMirrorValue()) { this.setMirrorValue(new_value); } this.__lock = false; } }; private toWYSIWYG = () => { if (this.__lock) { return; } const value: string = this.getMirrorValue(); if (value === this.__oldMirrorValue) { return; } this.__lock = true; this.jodit.setEditorValue(value); this.__lock = false; this.__oldMirrorValue = value; }; private getNormalPosition = (pos: number, str: string): number => { let start: number = pos; while (start > 0) { start--; if ( str[start] === '<' && str[start + 1] !== undefined && str[start + 1].match(/[\w\/]+/i) ) { return start; } if (str[start] === '>') { return pos; } } return pos; }; private __clear = (str: string): string => str.replace(consts.INVISIBLE_SPACE_REG_EXP, ''); private selectAll = () => { this.mirror.select(); }; private onSelectAll = (command: string): void | false => { if ( command.toLowerCase() === 'selectall' && this.jodit.getRealMode() === MODE_SOURCE ) { this.selectAll(); return false; } }; // override it for ace editors private getSelectionStart: () => number = (): number => { return this.mirror.selectionStart; }; private getSelectionEnd: () => number = (): number => { return this.mirror.selectionEnd; }; private getMirrorValue(): string { return this.mirror.value; } private setMirrorValue(value: string) { this.mirror.value = value; } private setFocusToMirror() { this.mirror.focus(); } private saveSelection = () => { if (this.jodit.getRealMode() === consts.MODE_WYSIWYG) { this.selInfo = this.jodit.selection.save() || []; this.jodit.setEditorValue(); this.fromWYSIWYG(true); } else { this.selInfo.length = 0; const value: string = this.getMirrorValue(); if (this.getSelectionStart() === this.getSelectionEnd()) { const marker: HTMLSpanElement = this.jodit.selection.marker( true ); this.selInfo[0] = { startId: marker.id, collapsed: true, startMarker: marker.outerHTML }; const selectionStart = this.getNormalPosition( this.getSelectionStart(), this.getMirrorValue() ); this.setMirrorValue( value.substr(0, selectionStart) + this.__clear(this.selInfo[0].startMarker) + value.substr(selectionStart) ); } else { const markerStart: HTMLSpanElement = this.jodit.selection.marker( true ); const markerEnd: HTMLSpanElement = this.jodit.selection.marker( false ); this.selInfo[0] = { startId: markerStart.id, endId: markerEnd.id, collapsed: false, startMarker: this.__clear(markerStart.outerHTML), endMarker: this.__clear(markerEnd.outerHTML) }; const selectionStart = this.getNormalPosition( this.getSelectionStart(), value ); const selectionEnd = this.getNormalPosition( this.getSelectionEnd(), value ); this.setMirrorValue( value.substr(0, selectionStart) + this.selInfo[0].startMarker + value.substr( selectionStart, selectionEnd - selectionStart ) + this.selInfo[0].endMarker + value.substr(selectionEnd) ); } this.toWYSIWYG(); } }; private restoreSelection = () => { if (!this.selInfo.length) { return; } if (this.jodit.getRealMode() === consts.MODE_WYSIWYG) { this.__lock = true; this.jodit.selection.restore(this.selInfo); this.__lock = false; return; } let value: string = this.getMirrorValue(); let selectionStart: number = 0, selectionEnd: number = 0; try { if (this.selInfo[0].startMarker) { value = value.replace( /<span[^>]+data-jodit_selection_marker="start"[^>]*>[<>]*?<\/span>/gim, this.tempMarkerStart ); } if (this.selInfo[0].endMarker) { value = value.replace( /<span[^>]+data-jodit_selection_marker="end"[^>]*>[<>]*?<\/span>/gim, this.tempMarkerEnd ); } if ( (this.jodit.ownerWindow as any).html_beautify && this.jodit.options.beautifyHTML ) { value = (this.jodit.ownerWindow as any).html_beautify(value); } selectionStart = value.indexOf(this.tempMarkerStart); selectionEnd = selectionStart; value = value.replace(this.tempMarkerStartReg, ''); if (!this.selInfo[0].collapsed || selectionStart === -1) { selectionEnd = value.indexOf(this.tempMarkerEnd); if (selectionStart === -1) { selectionStart = selectionEnd; } } value = value.replace(this.tempMarkerEndReg, ''); } finally { value = value .replace(this.tempMarkerEndReg, '') .replace(this.tempMarkerStartReg, ''); } this.setMirrorValue(value); this.setMirrorSelectionRange(selectionStart, selectionEnd); this.toWYSIWYG(); this.setFocusToMirror(); // need for setting focus after change mode }; /** * Proxy Method * @param e * @private */ private __proxyOnFocus = (e: MouseEvent) => { this.jodit.events.fire('focus', e); }; private __proxyOnMouseDown = (e: MouseEvent) => { this.jodit.events.fire('mousedown', e); }; private replaceMirrorToACE() { const editor: IJodit = this.jodit; let aceEditor: AceAjax.Editor, undoManager: AceAjax.UndoManager; const updateButtons = () => { if ( undoManager && editor.getRealMode() === consts.MODE_SOURCE ) { editor.events.fire('canRedo', undoManager.hasRedo()); editor.events.fire('canUndo', undoManager.hasUndo()); } }, getLastColumnIndex = (row: number): number => { return aceEditor.session.getLine(row).length; }, getLastColumnIndices = (): number[] => { const rows: number = aceEditor.session.getLength(); const lastColumnIndices: number[] = []; let lastColIndex: number = 0; for (let i = 0; i < rows; i++) { lastColIndex += getLastColumnIndex(i); if (i > 0) { lastColIndex += 1; } lastColumnIndices[i] = lastColIndex; } return lastColumnIndices; }, getRowColumnIndices = ( characterIndex: number ): { row: number; column: number } => { const lastColumnIndices: number[] = getLastColumnIndices(); if (characterIndex <= lastColumnIndices[0]) { return { row: 0, column: characterIndex }; } let row: number = 1; for (let i = 1; i < lastColumnIndices.length; i++) { if (characterIndex > lastColumnIndices[i]) { row = i + 1; } } const column: number = characterIndex - lastColumnIndices[row - 1] - 1; return { row, column }; }, setSelectionRangeIndices = (start: number, end: number) => { const startRowColumn = getRowColumnIndices(start); const endRowColumn = getRowColumnIndices(end); aceEditor.getSelection().setSelectionRange({ start: startRowColumn, end: endRowColumn }); }, getIndexByRowColumn = (row: number, column: number): number => { const lastColumnIndices: number[] = getLastColumnIndices(); return ( lastColumnIndices[row] - getLastColumnIndex(row) + column ); }, tryInitAceEditor = () => { if ( aceEditor === undefined && (this.jodit.ownerWindow as any).ace !== undefined ) { this.jodit.events.off( this.jodit.ownerWindow, 'aceReady', tryInitAceEditor ); const fakeMirror = this.jodit.create.div( 'jodit_source_mirror-fake' ); this.mirrorContainer.insertBefore( fakeMirror, this.mirrorContainer.firstChild ); this.aceEditor = aceEditor = ((this.jodit .ownerWindow as any).ace as AceAjax.Ace).edit( fakeMirror ); aceEditor.setTheme( editor.options.sourceEditorNativeOptions.theme ); aceEditor.renderer.setShowGutter( editor.options.sourceEditorNativeOptions.showGutter ); aceEditor .getSession() .setMode(editor.options.sourceEditorNativeOptions.mode); aceEditor.setHighlightActiveLine( editor.options.sourceEditorNativeOptions .highlightActiveLine ); aceEditor.getSession().setUseWrapMode(true); aceEditor.setOption('indentedSoftWrap', false); aceEditor.setOption( 'wrap', editor.options.sourceEditorNativeOptions.wrap ); aceEditor.getSession().setUseWorker(false); aceEditor.$blockScrolling = Infinity; // aceEditor.setValue(this.getMirrorValue()); // aceEditor.clearSelection(); aceEditor.setOptions({ maxLines: Infinity }); aceEditor.on('change', this.toWYSIWYG); aceEditor.on('focus', this.__proxyOnFocus); aceEditor.on('mousedown', this.__proxyOnMouseDown); this.mirror.style.display = 'none'; undoManager = aceEditor.getSession().getUndoManager(); this.setMirrorValue = (value: string) => { if ( editor.options.beautifyHTML && (editor.ownerWindow as any).html_beautify ) { aceEditor.setValue( (editor.ownerWindow as any).html_beautify(value) ); } else { aceEditor.setValue(value); } aceEditor.clearSelection(); updateButtons(); }; if (this.jodit.getRealMode() !== consts.MODE_WYSIWYG) { this.setMirrorValue(this.getMirrorValue()); } this.getMirrorValue = () => { return aceEditor.getValue(); }; this.setFocusToMirror = () => { aceEditor.focus(); }; this.getSelectionStart = (): number => { const range: AceAjax.Range = aceEditor.selection.getRange(); return getIndexByRowColumn( range.start.row, range.start.column ); }; this.getSelectionEnd = (): number => { const range: AceAjax.Range = aceEditor.selection.getRange(); return getIndexByRowColumn( range.end.row, range.end.column ); }; this.selectAll = () => { aceEditor.selection.selectAll(); }; this.insertHTML = (html: string) => { const start: AceAjax.Position = aceEditor.selection.getCursor(), end: AceAjax.Position = aceEditor.session.insert( start, html ); aceEditor.selection.setRange( { start, end } as AceAjax.Range, false ); }; this.setMirrorSelectionRange = ( start: number, end: number ) => { setSelectionRangeIndices(start, end); }; editor.events .on('afterResize', () => { aceEditor.resize(); }) .fire('aceInited', editor); } }; editor.events .on(this.jodit.ownerWindow, 'aceReady', tryInitAceEditor) // work in global scope .on('aceReady', tryInitAceEditor) // work in local scope .on('afterSetMode', () => { if ( editor.getRealMode() !== consts.MODE_SOURCE && editor.getMode() !== consts.MODE_SPLIT ) { return; } this.fromWYSIWYG(); tryInitAceEditor(); }) .on( 'beforeCommand', (command: string): false | void => { if ( editor.getRealMode() !== consts.MODE_WYSIWYG && (command === 'redo' || command === 'undo') && undoManager ) { if ( (undoManager as any)[ 'has' + command.substr(0, 1).toUpperCase() + command.substr(1) ] ) { aceEditor[command](); } updateButtons(); return false; } } ); tryInitAceEditor(); // global add ace editor in browser if ( (this.jodit.ownerWindow as any).ace === undefined && !$$('script.' + this.className, this.jodit.ownerDocument.body) .length ) { this.loadNext( 0, editor.options.sourceEditorCDNUrlsJS, 'aceReady', this.className ); } } public mirror: HTMLTextAreaElement; public aceEditor: AceAjax.Editor; public setMirrorSelectionRange: (start: number, end: number) => void = ( start: number, end: number ) => { this.mirror.setSelectionRange(start, end); }; private onReadonlyReact = () => { const isReadOnly: boolean = this.jodit.options.readonly; if (isReadOnly) { this.mirror.setAttribute('readonly', 'true'); } else { this.mirror.removeAttribute('readonly'); } if (this.aceEditor) { this.aceEditor.setReadOnly(isReadOnly); } }; afterInit(editor: IJodit): void { this.mirrorContainer = editor.create.div('jodit_source'); this.mirror = editor.create.fromHTML( '<textarea class="jodit_source_mirror"/>' ) as HTMLTextAreaElement; const addListeners = () => { // save restore selection editor.events .off('beforeSetMode.source afterSetMode.source') .on('beforeSetMode.source', this.saveSelection) .on('afterSetMode.source', this.restoreSelection); }; addListeners(); this.onReadonlyReact(); editor.events .on( this.mirror, 'mousedown keydown touchstart input', debounce(this.toWYSIWYG, editor.defaultTimeout) ) .on( this.mirror, 'change keydown mousedown touchstart input', this.autosize ) .on('afterSetMode.source', this.autosize) .on(this.mirror, 'mousedown focus', (e: Event) => { editor.events.fire(e.type, e); }); editor.events .on('setMinHeight.source', (minHeightD: number) => { this.mirror && css(this.mirror, 'minHeight', minHeightD); }) .on( 'insertHTML.source', (html: string): void | false => { if ( !editor.options.readonly && !this.jodit.isEditorMode() ) { this.insertHTML(html); return false; } } ) .on( 'aceInited', () => { this.onReadonlyReact(); addListeners(); }, void 0, void 0, true ) .on('readonly.source', this.onReadonlyReact) .on('placeholder.source', (text: string) => { this.mirror.setAttribute('placeholder', text); }) .on('beforeCommand.source', this.onSelectAll) .on('change.source', this.fromWYSIWYG); this.mirrorContainer.appendChild(this.mirror); editor.workplace.appendChild(this.mirrorContainer); this.autosize(); const className = 'beutyfy_html_jodit_helper'; if ( editor.options.beautifyHTML && (editor.ownerWindow as any).html_beautify === undefined && !$$('script.' + className, editor.ownerDocument.body).length ) { this.loadNext( 0, editor.options.beautifyHTMLCDNUrlsJS, false, className ); } if (editor.options.useAceEditor) { this.replaceMirrorToACE(); } this.fromWYSIWYG(); } beforeDestruct(jodit: IJodit): void { Dom.safeRemove(this.mirrorContainer); Dom.safeRemove(this.mirror); if (jodit && jodit.events) { jodit.events.off('aceInited.source'); } if (this.aceEditor) { this.setFocusToMirror = () => {}; this.aceEditor.off('change', this.toWYSIWYG); this.aceEditor.off('focus', this.__proxyOnFocus); this.aceEditor.off('mousedown', this.__proxyOnMouseDown); this.aceEditor.destroy(); delete this.aceEditor; } if (this.lastTuple) { this.lastTuple.element.removeEventListener( 'load', this.lastTuple.callback ); } } }