Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Search & Replace #1891

Closed
rezaffm opened this issue Sep 14, 2021 · 19 comments
Closed

Search & Replace #1891

rezaffm opened this issue Sep 14, 2021 · 19 comments
Labels
Type: Feature The issue or pullrequest is a new feature

Comments

@rezaffm
Copy link

rezaffm commented Sep 14, 2021

Hi guys,

I noticed you have the search and replace feature for tiptap v1.

Actually, I have the impression it is a must-have for every editor, so was wondering when you plan to bring it to v2?

Thank You.

@rezaffm rezaffm added Type: Feature The issue or pullrequest is a new feature v2 labels Sep 14, 2021
@hanspagel
Copy link
Contributor

Thanks! It’ll come back at some point, but it’s not very high on our list. I think you’re the first one missing it.

Let’s see if others chime in here.

@rezaffm
Copy link
Author

rezaffm commented Sep 14, 2021

well, I mean it is not like our life depends upon it, but we use it quite a lot (with tinymce, I promise I will write a guide btw...) for instance with templates for certain how-to's, guides, reviews et cetera.

So if it comes some when during the next weeks (?) that would be great. I am not sure how difficult it is to bring it from v1 to v2.

@sereneinserenade
Copy link
Contributor

Hey @hanspagel , I need this and was trying to translate the v1 extension to v2 but I wasn't able to do it. Here's what I have as a WIP. One thing I couldn't figure out is how do we translate these lines to the new Extension API... I've tried to do it using const but it doesn't seem to work.

/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { Extension, Command, CommandProps } from '@tiptap/core'
import { Plugin, PluginKey } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'

declare module '@tiptap/core' {
  interface Commands {
    search: {
      find: (searchTerm: string | RegExp) => Command
      replace: (term: string) => Command
      replaceAll: (term: string) => Command
      clearSearch: () => Command
    }
  }
}

export interface SearchOptions {
  autoSelectNext: boolean
  findClass: string
  searching: boolean
  caseSensitive: boolean
  disableRegex: boolean
  alwaysSearch: boolean
}

export interface Result {
  from: number
  to: number
  class: string
}

let results: Result[] = []

let searchTerm: string | null = null

let _updating = false

const autoSelectNext = true
const findClass = 'find'
const searching = false
const caseSensitive = false
const disableRegex = true
const alwaysSearch = false

const decorations: Decoration<{ [key: string]: any }>[] = []

export const getFindRegExp = (): RegExp | void => {
  if (!searchTerm) {
    return
  }
  return RegExp(searchTerm, !caseSensitive ? 'gui' : 'gu')
}

export const getDecorations = () => {
  return results.map((res) => {
    return Decoration.inline(res.from, res.to, { class: res.class })
  })
}

export const _search = (doc) => {
  results = []

  const mergedTextNodes: { text: any; pos: any }[] = []
  let index = 0

  if (!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 = getFindRegExp()
    if (!search) {
      return
    }

    let m
    // eslint-disable-next-line no-cond-assign
    while ((m = search.exec(text))) {
      if (m[0] === '') {
        break
      }

      results.push({
        from: pos + m.index,
        to: pos + m.index + m[0].length,
        class: 'find',
      })
    }
  })
}

export const replace = (replace, editor) => {
  return (state, dispatch) => {
    const firstResult = results[0]

    if (!firstResult) {
      return
    }

    const { from, to } = results[0]
    dispatch(state.tr.insertText(replace, from, to))

    editor.commands.find(searchTerm)
  }
}

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

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

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

  results[nextIndex] = {
    to: to - offset,
    from: from - offset,
    class: 'find',
  }

  return offset
}

export const replaceAll = (replace, editor) => {
  return ({ tr }, dispatch) => {
    let offset: number | null | undefined

    if (!results.length) {
      return
    }

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

    dispatch(tr)

    editor.commands.find(searchTerm)
  }
}

export const find = (searchTerm) => {
  return (state: { tr: any }, dispatch: any) => {
    searchTerm = disableRegex ? searchTerm.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') : searchTerm

    updateView(state, dispatch)
  }
}

export const clear = () => {
  return (state, dispatch) => {
    searchTerm = null

    updateView(state, dispatch)
  }
}

export const updateView = ({ tr }, dispatch) => {
  _updating = true
  dispatch(tr)
  _updating = false
}

export const createDeco = (doc) => {
  _search(doc)
  return decorations ? DecorationSet.create(doc, decorations) : []
}

