Skip to content

Commit

Permalink
feat: add search on editor
Browse files Browse the repository at this point in the history
  • Loading branch information
Kholid060 committed Aug 30, 2021
1 parent cedde2a commit e841af3
Show file tree
Hide file tree
Showing 2 changed files with 339 additions and 0 deletions.
135 changes: 135 additions & 0 deletions packages/renderer/src/components/note/NoteSearch.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<template>
<div
class="
fixed
bottom-0
pl-20
bg-white
dark:bg-gray-800
flex
items-center
left-0
w-full
py-2
pr-8
z-30
"
>
<ui-button
v-tooltip="'Use regex (Alt+R)'"
class="mr-2"
icon
@click="state.useRegex = !state.useRegex"
>
<v-remixicon
name="mdiRegex"
:class="{ 'text-primary': state.useRegex }"
/>
</ui-button>
<ui-button
v-tooltip="'Clear'"
class="mr-2"
icon
@click="editor.commands.clearSearch()"
>
<v-remixicon name="riDeleteBackLine" />
</ui-button>
<ui-input
v-model="state.query"
autofocus
prepend-icon="riSearch2Line"
placeholder="Search..."
class="flex-1 mr-2 editor-search"
@keyup.enter="search"
/>
<ui-input
v-model="state.replaceWith"
placeholder="Replace..."
class="flex-1 mr-4"
@keyup.enter="search"
/>
<ui-button class="mr-2" @click="search"> Find </ui-button>
<ui-button
v-tooltip="'Alt+Enter'"
:disabled="!state.replaceWith"
class="mr-2"
@click="replaceText"
>
Replace
</ui-button>
<ui-button
v-tooltip="'Ctrl+Alt+Enter'"
:disabled="!state.replaceWith"
@click="replaceAllText"
>
Replace all
</ui-button>
</div>
</template>
<script>
import { shallowReactive, onMounted, onUnmounted } from 'vue';
import Mousetrap from '@/lib/mousetrap';
export default {
props: {
editor: {
type: Object,
default: () => ({}),
},
},
setup(props) {
const state = shallowReactive({
query: '',
useRegex: false,
replaceWith: '',
});
function search() {
props.editor.commands.find(state.query, state.useRegex);
setTimeout(() => {
props.editor.commands.scrollIntoView();
}, 200);
}
function replaceText() {
if (state.replaceWith === '') return;
props.editor.commands.replace(state.replaceWith);
}
function replaceAllText() {
if (state.replaceWith === '') return;
props.editor.commands.replaceAll(state.replaceWith);
}
const shortcuts = {
'alt+r': () => (state.useRegex = !state.useRegex),
'alt+enter': replaceText,
'mod+alt+enter': replaceAllText,
};
Mousetrap.bind(Object.keys(shortcuts), (event, combo) => {
console.log(combo);
shortcuts[combo]();
});
onMounted(() => {
const { state: editorState } = props.editor;
const { from, to } = editorState.selection;
const text = editorState.doc.textBetween(from, to, ' ');
if (text) state.query = text;
});
onUnmounted(() => {
props.editor.commands.clearSearch();
Mousetrap.unbind(Object.keys(shortcuts));
});
return {
state,
search,
replaceText,
replaceAllText,
};
},
};
</script>
204 changes: 204 additions & 0 deletions packages/renderer/src/lib/tiptap/exts/search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { Extension } from '@tiptap/core';
import { Decoration, DecorationSet } from 'prosemirror-view';
import { Plugin } from 'prosemirror-state';

export class Search {
constructor() {
this.options = {};
this.results = [];
this.searchTerm = null;
this._updating = false;
}
get findRegExp() {
return RegExp(this.searchTerm, !this.options.caseSensitive ? 'gui' : 'gu');
}
get decorations() {
return this.results.map((deco) =>
Decoration.inline(deco.from, deco.to, { class: this.options.findClass })
);
}
_search(doc) {
this.results = [];
const mergedTextNodes = [];
let index = 0;

if (!this.searchTerm) {
return;
}

doc.descendants((node, pos) => {
if (node.isText) {
if (mergedTextNodes[index]) {
mergedTextNodes[index] = {
text: mergedTextNodes[index].text + node.text,
pos: mergedTextNodes[index].pos,
};
} else {
mergedTextNodes[index] = {
text: node.text,
pos,
};
}
} else {
index += 1;
}
});

mergedTextNodes.forEach(({ text, pos }) => {
const search = this.findRegExp;
let m;
// eslint-disable-next-line no-cond-assign
while ((m = search.exec(text))) {
if (m[0] === '') {
break;
}

this.results.push({
from: pos + m.index,
to: pos + m.index + m[0].length,
});
}
});
}
replace(replace) {
return ({ tr, dispatch }) => {
const firstResult = this.results[0];

if (!firstResult) {
return;
}

const { from, to } = this.results[0];
dispatch(tr.insertText(replace, from, to));
this.find(this.searchTerm);
};
}

rebaseNextResult(replace, index, lastOffset = 0) {
const nextIndex = index + 1;

if (!this.results[nextIndex]) {
return null;
}

const { from: currentFrom, to: currentTo } = this.results[index];
const offset = currentTo - currentFrom - replace.length + lastOffset;
const { from, to } = this.results[nextIndex];

this.results[nextIndex] = {
to: to - offset,
from: from - offset,
};

return offset;
}

replaceAll(replace) {
return ({ tr, dispatch }) => {
let offset;

if (!this.results.length) {
return;
}

this.results.forEach(({ from, to }, index) => {
tr.insertText(replace, from, to);
offset = this.rebaseNextResult(replace, index, offset);
});

dispatch(tr);

this.find(this.searchTerm);
};
}

find(searchTerm, useRegex) {
return ({ tr, dispatch }) => {
this.searchTerm =
this.options.disableRegex || !useRegex
? searchTerm.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')
: searchTerm;

this.updateView(tr, dispatch);
};
}

clear() {
return ({ tr, dispatch }) => {
this.searchTerm = null;

this.updateView(tr, dispatch);
};
}

updateView(tr, dispatch) {
this._updating = true;
dispatch(tr);
setTimeout(() => {
this._updating = false;
}, 100);
}

createDeco(doc) {
this._search(doc);

return this.decorations ? DecorationSet.create(doc, this.decorations) : [];
}
}

const search = new Search();

export default Extension.create({
name: 'search',
defaultOptions: {
autoSelectNext: true,
findClass: 'find',
searching: false,
caseSensitive: false,
disableRegex: true,
alwaysSearch: false,
},
addCommands() {
search.options = this.options;

return {
find: (attrs, useRegex) => search.find(attrs, useRegex),
replace: (attrs) => search.replace(attrs),
replaceAll: (attrs) => search.replaceAll(attrs),
clearSearch: () => search.clear(),
};
},
addProseMirrorPlugins() {
search.options = this.options;

return [
new Plugin({
state: {
init() {
return DecorationSet.empty;
},
apply: (tr, old) => {
if (
search._updating ||
this.options.searching ||
(tr.docChanged && this.options.alwaysSearch)
) {
return search.createDeco(tr.doc);
}

if (tr.docChanged) {
return old.map(tr.mapping, tr.doc);
}

return old;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
}),
];
},
});

0 comments on commit e841af3

Please sign in to comment.