Example: text-color
Install this example with
shadcn:npx shadcn@latest add @prosekit/react-example-text-colornpx shadcn@latest add @prosekit/preact-example-text-colornpx shadcn@latest add @prosekit/svelte-example-text-colornpx shadcn@latest add @prosekit/vue-example-text-colorimport 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { useMemo } from 'preact/hooks'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/preact'
import { defaultContent } from '../../sample/sample-doc-text-color'
import { defineExtension } from './extension'
import InlineMenu from './inline-menu'
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">
<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>
<InlineMenu />
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineTextColor } from './text-color'
export function defineExtension() {
return union(defineBasicExtension(), defineTextColor())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'import {
useMemo,
useState,
} from 'preact/hooks'
import type {
Editor,
Keymap,
} from 'prosekit/core'
import {
useEditor,
useEditorDerivedValue,
useKeymap,
} from 'prosekit/preact'
import { InlinePopover } from 'prosekit/preact/inline-popover'
import { Button } from '../../ui/button'
import type { EditorExtension } from './extension'
const colors = [
{ name: 'default', value: '' },
{ name: 'red', value: '#ef4444' },
{ name: 'orange', value: '#f97316' },
{ name: 'yellow', value: '#eab308' },
{ name: 'green', value: '#22c55e' },
{ name: 'blue', value: '#3b82f6' },
{ name: 'indigo', value: '#6366f1' },
{ name: 'violet', value: '#a855f7' },
]
function hasTextColor(editor: Editor<EditorExtension>, color: string) {
return editor.marks.textColor.isActive({ color })
}
function getColorState(editor: Editor<EditorExtension>) {
return colors.map((color) => ({
name: color.name,
value: color.value,
isActive: hasTextColor(editor, color.value),
}))
}
export default function InlineMenu() {
const editor = useEditor<EditorExtension>()
const colorState = useEditorDerivedValue(getColorState)
const [open, setOpen] = useState(false)
const toggleTextColor = (color: string) => {
if (!color || hasTextColor(editor, color)) {
editor.commands.removeTextColor()
} else {
editor.commands.setTextColor({ color })
}
}
const keymap: Keymap = useMemo(() => ({
Escape: () => {
if (open) {
setOpen(false)
return true
}
return false
},
}), [open])
useKeymap(keymap)
return (
<InlinePopover
className="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-md p-1"
open={open}
onOpenChange={setOpen}
>
{colorState.map((color) => (
<Button
key={color.name}
pressed={color.isActive}
tooltip={color.name}
onClick={() => toggleTextColor(color.value)}
>
<span style={{ color: color.value }}>A</span>
</Button>
))}
</InlinePopover>
)
}import {
addMark,
defineCommands,
defineMarkSpec,
removeMark,
union,
} from 'prosekit/core'
import type { Command } from 'prosekit/pm/state'
/**
* The attributes for the `textColor` mark.
*
* @public
*/
export interface TextColorAttrs {
color: string | null
}
/**
* Defines the `textColor` mark.
*
* @public
*/
export function defineTextColorSpec() {
return defineMarkSpec<'textColor', TextColorAttrs>({
name: 'textColor',
attrs: {
color: { default: null },
},
parseDOM: [
{
style: 'color',
getAttrs: (value) => {
return { color: value }
},
},
],
toDOM: (mark) => {
return ['span', { style: `color: ${mark.attrs.color};` }, 0]
},
})
}
function setTextColor(attrs: TextColorAttrs): Command {
return addMark({ type: 'textColor', attrs })
}
function removeTextColor(): Command {
return removeMark({ type: 'textColor' })
}
/**
* Defines some commands for the `textColor` mark.
*
* @public
*/
export function defineTextColorCommands() {
return defineCommands({
setTextColor,
removeTextColor,
})
}
/**
* Defines the `textColor` mark and some commands for it.
*
* @public
*/
export function defineTextColor() {
return union(defineTextColorSpec(), defineTextColorCommands())
}import type { NodeJSON } from 'prosekit/core'
export const defaultContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(239, 68, 68)',
},
},
],
text: 'Select',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(249, 115, 22)',
},
},
],
text: 'some',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(234, 179, 8)',
},
},
],
text: 'text',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(34, 197, 94)',
},
},
],
text: 'to',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(59, 130, 246)',
},
},
],
text: 'change',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(99, 102, 241)',
},
},
],
text: 'the',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(168, 85, 247)',
},
},
],
text: 'color',
},
],
},
],
}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'import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/react'
import { useMemo } from 'react'
import { defaultContent } from '../../sample/sample-doc-text-color'
import { defineExtension } from './extension'
import InlineMenu from './inline-menu'
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">
<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>
<InlineMenu />
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineTextColor } from './text-color'
export function defineExtension() {
return union(defineBasicExtension(), defineTextColor())
}
export type EditorExtension = ReturnType<typeof defineExtension>'use client'
export { default as ExampleEditor } from './editor'import type {
Editor,
Keymap,
} from 'prosekit/core'
import {
useEditor,
useEditorDerivedValue,
useKeymap,
} from 'prosekit/react'
import { InlinePopover } from 'prosekit/react/inline-popover'
import {
useMemo,
useState,
} from 'react'
import { Button } from '../../ui/button'
import type { EditorExtension } from './extension'
const colors = [
{ name: 'default', value: '' },
{ name: 'red', value: '#ef4444' },
{ name: 'orange', value: '#f97316' },
{ name: 'yellow', value: '#eab308' },
{ name: 'green', value: '#22c55e' },
{ name: 'blue', value: '#3b82f6' },
{ name: 'indigo', value: '#6366f1' },
{ name: 'violet', value: '#a855f7' },
]
function hasTextColor(editor: Editor<EditorExtension>, color: string) {
return editor.marks.textColor.isActive({ color })
}
function getColorState(editor: Editor<EditorExtension>) {
return colors.map((color) => ({
name: color.name,
value: color.value,
isActive: hasTextColor(editor, color.value),
}))
}
export default function InlineMenu() {
const editor = useEditor<EditorExtension>()
const colorState = useEditorDerivedValue(getColorState)
const [open, setOpen] = useState(false)
const toggleTextColor = (color: string) => {
if (!color || hasTextColor(editor, color)) {
editor.commands.removeTextColor()
} else {
editor.commands.setTextColor({ color })
}
}
const keymap: Keymap = useMemo(() => ({
Escape: () => {
if (open) {
setOpen(false)
return true
}
return false
},
}), [open])
useKeymap(keymap)
return (
<InlinePopover
className="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-md p-1"
open={open}
onOpenChange={setOpen}
>
{colorState.map((color) => (
<Button
key={color.name}
pressed={color.isActive}
tooltip={color.name}
onClick={() => toggleTextColor(color.value)}
>
<span style={{ color: color.value }}>A</span>
</Button>
))}
</InlinePopover>
)
}import {
addMark,
defineCommands,
defineMarkSpec,
removeMark,
union,
} from 'prosekit/core'
import type { Command } from 'prosekit/pm/state'
/**
* The attributes for the `textColor` mark.
*
* @public
*/
export interface TextColorAttrs {
color: string | null
}
/**
* Defines the `textColor` mark.
*
* @public
*/
export function defineTextColorSpec() {
return defineMarkSpec<'textColor', TextColorAttrs>({
name: 'textColor',
attrs: {
color: { default: null },
},
parseDOM: [
{
style: 'color',
getAttrs: (value) => {
return { color: value }
},
},
],
toDOM: (mark) => {
return ['span', { style: `color: ${mark.attrs.color};` }, 0]
},
})
}
function setTextColor(attrs: TextColorAttrs): Command {
return addMark({ type: 'textColor', attrs })
}
function removeTextColor(): Command {
return removeMark({ type: 'textColor' })
}
/**
* Defines some commands for the `textColor` mark.
*
* @public
*/
export function defineTextColorCommands() {
return defineCommands({
setTextColor,
removeTextColor,
})
}
/**
* Defines the `textColor` mark and some commands for it.
*
* @public
*/
export function defineTextColor() {
return union(defineTextColorSpec(), defineTextColorCommands())
}import type { NodeJSON } from 'prosekit/core'
export const defaultContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(239, 68, 68)',
},
},
],
text: 'Select',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(249, 115, 22)',
},
},
],
text: 'some',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(234, 179, 8)',
},
},
],
text: 'text',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(34, 197, 94)',
},
},
],
text: 'to',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(59, 130, 246)',
},
},
],
text: 'change',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(99, 102, 241)',
},
},
],
text: 'the',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(168, 85, 247)',
},
},
],
text: 'color',
},
],
},
],
}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'<script lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/svelte'
import { defaultContent } from '../../sample/sample-doc-text-color'
import { defineExtension } from './extension'
import InlineMenu from './inline-menu.svelte'
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">
<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>
<InlineMenu />
</div>
</div>
</ProseKit>import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineTextColor } from './text-color'
export function defineExtension() {
return union(defineBasicExtension(), defineTextColor())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.svelte'<script lang="ts">
import type { Editor } from 'prosekit/core'
import {
useEditor,
useEditorDerivedValue,
useKeymap,
} from 'prosekit/svelte'
import { InlinePopover } from 'prosekit/svelte/inline-popover'
import {
derived,
writable,
} from 'svelte/store'
import { Button } from '../../ui/button'
import type { EditorExtension } from './extension'
const colors = [
{ name: 'default', value: '' },
{ name: 'red', value: '#ef4444' },
{ name: 'orange', value: '#f97316' },
{ name: 'yellow', value: '#eab308' },
{ name: 'green', value: '#22c55e' },
{ name: 'blue', value: '#3b82f6' },
{ name: 'indigo', value: '#6366f1' },
{ name: 'violet', value: '#a855f7' },
]
function hasTextColor(editor: Editor<EditorExtension>, color: string) {
return editor.marks.textColor.isActive({ color })
}
function getColorState(editor: Editor<EditorExtension>) {
return colors.map((color) => ({
name: color.name,
value: color.value,
isActive: hasTextColor(editor, color.value),
}))
}
const editor = useEditor<EditorExtension>()
const colorState = useEditorDerivedValue(getColorState)
let open = $state(false)
function toggleTextColor(color: string) {
if (!color || hasTextColor($editor, color)) {
$editor.commands.removeTextColor()
} else {
$editor.commands.setTextColor({ color })
}
}
function handleOpenChange(value: boolean) {
open = value
}
// Create a store from the reactive open value
const openStore = writable(false)
// Update store when open changes
$effect(() => {
openStore.set(open)
})
// Create keymap derived from the open store
const keymap = derived(openStore, ($open) => ({
Escape: () => {
if ($open) {
open = false
return true
}
return false
},
}))
useKeymap(keymap)
</script>
<InlinePopover
class="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-md p-1"
{open}
onOpenChange={handleOpenChange}
>
{#each $colorState as color (color.name)}
<Button
pressed={color.isActive}
tooltip={color.name}
onClick={() => toggleTextColor(color.value)}
>
<span style:color={color.value}>A</span>
</Button>
{/each}
</InlinePopover>import {
addMark,
defineCommands,
defineMarkSpec,
removeMark,
union,
} from 'prosekit/core'
import type { Command } from 'prosekit/pm/state'
/**
* The attributes for the `textColor` mark.
*
* @public
*/
export interface TextColorAttrs {
color: string | null
}
/**
* Defines the `textColor` mark.
*
* @public
*/
export function defineTextColorSpec() {
return defineMarkSpec<'textColor', TextColorAttrs>({
name: 'textColor',
attrs: {
color: { default: null },
},
parseDOM: [
{
style: 'color',
getAttrs: (value) => {
return { color: value }
},
},
],
toDOM: (mark) => {
return ['span', { style: `color: ${mark.attrs.color};` }, 0]
},
})
}
function setTextColor(attrs: TextColorAttrs): Command {
return addMark({ type: 'textColor', attrs })
}
function removeTextColor(): Command {
return removeMark({ type: 'textColor' })
}
/**
* Defines some commands for the `textColor` mark.
*
* @public
*/
export function defineTextColorCommands() {
return defineCommands({
setTextColor,
removeTextColor,
})
}
/**
* Defines the `textColor` mark and some commands for it.
*
* @public
*/
export function defineTextColor() {
return union(defineTextColorSpec(), defineTextColorCommands())
}import type { NodeJSON } from 'prosekit/core'
export const defaultContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(239, 68, 68)',
},
},
],
text: 'Select',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(249, 115, 22)',
},
},
],
text: 'some',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(234, 179, 8)',
},
},
],
text: 'text',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(34, 197, 94)',
},
},
],
text: 'to',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(59, 130, 246)',
},
},
],
text: 'change',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(99, 102, 241)',
},
},
],
text: 'the',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(168, 85, 247)',
},
},
],
text: 'color',
},
],
},
],
}<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'<script setup lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/vue'
import {
ref,
watchPostEffect,
} from 'vue'
import { defaultContent } from '../../sample/sample-doc-text-color'
import { defineExtension } from './extension'
import InlineMenu from './inline-menu.vue'
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">
<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" />
<InlineMenu />
</div>
</div>
</ProseKit>
</template>import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineTextColor } from './text-color'
export function defineExtension() {
return union(defineBasicExtension(), defineTextColor())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.vue'<script setup lang="ts">
import type { Editor } from 'prosekit/core'
import {
useEditor,
useEditorDerivedValue,
useKeymap,
} from 'prosekit/vue'
import { InlinePopover } from 'prosekit/vue/inline-popover'
import { ref } from 'vue'
import { Button } from '../../ui/button'
import type { EditorExtension } from './extension'
const colors = [
{ name: 'default', value: '' },
{ name: 'red', value: '#ef4444' },
{ name: 'orange', value: '#f97316' },
{ name: 'yellow', value: '#eab308' },
{ name: 'green', value: '#22c55e' },
{ name: 'blue', value: '#3b82f6' },
{ name: 'indigo', value: '#6366f1' },
{ name: 'violet', value: '#a855f7' },
]
function hasTextColor(editor: Editor<EditorExtension>, color: string) {
return editor.marks.textColor.isActive({ color })
}
function getColorState(editor: Editor<EditorExtension>) {
return colors.map((color) => ({
name: color.name,
value: color.value,
isActive: hasTextColor(editor, color.value),
}))
}
const editor = useEditor<EditorExtension>()
const colorState = useEditorDerivedValue(getColorState)
const open = ref(false)
function toggleTextColor(color: string) {
if (!color || hasTextColor(editor.value, color)) {
editor.value.commands.removeTextColor()
} else {
editor.value.commands.setTextColor({ color })
}
}
function handleOpenChange(value: boolean) {
open.value = value
}
useKeymap({
Escape: () => {
if (open.value) {
open.value = false
return true
}
return false
},
})
</script>
<template>
<InlinePopover
class="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-md p-1"
:open="open"
@open-change="handleOpenChange"
>
<Button
v-for="color in colorState"
:key="color.name"
:pressed="color.isActive"
:tooltip="color.name"
@click="() => toggleTextColor(color.value)"
>
<span :style="{ color: color.value }">A</span>
</Button>
</InlinePopover>
</template>import {
addMark,
defineCommands,
defineMarkSpec,
removeMark,
union,
} from 'prosekit/core'
import type { Command } from 'prosekit/pm/state'
/**
* The attributes for the `textColor` mark.
*
* @public
*/
export interface TextColorAttrs {
color: string | null
}
/**
* Defines the `textColor` mark.
*
* @public
*/
export function defineTextColorSpec() {
return defineMarkSpec<'textColor', TextColorAttrs>({
name: 'textColor',
attrs: {
color: { default: null },
},
parseDOM: [
{
style: 'color',
getAttrs: (value) => {
return { color: value }
},
},
],
toDOM: (mark) => {
return ['span', { style: `color: ${mark.attrs.color};` }, 0]
},
})
}
function setTextColor(attrs: TextColorAttrs): Command {
return addMark({ type: 'textColor', attrs })
}
function removeTextColor(): Command {
return removeMark({ type: 'textColor' })
}
/**
* Defines some commands for the `textColor` mark.
*
* @public
*/
export function defineTextColorCommands() {
return defineCommands({
setTextColor,
removeTextColor,
})
}
/**
* Defines the `textColor` mark and some commands for it.
*
* @public
*/
export function defineTextColor() {
return union(defineTextColorSpec(), defineTextColorCommands())
}import type { NodeJSON } from 'prosekit/core'
export const defaultContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(239, 68, 68)',
},
},
],
text: 'Select',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(249, 115, 22)',
},
},
],
text: 'some',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(234, 179, 8)',
},
},
],
text: 'text',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(34, 197, 94)',
},
},
],
text: 'to',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(59, 130, 246)',
},
},
],
text: 'change',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(99, 102, 241)',
},
},
],
text: 'the',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [
{
type: 'textColor',
attrs: {
color: 'rgb(168, 85, 247)',
},
},
],
text: 'color',
},
],
},
],
}<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'