feat: first release !
This commit is contained in:
1 parent
77a333e8d9
commit
664527182d
66 files changed
+13812
-16
No files matched your search
@@ -0,0 +1,145 @@
|
||||
// Find and replace in the script body. The plugin keeps the query and its
|
||||
// matches and highlights them; the commands move between matches by
|
||||
// selecting them and replace through ordinary transactions, so undo works.
|
||||
import { Command, EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state'
|
||||
import { Decoration, DecorationSet } from 'prosemirror-view'
|
||||
import { Node as PMNode } from 'prosemirror-model'
|
||||
import { closeHistory } from 'prosemirror-history'
|
||||
import { blockText, flatElements } from './schema'
|
||||
import { replaceText } from './commands'
|
||||
|
||||
export interface SearchQuery {
|
||||
text: string
|
||||
caseSensitive: boolean
|
||||
wholeWord: boolean
|
||||
}
|
||||
|
||||
export interface Match {
|
||||
from: number
|
||||
to: number
|
||||
}
|
||||
|
||||
interface SearchState {
|
||||
query: SearchQuery
|
||||
matches: Match[]
|
||||
}
|
||||
|
||||
export const searchKey = new PluginKey<SearchState>('search')
|
||||
|
||||
const EMPTY: SearchQuery = { text: '', caseSensitive: false, wholeWord: false }
|
||||
|
||||
function pattern(query: SearchQuery): RegExp {
|
||||
const escaped = query.text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const source = query.wholeWord ? `(?<![\\p{L}\\p{N}_])${escaped}(?![\\p{L}\\p{N}_])` : escaped
|
||||
return new RegExp(source, query.caseSensitive ? 'gu' : 'giu')
|
||||
}
|
||||
|
||||
function findMatches(doc: PMNode, query: SearchQuery): Match[] {
|
||||
if (!query.text) return []
|
||||
const re = pattern(query)
|
||||
const matches: Match[] = []
|
||||
for (const { node, pos } of flatElements(doc)) {
|
||||
// blockText has one character per position (hard breaks included), so
|
||||
// string indexes map directly onto document positions.
|
||||
const text = blockText(node)
|
||||
for (const m of text.matchAll(re)) {
|
||||
matches.push({ from: pos + 1 + m.index, to: pos + 1 + m.index + m[0].length })
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
export function searchPlugin(): Plugin<SearchState> {
|
||||
return new Plugin<SearchState>({
|
||||
key: searchKey,
|
||||
state: {
|
||||
init: () => ({ query: EMPTY, matches: [] }),
|
||||
apply(tr, prev, _old, state) {
|
||||
const query = (tr.getMeta(searchKey) as SearchQuery | undefined) ?? prev.query
|
||||
if (query === prev.query && !tr.docChanged) return prev
|
||||
return { query, matches: findMatches(state.doc, query) }
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
const { matches } = searchKey.getState(state)!
|
||||
if (!matches.length) return null
|
||||
const { from, to } = state.selection
|
||||
return DecorationSet.create(
|
||||
state.doc,
|
||||
matches.map((m) =>
|
||||
Decoration.inline(m.from, m.to, { class: m.from === from && m.to === to ? 'search-match active' : 'search-match' }),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function searchMatches(state: EditorState): Match[] {
|
||||
return searchKey.getState(state)?.matches ?? []
|
||||
}
|
||||
|
||||
/** Index of the match that is currently selected, or -1. */
|
||||
export function activeMatch(state: EditorState): number {
|
||||
const { from, to } = state.selection
|
||||
return searchMatches(state).findIndex((m) => m.from === from && m.to === to)
|
||||
}
|
||||
|
||||
export function setSearch(query: SearchQuery): Command {
|
||||
return (state, dispatch) => {
|
||||
if (dispatch) dispatch(state.tr.setMeta(searchKey, query).setMeta('addToHistory', false))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the next match after the selection (dir 1) or the previous one
|
||||
* (dir -1), wrapping around the script. With `here`, a match starting at
|
||||
* the cursor counts as the next one (incremental search while typing).
|
||||
*/
|
||||
export function selectMatch(dir: 1 | -1, here = false): Command {
|
||||
return (state, dispatch) => {
|
||||
const matches = searchMatches(state)
|
||||
if (!matches.length) return false
|
||||
const { from, to } = state.selection
|
||||
const m =
|
||||
dir > 0
|
||||
? (matches.find((m) => m.from >= (here ? from : to)) ?? matches[0])
|
||||
: (matches.findLast((m) => m.to <= from) ?? matches[matches.length - 1])
|
||||
if (dispatch) dispatch(state.tr.setSelection(TextSelection.create(state.doc, m.from, m.to)).scrollIntoView())
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces the selected match, then selects the next one. */
|
||||
export function replaceMatch(text: string): Command {
|
||||
return (state, dispatch) => {
|
||||
const i = activeMatch(state)
|
||||
if (i < 0) return selectMatch(1)(state, dispatch)
|
||||
if (dispatch) {
|
||||
const m = searchMatches(state)[i]
|
||||
// Its own undo step, even right after typing or another replacement.
|
||||
const tr = replaceText(closeHistory(state.tr), m.from, m.to, text)
|
||||
const after = tr.mapping.map(m.to)
|
||||
const next = findMatches(tr.doc, searchKey.getState(state)!.query).find((n) => n.from >= after)
|
||||
if (next) tr.setSelection(TextSelection.create(tr.doc, next.from, next.to))
|
||||
dispatch(tr.scrollIntoView())
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces every match in one step, so a single undo restores them all. */
|
||||
export function replaceAll(text: string): Command {
|
||||
return (state, dispatch) => {
|
||||
const matches = searchMatches(state)
|
||||
if (!matches.length) return false
|
||||
if (dispatch) {
|
||||
const tr = closeHistory(state.tr)
|
||||
for (const m of [...matches].reverse()) replaceText(tr, m.from, m.to, text)
|
||||
dispatch(tr)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user