-
-
Notifications
You must be signed in to change notification settings - Fork 38
feat(cms): migrate editor to tiptap v3 #237
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 41 commits
a4b016a
30f2707
04cf73c
b14f333
0ea7454
1010b16
117c165
2d673db
2a26339
fb9b6c7
b3bdb61
2facbfe
bc0766e
7c67be1
12086f6
c88a983
fea5d6b
e9764ba
a51674d
e4cece1
5514d48
13d9910
4c119a5
eb59936
db0bdeb
02d8d04
39e0fd4
4c6cfb8
5e0cb4b
56c344e
cf93597
1f520ab
7575c1f
c48c2f3
6673eae
2da98ef
668c7f7
b44e9ce
be83391
a4e77cc
e3539f6
ba0ce0e
d6c90f7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,88 @@ | ||
| import { EditorBubble } from "novel"; | ||
| "use client"; | ||
|
|
||
| import { useCurrentEditor } from "@tiptap/react"; | ||
| import { BubbleMenu as TiptapBubbleMenu } from "@tiptap/react/menus"; | ||
| import { memo, useCallback, useRef } from "react"; | ||
| import { FloatingPortalProvider } from "@/components/editor/floating-portal-context"; | ||
| import { isColumnGripSelected } from "./extensions/table/menus/TableColumn/utils"; | ||
| import { isRowGripSelected } from "./extensions/table/menus/TableRow/utils"; | ||
| import { LinkSelector } from "./link-selector"; | ||
| import { TextButtons } from "./text-buttons"; | ||
|
|
||
| export function BubbleMenu() { | ||
| function BubbleMenuComponent() { | ||
| const { editor } = useCurrentEditor(); | ||
| const containerRef = useRef<HTMLDivElement>(null); | ||
|
|
||
| const shouldShow = useCallback( | ||
| ({ | ||
| view, | ||
| state, | ||
| from, | ||
| }: { | ||
| view: unknown; | ||
| state: unknown; | ||
| from: number; | ||
| }) => { | ||
| if (!editor || !state) { | ||
| return false; | ||
| } | ||
|
|
||
| // Hide bubble menu if image, imageUpload, youtube, or youtubeUpload is selected | ||
| if ( | ||
| editor.isActive("figure") || | ||
| editor.isActive("image") || | ||
| editor.isActive("imageUpload") || | ||
| editor.isActive("youtube") || | ||
| editor.isActive("youtubeUpload") | ||
| ) { | ||
| return false; | ||
| } | ||
|
|
||
| // Hide bubble menu if table row or column grip is selected | ||
| const isRowGrip = isRowGripSelected({ | ||
| editor, | ||
| view, | ||
| state, | ||
| from, | ||
| } as Parameters<typeof isRowGripSelected>[0]); | ||
|
|
||
| const isColumnGrip = isColumnGripSelected({ | ||
| editor, | ||
| view, | ||
| state, | ||
| from: from || 0, | ||
| } as Parameters<typeof isColumnGripSelected>[0]); | ||
|
|
||
| if (isRowGrip || isColumnGrip) { | ||
| return false; | ||
| } | ||
|
|
||
| // Show for normal text selection | ||
| return !editor.state.selection.empty; | ||
| }, | ||
| [editor] | ||
| ); | ||
|
|
||
| if (!editor) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <EditorBubble className="flex h-fit w-fit overflow-hidden rounded-md border bg-background p-1 shadow-sm"> | ||
| <TextButtons /> | ||
| <LinkSelector /> | ||
| </EditorBubble> | ||
| <div className="contents" ref={containerRef}> | ||
| <FloatingPortalProvider container={containerRef.current}> | ||
| <TiptapBubbleMenu | ||
| appendTo={() => document.body} | ||
| className="z-50 flex h-fit w-fit gap-0.5 overflow-hidden rounded-lg border bg-background p-1 shadow-sm" | ||
| editor={editor} | ||
| shouldShow={shouldShow} | ||
| > | ||
| <TextButtons /> | ||
| <LinkSelector /> | ||
| </TiptapBubbleMenu> | ||
| </FloatingPortalProvider> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| // Memoize to prevent context cascade rerenders | ||
| export const BubbleMenu = memo(BubbleMenuComponent); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,89 @@ | ||||||||||||||||||||||||||||||||||||||||||||
| import { Button } from "@marble/ui/components/button"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { Input } from "@marble/ui/components/input"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { ArrowCounterClockwiseIcon } from "@phosphor-icons/react"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { useCallback, useEffect, useState } from "react"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { HexColorPicker } from "react-colorful"; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| const PRESET_COLORS = [ | ||||||||||||||||||||||||||||||||||||||||||||
| "#fb7185", // Rose | ||||||||||||||||||||||||||||||||||||||||||||
| "#fdba74", // Orange | ||||||||||||||||||||||||||||||||||||||||||||
| "#d9f99d", // Lime | ||||||||||||||||||||||||||||||||||||||||||||
| "#a7f3d0", // Emerald | ||||||||||||||||||||||||||||||||||||||||||||
| "#a5f3fc", // Cyan | ||||||||||||||||||||||||||||||||||||||||||||
| "#a5b4fc", // Indigo | ||||||||||||||||||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| export const ColorPicker = ({ | ||||||||||||||||||||||||||||||||||||||||||||
| color, | ||||||||||||||||||||||||||||||||||||||||||||
| onChange, | ||||||||||||||||||||||||||||||||||||||||||||
| onClear, | ||||||||||||||||||||||||||||||||||||||||||||
| }: { | ||||||||||||||||||||||||||||||||||||||||||||
| color?: string; | ||||||||||||||||||||||||||||||||||||||||||||
| onChange: (color: string) => void; | ||||||||||||||||||||||||||||||||||||||||||||
| onClear: () => void; | ||||||||||||||||||||||||||||||||||||||||||||
| }) => { | ||||||||||||||||||||||||||||||||||||||||||||
| const [hexInput, setHexInput] = useState(color || ""); | ||||||||||||||||||||||||||||||||||||||||||||
YungKingJayy marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||||
| setHexInput(color || ""); | ||||||||||||||||||||||||||||||||||||||||||||
| }, [color]); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| const handleHexInputChange = useCallback( | ||||||||||||||||||||||||||||||||||||||||||||
| (e: React.ChangeEvent<HTMLInputElement>) => { | ||||||||||||||||||||||||||||||||||||||||||||
| const value = e.target.value; | ||||||||||||||||||||||||||||||||||||||||||||
| setHexInput(value); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // Validate hex color format | ||||||||||||||||||||||||||||||||||||||||||||
| if (/^#[0-9A-Fa-f]{6}$/.test(value)) { | ||||||||||||||||||||||||||||||||||||||||||||
| onChange(value); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||
| [onChange] | ||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| const handleColorChange = useCallback( | ||||||||||||||||||||||||||||||||||||||||||||
| (newColor: string) => { | ||||||||||||||||||||||||||||||||||||||||||||
| setHexInput(newColor); | ||||||||||||||||||||||||||||||||||||||||||||
| onChange(newColor); | ||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||
| [onChange] | ||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||||||||||
| <div className="flex flex-col gap-3 p-3"> | ||||||||||||||||||||||||||||||||||||||||||||
| <HexColorPicker color={color || "#000000"} onChange={handleColorChange} /> | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| <div className="flex items-center gap-2"> | ||||||||||||||||||||||||||||||||||||||||||||
| <Input | ||||||||||||||||||||||||||||||||||||||||||||
| className="h-8 font-mono text-xs" | ||||||||||||||||||||||||||||||||||||||||||||
| onChange={handleHexInputChange} | ||||||||||||||||||||||||||||||||||||||||||||
| placeholder="#000000" | ||||||||||||||||||||||||||||||||||||||||||||
| value={hexInput} | ||||||||||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+56
to
+63
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add accessible name for the hex input. Input lacks an associated label. Provide an aria-label to satisfy a11y guideline. - <Input
+ <Input
className="h-8 font-mono text-xs"
onChange={handleHexInputChange}
placeholder="#000000"
value={hexInput}
+ aria-label="Hex color"
/>📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| <div className="flex items-center gap-2"> | ||||||||||||||||||||||||||||||||||||||||||||
| {PRESET_COLORS.map((presetColor) => ( | ||||||||||||||||||||||||||||||||||||||||||||
| <button | ||||||||||||||||||||||||||||||||||||||||||||
| className="size-6 rounded border border-border transition-transform hover:scale-110" | ||||||||||||||||||||||||||||||||||||||||||||
| key={presetColor} | ||||||||||||||||||||||||||||||||||||||||||||
| onClick={() => handleColorChange(presetColor)} | ||||||||||||||||||||||||||||||||||||||||||||
| style={{ backgroundColor: presetColor }} | ||||||||||||||||||||||||||||||||||||||||||||
| title={presetColor} | ||||||||||||||||||||||||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||||||||||
| ))} | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+66
to
+75
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Icon-only preset buttons need accessible labels. Title isn’t a reliable accessible name. Add aria-label; keep type="button". <button
className="size-6 rounded border border-border transition-transform hover:scale-110"
key={presetColor}
onClick={() => handleColorChange(presetColor)}
style={{ backgroundColor: presetColor }}
- title={presetColor}
+ title={presetColor}
+ aria-label={`Set color ${presetColor}`}
type="button"
/>📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||
| <Button | ||||||||||||||||||||||||||||||||||||||||||||
| className="size-8 shrink-0" | ||||||||||||||||||||||||||||||||||||||||||||
| onClick={onClear} | ||||||||||||||||||||||||||||||||||||||||||||
| size="icon" | ||||||||||||||||||||||||||||||||||||||||||||
| title="Reset color" | ||||||||||||||||||||||||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||||||||||||||||||||||||
| variant="ghost" | ||||||||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||||||||
| <ArrowCounterClockwiseIcon className="size-4" /> | ||||||||||||||||||||||||||||||||||||||||||||
| </Button> | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+76
to
+85
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add aria-label to reset button and hide decorative SVG from AT. Provide an explicit accessible name and mark the icon as decorative. - <Button
+ <Button
className="size-8 shrink-0"
onClick={onClear}
size="icon"
- title="Reset color"
+ title="Reset color"
+ aria-label="Reset color"
type="button"
variant="ghost"
>
- <ArrowCounterClockwiseIcon className="size-4" />
+ <ArrowCounterClockwiseIcon className="size-4" aria-hidden="true" focusable="false" />
</Button>📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.