jodit
Version:
Jodit is awesome and usefully wysiwyg editor with filebrowser
705 lines (618 loc) • 16.9 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_WYSIWYG } from '../constants';
import { Dom } from '../modules/Dom';
import { ToolbarIcon } from '../modules/toolbar/icon';
import { Plugin } from '../modules/Plugin';
import { debounce } from '../modules/helpers/async';
import { trim } from '../modules/helpers/string';
import { ISelectionRange, markerInfo } from '../types/types';
import { IJodit } from '../types';
declare module '../Config' {
interface Config {
/**
* Enable custom search plugin
* 
*/
useSearch: boolean;
// searchByInput: boolean,
}
}
Config.prototype.useSearch = true;
/**
* Search plugin. it is used for custom search in text
* 
*
* @example
* ```typescript
* var jodit = new Jodit('#editor', {
* useSearch: false
* });
* // or
* var jodit = new Jodit('#editor', {
* disablePlugins: 'search'
* });
* ```
*/
export class search extends Plugin {
public static getSomePartOfStringIndex(
needle: string,
haystack: string,
start: boolean = true
): number | false {
return this.findSomePartOfString(needle, haystack, start, true) as
| number
| false;
}
public static findSomePartOfString(
needle: string,
haystack: string,
start: boolean = true,
getIndex: boolean = false
): boolean | string | number {
needle = trim(needle.toLowerCase().replace(consts.SPACE_REG_EXP, ' '));
haystack = haystack.toLowerCase();
let i: number = start ? 0 : haystack.length - 1,
needleStart: number = start ? 0 : needle.length - 1,
tmpEqualLength: number = 0,
startAtIndex: number | null = null;
const inc: number = start ? 1 : -1,
tmp: string[] = [];
for (; haystack[i] !== undefined; i += inc) {
const some: boolean = needle[needleStart] === haystack[i];
if (
some ||
(startAtIndex !== null &&
consts.SPACE_REG_EXP.test(haystack[i]))
) {
if (startAtIndex === null || !start) {
startAtIndex = i;
}
tmp.push(haystack[i]);
if (some) {
tmpEqualLength += 1;
needleStart += inc;
}
} else {
startAtIndex = null;
tmp.length = 0;
tmpEqualLength = 0;
needleStart = start ? 0 : needle.length - 1;
}
if (tmpEqualLength === needle.length) {
return getIndex ? (startAtIndex as number) : true;
}
}
return getIndex
? startAtIndex !== null
? startAtIndex
: false
: tmp.length
? start
? tmp.join('')
: tmp.reverse().join('')
: false;
}
private template: string =
'<div class="jodit_search">' +
'<div class="jodit_search_box">' +
'<div class="jodit_search_inputs">' +
'<input tabindex="0" class="jodit_search-query" placeholder="' +
this.jodit.i18n('Search for') +
'" type="text"/>' +
'<input tabindex="0" class="jodit_search-replace" placeholder="' +
this.jodit.i18n('Replace with') +
'" type="text"/>' +
'</div>' +
'<div class="jodit_search_counts">' +
'<span>0/0</span>' +
'</div>' +
'<div class="jodit_search_buttons">' +
'<button tabindex="0" type="button" class="jodit_search_buttons-next">' +
ToolbarIcon.getIcon('angle-down') +
'</button>' +
'<button tabindex="0" type="button" class="jodit_search_buttons-prev">' +
ToolbarIcon.getIcon('angle-up') +
'</button>' +
'<button tabindex="0" type="button" class="jodit_search_buttons-cancel">' +
ToolbarIcon.getIcon('cancel') +
'</button>' +
'<button tabindex="0" type="button" class="jodit_search_buttons-replace">' +
this.jodit.i18n('Replace') +
'</button>' +
'</div>' +
'</div>' +
'</div>';
private isOpened: boolean = false;
private selInfo: markerInfo[] | null = null;
private current: Node | false = false;
private eachMap = (
node: Node,
callback: (elm: Node) => boolean,
next: boolean
) => {
Dom.findWithCurrent(
node,
(child: Node | null): boolean => {
return !!child && callback(child);
},
this.jodit.editor,
next ? 'nextSibling' : 'previousSibling',
next ? 'firstChild' : 'lastChild'
);
};
private updateCounters = () => {
if (!this.isOpened) {
return;
}
this.counterBox.style.display = this.queryInput.value.length
? 'inline-block'
: 'none';
const range = this.jodit.selection.range,
counts: [number, number] = this.calcCounts(
this.queryInput.value,
range
);
this.counterBox.innerText = counts.join('/');
};
private boundAlreadyWas(
current: ISelectionRange,
bounds: ISelectionRange[]
): boolean {
return bounds.some((bound: ISelectionRange) => {
return (
bound.startContainer === current.startContainer &&
bound.endContainer === current.endContainer &&
bound.startOffset === current.startOffset &&
bound.endOffset === current.endOffset
);
}, false);
}
private tryScrollToElement(startContainer: Node) {
// find scrollable element
let parentBox: HTMLElement | false = Dom.closest(
startContainer,
elm => elm && elm.nodeType === Node.ELEMENT_NODE,
this.jodit.editor
) as HTMLElement | false;
if (!parentBox) {
parentBox = Dom.prev(
startContainer,
(elm: Node | null) => elm && elm.nodeType === Node.ELEMENT_NODE,
this.jodit.editor
) as HTMLElement | false;
}
parentBox &&
parentBox !== this.jodit.editor &&
parentBox.scrollIntoView();
}
public searchBox: HTMLDivElement;
public queryInput: HTMLInputElement;
public replaceInput: HTMLInputElement;
public closeButton: HTMLButtonElement;
public nextButton: HTMLButtonElement;
public prevButton: HTMLButtonElement;
public replaceButton: HTMLButtonElement;
public counterBox: HTMLSpanElement;
public calcCounts = (
query: string,
current: ISelectionRange | false = false
): [number, number] => {
const bounds: ISelectionRange[] = [];
let currentIndex: number = 0,
count: number = 0,
bound: ISelectionRange | false = false,
start: Node | null = this.jodit.editor.firstChild;
while (start && query.length) {
bound = this.find(
start,
query,
true,
0,
(bound as Range) || this.jodit.editorDocument.createRange()
);
if (bound) {
if (this.boundAlreadyWas(bound, bounds)) {
break;
}
bounds.push(bound);
start = bound.startContainer;
count += 1;
if (current && this.boundAlreadyWas(current, [bound])) {
currentIndex = count;
}
} else {
start = null;
}
}
return [currentIndex, count];
};
public findAndReplace = (start: Node | null, query: string): boolean => {
const
range = this.jodit.selection.range,
bound: ISelectionRange | false = this.find(
start,
query,
true,
0,
range
);
if (bound && bound.startContainer && bound.endContainer) {
const rng = this.jodit.editorDocument.createRange();
try {
if (bound && bound.startContainer && bound.endContainer) {
rng.setStart(
bound.startContainer,
bound.startOffset as number
);
rng.setEnd(bound.endContainer, bound.endOffset as number);
rng.deleteContents();
const textNode: Node = this.jodit.editorDocument.createTextNode(
this.replaceInput.value
);
rng.insertNode(textNode);
this.jodit.selection.select(textNode);
this.tryScrollToElement(textNode);
}
} catch {}
return true;
}
return false;
};
/**
*
* @param start
* @param query
* @param next
*/
public findAndSelect = (
start: Node | null,
query: string,
next: boolean
): boolean => {
const range = this.jodit.selection.range,
bound: ISelectionRange | false = this.find(
start,
query,
next,
0,
range
);
if (bound && bound.startContainer && bound.endContainer) {
const rng: Range = this.jodit.editorDocument.createRange();
try {
rng.setStart(bound.startContainer, bound.startOffset as number);
rng.setEnd(bound.endContainer, bound.endOffset as number);
this.jodit.selection.selectRange(rng);
} catch (e) {}
this.tryScrollToElement(bound.startContainer);
this.current = bound.startContainer;
this.updateCounters();
return true;
}
return false;
};
public find = (
start: Node | null,
query: string,
next: boolean,
deep: number,
range: Range
): false | ISelectionRange => {
if (start && query.length) {
let sentence: string = '',
bound: ISelectionRange = {
startContainer: null,
startOffset: null,
endContainer: null,
endOffset: null
};
this.eachMap(
start,
(elm: Node): boolean => {
if (
elm.nodeType === Node.TEXT_NODE &&
elm.nodeValue !== null &&
elm.nodeValue.length
) {
let value: string = elm.nodeValue;
if (!next && elm === range.startContainer) {
value = !deep
? value.substr(0, range.startOffset)
: value.substr(range.endOffset);
} else if (next && elm === range.endContainer) {
value = !deep
? value.substr(range.endOffset)
: value.substr(0, range.startOffset);
}
const tmpSentence: string = next
? sentence + value
: value + sentence;
const part:
| boolean
| string = search.findSomePartOfString(
query,
tmpSentence,
next
) as boolean | string;
if (part !== false) {
let currentPart:
| string
| boolean = search.findSomePartOfString(
query,
value,
next
) as string | boolean;
if (currentPart === true) {
currentPart = trim(query);
} else if (currentPart === false) {
currentPart = search.findSomePartOfString(
value,
query,
next
) as string | true;
if (currentPart === true) {
currentPart = trim(value);
}
}
let currentPartIndex: number =
search.getSomePartOfStringIndex(
query,
value,
next
) || 0;
if (
((next && !deep) || (!next && deep)) &&
elm.nodeValue.length - value.length > 0
) {
currentPartIndex +=
elm.nodeValue.length - value.length;
}
if (bound.startContainer === null) {
bound.startContainer = elm;
bound.startOffset = currentPartIndex;
}
if (part !== true) {
sentence = tmpSentence;
} else {
bound.endContainer = elm;
bound.endOffset = currentPartIndex;
bound.endOffset += (currentPart as string).length;
return true;
}
} else {
sentence = '';
bound = {
startContainer: null,
startOffset: null,
endContainer: null,
endOffset: null
};
}
} else if (
Dom.isBlock(elm, this.jodit.editorWindow) &&
sentence !== ''
) {
sentence = next ? sentence + ' ' : ' ' + sentence;
}
return false;
},
next
);
if (bound.startContainer && bound.endContainer) {
return bound;
}
if (!deep) {
this.current = next
? (this.jodit.editor.firstChild as Node)
: (this.jodit.editor.lastChild as Node);
return this.find(this.current, query, next, deep + 1, range);
}
}
return false;
};
public open = (searchAndReplace: boolean = false) => {
if (!this.isOpened) {
this.searchBox.classList.add('jodit_search-active');
this.isOpened = true;
}
this.jodit.events.fire('hidePopup');
this.searchBox.classList.toggle(
'jodit_search-and-replace',
searchAndReplace
);
this.current = this.jodit.selection.current();
this.selInfo = this.jodit.selection.save();
const selStr: string = (this.jodit.selection.sel || '').toString();
if (selStr) {
this.queryInput.value = selStr;
}
this.updateCounters();
if (selStr) {
this.queryInput.select();
} else {
this.queryInput.focus();
}
};
public close = () => {
if (!this.isOpened) {
return;
}
if (this.selInfo) {
this.jodit.selection.restore(this.selInfo);
this.selInfo = null;
}
this.searchBox.classList.remove('jodit_search-active');
this.isOpened = false;
};
afterInit(editor: IJodit) {
if (editor.options.useSearch) {
const self: search = this;
self.searchBox = editor.create.fromHTML(
self.template
) as HTMLDivElement;
const qs = self.searchBox.querySelector.bind(self.searchBox);
self.queryInput = qs(
'input.jodit_search-query'
) as HTMLInputElement;
self.replaceInput = qs(
'input.jodit_search-replace'
) as HTMLInputElement;
self.closeButton = qs(
'.jodit_search_buttons-cancel'
) as HTMLButtonElement;
self.nextButton = qs(
'.jodit_search_buttons-next'
) as HTMLButtonElement;
self.prevButton = qs(
'.jodit_search_buttons-prev'
) as HTMLButtonElement;
self.replaceButton = qs(
'.jodit_search_buttons-replace'
) as HTMLButtonElement;
self.counterBox = qs(
'.jodit_search_counts span'
) as HTMLButtonElement;
editor.workplace.appendChild(this.searchBox);
editor.events
.on(self.closeButton, 'click', this.close)
.on(self.queryInput, 'mousedown', () => {
if (editor.selection.isFocused()) {
editor.selection.removeMarkers();
self.selInfo = editor.selection.save();
}
})
.on(self.replaceButton, 'click', (e: MouseEvent) => {
self.findAndReplace(
editor.selection.current() || editor.editor.firstChild,
self.queryInput.value
);
this.updateCounters();
e.preventDefault();
e.stopImmediatePropagation();
})
.on([self.nextButton, self.prevButton], 'click', function(
this: HTMLButtonElement,
e: MouseEvent
) {
editor.events.fire(
self.nextButton === this
? 'searchNext'
: 'searchPrevious'
);
e.preventDefault();
e.stopImmediatePropagation();
})
.on(
this.queryInput,
'keydown',
debounce((e: KeyboardEvent) => {
switch (e.which) {
case consts.KEY_ENTER:
e.preventDefault();
e.stopImmediatePropagation();
if (editor.events.fire('searchNext')) {
this.close();
}
break;
default:
this.updateCounters();
break;
}
}, this.jodit.defaultTimeout)
)
.on(
this.jodit.container,
'keydown.search',
(e: KeyboardEvent) => {
if (editor.getRealMode() !== MODE_WYSIWYG) {
return;
}
switch (e.which) {
case consts.KEY_ESC:
this.close();
break;
case consts.KEY_F3:
if (self.queryInput.value) {
editor.events.fire(
!e.shiftKey
? 'searchNext'
: 'searchPrevious'
);
e.preventDefault();
}
break;
}
}
)
.on('beforeSetMode.search', () => {
this.close();
})
.on('keydown.search mousedown.search', () => {
if (this.selInfo) {
editor.selection.removeMarkers();
this.selInfo = null;
}
if (this.isOpened) {
this.current = this.jodit.selection.current();
this.updateCounters();
}
})
.on('searchNext.search searchPrevious.search', () => {
return self.findAndSelect(
editor.selection.current() || editor.editor.firstChild,
self.queryInput.value,
editor.events.current[
editor.events.current.length - 1
] === 'searchNext'
);
})
.on('search.search', (value: string, next: boolean = true) => {
editor.execCommand('search', value, next);
});
editor.registerCommand('search', {
exec: (
command: string,
value?: string,
next: boolean = true
) => {
self.findAndSelect(
editor.selection.current() || editor.editor.firstChild,
value || '',
next
);
return false;
}
});
editor.registerCommand('openSearchDialog', {
exec: () => {
self.open();
return false;
},
hotkeys: ['ctrl+f', 'cmd+f']
});
editor.registerCommand('openReplaceDialog', {
exec: () => {
if (!editor.options.readonly) {
self.open(true);
}
return false;
},
hotkeys: ['ctrl+h', 'cmd+h']
});
}
}
beforeDestruct(jodit: IJodit): void {
Dom.safeRemove(this.searchBox);
jodit.events && jodit.events.off('.search');
jodit.events && jodit.events.off(jodit.container, '.search');
}
}