Skip to content

Commit 21e983c

Browse files
authored
Merge pull request #191 from refactor-group/179-bug-tiptap-toolbar-is-missing-highlight-text-and-hr-buttons
Fix missing highlight and horizontal rule buttons in TipTap toolbar
2 parents 56ce1ab + 9202700 commit 21e983c

File tree

7 files changed

+269
-1
lines changed

7 files changed

+269
-1
lines changed

src/components/ui/coaching-sessions/coaching-notes/simple-toolbar.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
ListDropdownMenu,
1818
BlockquoteButton,
1919
CodeBlockButton,
20+
HorizontalRuleButton,
2021
LinkPopover,
2122
} from "@/components/ui/tiptap-ui";
2223

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

7476
<ToolbarSeparator />
7577

7678
<ToolbarGroup>
7779
<MarkButton type="bold" />
7880
<MarkButton type="italic" />
81+
<MarkButton type="underline" />
7982
<MarkButton type="strike" />
8083
<MarkButton type="code" />
81-
<MarkButton type="underline" />
84+
<MarkButton type="highlight" />
8285
<LinkPopover hideWhenUnavailable={false} containerRef={containerRef} />
8386
</ToolbarGroup>
8487

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import * as React from "react"
2+
3+
export const HighlighterIcon = React.memo(
4+
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
5+
return (
6+
<svg
7+
width="24"
8+
height="24"
9+
className={className}
10+
viewBox="0 0 24 24"
11+
fill="currentColor"
12+
xmlns="http://www.w3.org/2000/svg"
13+
{...props}
14+
>
15+
<path
16+
fillRule="evenodd"
17+
clipRule="evenodd"
18+
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"
19+
fill="currentColor"
20+
/>
21+
</svg>
22+
)
23+
}
24+
)
25+
26+
HighlighterIcon.displayName = "HighlighterIcon"
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import * as React from "react"
2+
3+
export const HorizontalRuleIcon = React.memo(
4+
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
5+
return (
6+
<svg
7+
width="24"
8+
height="24"
9+
className={className}
10+
viewBox="0 0 24 24"
11+
fill="currentColor"
12+
xmlns="http://www.w3.org/2000/svg"
13+
{...props}
14+
>
15+
<path
16+
fillRule="evenodd"
17+
clipRule="evenodd"
18+
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"
19+
fill="currentColor"
20+
/>
21+
</svg>
22+
)
23+
}
24+
)
25+
26+
HorizontalRuleIcon.displayName = "HorizontalRuleIcon"
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import React, { useMemo, useCallback } from "react"
2+
import { type Editor, useEditorState } from "@tiptap/react"
3+
4+
// --- Hooks ---
5+
import { useTiptapEditor } from "@/lib/hooks/use-tiptap-editor"
6+
7+
// --- Icons ---
8+
import { HorizontalRuleIcon } from "@/components/ui/tiptap-icons/horizontal-rule-icon"
9+
10+
// --- Lib ---
11+
import { isNodeInSchema } from "@/lib/tiptap-utils"
12+
13+
// --- UI Primitives ---
14+
import type { ButtonProps } from "@/components/ui/tiptap-ui-primitive/button"
15+
import { Button } from "@/components/ui/tiptap-ui-primitive/button"
16+
17+
export interface HorizontalRuleButtonProps extends Omit<ButtonProps, "type"> {
18+
/**
19+
* The TipTap editor instance.
20+
*/
21+
editor?: Editor | null
22+
/**
23+
* Optional text to display alongside the icon.
24+
*/
25+
text?: string
26+
/**
27+
* Whether the button should hide when the node is not available.
28+
* @default false
29+
*/
30+
hideWhenUnavailable?: boolean
31+
}
32+
33+
export function canInsertHorizontalRule(editor: Editor | null): boolean {
34+
if (!editor) return false
35+
36+
try {
37+
return editor.can().setHorizontalRule()
38+
} catch {
39+
return false
40+
}
41+
}
42+
43+
export function insertHorizontalRule(editor: Editor | null): boolean {
44+
if (!editor) return false
45+
return editor.chain().focus().setHorizontalRule().run()
46+
}
47+
48+
export function isHorizontalRuleButtonDisabled(
49+
editor: Editor | null,
50+
canInsert: boolean,
51+
userDisabled: boolean = false
52+
): boolean {
53+
if (!editor) return true
54+
if (userDisabled) return true
55+
if (!canInsert) return true
56+
return false
57+
}
58+
59+
export function shouldShowHorizontalRuleButton(params: {
60+
editor: Editor | null
61+
hideWhenUnavailable: boolean
62+
nodeInSchema: boolean
63+
canInsert: boolean
64+
}): boolean {
65+
const { editor, hideWhenUnavailable, nodeInSchema, canInsert } = params
66+
67+
if (!nodeInSchema || !editor) {
68+
return false
69+
}
70+
71+
if (hideWhenUnavailable && !canInsert) {
72+
return false
73+
}
74+
75+
return Boolean(editor?.isEditable)
76+
}
77+
78+
export function useHorizontalRuleState(
79+
editor: Editor | null,
80+
disabled: boolean = false,
81+
hideWhenUnavailable: boolean = false
82+
) {
83+
const nodeInSchema = useMemo(
84+
() => isNodeInSchema("horizontalRule", editor),
85+
[editor]
86+
)
87+
88+
// Use useEditorState to reactively track editor state changes
89+
const editorState = useEditorState({
90+
editor,
91+
selector: useCallback((ctx: { editor: Editor | null }) => {
92+
if (!ctx.editor) return { canInsert: false };
93+
return {
94+
canInsert: ctx.editor.can().setHorizontalRule(),
95+
};
96+
}, []),
97+
});
98+
99+
const canInsert = editorState?.canInsert ?? false;
100+
const isDisabled = isHorizontalRuleButtonDisabled(editor, canInsert, disabled)
101+
102+
const shouldShow = useMemo(
103+
() =>
104+
shouldShowHorizontalRuleButton({
105+
editor,
106+
hideWhenUnavailable,
107+
nodeInSchema,
108+
canInsert,
109+
}),
110+
[editor, hideWhenUnavailable, nodeInSchema, canInsert]
111+
)
112+
113+
const handleInsert = useCallback(() => {
114+
if (!isDisabled && editor) {
115+
return insertHorizontalRule(editor)
116+
}
117+
return false
118+
}, [editor, isDisabled])
119+
120+
const shortcutKey = "Ctrl-Shift-minus"
121+
const label = "Horizontal Rule"
122+
123+
return {
124+
nodeInSchema,
125+
canInsert,
126+
isDisabled,
127+
shouldShow,
128+
handleInsert,
129+
shortcutKey,
130+
label,
131+
}
132+
}
133+
134+
export const HorizontalRuleButton = React.forwardRef<
135+
HTMLButtonElement,
136+
HorizontalRuleButtonProps
137+
>(
138+
(
139+
{
140+
editor: providedEditor,
141+
text,
142+
hideWhenUnavailable = false,
143+
className = "",
144+
disabled,
145+
onClick,
146+
children,
147+
...buttonProps
148+
},
149+
ref
150+
) => {
151+
const editor = useTiptapEditor(providedEditor)
152+
153+
const {
154+
isDisabled,
155+
shouldShow,
156+
handleInsert,
157+
shortcutKey,
158+
label,
159+
} = useHorizontalRuleState(editor, disabled, hideWhenUnavailable)
160+
161+
const handleClick = useCallback(
162+
(e: React.MouseEvent<HTMLButtonElement>) => {
163+
onClick?.(e)
164+
165+
if (!e.defaultPrevented && !isDisabled) {
166+
handleInsert()
167+
}
168+
},
169+
[onClick, isDisabled, handleInsert]
170+
)
171+
172+
if (!shouldShow || !editor || !editor.isEditable) {
173+
return null
174+
}
175+
176+
return (
177+
<Button
178+
type="button"
179+
className={className.trim()}
180+
disabled={isDisabled}
181+
data-style="ghost"
182+
data-active-state="off"
183+
data-disabled={isDisabled}
184+
role="button"
185+
tabIndex={-1}
186+
aria-label="horizontal rule"
187+
tooltip={label}
188+
shortcutKeys={shortcutKey}
189+
onClick={handleClick}
190+
{...buttonProps}
191+
ref={ref}
192+
>
193+
{children || (
194+
<>
195+
<HorizontalRuleIcon className="tiptap-button-icon" />
196+
{text && <span className="tiptap-button-text">{text}</span>}
197+
</>
198+
)}
199+
</Button>
200+
)
201+
}
202+
)
203+
204+
HorizontalRuleButton.displayName = "HorizontalRuleButton"
205+
206+
export default HorizontalRuleButton
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { HorizontalRuleButton } from "./horizontal-rule-button"
2+
export type { HorizontalRuleButtonProps } from "./horizontal-rule-button"

