Search
Search and replace text within the editor. This is built on top of prosemirror-search.
'use client'
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import 'prosekit/extensions/search/style.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/react'
import { useMemo } from 'react'
import { sampleContent } from '../../sample/sample-doc-search.ts'
import { Search } from '../../ui/search/index.ts'
import { defineExtension } from './extension.ts'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps) {
const defaultContent = props.initialContent ?? sampleContent
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({
extension,
defaultContent,
})
}, [defaultContent])
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-[canvas] 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, defineSearchQuery } from 'prosekit/extensions/search'
export function defineExtension() {
return union(defineBasicExtension(), defineSearchQuery(), defineSearchCommands())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.tsx'import type { NodeJSON } from 'prosekit/core'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Baa, baa, black sheep,',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Have you any wool?',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Yes, sir, yes, sir,',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Three bags full;',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'One for the master,',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'And one for the dame,',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'And one for the little boy',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Who lives down the lane.',
},
],
},
],
}'use client'
import { TooltipPopup, TooltipPositioner, 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
? (
<TooltipPositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<TooltipPopup className="flex box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none duration-100 data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 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 text-nowrap">
{props.tooltip}
</TooltipPopup>
</TooltipPositioner>
)
: null}
</TooltipRoot>
)
}export { default as Button } from './button.tsx'export { default as Search } from './search.tsx''use client'
import { defineSearchStatusHandler, type SearchCommandsExtension, type SearchStatus } from 'prosekit/extensions/search'
import { useEditor, useExtension } from 'prosekit/react'
import { useEffect, useMemo, useState } from 'react'
import { Button } from '../button/index.ts'
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 [caseSensitive, setCaseSensitive] = useState(false)
const [wholeWord, setWholeWord] = useState(false)
const [regexp, setRegexp] = useState(false)
const [literal, setLiteral] = useState(false)
const [searchStatus, setSearchStatus] = useState<SearchStatus>({
total: 0,
active: 0,
})
useExtension(useMemo(() => defineSearchStatusHandler(setSearchStatus), []))
const editor = useEditor<SearchCommandsExtension>()
useEffect(() => {
editor.commands.setSearchQuery({
search: searchText,
replace: replaceText,
caseSensitive,
wholeWord,
regexp,
literal,
})
}, [editor, searchText, replaceText, caseSensitive, wholeWord, regexp, literal])
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-[canvas] 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">
{searchText
? (
<span className="flex items-center px-1 text-sm whitespace-nowrap tabular-nums text-gray-500 dark:text-gray-500">
{searchStatus.active} / {searchStatus.total}
</span>
)
: null}
<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>
<Button
pressed={caseSensitive}
tooltip="Case Sensitive"
onClick={() => setCaseSensitive((value) => !value)}
>
<span className="i-lucide-case-sensitive size-5 block" />
</Button>
<Button
pressed={wholeWord}
tooltip="Whole Word"
onClick={() => setWholeWord((value) => !value)}
>
<span className="i-lucide-whole-word size-5 block" />
</Button>
<Button
pressed={regexp}
tooltip="Regular Expression"
onClick={() => setRegexp((value) => !value)}
>
<span className="i-lucide-braces size-5 block" />
</Button>
<Button
pressed={literal}
tooltip="Literal Escape Sequences"
onClick={() => setLiteral((value) => !value)}
>
<span className="i-lucide-quote 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-[canvas] 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>
)
}
function isEnter(event: React.KeyboardEvent) {
return (
event.key === 'Enter'
&& !event.shiftKey
&& !event.metaKey
&& !event.altKey
&& !event.ctrlKey
&& !event.nativeEvent.isComposing
)
}
function isShiftEnter(event: React.KeyboardEvent) {
return (
event.key === 'Enter'
&& event.shiftKey
&& !event.metaKey
&& !event.altKey
&& !event.ctrlKey
&& !event.nativeEvent.isComposing
)
}import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import 'prosekit/extensions/search/style.css'
import { useMemo } from 'preact/hooks'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/preact'
import { sampleContent } from '../../sample/sample-doc-search.ts'
import { Search } from '../../ui/search/index.ts'
import { defineExtension } from './extension.ts'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps) {
const defaultContent = props.initialContent ?? sampleContent
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({
extension,
defaultContent,
})
}, [defaultContent])
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-[canvas] 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, defineSearchQuery } from 'prosekit/extensions/search'
export function defineExtension() {
return union(defineBasicExtension(), defineSearchQuery(), defineSearchCommands())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.tsx'import type { NodeJSON } from 'prosekit/core'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Baa, baa, black sheep,',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Have you any wool?',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Yes, sir, yes, sir,',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Three bags full;',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'One for the master,',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'And one for the dame,',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'And one for the little boy',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Who lives down the lane.',
},
],
},
],
}import type { ComponentChild, MouseEventHandler } from 'preact'
import { TooltipPopup, TooltipPositioner, TooltipRoot, TooltipTrigger } from 'prosekit/preact/tooltip'
export default function Button(props: {
pressed?: boolean
disabled?: boolean
onClick?: MouseEventHandler<HTMLButtonElement>
tooltip?: string
children: ComponentChild
}) {
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
? (
<TooltipPositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<TooltipPopup className="flex box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none duration-100 data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 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 text-nowrap">
{props.tooltip}
</TooltipPopup>
</TooltipPositioner>
)
: null}
</TooltipRoot>
)
}export { default as Button } from './button.tsx'export { default as Search } from './search.tsx'import { useEffect, useMemo, useState } from 'preact/hooks'
import { defineSearchStatusHandler, type SearchCommandsExtension, type SearchStatus } from 'prosekit/extensions/search'
import { useEditor, useExtension } from 'prosekit/preact'
import { Button } from '../button/index.ts'
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 [caseSensitive, setCaseSensitive] = useState(false)
const [wholeWord, setWholeWord] = useState(false)
const [regexp, setRegexp] = useState(false)
const [literal, setLiteral] = useState(false)
const [searchStatus, setSearchStatus] = useState<SearchStatus>({
total: 0,
active: 0,
})
useExtension(useMemo(() => defineSearchStatusHandler(setSearchStatus), []))
const editor = useEditor<SearchCommandsExtension>()
useEffect(() => {
editor.commands.setSearchQuery({
search: searchText,
replace: replaceText,
caseSensitive,
wholeWord,
regexp,
literal,
})
}, [editor, searchText, replaceText, caseSensitive, wholeWord, regexp, literal])
const handleSearchKeyDown = (event: KeyboardEvent) => {
if (isEnter(event)) {
event.preventDefault()
editor.commands.findNext()
} else if (isShiftEnter(event)) {
event.preventDefault()
editor.commands.findPrev()
}
}
const handleReplaceKeyDown = (event: 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.currentTarget.value)}
onKeyDown={handleSearchKeyDown}
className="flex h-9 rounded-md w-full bg-[canvas] 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">
{searchText
? (
<span className="flex items-center px-1 text-sm whitespace-nowrap tabular-nums text-gray-500 dark:text-gray-500">
{searchStatus.active} / {searchStatus.total}
</span>
)
: null}
<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>
<Button
pressed={caseSensitive}
tooltip="Case Sensitive"
onClick={() => setCaseSensitive((value) => !value)}
>
<span className="i-lucide-case-sensitive size-5 block" />
</Button>
<Button
pressed={wholeWord}
tooltip="Whole Word"
onClick={() => setWholeWord((value) => !value)}
>
<span className="i-lucide-whole-word size-5 block" />
</Button>
<Button
pressed={regexp}
tooltip="Regular Expression"
onClick={() => setRegexp((value) => !value)}
>
<span className="i-lucide-braces size-5 block" />
</Button>
<Button
pressed={literal}
tooltip="Literal Escape Sequences"
onClick={() => setLiteral((value) => !value)}
>
<span className="i-lucide-quote size-5 block" />
</Button>
</div>
{showReplace && (
<input
placeholder="Replace"
type="text"
value={replaceText}
onChange={(event) => setReplaceText(event.currentTarget.value)}
onKeyDown={handleReplaceKeyDown}
className="flex h-9 rounded-md w-full bg-[canvas] 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>
)
}
function isEnter(event: KeyboardEvent) {
return (
event.key === 'Enter'
&& !event.shiftKey
&& !event.metaKey
&& !event.altKey
&& !event.ctrlKey
&& !event.isComposing
)
}
function isShiftEnter(event: KeyboardEvent) {
return (
event.key === 'Enter'
&& event.shiftKey
&& !event.metaKey
&& !event.altKey
&& !event.ctrlKey
&& !event.isComposing
)
}import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import 'prosekit/extensions/search/style.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/solid'
import type { JSX } from 'solid-js'
import { sampleContent } from '../../sample/sample-doc-search.ts'
import { Search } from '../../ui/search/index.ts'
import { defineExtension } from './extension.ts'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps): JSX.Element {
const defaultContent = props.initialContent ?? sampleContent
const extension = defineExtension()
const editor = createEditor({
extension,
defaultContent,
})
return (
<ProseKit editor={editor}>
<div class="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-[canvas] text-black dark:text-white">
<div class="relative w-full flex-1 box-border overflow-y-auto">
<Search />
<div ref={editor.mount} class="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, defineSearchQuery } from 'prosekit/extensions/search'
export function defineExtension() {
return union(defineBasicExtension(), defineSearchQuery(), defineSearchCommands())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.tsx'import type { NodeJSON } from 'prosekit/core'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Baa, baa, black sheep,',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Have you any wool?',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Yes, sir, yes, sir,',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Three bags full;',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'One for the master,',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'And one for the dame,',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'And one for the little boy',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Who lives down the lane.',
},
],
},
],
}import { TooltipPopup, TooltipPositioner, TooltipRoot, TooltipTrigger } from 'prosekit/solid/tooltip'
import type { JSX } from 'solid-js'
export default function Button(props: {
pressed?: boolean
disabled?: boolean
onClick?: () => void
tooltip?: string
children: JSX.Element
}): JSX.Element {
return (
<TooltipRoot>
<TooltipTrigger class="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()
}}
class="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 class="sr-only">{props.tooltip}</span> : null}
</button>
</TooltipTrigger>
{props.tooltip
? (
<TooltipPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<TooltipPopup class="flex box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none duration-100 data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 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 text-nowrap">
{props.tooltip}
</TooltipPopup>
</TooltipPositioner>
)
: null}
</TooltipRoot>
)
}export { default as Button } from './button.tsx'export { default as Search } from './search.tsx'import { defineSearchStatusHandler, type SearchCommandsExtension, type SearchStatus } from 'prosekit/extensions/search'
import { useEditor, useExtension } from 'prosekit/solid'
import { createEffect, createSignal, type JSX } from 'solid-js'
import { Button } from '../button/index.ts'
export default function Search(props: { onClose?: VoidFunction }): JSX.Element {
const [showReplace, setShowReplace] = createSignal(false)
const toggleReplace = () => setShowReplace((value) => !value)
const [searchText, setSearchText] = createSignal('')
const [replaceText, setReplaceText] = createSignal('')
const [caseSensitive, setCaseSensitive] = createSignal(false)
const [wholeWord, setWholeWord] = createSignal(false)
const [regexp, setRegexp] = createSignal(false)
const [literal, setLiteral] = createSignal(false)
const [searchStatus, setSearchStatus] = createSignal<SearchStatus>({
total: 0,
active: 0,
})
const statusHandler = defineSearchStatusHandler(setSearchStatus)
useExtension(() => statusHandler)
const editor = useEditor<SearchCommandsExtension>()
createEffect(() => {
editor().commands.setSearchQuery({
search: searchText(),
replace: replaceText(),
caseSensitive: caseSensitive(),
wholeWord: wholeWord(),
regexp: regexp(),
literal: literal(),
})
})
const handleSearchKeyDown = (event: KeyboardEvent) => {
if (isEnter(event)) {
event.preventDefault()
editor().commands.findNext()
} else if (isShiftEnter(event)) {
event.preventDefault()
editor().commands.findPrev()
}
}
const handleReplaceKeyDown = (event: KeyboardEvent) => {
if (isEnter(event)) {
event.preventDefault()
editor().commands.replaceNext()
} else if (isShiftEnter(event)) {
event.preventDefault()
editor().commands.replaceAll()
}
}
return (
<div class="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
attr:data-rotate={showReplace() ? '' : undefined}
class="i-lucide-chevron-right size-5 block transition-transform data-rotate:rotate-90"
/>
</Button>
<input
placeholder="Search"
type="text"
value={searchText()}
onInput={(event) => setSearchText(event.currentTarget.value)}
onKeyDown={handleSearchKeyDown}
class="flex h-9 rounded-md w-full bg-[canvas] 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 class="flex items-center justify-between gap-1">
{searchText()
? (
<span class="flex items-center px-1 text-sm whitespace-nowrap tabular-nums text-gray-500 dark:text-gray-500">
{searchStatus().active} / {searchStatus().total}
</span>
)
: null}
<Button
tooltip="Previous (Shift Enter)"
onClick={() => editor().commands.findPrev()}
>
<span class="i-lucide-arrow-left size-5 block" />
</Button>
<Button tooltip="Next (Enter)" onClick={() => editor().commands.findNext()}>
<span class="i-lucide-arrow-right size-5 block" />
</Button>
<Button tooltip="Close" onClick={() => props.onClose?.()}>
<span class="i-lucide-x size-5 block" />
</Button>
<Button
pressed={caseSensitive()}
tooltip="Case Sensitive"
onClick={() => setCaseSensitive((value) => !value)}
>
<span class="i-lucide-case-sensitive size-5 block" />
</Button>
<Button
pressed={wholeWord()}
tooltip="Whole Word"
onClick={() => setWholeWord((value) => !value)}
>
<span class="i-lucide-whole-word size-5 block" />
</Button>
<Button
pressed={regexp()}
tooltip="Regular Expression"
onClick={() => setRegexp((value) => !value)}
>
<span class="i-lucide-braces size-5 block" />
</Button>
<Button
pressed={literal()}
tooltip="Literal Escape Sequences"
onClick={() => setLiteral((value) => !value)}
>
<span class="i-lucide-quote size-5 block" />
</Button>
</div>
{showReplace() && (
<input
placeholder="Replace"
type="text"
value={replaceText()}
onInput={(event) => setReplaceText(event.currentTarget.value)}
onKeyDown={handleReplaceKeyDown}
class="flex h-9 rounded-md w-full bg-[canvas] 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 class="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>
)
}
function isEnter(event: KeyboardEvent) {
return (
event.key === 'Enter'
&& !event.shiftKey
&& !event.metaKey
&& !event.altKey
&& !event.ctrlKey
&& !event.isComposing
)
}
function isShiftEnter(event: KeyboardEvent) {
return (
event.key === 'Enter'
&& event.shiftKey
&& !event.metaKey
&& !event.altKey
&& !event.ctrlKey
&& !event.isComposing
)
}<script lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import 'prosekit/extensions/search/style.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/svelte'
import { untrack } from 'svelte'
import { sampleContent } from '../../sample/sample-doc-search.ts'
import { Search } from '../../ui/search/index.ts'
import { defineExtension } from './extension.ts'
const props: {
initialContent?: NodeJSON
} = $props()
const extension = defineExtension()
const defaultContent = untrack(() => props.initialContent ?? sampleContent)
const editor = createEditor({ extension, defaultContent })
</script>
<ProseKit {editor}>
<div class="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-[canvas] text-black dark:text-white">
<div class="relative w-full flex-1 box-border overflow-y-auto">
<Search />
<div {@attach editor.mount} class="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, defineSearchQuery } from 'prosekit/extensions/search'
export function defineExtension() {
return union(defineBasicExtension(), defineSearchQuery(), defineSearchCommands())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.svelte'import type { NodeJSON } from 'prosekit/core'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Baa, baa, black sheep,',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Have you any wool?',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Yes, sir, yes, sir,',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Three bags full;',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'One for the master,',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'And one for the dame,',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'And one for the little boy',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Who lives down the lane.',
},
],
},
],
}<script lang="ts">
import { TooltipPopup, TooltipPositioner, TooltipRoot, TooltipTrigger } from 'prosekit/svelte/tooltip'
interface Props {
pressed?: boolean
disabled?: boolean
onClick?: () => void
tooltip?: string
children?: import('svelte').Snippet
}
const props: Props = $props()
const pressed = $derived(props.pressed ?? false)
const disabled = $derived(props.disabled ?? false)
</script>
<TooltipRoot>
<TooltipTrigger class="block">
<button
data-state={pressed ? 'on' : 'off'}
{disabled}
class="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"
onclick={props.onClick}
onmousedown={(e) => e.preventDefault()}
>
{@render props.children?.()}
{#if props.tooltip}
<span class="sr-only">{props.tooltip}</span>
{/if}
</button>
</TooltipTrigger>
{#if props.tooltip}
<TooltipPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<TooltipPopup class="flex box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none duration-100 data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 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 text-nowrap">
{props.tooltip}
</TooltipPopup>
</TooltipPositioner>
{/if}
</TooltipRoot>export { default as Button } from './button.svelte'export { default as Search } from './search.svelte'<script lang="ts">
import { defineSearchStatusHandler, type SearchCommandsExtension, type SearchStatus } from 'prosekit/extensions/search'
import { useEditor, useExtension } from 'prosekit/svelte'
import { toStore } from 'svelte/store'
import { Button } from '../button/index.ts'
interface Props {
onClose?: () => void
}
const props: Props = $props()
let showReplace = $state(false)
let searchText = $state('')
let replaceText = $state('')
let caseSensitive = $state(false)
let wholeWord = $state(false)
let regexp = $state(false)
let literal = $state(false)
let searchStatus = $state<SearchStatus>({ total: 0, active: 0 })
const statusHandler = defineSearchStatusHandler((status) => {
searchStatus = status
})
useExtension(toStore(() => statusHandler))
const editor = useEditor<SearchCommandsExtension>()
$effect(() => {
$editor.commands.setSearchQuery({
search: searchText,
replace: replaceText,
caseSensitive,
wholeWord,
regexp,
literal,
})
})
function toggleReplace() {
showReplace = !showReplace
}
function isPlainEnter(event: KeyboardEvent) {
return (
event.key === 'Enter'
&& !event.shiftKey
&& !event.metaKey
&& !event.altKey
&& !event.ctrlKey
&& !event.isComposing
)
}
function isShiftEnter(event: KeyboardEvent) {
return (
event.key === 'Enter'
&& event.shiftKey
&& !event.metaKey
&& !event.altKey
&& !event.ctrlKey
&& !event.isComposing
)
}
function handleSearchKeyDown(event: KeyboardEvent) {
if (isPlainEnter(event)) {
event.preventDefault()
$editor.commands.findNext()
} else if (isShiftEnter(event)) {
event.preventDefault()
$editor.commands.findPrev()
}
}
function handleReplaceKeyDown(event: KeyboardEvent) {
if (isPlainEnter(event)) {
event.preventDefault()
$editor.commands.replaceNext()
} else if (isShiftEnter(event)) {
event.preventDefault()
$editor.commands.replaceAll()
}
}
</script>
<div class="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}
class="i-lucide-chevron-right size-5 block transition-transform data-rotate:rotate-90"
></span>
</Button>
<input
bind:value={searchText}
placeholder="Search"
type="text"
class="flex h-9 rounded-md w-full bg-[canvas] 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"
onkeydown={handleSearchKeyDown}
/>
<div class="flex items-center justify-between gap-1">
{#if searchText}
<span class="flex items-center px-1 text-sm whitespace-nowrap tabular-nums text-gray-500 dark:text-gray-500">
{searchStatus.active} / {searchStatus.total}
</span>
{/if}
<Button
tooltip="Previous (Shift Enter)"
onClick={$editor.commands.findPrev}
>
<span class="i-lucide-arrow-left size-5 block"></span>
</Button>
<Button
tooltip="Next (Enter)"
onClick={$editor.commands.findNext}
>
<span class="i-lucide-arrow-right size-5 block"></span>
</Button>
<Button tooltip="Close" onClick={props.onClose}>
<span class="i-lucide-x size-5 block"></span>
</Button>
<Button
pressed={caseSensitive}
tooltip="Case Sensitive"
onClick={() => (caseSensitive = !caseSensitive)}
>
<span class="i-lucide-case-sensitive size-5 block"></span>
</Button>
<Button
pressed={wholeWord}
tooltip="Whole Word"
onClick={() => (wholeWord = !wholeWord)}
>
<span class="i-lucide-whole-word size-5 block"></span>
</Button>
<Button
pressed={regexp}
tooltip="Regular Expression"
onClick={() => (regexp = !regexp)}
>
<span class="i-lucide-braces size-5 block"></span>
</Button>
<Button
pressed={literal}
tooltip="Literal Escape Sequences"
onClick={() => (literal = !literal)}
>
<span class="i-lucide-quote size-5 block"></span>
</Button>
</div>
{#if showReplace}
<input
bind:value={replaceText}
placeholder="Replace"
type="text"
class="flex h-9 rounded-md w-full bg-[canvas] 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"
onkeydown={handleReplaceKeyDown}
/>
<div class="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>
{/if}
</div><script setup lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import 'prosekit/extensions/search/style.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/vue'
import { sampleContent } from '../../sample/sample-doc-search.ts'
import { Search } from '../../ui/search/index.ts'
import { defineExtension } from './extension.ts'
const props = defineProps<{
initialContent?: NodeJSON
}>()
const extension = defineExtension()
const defaultContent = props.initialContent ?? sampleContent
const editor = createEditor({
extension,
defaultContent,
})
</script>
<template>
<ProseKit :editor="editor">
<div class="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-[canvas] text-black dark:text-white">
<div class="relative w-full flex-1 box-border overflow-y-auto">
<Search />
<div :ref="(el) => editor.mount(el as HTMLElement | null)" class="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>
</ProseKit>
</template>import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineSearchCommands, defineSearchQuery } from 'prosekit/extensions/search'
export function defineExtension() {
return union(defineBasicExtension(), defineSearchQuery(), defineSearchCommands())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.vue'import type { NodeJSON } from 'prosekit/core'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Baa, baa, black sheep,',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Have you any wool?',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Yes, sir, yes, sir,',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Three bags full;',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'One for the master,',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'And one for the dame,',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'And one for the little boy',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Who lives down the lane.',
},
],
},
],
}<script setup lang="ts">
import { TooltipPopup, TooltipPositioner, TooltipRoot, TooltipTrigger } from 'prosekit/vue/tooltip'
const props = defineProps<{
pressed?: boolean
disabled?: boolean
onClick?: () => void
tooltip?: string
}>()
</script>
<template>
<TooltipRoot>
<TooltipTrigger class="block">
<button
:data-state="props.pressed ? 'on' : 'off'"
:disabled="props.disabled"
class="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"
@click="props.onClick"
@mousedown.prevent
>
<slot />
<span v-if="props.tooltip" class="sr-only">{{ props.tooltip }}</span>
</button>
</TooltipTrigger>
<TooltipPositioner v-if="props.tooltip" class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<TooltipPopup class="flex box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none duration-100 data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 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 text-nowrap">
{{ props.tooltip }}
</TooltipPopup>
</TooltipPositioner>
</TooltipRoot>
</template>export { default as Button } from './button.vue'export { default as Search } from './search.vue'<script setup lang="ts">
import { defineSearchStatusHandler, type SearchCommandsExtension, type SearchStatus } from 'prosekit/extensions/search'
import { useEditor, useExtension } from 'prosekit/vue'
import { ref, watchEffect } from 'vue'
import { Button } from '../button/index.ts'
const props = defineProps<{ onClose?: () => void }>()
const showReplace = ref(false)
const searchText = ref('')
const replaceText = ref('')
const caseSensitive = ref(false)
const wholeWord = ref(false)
const regexp = ref(false)
const literal = ref(false)
const searchStatus = ref<SearchStatus>({ total: 0, active: 0 })
useExtension(
defineSearchStatusHandler((status) => {
searchStatus.value = status
}),
)
const editor = useEditor<SearchCommandsExtension>()
watchEffect(() => {
editor.value.commands.setSearchQuery({
search: searchText.value,
replace: replaceText.value,
caseSensitive: caseSensitive.value,
wholeWord: wholeWord.value,
regexp: regexp.value,
literal: literal.value,
})
})
function toggleReplace() {
showReplace.value = !showReplace.value
}
function isPlainEnter(event: KeyboardEvent) {
return (
event.key === 'Enter'
&& !event.shiftKey
&& !event.metaKey
&& !event.altKey
&& !event.ctrlKey
&& !event.isComposing
)
}
function isShiftEnter(event: KeyboardEvent) {
return (
event.key === 'Enter'
&& event.shiftKey
&& !event.metaKey
&& !event.altKey
&& !event.ctrlKey
&& !event.isComposing
)
}
function handleSearchKeyDown(event: KeyboardEvent) {
if (isPlainEnter(event)) {
event.preventDefault()
editor.value.commands.findNext()
} else if (isShiftEnter(event)) {
event.preventDefault()
editor.value.commands.findPrev()
}
}
function handleReplaceKeyDown(event: KeyboardEvent) {
if (isPlainEnter(event)) {
event.preventDefault()
editor.value.commands.replaceNext()
} else if (isShiftEnter(event)) {
event.preventDefault()
editor.value.commands.replaceAll()
}
}
</script>
<template>
<div class="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" @click="toggleReplace">
<span
:data-rotate="showReplace ? '' : undefined"
class="i-lucide-chevron-right size-5 block transition-transform data-rotate:rotate-90"
/>
</Button>
<input
v-model="searchText"
placeholder="Search"
type="text"
class="flex h-9 rounded-md w-full bg-[canvas] 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"
@keydown="handleSearchKeyDown"
>
<div class="flex items-center justify-between gap-1">
<span
v-if="searchText"
class="flex items-center px-1 text-sm whitespace-nowrap tabular-nums text-gray-500 dark:text-gray-500"
>
{{ searchStatus.active }} / {{ searchStatus.total }}
</span>
<Button
tooltip="Previous (Shift Enter)"
@click="editor.commands.findPrev"
>
<span class="i-lucide-arrow-left size-5 block" />
</Button>
<Button
tooltip="Next (Enter)"
@click="editor.commands.findNext"
>
<span class="i-lucide-arrow-right size-5 block" />
</Button>
<Button tooltip="Close" @click="props.onClose">
<span class="i-lucide-x size-5 block" />
</Button>
<Button
:pressed="caseSensitive"
tooltip="Case Sensitive"
@click="caseSensitive = !caseSensitive"
>
<span class="i-lucide-case-sensitive size-5 block" />
</Button>
<Button
:pressed="wholeWord"
tooltip="Whole Word"
@click="wholeWord = !wholeWord"
>
<span class="i-lucide-whole-word size-5 block" />
</Button>
<Button
:pressed="regexp"
tooltip="Regular Expression"
@click="regexp = !regexp"
>
<span class="i-lucide-braces size-5 block" />
</Button>
<Button
:pressed="literal"
tooltip="Literal Escape Sequences"
@click="literal = !literal"
>
<span class="i-lucide-quote size-5 block" />
</Button>
</div>
<template v-if="showReplace">
<input
v-model="replaceText"
placeholder="Replace"
type="text"
class="flex h-9 rounded-md w-full bg-[canvas] 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"
@keydown="handleReplaceKeyDown"
>
<div class="flex items-center justify-between gap-1">
<Button
tooltip="Replace (Enter)"
@click="editor.commands.replaceNext"
>
Replace
</Button>
<Button
tooltip="Replace All (Shift Enter)"
@click="editor.commands.replaceAll"
>
All
</Button>
</div>
</template>
</div>
</template>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 defineSearchQuery() to store the search state, and defineSearchCommands() to define related commands.
import { union function union<const E extends readonly Extension[]>(...exts: E): Union<E> (+1 overload)Merges multiple extensions into one. You can pass multiple extensions as
arguments or a single array containing multiple extensions.
Throws if no extensions are provided.@example```ts
function defineFancyNodes() {
return union(
defineFancyParagraph(),
defineFancyHeading(),
)
}
```@example```ts
function defineFancyNodes() {
return union([
defineFancyParagraph(),
defineFancyHeading(),
])
}
``` } from 'prosekit/core'
import { defineSearchCommands function defineSearchCommands(): SearchCommandsExtensionDefines commands for search and replace. , defineSearchQuery function defineSearchQuery(options?: SearchQueryOptions): PlainExtensionDefines an extension that stores a current search query and replace string.
When called without options, it stores an empty query, which can be updated
later with the `setSearchQuery` command. } from 'prosekit/extensions/search'
const extension const extension: Union<readonly [PlainExtension, SearchCommandsExtension]> = union union<readonly [PlainExtension, SearchCommandsExtension]>(exts_0: PlainExtension, exts_1: SearchCommandsExtension): Union<readonly [PlainExtension, SearchCommandsExtension]> (+1 overload)Merges multiple extensions into one. You can pass multiple extensions as
arguments or a single array containing multiple extensions.
Throws if no extensions are provided.@example```ts
function defineFancyNodes() {
return union(
defineFancyParagraph(),
defineFancyHeading(),
)
}
```@example```ts
function defineFancyNodes() {
return union([
defineFancyParagraph(),
defineFancyHeading(),
])
}
``` (defineSearchQuery function defineSearchQuery(options?: SearchQueryOptions): PlainExtensionDefines an extension that stores a current search query and replace string.
When called without options, it stores an empty query, which can be updated
later with the `setSearchQuery` command. (), defineSearchCommands function defineSearchCommands(): SearchCommandsExtensionDefines commands for search and replace. ())In your search component, dispatch the setSearchQuery command whenever the search text or a search option changes. The command is a no-op when the query is unchanged, so you can dispatch it on every input change. You can also pass options to defineSearchQuery() to start with a fixed query.
Search status
Section titled “Search status”Read the current match count with getSearchStatus(), or register a handler with defineSearchStatusHandler() to render a match counter.
import { createEditor function createEditor<E extends Extension>(options: EditorOptions<E>): Editor<E> } from 'prosekit/core'
import { defineSearchStatusHandler function defineSearchStatusHandler(handler: SearchStatusHandler): PlainExtensionRegisters a handler that is called whenever the search status changes. It
can be used to render a match counter. , getSearchStatus function getSearchStatus(state: EditorState): SearchStatusReturns the current search status. } from 'prosekit/extensions/search'
const extension const extension: PlainExtension = defineSearchStatusHandler function defineSearchStatusHandler(handler: SearchStatusHandler): PlainExtensionRegisters a handler that is called whenever the search status changes. It
can be used to render a match counter. ((status status: SearchStatus ) => {
console var console: Console .log Console.log(...data: any[]): voidThe **`console.log()`** static method outputs a message to the console.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static) (`Match ${status status: SearchStatus .active SearchStatus.active: numberThe one-based position of the match that the selection sits on, or 0 when
the selection is not on a match. } of ${status status: SearchStatus .total SearchStatus.total: numberThe total number of matches for the current search query. }`)
})
const editor const editor: Editor<PlainExtension> = createEditor createEditor<PlainExtension>(options: EditorOptions<PlainExtension>): Editor<PlainExtension> ({ extension EditorOptions<PlainExtension>.extension: PlainExtensionThe extension to use when creating the editor. })
const status const status: SearchStatus = getSearchStatus function getSearchStatus(state: EditorState): SearchStatusReturns the current search status. (editor const editor: Editor<PlainExtension> .state Editor<PlainExtension>.state: EditorStateThe editor's current state. )Commands
Section titled “Commands”setSearchQuery
Section titled “setSearchQuery”Update the search query and select the first match at or after the selection start, wrapping around to the first match in the document. When the query matches nothing, a selection left by a previous query collapses to its start. An empty query clears the highlights and leaves the selection alone.
editor const editor: Editor<SearchCommandsExtension> .commands Editor<SearchCommandsExtension>.commands: ToCommandAction<{
setSearchQuery: [options: SearchQueryOptions];
findNext: [];
findPrev: [];
findNextNoWrap: [];
findPrevNoWrap: [];
replaceNext: [];
replaceNextNoWrap: [];
replaceCurrent: [];
replaceAll: [];
}>
All
{@link
CommandAction
}
s defined by the editor. .setSearchQuery setSearchQuery: CommandAction
(options: SearchQueryOptions) => boolean
Execute the current command. Return `true` if the command was successfully
executed, otherwise `false`. ({ search SearchQueryOptions.search: stringThe search string (or regular expression). : 'ProseKit' })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<{
setSearchQuery: [options: SearchQueryOptions];
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<{
setSearchQuery: [options: SearchQueryOptions];
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<{
setSearchQuery: [options: SearchQueryOptions];
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<{
setSearchQuery: [options: SearchQueryOptions];
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<{
setSearchQuery: [options: SearchQueryOptions];
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<{
setSearchQuery: [options: SearchQueryOptions];
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<{
setSearchQuery: [options: SearchQueryOptions];
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<{
setSearchQuery: [options: SearchQueryOptions];
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`. ()