export const Search = Extension.create<SearchOptions>({
  name: 'search',

  defaultOptions: {
    autoSelectNext: true,
    findClass: 'find',
    searching: false,
    caseSensitive: false,
    disableRegex: true,
    alwaysSearch: false,
  },

  addCommands() {
    return {
      find: (searchTerm) => () => {
        find(searchTerm)
        return false
      },
      replace:
        (term) =>
        ({ editor }) => {
          replace(term, editor)
          return false
        },
      replaceAll:
        (term) =>
        ({ editor }) => {
          replaceAll(term, editor)
          return false
        },
      clearSearch: () => () => {
        clear()
        return false
      },
    }
  },

  addProseMirrorPlugins() {
    return [
      new Plugin({
        key: new PluginKey('searchNReplace'),
        state: {
          init() {
            return DecorationSet.empty
          },
          apply(tr, value, oldState) {
            if (_updating || searching || (tr.docChanged && alwaysSearch)) {
              return createDeco(tr.doc)
            }

            if (tr.docChanged) {
              // todo: take care of this thing, I don't understand what this is
              // return oldState.map(tr.mapping, tr.doc);
            }

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

@jamesg-rimsys
Copy link

Upvote

@fyeeme
Copy link

fyeeme commented Sep 27, 2021

Required too!

@hanspagel hanspagel removed the v2 label Sep 28, 2021
@Flo0295
Copy link

Flo0295 commented Oct 1, 2021

Upvote

@rezaffm
Copy link
Author

rezaffm commented Oct 1, 2021

@sereneinserenade I also thought of migrating it myself, but didn't find the time.

Based on your mentioned lines, you have troubles to set some options (default options), correct?

You might have a look at https://github.com/ueberdosis/tiptap/blob/main/packages/extension-text-align/src/text-align.ts

And there you find a good example how you can set up (default) options, like:

  defaultOptions: {
    types: [],
    alignments: ['left', 'center', 'right', 'justify'],
    defaultAlignment: 'left',
  },

So in the case of your mentioned lines, it would be then easy as:

  defaultOptions: {
    results : [],
    searchTerm : null,
    _updating: false,
  },

I didn't test it myself, just saw your comment and that might help you.

If that does the job ;-) and the extension work, do not forget the pull request.

@Deckluhm
Copy link
Contributor

Deckluhm commented Oct 20, 2021

I'm also interested by a v2 version of the "search" extension.

I tried to migrate the v1 version to v2 using the defaultOptions as a this replacement.
I now have something ok from a TS perspective but it doesn't work because the createDeco method is never called (this condition is never satisfied).

I guess there is more to change than just migrating things to the v2 syntax.
Also, using defaultOptions as a replacement of this doesn't feel right but there is no other complex extension like this one that has been ported from v1 to v2 so I don't really have good example I can get inspiration from.

@maxbaluev
Copy link

@sereneinserenade Is there any news on this? A much needed feature!

@sereneinserenade
Copy link
Contributor

no news, but I am planning to work on it this weekend, see what comes up. Might try @rezaffm 's suggestion and see what emerges. Will update here definitely.

@sereneinserenade
Copy link
Contributor

sereneinserenade commented Oct 22, 2021

So... I've been trying and it seems like it works. Right now it's a mess that no one would be able to use with lowest effort. But will publish after clearing up.

Bildschirmaufnahme.2021-10-22.um.21.03.42.mov

@rezaffm
Copy link
Author

rezaffm commented Oct 22, 2021

Wohow! Great news, not sure if my comment helped at all, but it is great to see you got something working. I am quite sure the tiptap guys will appreciate it when you push it.

@sereneinserenade
Copy link
Contributor

Hey @rezaffm yess, I implemented it based on your comment. Here's the PR #2075 . and here's my repo of Running version of search and replace in vue 3 https://github.com/sereneinserenade/tiptap-search-n-replace-demo which you can test at https://tiptap-search-n-replace-demo.vercel.app/

@fyeeme
Copy link

fyeeme commented Oct 25, 2021

Tried the example and seams that looks great!

@fyeeme
Copy link

fyeeme commented Oct 25, 2021

@sereneinserenade , found a bug, could not match first world of the start line. maybe the regex expression has sth wrong.

@sereneinserenade
Copy link
Contributor

@fyeeme I'll take a look but I am not a regex master, I copied the regex from https://github.dev/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/Search.js

If you or anyone looking this have an idea how to fix it, let me know.

@sereneinserenade
Copy link
Contributor

sereneinserenade commented Oct 25, 2021

@fyeeme fixed, it wasn't an issue with regex after all. here's the commit 1898e72

@fyeeme
Copy link

fyeeme commented Oct 26, 2021

@sereneinserenade , Yeah, thanks a lot.

@stale
Copy link

stale bot commented Jul 6, 2022

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

@stale stale bot added the stale label Jul 6, 2022
@stale stale bot closed this as completed Jul 14, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Type: Feature The issue or pullrequest is a new feature
Projects
None yet
Development

No branches or pull requests

8 participants