Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
ListDropdownMenu,
BlockquoteButton,
CodeBlockButton,
HorizontalRuleButton,
LinkPopover,
} from "@/components/ui/tiptap-ui";

Expand Down Expand Up @@ -69,16 +70,18 @@ export const SimpleToolbar: React.FC<SimpleToolbarProps> = ({ containerRef }) =>
<ListDropdownMenu types={["bulletList", "orderedList", "taskList"]} />
<BlockquoteButton />
<CodeBlockButton />
<HorizontalRuleButton />
</ToolbarGroup>

<ToolbarSeparator />

<ToolbarGroup>
<MarkButton type="bold" />
<MarkButton type="italic" />
<MarkButton type="underline" />
<MarkButton type="strike" />
<MarkButton type="code" />
<MarkButton type="underline" />
<MarkButton type="highlight" />
<LinkPopover hideWhenUnavailable={false} containerRef={containerRef} />
</ToolbarGroup>

Expand Down
26 changes: 26 additions & 0 deletions src/components/ui/tiptap-icons/highlighter-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as React from "react"

export const HighlighterIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.7072 4.70711C15.0977 4.31658 15.0977 3.68342 14.7072 3.29289C14.3167 2.90237 13.6835 2.90237 13.293 3.29289L8.69294 7.89286L8.68594 7.9C8.13626 8.46079 7.82837 9.21474 7.82837 10C7.82837 10.2306 7.85491 10.4584 7.90631 10.6795L2.29289 16.2929C2.10536 16.4804 2 16.7348 2 17V20C2 20.5523 2.44772 21 3 21H12C12.2652 21 12.5196 20.8946 12.7071 20.7071L15.3205 18.0937C15.5416 18.1452 15.7695 18.1717 16.0001 18.1717C16.7853 18.1717 17.5393 17.8639 18.1001 17.3142L22.7072 12.7071C23.0977 12.3166 23.0977 11.6834 22.7072 11.2929C22.3167 10.9024 21.6835 10.9024 21.293 11.2929L16.6971 15.8887C16.5105 16.0702 16.2605 16.1717 16.0001 16.1717C15.7397 16.1717 15.4897 16.0702 15.303 15.8887L10.1113 10.697C9.92992 10.5104 9.82837 10.2604 9.82837 10C9.82837 9.73963 9.92992 9.48958 10.1113 9.30297L14.7072 4.70711ZM13.5858 17L9.00004 12.4142L4 17.4142V19H11.5858L13.5858 17Z"
fill="currentColor"
/>
</svg>
)
}
)

HighlighterIcon.displayName = "HighlighterIcon"
26 changes: 26 additions & 0 deletions src/components/ui/tiptap-icons/horizontal-rule-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as React from "react"

export const HorizontalRuleIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3 12C3 11.4477 3.44772 11 4 11H20C20.5523 11 21 11.4477 21 12C21 12.5523 20.5523 13 20 13H4C3.44772 13 3 12.5523 3 12Z"
fill="currentColor"
/>
</svg>
)
}
)

HorizontalRuleIcon.displayName = "HorizontalRuleIcon"
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import React, { useMemo, useCallback } from "react"
import { type Editor, useEditorState } from "@tiptap/react"

// --- Hooks ---
import { useTiptapEditor } from "@/lib/hooks/use-tiptap-editor"

// --- Icons ---
import { HorizontalRuleIcon } from "@/components/ui/tiptap-icons/horizontal-rule-icon"

// --- Lib ---
import { isNodeInSchema } from "@/lib/tiptap-utils"

// --- UI Primitives ---
import type { ButtonProps } from "@/components/ui/tiptap-ui-primitive/button"
import { Button } from "@/components/ui/tiptap-ui-primitive/button"

export interface HorizontalRuleButtonProps extends Omit<ButtonProps, "type"> {
/**
* The TipTap editor instance.
*/
editor?: Editor | null
/**
* Optional text to display alongside the icon.
*/
text?: string
/**
* Whether the button should hide when the node is not available.
* @default false
*/
hideWhenUnavailable?: boolean
}

export function canInsertHorizontalRule(editor: Editor | null): boolean {
if (!editor) return false

try {
return editor.can().setHorizontalRule()
} catch {
return false
}
}

export function insertHorizontalRule(editor: Editor | null): boolean {
if (!editor) return false
return editor.chain().focus().setHorizontalRule().run()
}

export function isHorizontalRuleButtonDisabled(
editor: Editor | null,
canInsert: boolean,
userDisabled: boolean = false
): boolean {
if (!editor) return true
if (userDisabled) return true
if (!canInsert) return true
return false
}

export function shouldShowHorizontalRuleButton(params: {
editor: Editor | null
hideWhenUnavailable: boolean
nodeInSchema: boolean
canInsert: boolean
}): boolean {
const { editor, hideWhenUnavailable, nodeInSchema, canInsert } = params

if (!nodeInSchema || !editor) {
return false
}

if (hideWhenUnavailable && !canInsert) {
return false
}

return Boolean(editor?.isEditable)
}

