diff --git a/examples/Components/Routes/Search/index.vue b/examples/Components/Routes/Search/index.vue new file mode 100644 index 00000000..2cb1f3c2 --- /dev/null +++ b/examples/Components/Routes/Search/index.vue @@ -0,0 +1,264 @@ + + + + + diff --git a/examples/Components/Subnavigation/index.vue b/examples/Components/Subnavigation/index.vue index 953b8a6d..a871baf0 100644 --- a/examples/Components/Subnavigation/index.vue +++ b/examples/Components/Subnavigation/index.vue @@ -24,6 +24,9 @@ Tables + + Search + Suggestions diff --git a/examples/assets/sass/menubar.scss b/examples/assets/sass/menubar.scss index 26c0781b..0f5537b1 100644 --- a/examples/assets/sass/menubar.scss +++ b/examples/assets/sass/menubar.scss @@ -33,4 +33,8 @@ background-color: rgba($color-black, 0.1); } } + + span#{&}__button { + font-size: 13.3333px; + } } diff --git a/examples/main.js b/examples/main.js index 933ff7c0..705007f7 100644 --- a/examples/main.js +++ b/examples/main.js @@ -68,6 +68,13 @@ const routes = [ githubUrl: 'https://github.com/scrumpy/tiptap/tree/master/examples/Components/Routes/TodoList', }, }, + { + path: '/search', + component: () => import('Components/Routes/Search'), + meta: { + githubUrl: 'https://github.com/scrumpy/tiptap/tree/master/examples/Components/Routes/Search', + }, + }, { path: '/suggestions', component: () => import('Components/Routes/Suggestions'), diff --git a/packages/tiptap-extensions/src/extensions/Search.js b/packages/tiptap-extensions/src/extensions/Search.js new file mode 100644 index 00000000..dd63d60a --- /dev/null +++ b/packages/tiptap-extensions/src/extensions/Search.js @@ -0,0 +1,198 @@ +import { Extension, Plugin } from 'tiptap' +import { Decoration, DecorationSet } from 'prosemirror-view' + +export default class Search extends Extension { + + constructor(options = {}) { + super(options) + + this.results = [] + this.searchTerm = null + this._updating = false + } + + get name() { + return 'search' + } + + init() { + this.editor.events.push('toggleSearch') + } + + get defaultOptions() { + return { + autoSelectNext: true, + findClass: 'find', + searching: false, + caseSensitive: false, + disableRegex: true, + alwaysSearch: false, + } + } + + toggleSearch() { + return () => { + this.options.searching = !this.options.searching + this.editor.emit('toggleSearch', this.options.searching) + return true + } + } + + keys() { + return { + 'Mod-f': this.toggleSearch(), + } + } + + commands() { + return { + find: attrs => this.find(attrs), + replace: attrs => this.replace(attrs), + replaceAll: attrs => this.replaceAll(attrs), + clearSearch: () => this.clear(), + toggleSearch: () => this.toggleSearch(), + } + } + + 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 (state, dispatch) => { + const { from, to } = this.results[0] + + dispatch(state.tr.insertText(replace, from, to)) + } + } + + 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 + this.results.forEach(({ from, to }, index) => { + tr.insertText(replace, from, to) + + offset = this.rebaseNextResult(replace, index, offset) + }) + + dispatch(tr) + } + } + + find(searchTerm) { + return (state, dispatch) => { + this.searchTerm = (this.options.disableRegex) ? searchTerm.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') : searchTerm + + this.updateView(state, dispatch) + } + } + + clear() { + return (state, dispatch) => { + this.searchTerm = null + + this.updateView(state, dispatch) + } + } + + updateView({ tr }, dispatch) { + this._updating = true + dispatch(tr) + this._updating = false + } + + createDeco(doc) { + this._search(doc) + return this.decorations ? DecorationSet.create(doc, this.decorations) : [] + } + + get plugins() { + return [ + new Plugin({ + state: { + init() { return DecorationSet.empty }, + apply: (tr, old) => { + if (this._updating + || this.options.searching + || (tr.docChanged && this.options.alwaysSearch)) { + return this.createDeco(tr.doc) + } + if (tr.docChanged) { + return old.map(tr.mapping, tr.doc) + } + return old + }, + }, + props: { + decorations(state) { return this.getState(state) }, + }, + }), + ] + } + +} diff --git a/packages/tiptap-extensions/src/index.js b/packages/tiptap-extensions/src/index.js index 4851bb51..b3817696 100644 --- a/packages/tiptap-extensions/src/index.js +++ b/packages/tiptap-extensions/src/index.js @@ -26,6 +26,7 @@ export { default as Underline } from './marks/Underline' export { default as Collaboration } from './extensions/Collaboration' export { default as History } from './extensions/History' export { default as Placeholder } from './extensions/Placeholder' +export { default as Search } from './extensions/Search' export { default as Suggestions } from './plugins/Suggestions' export { default as Highlight } from './plugins/Highlight' diff --git a/packages/tiptap/src/Editor.js b/packages/tiptap/src/Editor.js index 4a941577..ae43663e 100644 --- a/packages/tiptap/src/Editor.js +++ b/packages/tiptap/src/Editor.js @@ -84,7 +84,7 @@ export default class Editor extends Emitter { } this.events.forEach(name => { - this.on(name, this.options[camelCase(`on ${name}`)]) + this.on(name, this.options[camelCase(`on ${name}`)] || (() => {})) }) this.emit('init', {