jodit
Version:
Jodit is awesome and usefully wysiwyg editor with filebrowser
698 lines (602 loc) • 15.8 kB
text/typescript
/*!
* 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 './search.less';
import { Config } from '../../config';
import * as consts from '../../core/constants';
import { MODE_WYSIWYG } from '../../core/constants';
import { Dom } from '../../core/dom';
import { Plugin } from '../../core/plugin';
import { ISelectionRange, markerInfo, IJodit, Nullable } from '../../types';
import { Icon } from '../../core/ui';
import { refs, trim } from '../../core/helpers';
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 {
static getSomePartOfStringIndex(
needle: string,
haystack: string,
start: boolean = true
): number | false {
return this.findSomePartOfString(needle, haystack, start, true) as
| number
| false;
}
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 = 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;
}
}
if (getIndex) {
return startAtIndex ?? false;
}
if (tmp.length) {
return start ? tmp.join('') : tmp.reverse().join('');
}
return false;
}
private template = `<div class="jodit-search">
<div class="jodit-search__box">
<div class="jodit-search__inputs">
<input data-ref="query" tabindex="0" placeholder="${this.j.i18n(
'Search for'
)}" type="text"/>
<input data-ref="replace" tabindex="0" placeholder="${this.j.i18n(
'Replace with'
)}" type="text"/>
</div>
<div class="jodit-search__counts">
<span data-ref="counter-box">0/0</span>
</div>
<div class="jodit-search__buttons">
<button data-ref="next" tabindex="0" type="button">${Icon.get(
'angle-down'
)}</button>
<button data-ref="prev" tabindex="0" type="button">${Icon.get(
'angle-up'
)}</button>
<button data-ref="cancel" tabindex="0" type="button">${Icon.get(
'cancel'
)}</button>
<button data-ref="replace-btn" tabindex="0" type="button" class="jodit-ui-button">${this.j.i18n(
'Replace'
)}</button>
</div>
</div>
</div>`;
private isOpened: boolean = false;
private selInfo: Nullable<markerInfo[]> = null;
private current: Nullable<Node> = null;
private eachMap = (
node: Node,
callback: (elm: Node) => boolean,
next: boolean
) => {
Dom.findWithCurrent(
node,
(child: Node | null): boolean => {
return !!child && callback(child);
},
this.j.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.j.s.range,
counts: [number, number] = this.calcCounts(
this.queryInput.value,
range
);
this.counterBox.textContent = 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,
Dom.isElement,
this.j.editor
) as HTMLElement | false;
if (!parentBox) {
parentBox = Dom.prev(
startContainer,
Dom.isElement,
this.j.editor
) as HTMLElement | false;
}
parentBox && parentBox !== this.j.editor && parentBox.scrollIntoView();
}
searchBox!: HTMLDivElement;
queryInput!: HTMLInputElement;
replaceInput!: HTMLInputElement;
closeButton!: HTMLButtonElement;
nextButton!: HTMLButtonElement;
prevButton!: HTMLButtonElement;
replaceButton!: HTMLButtonElement;
counterBox!: HTMLSpanElement;
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.j.editor.firstChild;
while (start && query.length) {
bound = this.find(
start,
query,
true,
0,
(bound as Range) || this.j.ed.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];
};
findAndReplace = (start: Node | null, query: string): boolean => {
const range = this.j.s.range,
bound: ISelectionRange | false = this.find(
start,
query,
true,
0,
range
);
if (bound && bound.startContainer && bound.endContainer) {
const rng = this.j.ed.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.j.createInside.text(
this.replaceInput.value
);
rng.insertNode(textNode);
this.j.s.select(textNode);
this.tryScrollToElement(textNode);
}
} catch {}
return true;
}
return false;
};
/**
*
* @param start
* @param query
* @param next
*/
findAndSelect = (
start: Node | null,
query: string,
next: boolean
): boolean => {
const range = this.j.s.range,
bound: ISelectionRange | false = this.find(
start,
query,
next,
0,
range
);
if (bound && bound.startContainer && bound.endContainer) {
const rng: Range = this.j.ed.createRange();
try {
rng.setStart(bound.startContainer, bound.startOffset as number);
rng.setEnd(bound.endContainer, bound.endOffset as number);
this.j.s.selectRange(rng);
} catch (e) {}
this.tryScrollToElement(bound.startContainer);
this.current = bound.startContainer;
this.updateCounters();
return true;
}
return false;
};
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 (
Dom.isText(elm) &&
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.j.ew) && sentence !== '') {
sentence = next ? sentence + ' ' : ' ' + sentence;
}
return false;
},
next
);
if (bound.startContainer && bound.endContainer) {
return bound;
}
if (!deep) {
this.current = next
? (this.j.editor.firstChild as Node)
: (this.j.editor.lastChild as Node);
return this.find(this.current, query, next, deep + 1, range);
}
}
return false;
};
open = (searchAndReplace: boolean = false) => {
if (!this.isOpened) {
this.searchBox.classList.add('jodit-search_active');
this.isOpened = true;
}
this.j.e.fire('hidePopup');
this.searchBox.classList.toggle(
'jodit-search_replace',
searchAndReplace
);
this.current = this.j.s.current();
this.selInfo = this.j.s.save();
const selStr: string = (this.j.s.sel || '').toString();
if (selStr) {
this.queryInput.value = selStr;
}
this.updateCounters();
if (selStr) {
this.queryInput.select();
} else {
this.queryInput.focus();
}
};
close = (): void => {
if (!this.isOpened) {
return;
}
if (this.selInfo) {
this.j.s.restore(this.selInfo);
this.selInfo = null;
}
this.searchBox.classList.remove('jodit-search_active');
this.isOpened = false;
};
afterInit(editor: IJodit): void {
if (editor.o.useSearch) {
const self: search = this;
self.searchBox = editor.c.fromHTML(self.template) as HTMLDivElement;
const {
query,
replace,
cancel,
next,
prev,
replaceBtn,
counterBox
} = refs(self.searchBox);
self.queryInput = query as HTMLInputElement;
self.replaceInput = replace as HTMLInputElement;
self.closeButton = cancel as HTMLButtonElement;
self.nextButton = next as HTMLButtonElement;
self.prevButton = prev as HTMLButtonElement;
self.replaceButton = replaceBtn as HTMLButtonElement;
self.counterBox = counterBox as HTMLButtonElement;
const onInit = () => {
editor.workplace.appendChild(this.searchBox);
editor.e
.off(this.j.container, 'keydown.search')
.on(
this.j.container,
'keydown.search',
(e: KeyboardEvent) => {
if (editor.getRealMode() !== MODE_WYSIWYG) {
return;
}
switch (e.key) {
case consts.KEY_ESC:
this.close();
break;
case consts.KEY_F3:
if (self.queryInput.value) {
editor.e.fire(
!e.shiftKey
? 'searchNext'
: 'searchPrevious'
);
e.preventDefault();
}
break;
}
}
);
};
onInit();
editor.e
.on('changePlace', onInit)
.on(self.closeButton, 'click', this.close)
.on(self.queryInput, 'mousedown', () => {
if (editor.s.isFocused()) {
editor.s.removeMarkers();
self.selInfo = editor.s.save();
}
})
.on(self.replaceButton, 'click', (e: MouseEvent) => {
self.findAndReplace(
editor.s.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.e.fire(
self.nextButton === this
? 'searchNext'
: 'searchPrevious'
);
e.preventDefault();
e.stopImmediatePropagation();
})
.on(
this.queryInput,
'keydown',
this.j.async.debounce((e: KeyboardEvent) => {
switch (e.key) {
case consts.KEY_ENTER:
e.preventDefault();
e.stopImmediatePropagation();
if (editor.e.fire('searchNext')) {
this.close();
}
break;
default:
this.updateCounters();
break;
}
}, this.j.defaultTimeout)
)
.on('beforeSetMode.search', () => {
this.close();
})
.on('keydown.search mousedown.search', () => {
if (this.selInfo) {
editor.s.removeMarkers();
this.selInfo = null;
}
if (this.isOpened) {
this.current = this.j.s.current();
this.updateCounters();
}
})
.on('searchNext.search searchPrevious.search', () => {
return self.findAndSelect(
editor.s.current() || editor.editor.firstChild,
self.queryInput.value,
editor.e.current === '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.s.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.o.readonly) {
self.open(true);
}
return false;
},
hotkeys: ['ctrl+h', 'cmd+h']
});
}
}
beforeDestruct(jodit: IJodit): void {
Dom.safeRemove(this.searchBox);
jodit.events?.off('.search');
}
}