export function useHorizontalRuleState(
editor: Editor | null,
disabled: boolean = false,
hideWhenUnavailable: boolean = false
) {
const nodeInSchema = useMemo(
() => isNodeInSchema("horizontalRule", editor),
[editor]
)

// Use useEditorState to reactively track editor state changes
const editorState = useEditorState({
editor,
selector: useCallback((ctx: { editor: Editor | null }) => {
if (!ctx.editor) return { canInsert: false };
return {
canInsert: ctx.editor.can().setHorizontalRule(),
};
}, []),
});

const canInsert = editorState?.canInsert ?? false;
const isDisabled = isHorizontalRuleButtonDisabled(editor, canInsert, disabled)

const shouldShow = useMemo(
() =>
shouldShowHorizontalRuleButton({
editor,
hideWhenUnavailable,
nodeInSchema,
canInsert,
}),
[editor, hideWhenUnavailable, nodeInSchema, canInsert]
)

const handleInsert = useCallback(() => {
if (!isDisabled && editor) {
return insertHorizontalRule(editor)
}
return false
}, [editor, isDisabled])

const shortcutKey = "Ctrl-Shift-minus"
const label = "Horizontal Rule"

return {
nodeInSchema,
canInsert,
isDisabled,
shouldShow,
handleInsert,
shortcutKey,
label,
}
}

export const HorizontalRuleButton = React.forwardRef<
HTMLButtonElement,
HorizontalRuleButtonProps
>(
(
{
editor: providedEditor,
text,
hideWhenUnavailable = false,
className = "",
disabled,
onClick,
children,
...buttonProps
},
ref
) => {
const editor = useTiptapEditor(providedEditor)

const {
isDisabled,
shouldShow,
handleInsert,
shortcutKey,
label,
} = useHorizontalRuleState(editor, disabled, hideWhenUnavailable)

const handleClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(e)

if (!e.defaultPrevented && !isDisabled) {
handleInsert()
}
},
[onClick, isDisabled, handleInsert]
)

if (!shouldShow || !editor || !editor.isEditable) {
return null
}

return (
<Button
type="button"
className={className.trim()}
disabled={isDisabled}
data-style="ghost"
data-active-state="off"
data-disabled={isDisabled}
role="button"
tabIndex={-1}
aria-label="horizontal rule"
tooltip={label}
shortcutKeys={shortcutKey}
onClick={handleClick}
{...buttonProps}
ref={ref}
>
{children || (
<>
<HorizontalRuleIcon className="tiptap-button-icon" />
{text && <span className="tiptap-button-text">{text}</span>}
</>
)}
</Button>
)
}
)

HorizontalRuleButton.displayName = "HorizontalRuleButton"

export default HorizontalRuleButton
2 changes: 2 additions & 0 deletions src/components/ui/tiptap-ui/horizontal-rule-button/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { HorizontalRuleButton } from "./horizontal-rule-button"
export type { HorizontalRuleButtonProps } from "./horizontal-rule-button"
1 change: 1 addition & 0 deletions src/components/ui/tiptap-ui/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export { HeadingDropdownMenu } from "./heading-dropdown-menu"
export { ListDropdownMenu } from "./list-dropdown-menu"
export { BlockquoteButton } from "./blockquote-button"
export { CodeBlockButton } from "./code-block-button"
export { HorizontalRuleButton } from "./horizontal-rule-button"
export { LinkPopover } from "./link-popover"
4 changes: 4 additions & 0 deletions src/components/ui/tiptap-ui/mark-button/mark-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Code2Icon } from "@/components/ui/tiptap-icons/code2-icon"
import { ItalicIcon } from "@/components/ui/tiptap-icons/italic-icon"
import { StrikeIcon } from "@/components/ui/tiptap-icons/strike-icon"
import { UnderlineIcon } from "@/components/ui/tiptap-icons/underline-icon"
import { HighlighterIcon } from "@/components/ui/tiptap-icons/highlighter-icon"

// --- Lib ---
import { isMarkInSchema } from "@/lib/tiptap-utils"
Expand All @@ -24,6 +25,7 @@ export type Mark =
| "strike"
| "code"
| "underline"
| "highlight"

export interface MarkButtonProps extends Omit<ButtonProps, "type"> {
/**
Expand All @@ -50,6 +52,7 @@ export const markIcons = {
underline: UnderlineIcon,
strike: StrikeIcon,
code: Code2Icon,
highlight: HighlighterIcon,
}

export const markShortcutKeys: Partial<Record<Mark, string>> = {
Expand All @@ -58,6 +61,7 @@ export const markShortcutKeys: Partial<Record<Mark, string>> = {
underline: "Ctrl-u",
strike: "Ctrl-Shift-s",
code: "Ctrl-e",
highlight: "Ctrl-Shift-h",
}

export function canToggleMark(editor: Editor | null, type: Mark): boolean {
Expand Down