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', {