src/components/ui/tiptap-ui/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export { HeadingDropdownMenu } from "./heading-dropdown-menu"
44
export { ListDropdownMenu } from "./list-dropdown-menu"
55
export { BlockquoteButton } from "./blockquote-button"
66
export { CodeBlockButton } from "./code-block-button"
7+
export { HorizontalRuleButton } from "./horizontal-rule-button"
78
export { LinkPopover } from "./link-popover"

src/components/ui/tiptap-ui/mark-button/mark-button.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Code2Icon } from "@/components/ui/tiptap-icons/code2-icon"
1010
import { ItalicIcon } from "@/components/ui/tiptap-icons/italic-icon"
1111
import { StrikeIcon } from "@/components/ui/tiptap-icons/strike-icon"
1212
import { UnderlineIcon } from "@/components/ui/tiptap-icons/underline-icon"
13+
import { HighlighterIcon } from "@/components/ui/tiptap-icons/highlighter-icon"
1314

1415
// --- Lib ---
1516
import { isMarkInSchema } from "@/lib/tiptap-utils"
@@ -24,6 +25,7 @@ export type Mark =
2425
| "strike"
2526
| "code"
2627
| "underline"
28+
| "highlight"
2729

2830
export interface MarkButtonProps extends Omit<ButtonProps, "type"> {
2931
/**
@@ -50,6 +52,7 @@ export const markIcons = {
5052
underline: UnderlineIcon,
5153
strike: StrikeIcon,
5254
code: Code2Icon,
55+
highlight: HighlighterIcon,
5356
}
5457

5558
export const markShortcutKeys: Partial<Record<Mark, string>> = {
@@ -58,6 +61,7 @@ export const markShortcutKeys: Partial<Record<Mark, string>> = {
5861
underline: "Ctrl-u",
5962
strike: "Ctrl-Shift-s",
6063
code: "Ctrl-e",
64+
highlight: "Ctrl-Shift-h",
6165
}
6266

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

0 commit comments

Comments
 (0)