Search
Search and replace text within the editor. This is built on top of prosemirror-search.
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import 'prosekit/extensions/search/style.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/react'
import { useMemo } from 'react'
import { defineExtension } from './extension'
import Search from './search'
export default function Editor() {
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({
extension,
defaultContent: '<p>Baa, baa, black sheep,</p>'
+ '<p>Have you any wool?</p>'
+ '<p>Yes, sir, yes, sir,</p>'
+ '<p>Three bags full;</p>'
+ '<p>One for the master,</p>'
+ '<p>And one for the dame,</p>'
+ '<p>And one for the little boy</p>'
+ '<p>Who lives down the lane.</p>',
})
}, [])
return (
<ProseKit editor={editor}>
<div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow flex flex-col bg-white dark:bg-gray-950 color-black dark:color-white">
<div className="relative w-full flex-1 box-border overflow-y-scroll">
<Search />
<div ref={editor.mount} className="ProseMirror box-border min-h-full px-[max(4rem,_calc(50%-20rem))] py-8 outline-none outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
</div>
</div>
</ProseKit>
)
}
import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineSearchCommands } from 'prosekit/extensions/search'
export function defineExtension() {
return union(defineBasicExtension(), defineSearchCommands())
}
export type EditorExtension = ReturnType<typeof defineExtension>
import { defineSearchQuery } from 'prosekit/extensions/search'
import {
useEditor,
useExtension,
} from 'prosekit/react'
import {
useMemo,
useState,
} from 'react'
import Button from './button'
import type { EditorExtension } from './extension'
export default function Search({ onClose }: { onClose?: VoidFunction }) {
const [showReplace, setShowReplace] = useState(false)
const toggleReplace = () => setShowReplace((value) => !value)
const [searchText, setSearchText] = useState('')
const [replaceText, setReplaceText] = useState('')
const extension = useMemo(() => {
if (!searchText) {
return null
}
return defineSearchQuery({ search: searchText, replace: replaceText })
}, [searchText, replaceText])
useExtension(extension)
const editor = useEditor<EditorExtension>()
const isEnter = (event: React.KeyboardEvent) => {
return (
event.key === 'Enter'
&& !event.shiftKey
&& !event.metaKey
&& !event.altKey
&& !event.ctrlKey
&& !event.nativeEvent.isComposing
)
}
const isShiftEnter = (event: React.KeyboardEvent) => {
return (
event.key === 'Enter'
&& event.shiftKey
&& !event.metaKey
&& !event.altKey
&& !event.ctrlKey
&& !event.nativeEvent.isComposing
)
}
const handleSearchKeyDown = (event: React.KeyboardEvent) => {
if (isEnter(event)) {
event.preventDefault()
editor.commands.findNext()
} else if (isShiftEnter(event)) {
event.preventDefault()
editor.commands.findPrev()
}
}
const handleReplaceKeyDown = (event: React.KeyboardEvent) => {
if (isEnter(event)) {
event.preventDefault()
editor.commands.replaceNext()
} else if (isShiftEnter(event)) {
event.preventDefault()
editor.commands.replaceAll()
}
}
return (
<div className="z-2 box-border border-gray-200 dark:border-gray-800 border-solid border-l-0 border-r-0 border-t-0 border-b grid grid-cols-[min-content_1fr_min-content] gap-2 p-2">
<Button tooltip="Toggle Replace" onClick={toggleReplace}>
<span
data-rotate={showReplace ? '' : undefined}
className="i-lucide-chevron-right h-5 w-5 transition-transform data-[rotate]:rotate-90"
/>
</Button>
<input
placeholder="Search"
type="text"
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
onKeyDown={handleSearchKeyDown}
className="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-none focus-visible:outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50 col-start-2"
/>
<div className="flex items-center justify-between gap-1">
<Button
tooltip="Previous (Shift Enter)"
onClick={editor.commands.findPrev}
>
<span className="i-lucide-arrow-left h-5 w-5" />
</Button>
<Button tooltip="Next (Enter)" onClick={editor.commands.findNext}>
<span className="i-lucide-arrow-right h-5 w-5" />
</Button>
<Button tooltip="Close" onClick={onClose}>
<span className="i-lucide-x h-5 w-5" />
</Button>
</div>
{showReplace && (
<input
placeholder="Replace"
type="text"
value={replaceText}
onChange={(event) => setReplaceText(event.target.value)}
onKeyDown={handleReplaceKeyDown}
className="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-none focus-visible:outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50 col-start-2"
/>
)}
{showReplace && (
<div className="flex items-center justify-between gap-1">
<Button
tooltip="Replace (Enter)"
onClick={editor.commands.replaceNext}
>
Replace
</Button>
<Button
tooltip="Replace All (Shift Enter)"
onClick={editor.commands.replaceAll}
>
All
</Button>
</div>
)}
</div>
)
}
import {
TooltipContent,
TooltipRoot,
TooltipTrigger,
} from 'prosekit/react/tooltip'
import type { ReactNode } from 'react'
export default function Button({
pressed,
disabled,
onClick,
tooltip,
children,
}: {
pressed?: boolean
disabled?: boolean
onClick?: VoidFunction
tooltip?: string
children: ReactNode
}) {
return (
<TooltipRoot>
<TooltipTrigger className="block">
<button
data-state={pressed ? 'on' : 'off'}
disabled={disabled}
onClick={() => onClick?.()}
onMouseDown={(event) => event.preventDefault()}
className="outline-unset focus-visible:outline-unset flex items-center justify-center rounded-md p-2 font-medium transition focus-visible:ring-2 text-sm focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 disabled:pointer-events-none min-w-9 min-h-9 disabled:opacity-50 hover:disabled:opacity-50 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 data-[state=on]:bg-gray-200 dark:data-[state=on]:bg-gray-700"
>
{children}
{tooltip ? <span className="sr-only">{tooltip}</span> : null}
</button>
</TooltipTrigger>
{tooltip
? (
<TooltipContent className="z-50 overflow-hidden rounded-md border border-solid bg-gray-900 dark:bg-gray-50 px-3 py-1.5 text-xs text-gray-50 dark:text-gray-900 shadow-sm [&:not([data-state])]:hidden will-change-transform data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[state=open]:animate-duration-150 data-[state=closed]:animate-duration-200 data-[side=bottom]:slide-in-from-top-2 data-[side=bottom]:slide-out-to-top-2 data-[side=left]:slide-in-from-right-2 data-[side=left]:slide-out-to-right-2 data-[side=right]:slide-in-from-left-2 data-[side=right]:slide-out-to-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=top]:slide-out-to-bottom-2">
{tooltip}
</TooltipContent>
)
: null}
</TooltipRoot>
)
}
To highlight search matches, you must load the style.css
file or define your own styles for the .ProseMirror-search-match
(search match) and .ProseMirror-active-search-match
(active match) classes.
import 'prosekit/extensions/search/style.css'
Call defineSearchCommands()
to define related commands.
import { defineSearchCommands function defineSearchCommands(): SearchCommandsExtension
Defines commands for search and replace. } from 'prosekit/extensions/search'
const extension const extension: SearchCommandsExtension
= defineSearchCommands function defineSearchCommands(): SearchCommandsExtension
Defines commands for search and replace. ()
In your search component, when the search text and replace text change, you should create a new extension with the specified text. Typically, you will need to call the useExtension()
function from various framework bindings.
import { defineSearchQuery function defineSearchQuery(options: SearchQueryOptions): PlainExtension
Defines an extension that stores a current search query and replace string. } from 'prosekit/extensions/search'
const extension const extension: PlainExtension
= defineSearchQuery function defineSearchQuery(options: SearchQueryOptions): PlainExtension
Defines an extension that stores a current search query and replace string. ({ search SearchQueryOptions.search: string
The search string (or regular expression). : 'foo', replace SearchQueryOptions.replace?: string | undefined
The replace text. : 'bar' })
Commands
Section titled “Commands”findNext
Section titled “findNext”Find the next instance of the search query after the current selection and move the selection to it.
editor const editor: Editor<SearchCommandsExtension>
.commands Editor<SearchCommandsExtension>.commands: ToCommandAction<{
findNext: [];
findPrev: [];
findNextNoWrap: [];
findPrevNoWrap: [];
replaceNext: [];
replaceNextNoWrap: [];
replaceCurrent: [];
replaceAll: [];
}>
All
{@link
CommandAction
}
s defined by the editor. .findNext findNext: CommandAction
() => boolean
Execute the current command. Return `true` if the command was successfully
executed, otherwise `false`. ()
findPrev
Section titled “findPrev”Find the previous instance of the search query and move the selection to it.
editor const editor: Editor<SearchCommandsExtension>
.commands Editor<SearchCommandsExtension>.commands: ToCommandAction<{
findNext: [];
findPrev: [];
findNextNoWrap: [];
findPrevNoWrap: [];
replaceNext: [];
replaceNextNoWrap: [];
replaceCurrent: [];
replaceAll: [];
}>
All
{@link
CommandAction
}
s defined by the editor. .findPrev findPrev: CommandAction
() => boolean
Execute the current command. Return `true` if the command was successfully
executed, otherwise `false`. ()
findNextNoWrap
Section titled “findNextNoWrap”Find the next instance of the search query and move the selection to it. Don’t wrap around at the end of document or search range.
editor const editor: Editor<SearchCommandsExtension>
.commands Editor<SearchCommandsExtension>.commands: ToCommandAction<{
findNext: [];
findPrev: [];
findNextNoWrap: [];
findPrevNoWrap: [];
replaceNext: [];
replaceNextNoWrap: [];
replaceCurrent: [];
replaceAll: [];
}>
All
{@link
CommandAction
}
s defined by the editor. .findNextNoWrap findNextNoWrap: CommandAction
() => boolean
Execute the current command. Return `true` if the command was successfully
executed, otherwise `false`. ()
findPrevNoWrap
Section titled “findPrevNoWrap”Find the previous instance of the search query and move the selection to it. Don’t wrap at the start of the document or search range.
editor const editor: Editor<SearchCommandsExtension>
.commands Editor<SearchCommandsExtension>.commands: ToCommandAction<{
findNext: [];
findPrev: [];
findNextNoWrap: [];
findPrevNoWrap: [];
replaceNext: [];
replaceNextNoWrap: [];
replaceCurrent: [];
replaceAll: [];
}>
All
{@link
CommandAction
}
s defined by the editor. .findPrevNoWrap findPrevNoWrap: CommandAction
() => boolean
Execute the current command. Return `true` if the command was successfully
executed, otherwise `false`. ()
replaceNext
Section titled “replaceNext”Replace the currently selected instance of the search query, and move to the next one. Or select the next match, if none is already selected.
editor const editor: Editor<SearchCommandsExtension>
.commands Editor<SearchCommandsExtension>.commands: ToCommandAction<{
findNext: [];
findPrev: [];
findNextNoWrap: [];
findPrevNoWrap: [];
replaceNext: [];
replaceNextNoWrap: [];
replaceCurrent: [];
replaceAll: [];
}>
All
{@link
CommandAction
}
s defined by the editor. .replaceNext replaceNext: CommandAction
() => boolean
Execute the current command. Return `true` if the command was successfully
executed, otherwise `false`. ()
replaceNextNoWrap
Section titled “replaceNextNoWrap”Replace the next instance of the search query. Don’t wrap around at the end of the document.
editor const editor: Editor<SearchCommandsExtension>
.commands Editor<SearchCommandsExtension>.commands: ToCommandAction<{
findNext: [];
findPrev: [];
findNextNoWrap: [];
findPrevNoWrap: [];
replaceNext: [];
replaceNextNoWrap: [];
replaceCurrent: [];
replaceAll: [];
}>
All
{@link
CommandAction
}
s defined by the editor. .replaceNextNoWrap replaceNextNoWrap: CommandAction
() => boolean
Execute the current command. Return `true` if the command was successfully
executed, otherwise `false`. ()
replaceCurrent
Section titled “replaceCurrent”Replace the currently selected instance of the search query, if any, and keep it selected.
editor const editor: Editor<SearchCommandsExtension>
.commands Editor<SearchCommandsExtension>.commands: ToCommandAction<{
findNext: [];
findPrev: [];
findNextNoWrap: [];
findPrevNoWrap: [];
replaceNext: [];
replaceNextNoWrap: [];
replaceCurrent: [];
replaceAll: [];
}>
All
{@link
CommandAction
}
s defined by the editor. .replaceCurrent replaceCurrent: CommandAction
() => boolean
Execute the current command. Return `true` if the command was successfully
executed, otherwise `false`. ()
replaceAll
Section titled “replaceAll”Replace all instances of the search query.
editor const editor: Editor<SearchCommandsExtension>
.commands Editor<SearchCommandsExtension>.commands: ToCommandAction<{
findNext: [];
findPrev: [];
findNextNoWrap: [];
findPrevNoWrap: [];
replaceNext: [];
replaceNextNoWrap: [];
replaceCurrent: [];
replaceAll: [];
}>
All
{@link
CommandAction
}
s defined by the editor. .replaceAll replaceAll: CommandAction
() => boolean
Execute the current command. Return `true` if the command was successfully
executed, otherwise `false`. ()