jodit
Version:
Jodit is awesome and usefully wysiwyg editor with filebrowser
811 lines (706 loc) • 19.7 kB
text/typescript
/*!
* 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
);
}
}
}