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 { Search } from '../../ui/search'
import { defineExtension } from './extension'
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-sm flex flex-col bg-white dark:bg-gray-950 text-black dark:text-white">
<div className="relative w-full flex-1 box-border overflow-y-auto">
<Search />
<div ref={editor.mount} className="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden 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>export { default as ExampleEditor } from './editor'import {
TooltipContent,
TooltipRoot,
TooltipTrigger,
} from 'prosekit/react/tooltip'
import type {
MouseEventHandler,
ReactNode,
} from 'react'
export default function Button(props: {
pressed?: boolean
disabled?: boolean
onClick?: MouseEventHandler<HTMLButtonElement>
tooltip?: string
children: ReactNode
}) {
return (
<TooltipRoot>
<TooltipTrigger className="block">
<button
data-state={props.pressed ? 'on' : 'off'}
disabled={props.disabled}
onClick={props.onClick}
onMouseDown={(event) => {
// Prevent the editor from being blurred when the button is clicked
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 text-gray-900 dark:text-gray-50 disabled:text-gray-900/50 dark:disabled:text-gray-50/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"
>
{props.children}
{props.tooltip ? <span className="sr-only">{props.tooltip}</span> : null}
</button>
</TooltipTrigger>
{props.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-xs [&:not([data-state])]:hidden will-change-transform motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:animate-duration-150 motion-safe:data-[state=closed]:animate-duration-200 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=bottom]:slide-out-to-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=left]:slide-out-to-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=right]:slide-out-to-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 motion-safe:data-[side=top]:slide-out-to-bottom-2">
{props.tooltip}
</TooltipContent>
)
: null}
</TooltipRoot>
)
}export { default as Button } from './button'export { default as Search } from './search'import {
defineSearchQuery,
type SearchCommandsExtension,
} from 'prosekit/extensions/search'
import {
useEditor,
useExtension,
} from 'prosekit/react'
import {
useMemo,
useState,
} from 'react'
import { Button } from '../button'
export default function Search(props: { 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<SearchCommandsExtension>()
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 size-5 block 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-hidden focus-visible:outline-hidden 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 size-5 block" />
</Button>
<Button tooltip="Next (Enter)" onClick={editor.commands.findNext}>
<span className="i-lucide-arrow-right size-5 block" />
</Button>
<Button tooltip="Close" onClick={props.onClose}>
<span className="i-lucide-x size-5 block" />
</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-hidden focus-visible:outline-hidden 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>
)
}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(): SearchCommandsExtensionDefines commands for search and replace. } from 'prosekit/extensions/search'
const extension const extension: SearchCommandsExtension = defineSearchCommands function defineSearchCommands(): SearchCommandsExtensionDefines 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): PlainExtensionDefines 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): PlainExtensionDefines an extension that stores a current search query and replace string. ({ search SearchQueryOptions.search: stringThe search string (or regular expression). : 'foo', replace SearchQueryOptions.replace?: string | undefinedThe 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`. ()