Example: search
Install this example with
shadcn:npx shadcn@latest add @prosekit/react-example-searchnpx shadcn@latest add @prosekit/preact-example-searchnpx shadcn@latest add @prosekit/svelte-example-searchnpx shadcn@latest add @prosekit/vue-example-searchimport 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import 'prosekit/extensions/search/style.css'
import { useMemo } from 'preact/hooks'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/preact'
import { defaultContent } from '../../sample/sample-doc-search'
import { Search } from '../../ui/search'
import { defineExtension } from './extension'
export default function Editor() {
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({
extension,
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-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 type { NodeJSON } from 'prosekit/core'
export const defaultContent: 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 {
TooltipContent,
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
? (
<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 {
useMemo,
useState,
} from 'preact/hooks'
import {
defineSearchQuery,
type SearchCommandsExtension,
} from 'prosekit/extensions/search'
import {
useEditor,
useExtension,
} from 'prosekit/preact'
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: KeyboardEvent) => {
return (
event.key === 'Enter'
&& !event.shiftKey
&& !event.metaKey
&& !event.altKey
&& !event.ctrlKey
&& !event.isComposing
)
}
const isShiftEnter = (event: KeyboardEvent) => {
return (
event.key === 'Enter'
&& event.shiftKey
&& !event.metaKey
&& !event.altKey
&& !event.ctrlKey
&& !event.isComposing
)
}
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-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.currentTarget.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>
)
}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 { defaultContent } from '../../sample/sample-doc-search'
import { Search } from '../../ui/search'
import { defineExtension } from './extension'
export default function Editor() {
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({
extension,
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-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>'use client'
export { default as ExampleEditor } from './editor'import type { NodeJSON } from 'prosekit/core'
export const defaultContent: 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 {
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>
)
}<script lang="ts">
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/svelte'
import { defaultContent } from '../../sample/sample-doc-search'
import { Search } from '../../ui/search'
import { defineExtension } from './extension'
const extension = defineExtension()
const editor = createEditor({
extension,
defaultContent,
})
const mount = (element: HTMLElement) => {
editor.mount(element)
return { destroy: () => editor.unmount() }
}
</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-white dark:bg-gray-950 text-black dark:text-white">
<div class="relative w-full flex-1 box-border overflow-y-auto">
<Search />
<div use: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 } from 'prosekit/extensions/search'
export function defineExtension() {
return union(defineBasicExtension(), defineSearchCommands())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.svelte'import type { NodeJSON } from 'prosekit/core'
export const defaultContent: 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 {
TooltipContent,
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}
<TooltipContent class="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>
{/if}
</TooltipRoot>export { default as Button } from './button.svelte'export { default as Search } from './search.svelte'<script lang="ts">
import {
defineSearchQuery,
type SearchCommandsExtension,
} from 'prosekit/extensions/search'
import {
useEditor,
useExtension,
} from 'prosekit/svelte'
import {
derived,
writable,
} from 'svelte/store'
import { Button } from '../button'
interface Props {
onClose?: () => void
}
const props: Props = $props()
let showReplace = $state(false)
let searchText = $state('')
let replaceText = $state('')
const editor = useEditor<SearchCommandsExtension>()
// Create stores from the reactive values
const searchTextStore = writable('')
const replaceTextStore = writable('')
// Update stores when reactive values change
$effect(() => {
searchTextStore.set(searchText)
})
$effect(() => {
replaceTextStore.set(replaceText)
})
// Create extension derived from the stores
const extension = derived(
[searchTextStore, replaceTextStore],
([$searchText, $replaceText]) => {
if (!$searchText) {
return null
}
return defineSearchQuery({
search: $searchText,
replace: $replaceText,
})
},
)
useExtension(extension)
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-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"
onkeydown={handleSearchKeyDown}
/>
<div class="flex items-center justify-between gap-1">
<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>
</div>
{#if showReplace}
<input
bind:value={replaceText}
placeholder="Replace"
type="text"
class="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"
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 } from 'prosekit/core'
import { ProseKit } from 'prosekit/vue'
import {
ref,
watchPostEffect,
} from 'vue'
import { defaultContent } from '../../sample/sample-doc-search'
import { Search } from '../../ui/search'
import { defineExtension } from './extension'
const extension = defineExtension()
const editor = createEditor({
extension,
defaultContent,
})
const editorRef = ref<HTMLDivElement | null>(null)
watchPostEffect((onCleanup) => {
editor.mount(editorRef.value)
onCleanup(() => editor.unmount())
})
</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-white dark:bg-gray-950 text-black dark:text-white">
<div class="relative w-full flex-1 box-border overflow-y-auto">
<Search />
<div ref="editorRef" 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 } from 'prosekit/extensions/search'
export function defineExtension() {
return union(defineBasicExtension(), defineSearchCommands())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.vue'import type { NodeJSON } from 'prosekit/core'
export const defaultContent: 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 {
TooltipContent,
TooltipRoot,
TooltipTrigger,
} from 'prosekit/vue/tooltip'
defineProps<{
pressed?: boolean
disabled?: boolean
onClick?: () => void
tooltip?: string
}>()
</script>
<template>
<TooltipRoot>
<TooltipTrigger class="block">
<button
:data-state="pressed ? 'on' : 'off'"
:disabled="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="onClick"
@mousedown.prevent
>
<slot />
<span v-if="tooltip" class="sr-only">{{ tooltip }}</span>
</button>
</TooltipTrigger>
<TooltipContent v-if="tooltip" class="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">
{{ tooltip }}
</TooltipContent>
</TooltipRoot>
</template>export { default as Button } from './button.vue'export { default as Search } from './search.vue'<script setup lang="ts">
import {
defineSearchQuery,
type SearchCommandsExtension,
} from 'prosekit/extensions/search'
import {
useEditor,
useExtension,
} from 'prosekit/vue'
import {
computed,
ref,
} from 'vue'
import { Button } from '../button'
const props = defineProps<{ onClose?: () => void }>()
const showReplace = ref(false)
const searchText = ref('')
const replaceText = ref('')
const editor = useEditor<SearchCommandsExtension>()
const extension = computed(() => {
if (!searchText.value) {
return null
}
return defineSearchQuery({
search: searchText.value,
replace: replaceText.value,
})
})
useExtension(extension)
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-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"
@keydown="handleSearchKeyDown"
>
<div class="flex items-center justify-between gap-1">
<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>
</div>
<template v-if="showReplace">
<input
v-model="replaceText"
placeholder="Replace"
type="text"
class="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"
@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>