233 lines
5.5 KiB
TypeScript
233 lines
5.5 KiB
TypeScript
|
|
"use client"
|
||
|
|
|
||
|
|
import * as React from "react"
|
||
|
|
import { isNodeSelection, type Editor } from "@tiptap/react"
|
||
|
|
|
||
|
|
// --- Hooks ---
|
||
|
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||
|
|
|
||
|
|
// --- Icons ---
|
||
|
|
import { HeadingOneIcon } from "@/components/tiptap-icons/heading-one-icon"
|
||
|
|
import { HeadingTwoIcon } from "@/components/tiptap-icons/heading-two-icon"
|
||
|
|
import { HeadingThreeIcon } from "@/components/tiptap-icons/heading-three-icon"
|
||
|
|
import { HeadingFourIcon } from "@/components/tiptap-icons/heading-four-icon"
|
||
|
|
import { HeadingFiveIcon } from "@/components/tiptap-icons/heading-five-icon"
|
||
|
|
import { HeadingSixIcon } from "@/components/tiptap-icons/heading-six-icon"
|
||
|
|
|
||
|
|
// --- Lib ---
|
||
|
|
import { isNodeInSchema } 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 Level = 1 | 2 | 3 | 4 | 5 | 6
|
||
|
|
|
||
|
|
export interface HeadingButtonProps extends Omit<ButtonProps, "type"> {
|
||
|
|
/**
|
||
|
|
* The TipTap editor instance.
|
||
|
|
*/
|
||
|
|
editor?: Editor | null
|
||
|
|
/**
|
||
|
|
* The heading level.
|
||
|
|
*/
|
||
|
|
level: Level
|
||
|
|
/**
|
||
|
|
* Optional text to display alongside the icon.
|
||
|
|
*/
|
||
|
|
text?: string
|
||
|
|
/**
|
||
|
|
* Whether the button should hide when the heading is not available.
|
||
|
|
* @default false
|
||
|
|
*/
|
||
|
|
hideWhenUnavailable?: boolean
|
||
|
|
}
|
||
|
|
|
||
|
|
export const headingIcons = {
|
||
|
|
1: HeadingOneIcon,
|
||
|
|
2: HeadingTwoIcon,
|
||
|
|
3: HeadingThreeIcon,
|
||
|
|
4: HeadingFourIcon,
|
||
|
|
5: HeadingFiveIcon,
|
||
|
|
6: HeadingSixIcon,
|
||
|
|
}
|
||
|
|
|
||
|
|
export const headingShortcutKeys: Partial<Record<Level, string>> = {
|
||
|
|
1: "Ctrl-Alt-1",
|
||
|
|
2: "Ctrl-Alt-2",
|
||
|
|
3: "Ctrl-Alt-3",
|
||
|
|
4: "Ctrl-Alt-4",
|
||
|
|
5: "Ctrl-Alt-5",
|
||
|
|
6: "Ctrl-Alt-6",
|
||
|
|
}
|
||
|
|
|
||
|
|
export function canToggleHeading(editor: Editor | null, level: Level): boolean {
|
||
|
|
if (!editor) return false
|
||
|
|
|
||
|
|
try {
|
||
|
|
return editor.can().toggleNode("heading", "paragraph", { level })
|
||
|
|
} catch {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export function isHeadingActive(editor: Editor | null, level: Level): boolean {
|
||
|
|
if (!editor) return false
|
||
|
|
return editor.isActive("heading", { level })
|
||
|
|
}
|
||
|
|
|
||
|
|
export function toggleHeading(editor: Editor | null, level: Level): void {
|
||
|
|
if (!editor) return
|
||
|
|
|
||
|
|
if (editor.isActive("heading", { level })) {
|
||
|
|
editor.chain().focus().setNode("paragraph").run()
|
||
|
|
} else {
|
||
|
|
editor.chain().focus().toggleNode("heading", "paragraph", { level }).run()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export function isHeadingButtonDisabled(
|
||
|
|
editor: Editor | null,
|
||
|
|
level: Level,
|
||
|
|
userDisabled: boolean = false
|
||
|
|
): boolean {
|
||
|
|
if (!editor) return true
|
||
|
|
if (userDisabled) return true
|
||
|
|
if (!canToggleHeading(editor, level)) return true
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
export function shouldShowHeadingButton(params: {
|
||
|
|
editor: Editor | null
|
||
|
|
level: Level
|
||
|
|
hideWhenUnavailable: boolean
|
||
|
|
headingInSchema: boolean
|
||
|
|
}): boolean {
|
||
|
|
const { editor, hideWhenUnavailable, headingInSchema } = params
|
||
|
|
|
||
|
|
if (!headingInSchema || !editor) {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
if (hideWhenUnavailable) {
|
||
|
|
if (isNodeSelection(editor.state.selection)) {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getFormattedHeadingName(level: Level): string {
|
||
|
|
return `Heading ${level}`
|
||
|
|
}
|
||
|
|
|
||
|
|
export function useHeadingState(
|
||
|
|
editor: Editor | null,
|
||
|
|
level: Level,
|
||
|
|
disabled: boolean = false
|
||
|
|
) {
|
||
|
|
const headingInSchema = isNodeInSchema("heading", editor)
|
||
|
|
const isDisabled = isHeadingButtonDisabled(editor, level, disabled)
|
||
|
|
const isActive = isHeadingActive(editor, level)
|
||
|
|
|
||
|
|
const Icon = headingIcons[level]
|
||
|
|
const shortcutKey = headingShortcutKeys[level]
|
||
|
|
const formattedName = getFormattedHeadingName(level)
|
||
|
|
|
||
|
|
return {
|
||
|
|
headingInSchema,
|
||
|
|
isDisabled,
|
||
|
|
isActive,
|
||
|
|
Icon,
|
||
|
|
shortcutKey,
|
||
|
|
formattedName,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export const HeadingButton = React.forwardRef<
|
||
|
|
HTMLButtonElement,
|
||
|
|
HeadingButtonProps
|
||
|
|
>(
|
||
|
|
(
|
||
|
|
{
|
||
|
|
editor: providedEditor,
|
||
|
|
level,
|
||
|
|
text,
|
||
|
|
hideWhenUnavailable = false,
|
||
|
|
className = "",
|
||
|
|
disabled,
|
||
|
|
onClick,
|
||
|
|
children,
|
||
|
|
...buttonProps
|
||
|
|
},
|
||
|
|
ref
|
||
|
|
) => {
|
||
|
|
const editor = useTiptapEditor(providedEditor)
|
||
|
|
|
||
|
|
const {
|
||
|
|
headingInSchema,
|
||
|
|
isDisabled,
|
||
|
|
isActive,
|
||
|
|
Icon,
|
||
|
|
shortcutKey,
|
||
|
|
formattedName,
|
||
|
|
} = useHeadingState(editor, level, disabled)
|
||
|
|
|
||
|
|
const handleClick = React.useCallback(
|
||
|
|
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||
|
|
onClick?.(e)
|
||
|
|
|
||
|
|
if (!e.defaultPrevented && !isDisabled && editor) {
|
||
|
|
toggleHeading(editor, level)
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[onClick, isDisabled, editor, level]
|
||
|
|
)
|
||
|
|
|
||
|
|
const show = React.useMemo(() => {
|
||
|
|
return shouldShowHeadingButton({
|
||
|
|
editor,
|
||
|
|
level,
|
||
|
|
hideWhenUnavailable,
|
||
|
|
headingInSchema,
|
||
|
|
})
|
||
|
|
}, [editor, level, hideWhenUnavailable, headingInSchema])
|
||
|
|
|
||
|
|
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={formattedName}
|
||
|
|
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>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
HeadingButton.displayName = "HeadingButton"
|
||
|
|
|
||
|
|
export default HeadingButton
|