238 lines
5.5 KiB
TypeScript
Executable File
238 lines
5.5 KiB
TypeScript
Executable File
"use client"
|
|
|
|
import * as React from "react"
|
|
import { isNodeSelection, type Editor } from "@tiptap/react"
|
|
|
|
// --- Hooks ---
|
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
|
|
|
// --- Icons ---
|
|
import { BoldIcon } from "@/components/tiptap-icons/bold-icon"
|
|
import { Code2Icon } from "@/components/tiptap-icons/code2-icon"
|
|
import { ItalicIcon } from "@/components/tiptap-icons/italic-icon"
|
|
import { StrikeIcon } from "@/components/tiptap-icons/strike-icon"
|
|
import { SubscriptIcon } from "@/components/tiptap-icons/subscript-icon"
|
|
import { SuperscriptIcon } from "@/components/tiptap-icons/superscript-icon"
|
|
import { UnderlineIcon } from "@/components/tiptap-icons/underline-icon"
|
|
|
|
// --- Lib ---
|
|
import { isMarkInSchema } from "@/lib/tiptap-utils"
|
|
|
|
// --- UI Primitives ---
|
|
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
|
import { Button } from "@/components/tiptap-ui-primitive/button"
|
|
|
|
export type Mark =
|
|
| "bold"
|
|
| "italic"
|
|
| "strike"
|
|
| "code"
|
|
| "underline"
|
|
| "superscript"
|
|
| "subscript"
|
|
|
|
export interface MarkButtonProps extends Omit<ButtonProps, "type"> {
|
|
/**
|
|
* The type of mark to toggle
|
|
*/
|
|
type: Mark
|
|
/**
|
|
* Optional editor instance. If not provided, will use editor from context
|
|
*/
|
|
editor?: Editor | null
|
|
/**
|
|
* Display text for the button (optional)
|
|
*/
|
|
text?: string
|
|
/**
|
|
* Whether this button should be hidden when the mark is not available
|
|
*/
|
|
hideWhenUnavailable?: boolean
|
|
}
|
|
|
|
export const markIcons = {
|
|
bold: BoldIcon,
|
|
italic: ItalicIcon,
|
|
underline: UnderlineIcon,
|
|
strike: StrikeIcon,
|
|
code: Code2Icon,
|
|
superscript: SuperscriptIcon,
|
|
subscript: SubscriptIcon,
|
|
}
|
|
|
|
export const markShortcutKeys: Partial<Record<Mark, string>> = {
|
|
bold: "Ctrl-b",
|
|
italic: "Ctrl-i",
|
|
underline: "Ctrl-u",
|
|
strike: "Ctrl-Shift-s",
|
|
code: "Ctrl-e",
|
|
superscript: "Ctrl-.",
|
|
subscript: "Ctrl-,",
|
|
}
|
|
|
|
export function canToggleMark(editor: Editor | null, type: Mark): boolean {
|
|
if (!editor) return false
|
|
|
|
try {
|
|
return editor.can().toggleMark(type)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export function isMarkActive(editor: Editor | null, type: Mark): boolean {
|
|
if (!editor) return false
|
|
return editor.isActive(type)
|
|
}
|
|
|
|
export function toggleMark(editor: Editor | null, type: Mark): void {
|
|
if (!editor) return
|
|
editor.chain().focus().toggleMark(type).run()
|
|
}
|
|
|
|
export function isMarkButtonDisabled(
|
|
editor: Editor | null,
|
|
type: Mark,
|
|
userDisabled: boolean = false
|
|
): boolean {
|
|
if (!editor) return true
|
|
if (userDisabled) return true
|
|
if (editor.isActive("codeBlock")) return true
|
|
if (!canToggleMark(editor, type)) return true
|
|
return false
|
|
}
|
|
|
|
export function shouldShowMarkButton(params: {
|
|
editor: Editor | null
|
|
type: Mark
|
|
hideWhenUnavailable: boolean
|
|
markInSchema: boolean
|
|
}): boolean {
|
|
const { editor, type, hideWhenUnavailable, markInSchema } = params
|
|
|
|
if (!markInSchema || !editor) {
|
|
return false
|
|
}
|
|
|
|
if (hideWhenUnavailable) {
|
|
if (
|
|
isNodeSelection(editor.state.selection) ||
|
|
!canToggleMark(editor, type)
|
|
) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
export function getFormattedMarkName(type: Mark): string {
|
|
return type.charAt(0).toUpperCase() + type.slice(1)
|
|
}
|
|
|
|
export function useMarkState(
|
|
editor: Editor | null,
|
|
type: Mark,
|
|
disabled: boolean = false
|
|
) {
|
|
const markInSchema = isMarkInSchema(type, editor)
|
|
const isDisabled = isMarkButtonDisabled(editor, type, disabled)
|
|
const isActive = isMarkActive(editor, type)
|
|
|
|
const Icon = markIcons[type]
|
|
const shortcutKey = markShortcutKeys[type]
|
|
const formattedName = getFormattedMarkName(type)
|
|
|
|
return {
|
|
markInSchema,
|
|
isDisabled,
|
|
isActive,
|
|
Icon,
|
|
shortcutKey,
|
|
formattedName,
|
|
}
|
|
}
|
|
|
|
export const MarkButton = React.forwardRef<HTMLButtonElement, MarkButtonProps>(
|
|
(
|
|
{
|
|
editor: providedEditor,
|
|
type,
|
|
text,
|
|
hideWhenUnavailable = false,
|
|
className = "",
|
|
disabled,
|
|
onClick,
|
|
children,
|
|
...buttonProps
|
|
},
|
|
ref
|
|
) => {
|
|
const editor = useTiptapEditor(providedEditor)
|
|
|
|
const {
|
|
markInSchema,
|
|
isDisabled,
|
|
isActive,
|
|
Icon,
|
|
shortcutKey,
|
|
formattedName,
|
|
} = useMarkState(editor, type, disabled)
|
|
|
|
const handleClick = React.useCallback(
|
|
(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
onClick?.(e)
|
|
|
|
if (!e.defaultPrevented && !isDisabled && editor) {
|
|
toggleMark(editor, type)
|
|
}
|
|
},
|
|
[onClick, isDisabled, editor, type]
|
|
)
|
|
|
|
const show = React.useMemo(() => {
|
|
return shouldShowMarkButton({
|
|
editor,
|
|
type,
|
|
hideWhenUnavailable,
|
|
markInSchema,
|
|
})
|
|
}, [editor, type, hideWhenUnavailable, markInSchema])
|
|
|
|
if (!show || !editor || !editor.isEditable) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<Button
|
|
type="button"
|
|
className={className.trim()}
|
|
disabled={isDisabled}
|
|
data-style="ghost"
|
|
data-active-state={isActive ? "on" : "off"}
|
|
data-disabled={isDisabled}
|
|
role="button"
|
|
tabIndex={-1}
|
|
aria-label={type}
|
|
aria-pressed={isActive}
|
|
tooltip={formattedName}
|
|
shortcutKeys={shortcutKey}
|
|
onClick={handleClick}
|
|
{...buttonProps}
|
|
ref={ref}
|
|
>
|
|
{children || (
|
|
<>
|
|
<Icon className="tiptap-button-icon" />
|
|
{text && <span className="tiptap-button-text">{text}</span>}
|
|
</>
|
|
)}
|
|
</Button>
|
|
)
|
|
}
|
|
)
|
|
|
|
MarkButton.displayName = "MarkButton"
|
|
|
|
export default MarkButton
|