diff --git a/apps/cms/package.json b/apps/cms/package.json index 9f5b2c89..c6b9cb24 100644 --- a/apps/cms/package.json +++ b/apps/cms/package.json @@ -16,6 +16,7 @@ "@better-fetch/fetch": "^1.1.18", "@databuddy/sdk": "^2.1.75", "@discordjs/builders": "^1.11.3", + "@floating-ui/dom": "^1.6.11", "@hookform/resolvers": "^5.2.0", "@marble/db": "workspace:*", "@marble/parser": "workspace:*", @@ -28,6 +29,7 @@ "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.1.4", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", @@ -40,10 +42,34 @@ "@tanstack/react-query-devtools": "^5.85.5", "@tanstack/react-table": "^8.20.5", "@tanstack/react-virtual": "^3.13.12", - "@tiptap/core": "^2.11.2", - "@tiptap/extension-text-align": "^2.11.2", - "@tiptap/extension-underline": "^2.11.2", - "@tiptap/react": "^2.11.2", + "@tiptap/core": "^3.7.2", + "@tiptap/extension-bubble-menu": "^3.7.2", + "@tiptap/extension-code-block": "^3.7.1", + "@tiptap/extension-code-block-lowlight": "^3.7.1", + "@tiptap/extension-collaboration": "^3.7.2", + "@tiptap/extension-color": "^3.7.2", + "@tiptap/extension-document": "^3.8.0", + "@tiptap/extension-drag-handle": "^3.7.2", + "@tiptap/extension-drag-handle-react": "^3.7.2", + "@tiptap/extension-file-handler": "^3.8.0", + "@tiptap/extension-highlight": "^3.7.2", + "@tiptap/extension-horizontal-rule": "^3.7.1", + "@tiptap/extension-image": "^3.7.1", + "@tiptap/extension-list": "^3.7.1", + "@tiptap/extension-node-range": "^3.7.2", + "@tiptap/extension-subscript": "^3.7.2", + "@tiptap/extension-superscript": "^3.7.2", + "@tiptap/extension-table": "^3.7.2", + "@tiptap/extension-text-align": "^3.7.1", + "@tiptap/extension-text-style": "^3.7.2", + "@tiptap/extension-underline": "^3.7.1", + "@tiptap/extension-youtube": "^3.7.1", + "@tiptap/extensions": "^3.7.1", + "@tiptap/markdown": "^3.9.0", + "@tiptap/pm": "^3.7.2", + "@tiptap/react": "^3.7.2", + "@tiptap/starter-kit": "^3.7.1", + "@tiptap/suggestion": "^3.7.2", "@upstash/qstash": "^2.8.2", "@upstash/ratelimit": "^2.0.5", "@upstash/redis": "^1.35.3", @@ -64,9 +90,9 @@ "next": "16.0.0", "next-themes": "^0.4.4", "node-html-markdown": "^1.3.0", - "novel": "^1.0.2", "nuqs": "^2.6.0", "react": "^19.2.0", + "react-colorful": "^5.6.1", "react-dom": "^19.2.0", "react-dropzone": "^14.3.8", "react-hook-form": "^7.61.1", @@ -80,6 +106,8 @@ "sonner": "^1.7.1", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2", + "y-protocols": "^1.0.6", + "yjs": "^13.6.27", "zod": "^3.25.76" }, "devDependencies": { diff --git a/apps/cms/src/components/editor/ai/readability-suggestions.tsx b/apps/cms/src/components/editor/ai/readability-suggestions.tsx index 939e02c7..2265fd34 100644 --- a/apps/cms/src/components/editor/ai/readability-suggestions.tsx +++ b/apps/cms/src/components/editor/ai/readability-suggestions.tsx @@ -2,7 +2,7 @@ import { cn } from "@marble/ui/lib/utils"; import { CursorClickIcon } from "@phosphor-icons/react"; -import type { EditorInstance } from "novel"; +import type { Editor } from "@tiptap/core"; import React from "react"; export type ReadabilitySuggestion = { @@ -12,13 +12,13 @@ export type ReadabilitySuggestion = { }; type ReadabilitySuggestionsProps = { - editor?: EditorInstance | null; + editor?: Editor | null; suggestions: ReadabilitySuggestion[]; isLoading?: boolean; onRefresh?: () => void; }; -function highlightTextInEditor(editor: EditorInstance, textReference: string) { +function highlightTextInEditor(editor: Editor, textReference: string) { const trimmed = textReference.trim(); if (!trimmed) { return; diff --git a/apps/cms/src/components/editor/bubble-menu.tsx b/apps/cms/src/components/editor/bubble-menu.tsx index 34d452b8..afa2f348 100644 --- a/apps/cms/src/components/editor/bubble-menu.tsx +++ b/apps/cms/src/components/editor/bubble-menu.tsx @@ -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(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[0]); + + const isColumnGrip = isColumnGripSelected({ + editor, + view, + state, + from: from || 0, + } as Parameters[0]); + + if (isRowGrip || isColumnGrip) { + return false; + } + + // Show for normal text selection + return !editor.state.selection.empty; + }, + [editor] + ); + + if (!editor) { + return null; + } + return ( - - - - +
+ + 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} + > + + + + +
); } + +// Memoize to prevent context cascade rerenders +export const BubbleMenu = memo(BubbleMenuComponent); diff --git a/apps/cms/src/components/editor/color-picker.tsx b/apps/cms/src/components/editor/color-picker.tsx new file mode 100644 index 00000000..ff36bc80 --- /dev/null +++ b/apps/cms/src/components/editor/color-picker.tsx @@ -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 || ""); + + useEffect(() => { + setHexInput(color || ""); + }, [color]); + + const handleHexInputChange = useCallback( + (e: React.ChangeEvent) => { + 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 ( +
+ + +
+ +
+ +
+ {PRESET_COLORS.map((presetColor) => ( + +
+
+ ); +}; diff --git a/apps/cms/src/components/editor/content-type-picker.tsx b/apps/cms/src/components/editor/content-type-picker.tsx new file mode 100644 index 00000000..cb9ee032 --- /dev/null +++ b/apps/cms/src/components/editor/content-type-picker.tsx @@ -0,0 +1,226 @@ +"use client"; + +import { Button } from "@marble/ui/components/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@marble/ui/components/dropdown-menu"; +import { cn } from "@marble/ui/lib/utils"; +import type { Editor } from "@tiptap/core"; +import { useCurrentEditor, useEditorState } from "@tiptap/react"; +import { + ChevronDown, + Heading1, + Heading2, + Heading3, + List, + ListOrdered, + ListTodo, + Pilcrow, +} from "lucide-react"; +import type { FC } from "react"; +import { useFloatingPortalContainer } from "./floating-portal-context"; + +type ContentTypeOption = { + label: string; + id: string; + type: "option"; + disabled: (editor: Editor) => boolean; + isActive: (editor: Editor) => boolean; + onClick: (editor: Editor) => void; + icon: typeof Pilcrow; +}; + +type ContentTypeCategory = { + label: string; + id: string; + type: "category"; +}; + +type ContentPickerOption = ContentTypeOption | ContentTypeCategory; + +const CONTENT_TYPES: ContentPickerOption[] = [ + { + type: "category", + label: "Hierarchy", + id: "hierarchy", + }, + { + icon: Pilcrow, + onClick: (editor) => + editor + .chain() + .focus() + .lift("taskItem") + .liftListItem("listItem") + .setParagraph() + .run(), + id: "paragraph", + disabled: (editor) => !editor.can().setParagraph(), + isActive: (editor) => + editor.isActive("paragraph") && + !editor.isActive("orderedList") && + !editor.isActive("bulletList") && + !editor.isActive("taskList"), + label: "Paragraph", + type: "option", + }, + { + icon: Heading1, + onClick: (editor) => + editor + .chain() + .focus() + .lift("taskItem") + .liftListItem("listItem") + .setHeading({ level: 1 }) + .run(), + id: "heading1", + disabled: (editor) => !editor.can().setHeading({ level: 1 }), + isActive: (editor) => editor.isActive("heading", { level: 1 }), + label: "Heading 1", + type: "option", + }, + { + icon: Heading2, + onClick: (editor) => + editor + .chain() + .focus() + .lift("taskItem") + .liftListItem("listItem") + .setHeading({ level: 2 }) + .run(), + id: "heading2", + disabled: (editor) => !editor.can().setHeading({ level: 2 }), + isActive: (editor) => editor.isActive("heading", { level: 2 }), + label: "Heading 2", + type: "option", + }, + { + icon: Heading3, + onClick: (editor) => + editor + .chain() + .focus() + .lift("taskItem") + .liftListItem("listItem") + .setHeading({ level: 3 }) + .run(), + id: "heading3", + disabled: (editor) => !editor.can().setHeading({ level: 3 }), + isActive: (editor) => editor.isActive("heading", { level: 3 }), + label: "Heading 3", + type: "option", + }, + { + type: "category", + label: "Lists", + id: "lists", + }, + { + icon: List, + onClick: (editor) => editor.chain().focus().toggleBulletList().run(), + id: "bulletList", + disabled: (editor) => !editor.can().toggleBulletList(), + isActive: (editor) => editor.isActive("bulletList"), + label: "Bullet list", + type: "option", + }, + { + icon: ListOrdered, + onClick: (editor) => editor.chain().focus().toggleOrderedList().run(), + id: "orderedList", + disabled: (editor) => !editor.can().toggleOrderedList(), + isActive: (editor) => editor.isActive("orderedList"), + label: "Numbered list", + type: "option", + }, + { + icon: ListTodo, + onClick: (editor) => editor.chain().focus().toggleTaskList().run(), + id: "todoList", + disabled: (editor) => !editor.can().toggleTaskList(), + isActive: (editor) => editor.isActive("taskList"), + label: "Todo list", + type: "option", + }, +]; + +function ContentTypePickerComponent() { + const { editor } = useCurrentEditor(); + const portalContainer = useFloatingPortalContainer(); + + const activeItem = useEditorState({ + editor: editor as Editor, + selector: (ctx) => + CONTENT_TYPES.find( + (option) => option.type === "option" && option.isActive(ctx.editor) + ) as ContentTypeOption | undefined, + }); + + if (!editor) { + return null; + } + + const ActiveIcon = activeItem?.icon || Pilcrow; + const isActive = activeItem?.id !== "paragraph" && !!activeItem; + + return ( + + + + + + {CONTENT_TYPES.map((option) => { + if (option.type === "category") { + return ( +
+ {option.id !== "hierarchy" && } + + {option.label} + +
+ ); + } + + const isOptionActive = option.isActive(editor); + const isDisabled = option.disabled(editor); + const Icon = option.icon; + + return ( + option.onClick(editor)} + > + + {option.label} + + ); + })} +
+
+ ); +} + +export const ContentTypePicker: FC = ContentTypePickerComponent; diff --git a/apps/cms/src/components/editor/drag-handle/hooks/use-drag-actions.tsx b/apps/cms/src/components/editor/drag-handle/hooks/use-drag-actions.tsx new file mode 100644 index 00000000..473abbc7 --- /dev/null +++ b/apps/cms/src/components/editor/drag-handle/hooks/use-drag-actions.tsx @@ -0,0 +1,95 @@ +"use client"; + +import type { Node } from "@tiptap/pm/model"; +import type { NodeSelection } from "@tiptap/pm/state"; +import type { Editor } from "@tiptap/react"; +import { useCallback } from "react"; + +export function useDragActions( + editor: Editor, + currentNode: Node | null, + currentNodePos: number +) { + const resetTextFormatting = useCallback(() => { + const chain = editor.chain(); + + chain.setNodeSelection(currentNodePos).unsetAllMarks(); + + if (currentNode?.type.name !== "paragraph") { + chain.setParagraph(); + } + + chain.run(); + }, [editor, currentNodePos, currentNode?.type.name]); + + const duplicateNode = useCallback(() => { + editor.commands.setNodeSelection(currentNodePos); + + const { $anchor } = editor.state.selection; + const selectedNode = + $anchor.node(1) || (editor.state.selection as NodeSelection).node; + + editor + .chain() + .insertContentAt( + currentNodePos + (currentNode?.nodeSize || 0), + selectedNode.toJSON() + ) + .run(); + }, [editor, currentNodePos, currentNode?.nodeSize]); + + const copyNodeToClipboard = useCallback(() => { + editor.chain().setNodeSelection(currentNodePos).run(); + + document.execCommand("copy"); + }, [editor, currentNodePos]); + + const deleteNode = useCallback(() => { + editor.chain().setNodeSelection(currentNodePos).deleteSelection().run(); + }, [editor, currentNodePos]); + + const handleAdd = useCallback(() => { + if (currentNodePos !== -1) { + const currentNodeSize = currentNode?.nodeSize || 0; + const insertPos = currentNodePos + currentNodeSize; + const currentNodeIsEmptyParagraph = + currentNode?.type.name === "paragraph" && + currentNode?.content?.size === 0; + const focusPos = currentNodeIsEmptyParagraph + ? currentNodePos + 2 + : insertPos + 2; + + editor + .chain() + .command(({ dispatch, tr, state }) => { + if (dispatch) { + if (currentNodeIsEmptyParagraph) { + tr.insertText("/", currentNodePos, currentNodePos + 1); + } else { + const paragraphNode = state.schema.nodes.paragraph; + if (paragraphNode) { + tr.insert( + insertPos, + paragraphNode.create(null, [state.schema.text("/")]) + ); + } + } + + return dispatch(tr); + } + + return true; + }) + .focus(focusPos) + .run(); + } + }, [currentNode, currentNodePos, editor]); + + return { + resetTextFormatting, + duplicateNode, + copyNodeToClipboard, + deleteNode, + handleAdd, + }; +} diff --git a/apps/cms/src/components/editor/drag-handle/hooks/use-drag-data.tsx b/apps/cms/src/components/editor/drag-handle/hooks/use-drag-data.tsx new file mode 100644 index 00000000..06ddfe5e --- /dev/null +++ b/apps/cms/src/components/editor/drag-handle/hooks/use-drag-data.tsx @@ -0,0 +1,27 @@ +"use client"; + +import type { Editor } from "@tiptap/core"; +import type { Node } from "@tiptap/pm/model"; +import { useCallback, useState } from "react"; + +export function useDragData() { + const [currentNode, setCurrentNode] = useState(null); + const [currentNodePos, setCurrentNodePos] = useState(-1); + + const handleNodeChange = useCallback( + (data: { node: Node | null; editor: Editor; pos: number }) => { + setCurrentNode(data.node); + + setCurrentNodePos(data.pos); + }, + [] + ); + + return { + currentNode, + currentNodePos, + setCurrentNode, + setCurrentNodePos, + handleNodeChange, + }; +} diff --git a/apps/cms/src/components/editor/drag-handle/index.tsx b/apps/cms/src/components/editor/drag-handle/index.tsx new file mode 100644 index 00000000..1aa4e7f1 --- /dev/null +++ b/apps/cms/src/components/editor/drag-handle/index.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { offset } from "@floating-ui/dom"; +import { Button } from "@marble/ui/components/button"; +import { Separator } from "@marble/ui/components/separator"; +import { + Close as PopoverClose, + Content as PopoverContent, + Root as PopoverRoot, + Trigger as PopoverTrigger, +} from "@radix-ui/react-popover"; +import { DragHandle as TiptapDragHandle } from "@tiptap/extension-drag-handle-react"; +import type { Editor } from "@tiptap/react"; +import { + Clipboard, + Copy, + GripVertical, + Plus, + RemoveFormatting, + Trash2, +} from "lucide-react"; +import { memo, useEffect, useState } from "react"; +import { useDragActions } from "./hooks/use-drag-actions"; +import { useDragData } from "./hooks/use-drag-data"; + +export type DragHandleProps = { + editor: Editor; +}; + +function DragHandleComponent({ editor }: DragHandleProps) { + const [menuOpen, setMenuOpen] = useState(false); + const data = useDragData(); + const actions = useDragActions(editor, data.currentNode, data.currentNodePos); + + useEffect(() => { + if (menuOpen) { + editor.commands.setMeta("lockDragHandle", true); + } else { + editor.commands.setMeta("lockDragHandle", false); + } + }, [menuOpen, editor]); + + // Don't render until editor view is fully initialized + if (!editor?.view?.dom) { + return null; + } + + return ( + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + +
+
+
+
+
+ ); +} + +// Memoize to prevent unnecessary re-renders that cause plugin unregister/register cycles +export const DragHandle = memo(DragHandleComponent); diff --git a/apps/cms/src/components/editor/editor-page.tsx b/apps/cms/src/components/editor/editor-page.tsx index 863e5a63..940b0540 100644 --- a/apps/cms/src/components/editor/editor-page.tsx +++ b/apps/cms/src/components/editor/editor-page.tsx @@ -16,21 +16,20 @@ import { import { cn } from "@marble/ui/lib/utils"; import { SidebarSimpleIcon, XIcon } from "@phosphor-icons/react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { Editor, JSONContent } from "@tiptap/core"; +import { EditorContent, EditorContext, useEditor } from "@tiptap/react"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; -import { - CharacterCount, - EditorContent, - type EditorInstance, - EditorRoot, - handleCommandNavigation, - handleImageDrop, - handleImagePaste, - type JSONContent, -} from "novel"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; +import { BubbleMenu } from "@/components/editor/bubble-menu"; +import { DragHandle } from "@/components/editor/drag-handle"; import { EditorSidebar } from "@/components/editor/editor-sidebar"; +import { ColumnsMenu } from "@/components/editor/extensions/multi-column/menus"; +import { + TableColumnMenu, + TableRowMenu, +} from "@/components/editor/extensions/table/menus"; import { HiddenScrollbar } from "@/components/editor/hidden-scrollbar"; import { useDebounce } from "@/hooks/use-debounce"; import { useWorkspaceId } from "@/hooks/use-workspace-id"; @@ -38,12 +37,8 @@ import { QUERY_KEYS } from "@/lib/queries/keys"; import { type PostValues, postSchema } from "@/lib/validations/post"; import { useUnsavedChanges } from "@/providers/unsaved-changes"; import { generateSlug } from "@/utils/string"; -import { BubbleMenu } from "./bubble-menu"; import { defaultExtensions } from "./extensions"; -import { uploadFn } from "./image-upload"; import { ShareModal } from "./share-modal"; -import { slashCommand } from "./slash-command-items"; -import { SlashCommandMenu } from "./slash-command-menu"; import { TextareaAutosize } from "./textarea-autosize"; const getToggleSidebarShortcut = () => { @@ -67,10 +62,6 @@ function EditorPage({ initialData, id }: EditorPageProps) { const workspaceId = useWorkspaceId(); const { open, isMobile } = useSidebar(); const formRef = useRef(null); - const editorRef = useRef(null); - const [editorInstance, setEditorInstance] = useState( - null - ); const [showSettings, setShowSettings] = useState(false); const { setHasUnsavedChanges } = useUnsavedChanges(); const initialDataRef = useRef(initialData); @@ -106,7 +97,8 @@ function EditorPage({ initialData, id }: EditorPageProps) { }), onSuccess: (data) => { toast.success("Post created"); - router.push(`/${params.workspace}/editor/p/${data.id}`); + window.location.href = `/${params.workspace}/editor/p/${data.id}`; + // router.push(`/${params.workspace}/editor/p/${data.id}`); if (workspaceId) { queryClient.invalidateQueries({ queryKey: QUERY_KEYS.POSTS(workspaceId), @@ -157,44 +149,127 @@ function EditorPage({ initialData, id }: EditorPageProps) { initialDataRef.current = initialData; }, [initialData, form.reset]); + // Debounced form update to reduce React Hook Form rerenders + const updateFormValues = useCallback( + (html: string, json: JSONContent) => { + if (html.length > 0) { + clearErrors("content"); + } + setValue("content", html); + setValue("contentJson", JSON.stringify(json)); + }, + [setValue, clearErrors] + ); + + const debouncedUpdateFormValues = useMemo(() => { + let timeoutId: NodeJS.Timeout; + return (html: string, json: JSONContent) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + updateFormValues(html, json); + }, 150); + }; + }, [updateFormValues]); + + // Create stable onUpdate callback ref to avoid recreating editor + const onUpdateRef = useRef< + ((html: string, json: JSONContent) => void) | null + >(null); + useEffect(() => { - const subscription = watch((currentValues) => { - const initial = initialDataRef.current; - // Ensure all relevant fields are stringified for comparison - const hasChanged = - JSON.stringify({ - ...currentValues, - contentJson: currentValues.contentJson - ? JSON.parse(currentValues.contentJson) - : {}, - }) !== - JSON.stringify({ - ...initial, - contentJson: initial.contentJson - ? JSON.parse(initial.contentJson) - : {}, - }); - if (hasChanged) { - setHasUnsavedChanges(true); + onUpdateRef.current = debouncedUpdateFormValues; + }, [debouncedUpdateFormValues]); + + // Track content changes and idle callback for performance + const contentChangedRef = useRef(false); + const idleCallbackId = useRef(null); + const editorRef = useRef(null); + + const initialContentRef = useRef(initialData?.content ?? ""); + const editor = useEditor({ + extensions: defaultExtensions, + content: initialContentRef.current, + editorProps: { + attributes: { + class: + "prose dark:prose-invert min-h-96 h-full sm:px-4 focus:outline-hidden max-w-full prose-blockquote:border-border", + }, + }, + onUpdate: ({ editor }) => { + contentChangedRef.current = true; + editorRef.current = editor; + + // Cancel previous idle callback if exists + if (idleCallbackId.current !== null) { + cancelIdleCallback(idleCallbackId.current); } + + // Schedule serialization when browser is idle + idleCallbackId.current = requestIdleCallback( + () => { + if (contentChangedRef.current && editorRef.current) { + const html = editorRef.current.getHTML(); + const json = editorRef.current.getJSON(); + onUpdateRef.current?.(html, json); + contentChangedRef.current = false; + } + }, + { timeout: 100 } + ); + }, + immediatelyRender: false, + shouldRerenderOnTransaction: false, + }); + + // Cleanup idle callback on unmount + useEffect( + () => () => { + if (idleCallbackId.current !== null) { + cancelIdleCallback(idleCallbackId.current); + } + }, + [] + ); + + // Debounce unsaved changes check to reduce overhead + useEffect(() => { + let timeoutId: NodeJS.Timeout; + const subscription = watch((currentValues) => { + // Debounce the check to avoid running on every keystroke + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + const initial = initialDataRef.current; + // Simple field comparison first (faster than JSON operations) + const simpleFieldsChanged = + currentValues.title !== initial.title || + currentValues.slug !== initial.slug || + currentValues.status !== initial.status || + currentValues.description !== initial.description; + + if (simpleFieldsChanged) { + setHasUnsavedChanges(true); + return; + } + + // Only do expensive content comparison if simple fields haven't changed + const contentChanged = currentValues.content !== initial.content; + if (contentChanged) { + setHasUnsavedChanges(true); + } + }, 300); }); - return () => subscription.unsubscribe(); + return () => { + clearTimeout(timeoutId); + subscription.unsubscribe(); + }; }, [watch, setHasUnsavedChanges]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); - editorRef.current?.commands.focus(); - } - }; - - const handleEditorChange = (html: string, json: JSONContent) => { - if (html.length > 0) { - clearErrors("content"); + editor?.commands.focus(); } - setValue("content", html); - setValue("contentJson", JSON.stringify(json)); }; function onSubmit(values: PostValues) { @@ -216,8 +291,22 @@ function EditorPage({ initialData, id }: EditorPageProps) { } }, [debouncedTitle, setValue, clearErrors, isUpdateMode]); + // Create context value that updates when editor initializes + const editorContextValue = useMemo(() => ({ editor }), [editor]); + + // Memoize DragHandle to prevent plugin unregister/register cycles + const dragHandle = useMemo( + () => (editor ? : null), + [editor] + ); + return ( - + + + {editor && } + {editor && } + {editor && } + {dragHandle}
@@ -279,49 +368,7 @@ function EditorPage({ initialData, id }: EditorPageProps) { )}
- handleCommandNavigation(event), - }, - handlePaste: (view, event) => - handleImagePaste(view, event, uploadFn), - handleDrop: (view, event, _slice, moved) => - handleImageDrop(view, event, moved, uploadFn), - attributes: { - class: - "prose dark:prose-invert min-h-96 h-full sm:px-4 focus:outline-hidden max-w-full prose-blockquote:border-border", - }, - }} - extensions={[ - // @ts-expect-error - ...defaultExtensions, - // @ts-expect-error - slashCommand, - // @ts-expect-error - CharacterCount, - ]} - immediatelyRender={false} - initialContent={JSON.parse(watch("contentJson") || "{}")} - onCreate={({ editor }) => { - // @ts-expect-error - editorRef.current = editor; - // @ts-expect-error - setEditorInstance(editor); - }} - onUpdate={({ editor }) => { - // @ts-expect-error - editorRef.current = editor; - // @ts-expect-error - setEditorInstance(editor); - const html = editor.getHTML(); - const json = editor.getJSON(); - handleEditorChange(html, json); - }} - > - - - + {errors.content && (

{errors.content.message} @@ -342,7 +389,7 @@ function EditorPage({ initialData, id }: EditorPageProps) { )} - + ); } export default EditorPage; diff --git a/apps/cms/src/components/editor/editor-sidebar.tsx b/apps/cms/src/components/editor/editor-sidebar.tsx index b512905d..3bfea98a 100644 --- a/apps/cms/src/components/editor/editor-sidebar.tsx +++ b/apps/cms/src/components/editor/editor-sidebar.tsx @@ -16,9 +16,17 @@ import { import { cn } from "@marble/ui/lib/utils"; import { SpinnerIcon } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; -import type { EditorInstance } from "novel"; +import type { Editor } from "@tiptap/core"; import { parseAsStringLiteral, useQueryState } from "nuqs"; -import { lazy, Suspense, useEffect, useMemo, useState } from "react"; +import { + lazy, + memo, + Suspense, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import type { Control, FieldErrors, UseFormWatch } from "react-hook-form"; import { useDebounce } from "@/hooks/use-debounce"; import { fetchAiReadabilitySuggestionsObject } from "@/lib/ai/readability"; @@ -57,10 +65,10 @@ type EditorSidebarProps = React.ComponentProps & { isOpen: boolean; setIsOpen: React.Dispatch>; mode?: "create" | "update"; - editor?: EditorInstance | null; + editor?: Editor | null; }; -export function EditorSidebar({ +function EditorSidebarComponent({ control, errors, formRef, @@ -79,23 +87,53 @@ export function EditorSidebar({ const [editorText, setEditorText] = useState(""); const [editorHTML, setEditorHTML] = useState(""); + // Track content changes and idle callback for performance + const contentChangedRef = useRef(false); + const idleCallbackId = useRef(null); + useEffect(() => { if (!editor) { return; } + + // Initial content setEditorText(editor.getText()); setEditorHTML(editor.getHTML()); + const handler = () => { - const nextText = editor.getText(); - const nextHTML = editor.getHTML(); - setEditorText((prev) => (prev === nextText ? prev : nextText)); - setEditorHTML((prev) => (prev === nextHTML ? prev : nextHTML)); + // CRITICAL OPTIMIZATION: Don't serialize on every keystroke! + // Just mark that content changed and schedule update for idle time + contentChangedRef.current = true; + + // Cancel previous idle callback if exists + if (idleCallbackId.current !== null) { + cancelIdleCallback(idleCallbackId.current); + } + + // Schedule update when browser is idle + idleCallbackId.current = requestIdleCallback( + () => { + if (contentChangedRef.current && editor) { + const nextText = editor.getText(); + const nextHTML = editor.getHTML(); + setEditorText((prev) => (prev === nextText ? prev : nextText)); + setEditorHTML((prev) => (prev === nextHTML ? prev : nextHTML)); + contentChangedRef.current = false; + } + }, + { timeout: 500 } // Longer timeout for sidebar (lower priority than main editor) + ); }; + editor.on("update", handler); editor.on("create", handler); + return () => { editor.off("update", handler); editor.off("create", handler); + if (idleCallbackId.current !== null) { + cancelIdleCallback(idleCallbackId.current); + } }; }, [editor]); @@ -300,3 +338,6 @@ export function EditorSidebar({

); } + +// Memoize to prevent context cascade rerenders +export const EditorSidebar = memo(EditorSidebarComponent); diff --git a/apps/cms/src/components/editor/extensions.ts b/apps/cms/src/components/editor/extensions.ts index ca63bf6c..34515301 100644 --- a/apps/cms/src/components/editor/extensions.ts +++ b/apps/cms/src/components/editor/extensions.ts @@ -1,54 +1,56 @@ import type { Extension } from "@tiptap/core"; +import { CodeBlockLowlight } from "@tiptap/extension-code-block-lowlight"; +import { Color } from "@tiptap/extension-color"; +import { FileHandler } from "@tiptap/extension-file-handler"; +import { Highlight } from "@tiptap/extension-highlight"; +import { HorizontalRule } from "@tiptap/extension-horizontal-rule"; +import { Image } from "@tiptap/extension-image"; +import { TaskItem, TaskList } from "@tiptap/extension-list"; +import { NodeRange } from "@tiptap/extension-node-range"; +import { Subscript } from "@tiptap/extension-subscript"; +import { Superscript } from "@tiptap/extension-superscript"; import TextAlign from "@tiptap/extension-text-align"; +import { TextStyle } from "@tiptap/extension-text-style"; +import { Youtube } from "@tiptap/extension-youtube"; +import { CharacterCount, Dropcursor, Placeholder } from "@tiptap/extensions"; +import { Markdown } from "@tiptap/markdown"; +import { StarterKit } from "@tiptap/starter-kit"; import { cx } from "class-variance-authority"; import { common, createLowlight } from "lowlight"; -import { - CodeBlockLowlight, - HorizontalRule, - Placeholder, - StarterKit, - TaskItem, - TaskList, - TiptapImage, - TiptapLink, - TiptapUnderline, - // UpdatedImage, - UploadImagesPlugin, - Youtube, -} from "novel"; +import { Document } from "./extensions/document/Document"; +import { Figure } from "./extensions/figure"; +import { ImageUpload } from "./extensions/image-upload"; +import { MarkdownFileDrop } from "./extensions/markdown-file-drop"; +import { MarkdownPaste } from "./extensions/markdown-paste"; +import { Column, Columns } from "./extensions/multi-column"; +import { Table, TableCell, TableHeader, TableRow } from "./extensions/table"; +import { YouTubeUpload } from "./extensions/youtube-upload"; +import { SlashCommand } from "./slash-command"; // You can overwrite the placeholder with your own configuration -const placeholder = Placeholder; - -const tiptapLink = TiptapLink.configure({ - HTMLAttributes: { - class: cx( - "text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer" - ), +const placeholder = Placeholder.configure({ + placeholder: ({ editor }) => { + // Check if currently in a table using isActive + if ( + editor.isActive("table") || + editor.isActive("tableCell") || + editor.isActive("tableHeader") + ) { + return ""; // Hide placeholder inside tables + } + return "Press '/' for commands"; }, + showOnlyWhenEditable: true, + showOnlyCurrent: true, }); -const tiptapImage = TiptapImage.extend({ - addProseMirrorPlugins() { - return [ - UploadImagesPlugin({ - imageClass: cx("opacity-40 rounded-lg border border-stone-200"), - }), - ]; - }, -}).configure({ +const tiptapImage = Image.configure({ allowBase64: true, HTMLAttributes: { class: cx("rounded-md border border-muted"), }, }); -// const updatedImage = UpdatedImage.configure({ -// HTMLAttributes: { -// class: cx("rounded-lg border border-muted"), -// }, -// }); - const taskList = TaskList.configure({ HTMLAttributes: { class: cx("not-prose pl-2"), @@ -67,12 +69,6 @@ const horizontalRule = HorizontalRule.configure({ }, }); -const underline = TiptapUnderline.configure({ - HTMLAttributes: { - class: cx("underline"), - }, -}); - const youtube = Youtube.configure({ HTMLAttributes: { class: cx("w-full aspect-video"), @@ -89,7 +85,17 @@ const CodeBlockLowlightEx = CodeBlockLowlight.configure({ lowlight: createLowlight(common), }); +const markdown = Markdown.configure({ + markedOptions: { + gfm: true, // GitHub-flavored markdown + breaks: true, // Newlines become
+ }, +}); + const starterKit = StarterKit.configure({ + link: { + openOnClick: false, + }, bulletList: { HTMLAttributes: { class: cx("list-disc list-outside leading-3 -mt-2"), @@ -117,19 +123,58 @@ const starterKit = StarterKit.configure({ }, gapcursor: false, codeBlock: false, + document: false, +}); + +const fileHandler = FileHandler.configure({ + allowedMimeTypes: ["image/png", "image/jpeg", "image/gif", "image/webp"], + onDrop: (currentEditor, files, _pos) => { + for (const file of files) { + // Insert imageUpload node at drop position with the file + currentEditor.chain().focus().setImageUpload({ file }).run(); + } + }, + onPaste: (currentEditor, files) => { + for (const file of files) { + // Insert imageUpload node at cursor with the file + currentEditor.chain().focus().setImageUpload({ file }).run(); + } + }, }); export const defaultExtensions: Extension[] = [ + Document as unknown as Extension, + markdown as unknown as Extension, + MarkdownPaste as unknown as Extension, starterKit as unknown as Extension, placeholder as unknown as Extension, textAlign, + TextStyle as unknown as Extension, + Color as unknown as Extension, + Highlight.configure({ multicolor: true }) as unknown as Extension, + Subscript as unknown as Extension, + Superscript as unknown as Extension, CodeBlockLowlightEx as unknown as Extension, + Dropcursor as unknown as Extension, tiptapImage as unknown as Extension, - // updatedImage as unknown as Extension, + Figure as unknown as Extension, + ImageUpload as unknown as Extension, + MarkdownFileDrop as unknown as Extension, + fileHandler as unknown as Extension, youtube as unknown as Extension, - tiptapLink as unknown as Extension, + YouTubeUpload as unknown as Extension, taskList as unknown as Extension, taskItem as unknown as Extension, horizontalRule as unknown as Extension, - underline as unknown as Extension, + Table as unknown as Extension, + TableRow as unknown as Extension, + TableCell as unknown as Extension, + TableHeader as unknown as Extension, + Columns as unknown as Extension, + Column as unknown as Extension, + CharacterCount as unknown as Extension, + SlashCommand as unknown as Extension, + // DragHandle as unknown as Extension, + NodeRange as unknown as Extension, + Dropcursor as unknown as Extension, ]; diff --git a/apps/cms/src/components/editor/extensions/document/Document.ts b/apps/cms/src/components/editor/extensions/document/Document.ts new file mode 100644 index 00000000..98bdf660 --- /dev/null +++ b/apps/cms/src/components/editor/extensions/document/Document.ts @@ -0,0 +1,7 @@ +import { Document as TiptapDocument } from "@tiptap/extension-document"; + +export const Document = TiptapDocument.extend({ + content: "(block|columns)+", +}); + +export default Document; diff --git a/apps/cms/src/components/editor/extensions/figure/figure-view.tsx b/apps/cms/src/components/editor/extensions/figure/figure-view.tsx new file mode 100644 index 00000000..75ffe855 --- /dev/null +++ b/apps/cms/src/components/editor/extensions/figure/figure-view.tsx @@ -0,0 +1,440 @@ +import { Button } from "@marble/ui/components/button"; +import { Input } from "@marble/ui/components/input"; +import { Label } from "@marble/ui/components/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@marble/ui/components/popover"; +import { cn } from "@marble/ui/lib/utils"; +import type { NodeViewProps } from "@tiptap/core"; +import { NodeViewWrapper } from "@tiptap/react"; +import { AlignCenter, AlignLeft, AlignRight } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useFloatingPortalContainer } from "@/components/editor/floating-portal-context"; + +export const FigureView = ({ + node, + updateAttributes, + selected, +}: NodeViewProps) => { + const { src, alt, caption, href, width, height, widthUnit, align } = + node.attrs as { + src: string; + alt: string; + caption: string; + href: string | null; + width: string; + height: string | null; + widthUnit: "percent" | "pixel"; + align: "left" | "center" | "right"; + }; + + const [altValue, setAltValue] = useState(alt || ""); + const [captionValue, setCaptionValue] = useState(caption || ""); + const [hrefValue, setHrefValue] = useState(href || ""); + const [widthValue, setWidthValue] = useState(width || "100"); + const [heightValue, setHeightValue] = useState(height || null); + const [widthUnitValue, setWidthUnitValue] = useState<"percent" | "pixel">( + widthUnit || "percent" + ); + const [alignValue, setAlignValue] = useState<"left" | "center" | "right">( + align || "center" + ); + const [isResizing, setIsResizing] = useState(false); + const [imageNaturalWidth, setImageNaturalWidth] = useState( + null + ); + const [imageNaturalHeight, setImageNaturalHeight] = useState( + null + ); + const figureRef = useRef(null); + const startXRef = useRef(0); + const startWidthRef = useRef(0); + const portalContainer = useFloatingPortalContainer(); + + // Sync local state with node attributes when they change externally + useEffect(() => { + setAltValue(alt || ""); + setCaptionValue(caption || ""); + setHrefValue(href || ""); + setWidthValue(width || "100"); + setHeightValue(height || null); + setWidthUnitValue(widthUnit || "percent"); + setAlignValue(align || "center"); + }, [alt, caption, href, width, height, widthUnit, align]); + + // Load actual image dimensions + useEffect(() => { + const img = new Image(); + img.onload = () => { + setImageNaturalWidth(img.naturalWidth); + setImageNaturalHeight(img.naturalHeight); + // Store height if not already set + if (!height) { + updateAttributes({ height: String(img.naturalHeight) }); + } + }; + img.src = src; + }, [src, height, updateAttributes]); + + const handleAltChange = useCallback( + (e: React.ChangeEvent) => { + const newAlt = e.target.value; + setAltValue(newAlt); + updateAttributes({ alt: newAlt }); + }, + [updateAttributes] + ); + + const handleCaptionChange = useCallback( + (e: React.ChangeEvent) => { + const newCaption = e.target.value; + setCaptionValue(newCaption); + updateAttributes({ caption: newCaption }); + }, + [updateAttributes] + ); + + const handleHrefChange = useCallback( + (e: React.ChangeEvent) => { + const newHref = e.target.value; + setHrefValue(newHref); + updateAttributes({ href: newHref || null }); + }, + [updateAttributes] + ); + + const handleWidthChange = useCallback( + (e: React.ChangeEvent) => { + const newWidth = e.target.value; + // Only allow numbers and empty string + if (!/^\d*$/.test(newWidth)) { + return; + } + + // Allow any valid number input during typing + setWidthValue(newWidth); + + // Only update attributes if we have a valid number + if (newWidth && Number.parseInt(newWidth, 10) > 0) { + updateAttributes({ width: newWidth }); + } + }, + [updateAttributes] + ); + + const handleWidthBlur = useCallback(() => { + // Validate and clamp on blur + const numValue = Number.parseInt(widthValue, 10) || 100; + const minValue = widthUnitValue === "percent" ? 10 : 50; + + // Calculate max value based on actual image width and container + let maxValue: number; + if (widthUnitValue === "percent") { + maxValue = 100; + } else { + // For pixels, use the smaller of: image natural width or container width + const containerWidth = + figureRef.current?.parentElement?.clientWidth || 800; + maxValue = imageNaturalWidth + ? Math.min(imageNaturalWidth, containerWidth) + : containerWidth; + } + + const clampedValue = Math.max(minValue, Math.min(maxValue, numValue)); + const finalWidth = String(clampedValue); + + setWidthValue(finalWidth); + updateAttributes({ width: finalWidth }); + }, [widthValue, widthUnitValue, imageNaturalWidth, updateAttributes]); + + const handleWidthUnitChange = useCallback( + (newUnit: "percent" | "pixel") => { + setWidthUnitValue(newUnit); + // Convert width value when switching units + const currentNum = Number.parseInt(widthValue, 10) || 100; + const containerWidth = + figureRef.current?.parentElement?.clientWidth || 800; + let newWidth = widthValue; + + if (newUnit === "pixel" && widthUnitValue === "percent") { + // Converting from % to px - use actual container width + newWidth = String(Math.round((currentNum / 100) * containerWidth)); + } else if (newUnit === "percent" && widthUnitValue === "pixel") { + // Converting from px to % + newWidth = String(Math.round((currentNum / containerWidth) * 100)); + } + + setWidthValue(newWidth); + updateAttributes({ width: newWidth, widthUnit: newUnit }); + }, + [updateAttributes, widthValue, widthUnitValue] + ); + + const handleAlignChange = useCallback( + (newAlign: "left" | "center" | "right") => { + setAlignValue(newAlign); + // Use setTimeout to avoid Tiptap position conflicts + setTimeout(() => { + updateAttributes({ align: newAlign }); + }, 0); + }, + [updateAttributes] + ); + + // Resize handle drag handlers + const handleResizeStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + startXRef.current = e.clientX; + const currentWidth = Number.parseInt(widthValue, 10) || 100; + startWidthRef.current = currentWidth; + }, + [widthValue] + ); + + useEffect(() => { + if (!isResizing) { + return; + } + + const handleMouseMove = (e: MouseEvent) => { + const deltaX = e.clientX - startXRef.current; + const containerWidth = + figureRef.current?.parentElement?.clientWidth || 800; + + let newWidth: number; + if (widthUnitValue === "percent") { + const deltaPercent = (deltaX / containerWidth) * 100; + newWidth = Math.max( + 10, + Math.min(100, startWidthRef.current + deltaPercent) + ); + } else { + // For pixels, respect image natural width and container width + const maxPixelWidth = imageNaturalWidth + ? Math.min(imageNaturalWidth, containerWidth) + : containerWidth; + newWidth = Math.max( + 50, + Math.min(maxPixelWidth, startWidthRef.current + deltaX) + ); + } + + const roundedWidth = Math.round(newWidth); + setWidthValue(String(roundedWidth)); + updateAttributes({ width: String(roundedWidth) }); + }; + + const handleMouseUp = () => { + setIsResizing(false); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isResizing, updateAttributes, widthUnitValue, imageNaturalWidth]); + + // Calculate alignment styles + const alignmentStyles: React.CSSProperties = { + width: widthUnitValue === "percent" ? `${widthValue}%` : `${widthValue}px`, + marginLeft: alignValue === "left" ? 0 : "auto", + marginRight: alignValue === "right" ? 0 : "auto", + }; + + return ( + + + +
+ {/* Render image wrapped in anchor if href exists */} + {href ? ( + + {/* biome-ignore lint: Tiptap NodeView requires standard img element */} + {altValue} + + ) : ( + /* biome-ignore lint: Tiptap NodeView requires standard img element */ + {altValue} + )} + + {/* Resize handles - only shown when selected */} + {selected && ( + <> + {/* Left handle */} +
+
+ + {/* Toolbar in Popover - only shown when selected */} + + {/* Width Controls */} +
+ +
+ +
+ + +
+
+
+ + {/* Alignment Controls */} +
+ +
+ + + +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+
+
+ ); +}; diff --git a/apps/cms/src/components/editor/extensions/figure/index.ts b/apps/cms/src/components/editor/extensions/figure/index.ts new file mode 100644 index 00000000..99cec7b1 --- /dev/null +++ b/apps/cms/src/components/editor/extensions/figure/index.ts @@ -0,0 +1,174 @@ +import type { CommandProps } from "@tiptap/core"; +import { mergeAttributes, Node } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { FigureView } from "./figure-view"; + +declare module "@tiptap/core" { + // biome-ignore lint/style/useConsistentTypeDefinitions: Extending tiptap commands + interface Commands { + figure: { + setFigure: (options: { + src: string; + alt?: string; + caption?: string; + href?: string; + width?: string; + height?: string; + widthUnit?: "percent" | "pixel"; + align?: "left" | "center" | "right"; + }) => ReturnType; + updateFigure: (attrs: { + alt?: string; + caption?: string; + href?: string; + width?: string; + height?: string; + widthUnit?: "percent" | "pixel"; + align?: "left" | "center" | "right"; + }) => ReturnType; + }; + } +} + +export const Figure = Node.create({ + name: "figure", + group: "block", + content: "", + draggable: true, + selectable: true, + isolating: true, + + addAttributes() { + return { + src: { + default: null, + parseHTML: (element) => + element.querySelector("img")?.getAttribute("src") || + element.querySelector("a img")?.getAttribute("src"), + renderHTML: (attributes) => { + if (!attributes.src) { + return {}; + } + return { + src: attributes.src, + }; + }, + }, + alt: { + default: "", + parseHTML: (element) => + element.querySelector("img")?.getAttribute("alt") || + element.querySelector("a img")?.getAttribute("alt") || + "", + renderHTML: (attributes) => ({ + alt: attributes.alt, + }), + }, + caption: { + default: "", + parseHTML: (element) => + element.querySelector("figcaption")?.textContent || "", + renderHTML: (attributes) => ({ + caption: attributes.caption, + }), + }, + href: { + default: null, + parseHTML: (element) => + element.querySelector("a")?.getAttribute("href") || null, + renderHTML: (attributes) => ({ + href: attributes.href, + }), + }, + width: { + default: "100", + parseHTML: (element) => element.getAttribute("data-width") || "100", + renderHTML: (attributes) => ({ + "data-width": attributes.width, + }), + }, + height: { + default: null, + parseHTML: (element) => element.getAttribute("data-height") || null, + renderHTML: (attributes) => ({ + "data-height": attributes.height, + }), + }, + widthUnit: { + default: "percent", + parseHTML: (element) => + element.getAttribute("data-width-unit") || "percent", + renderHTML: (attributes) => ({ + "data-width-unit": attributes.widthUnit, + }), + }, + align: { + default: "center", + parseHTML: (element) => element.getAttribute("data-align") || "center", + renderHTML: (attributes) => ({ + "data-align": attributes.align, + }), + }, + }; + }, + + parseHTML() { + return [ + { + tag: "figure", + getAttrs: (element) => { + if (typeof element === "string") { + return false; + } + const img = element.querySelector("img"); + return img ? {} : false; + }, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + const { href, ...attrs } = HTMLAttributes; + + // If href exists, wrap img in anchor tag + if (href) { + return [ + "figure", + mergeAttributes(attrs), + ["a", { href }, ["img"]], + ["figcaption"], + ]; + } + + // Otherwise, render img directly + return ["figure", mergeAttributes(attrs), ["img"], ["figcaption"]]; + }, + + addCommands() { + return { + setFigure: + (options) => + ({ commands }: CommandProps) => + commands.insertContent({ + type: this.name, + attrs: options, + }), + updateFigure: + (attrs) => + ({ commands, tr, state }: CommandProps) => { + const { selection } = state; + const node = tr.doc.nodeAt(selection.from); + + if (node?.type.name === this.name) { + return commands.updateAttributes(this.name, attrs); + } + + return false; + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(FigureView); + }, +}); diff --git a/apps/cms/src/components/editor/extensions/image-upload/hooks.ts b/apps/cms/src/components/editor/extensions/image-upload/hooks.ts new file mode 100644 index 00000000..76a64772 --- /dev/null +++ b/apps/cms/src/components/editor/extensions/image-upload/hooks.ts @@ -0,0 +1,99 @@ +import { toast } from "@marble/ui/components/sonner"; +import type { DragEvent } from "react"; +import { useCallback, useRef, useState } from "react"; +import { uploadFile } from "@/lib/media/upload"; + +export const useFileUpload = () => { + const fileInput = useRef(null); + + const handleUploadClick = useCallback(() => { + fileInput.current?.click(); + }, []); + + return { ref: fileInput, handleUploadClick }; +}; + +export const useUploader = ({ + onUpload, +}: { + onUpload: (url: string) => void; +}) => { + const [loading, setLoading] = useState(false); + + const uploadImage = useCallback( + async (file: File) => { + setLoading(true); + try { + const media = await uploadFile({ file, type: "media" }); + if (media?.url) { + onUpload(media.url); + } else { + toast.error("Upload failed: Invalid response from server."); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to upload image"; + toast.error(errorMessage); + } + setLoading(false); + }, + [onUpload] + ); + + return { loading, uploadImage }; +}; + +export const useDropZone = ({ + uploader, +}: { + uploader: (file: File) => void; +}) => { + const [draggedInside, setDraggedInside] = useState(false); + + const onDrop = useCallback( + (e: DragEvent) => { + setDraggedInside(false); + e.preventDefault(); + e.stopPropagation(); + + const fileList = e.dataTransfer.files; + const files: File[] = []; + + for (let i = 0; i < fileList.length; i += 1) { + const item = fileList.item(i); + if (item) { + files.push(item); + } + } + + // Validate only image files + if (files.some((file) => !file.type.startsWith("image/"))) { + toast.error("Only image files are allowed"); + return; + } + + const filteredFiles = files.filter((f) => f.type.startsWith("image/")); + const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined; + + if (file) { + uploader(file); + } + }, + [uploader] + ); + + const onDragEnter = useCallback(() => { + setDraggedInside(true); + }, []); + + const onDragLeave = useCallback(() => { + setDraggedInside(false); + }, []); + + const onDragOver = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + return { draggedInside, onDragEnter, onDragLeave, onDrop, onDragOver }; +}; diff --git a/apps/cms/src/components/editor/extensions/image-upload/image-upload-view.tsx b/apps/cms/src/components/editor/extensions/image-upload/image-upload-view.tsx new file mode 100644 index 00000000..8890117e --- /dev/null +++ b/apps/cms/src/components/editor/extensions/image-upload/image-upload-view.tsx @@ -0,0 +1,42 @@ +import type { NodeViewProps } from "@tiptap/core"; +import { NodeViewWrapper } from "@tiptap/react"; +import { useCallback } from "react"; +import { ImageUploader } from "./image-uploader"; +import { pendingUploads } from "./index"; + +export const ImageUploadView = ({ getPos, editor, node }: NodeViewProps) => { + // Get fileId from node attributes + const fileId = node.attrs.fileId as string | null; + const initialFile = fileId ? pendingUploads.get(fileId) : undefined; + + const onUpload = useCallback( + (url: string) => { + if (url && typeof getPos === "function") { + const pos = getPos(); + if (typeof pos === "number") { + // Clean up pending upload if it exists + if (fileId) { + pendingUploads.delete(fileId); + } + + // Replace the imageUpload node with a figure (image with caption support) + editor + .chain() + .focus() + .deleteRange({ from: pos, to: pos + 1 }) + .setFigure({ src: url, alt: "", caption: "" }) + .run(); + } + } + }, + [getPos, editor, fileId] + ); + + return ( + +
+ +
+
+ ); +}; diff --git a/apps/cms/src/components/editor/extensions/image-upload/image-uploader.tsx b/apps/cms/src/components/editor/extensions/image-upload/image-uploader.tsx new file mode 100644 index 00000000..2e0b9e43 --- /dev/null +++ b/apps/cms/src/components/editor/extensions/image-upload/image-uploader.tsx @@ -0,0 +1,313 @@ +import { Button } from "@marble/ui/components/button"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, +} from "@marble/ui/components/drawer"; +import { Input } from "@marble/ui/components/input"; +import { ScrollArea } from "@marble/ui/components/scroll-area"; +import { cn } from "@marble/ui/lib/utils"; +import { + CheckIcon, + ImageIcon, + ImagesIcon, + LinkIcon, + SpinnerIcon, + UploadSimpleIcon, +} from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; +import type { ChangeEvent } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { z } from "zod"; +import { useWorkspaceId } from "@/hooks/use-workspace-id"; +import { QUERY_KEYS } from "@/lib/queries/keys"; +import type { MediaListResponse } from "@/types/media"; +import { useDropZone, useFileUpload, useUploader } from "./hooks"; + +// URL schema for validation +const urlSchema = z.string().url({ + message: "Please enter a valid URL", +}); + +export const ImageUploader = ({ + initialFile, + onUpload, +}: { + initialFile?: File; + onUpload: (url: string) => void; +}) => { + const [showEmbedInput, setShowEmbedInput] = useState(false); + const [embedUrl, setEmbedUrl] = useState(""); + const [urlError, setUrlError] = useState(null); + const [isValidatingUrl, setIsValidatingUrl] = useState(false); + const [isGalleryOpen, setIsGalleryOpen] = useState(false); + const workspaceId = useWorkspaceId(); + + const { loading, uploadImage } = useUploader({ onUpload }); + const { handleUploadClick, ref } = useFileUpload(); + const { draggedInside, onDrop, onDragEnter, onDragLeave, onDragOver } = + useDropZone({ + uploader: uploadImage, + }); + + // Fetch media + const { data: media } = useQuery({ + // biome-ignore lint/style/noNonNullAssertion: workspaceId is required for media query + queryKey: QUERY_KEYS.MEDIA(workspaceId!), + staleTime: 1000 * 60 * 60, + queryFn: async () => { + try { + const res = await fetch("/api/media"); + const data: MediaListResponse = await res.json(); + return data.media; + } catch (_error) { + return []; + } + }, + enabled: !!workspaceId, + }); + + // Auto-upload if initialFile is provided + useEffect(() => { + if (initialFile) { + uploadImage(initialFile); + } + }, [initialFile, uploadImage]); + + const onFileChange = useCallback( + (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + uploadImage(file); + } + }, + [uploadImage] + ); + + const handleEmbedUrl = useCallback( + async (url: string) => { + if (!url) { + return; + } + + setIsValidatingUrl(true); + setUrlError(null); + + try { + await urlSchema.parseAsync(url); + const img = new Image(); + img.onload = () => { + onUpload(url); + setEmbedUrl(""); + setShowEmbedInput(false); + setIsValidatingUrl(false); + }; + img.onerror = () => { + setUrlError("Invalid image URL"); + setIsValidatingUrl(false); + }; + img.src = url; + } catch (error) { + if (error instanceof z.ZodError) { + setUrlError(error.errors?.[0]?.message || "Invalid URL"); + } else { + setUrlError("Invalid URL"); + } + setIsValidatingUrl(false); + } + }, + [onUpload] + ); + + const handleMediaSelect = useCallback( + (url: string) => { + onUpload(url); + setIsGalleryOpen(false); + }, + [onUpload] + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleUploadClick(); + } + }, + [handleUploadClick] + ); + + if (loading) { + return ( +
+
+ +

Uploading image...

+
+
+ ); + } + + return ( + <> + {/* biome-ignore lint/a11y/useSemanticElements: Drag-and-drop zone requires div element for proper event handling */} +
+ +
+
+ {draggedInside + ? "Drop image here" + : "Drag and drop or choose an option"} +
+ + {/* Action Buttons */} +
+ + + +
+ + {/* Inline URL Input */} + {showEmbedInput && ( +
+
+ { + setEmbedUrl(target.value); + setUrlError(null); + }} + placeholder="Paste image URL" + value={embedUrl} + /> + +
+ {urlError && ( +

{urlError}

+ )} +
+ )} +
+ +
+ + {/* Media Gallery Drawer */} + + + + Gallery + + Select an image from your media library. + + + {media && media.length > 0 ? ( +
+ +
    + {media + .filter((item) => item.type === "image") + .map((item) => ( +
  • + +
  • + ))} +
+
+
+ ) : ( +
+
+ +

+ Your gallery is empty. Upload some media to get started. +

+
+
+ )} +
+
+ + ); +}; diff --git a/apps/cms/src/components/editor/extensions/image-upload/index.ts b/apps/cms/src/components/editor/extensions/image-upload/index.ts new file mode 100644 index 00000000..10a2fd0a --- /dev/null +++ b/apps/cms/src/components/editor/extensions/image-upload/index.ts @@ -0,0 +1,93 @@ +import type { CommandProps } from "@tiptap/core"; +import { Node } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { ImageUploadView } from "./image-upload-view"; + +declare module "@tiptap/core" { + // biome-ignore lint/style/useConsistentTypeDefinitions: Required for TypeScript module augmentation + interface Commands { + imageUpload: { + setImageUpload: (options?: { file?: File }) => ReturnType; + }; + } +} + +// Storage for pending file uploads +export const pendingUploads = new Map(); + +export const ImageUpload = Node.create({ + name: "imageUpload", + isolating: true, + defining: true, + group: "block", + draggable: true, + selectable: true, + inline: false, + + addAttributes() { + return { + fileId: { + default: null, + parseHTML: (element) => element.getAttribute("data-file-id"), + renderHTML: (attributes) => { + if (!attributes.fileId) { + return {}; + } + return { + "data-file-id": attributes.fileId, + }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: `div[data-type="${this.name}"]`, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["div", { "data-type": this.name, ...HTMLAttributes }]; + }, + + addCommands() { + return { + setImageUpload: + (options) => + ({ commands }: CommandProps) => { + const { file } = options || {}; + + if (file) { + // Generate unique ID and store file + const fileId = `upload-${Date.now()}-${Math.random()}`; + pendingUploads.set(fileId, file); + + return commands.insertContent({ + type: this.name, + attrs: { fileId }, + }); + } + + return commands.insertContent({ + type: this.name, + }); + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(ImageUploadView, { + // Pass pendingUploads map through context + as: "div", + }); + }, + + addStorage() { + return { + pendingUploads, + }; + }, +}); diff --git a/apps/cms/src/components/editor/extensions/markdown-file-drop/index.ts b/apps/cms/src/components/editor/extensions/markdown-file-drop/index.ts new file mode 100644 index 00000000..ceecfb5b --- /dev/null +++ b/apps/cms/src/components/editor/extensions/markdown-file-drop/index.ts @@ -0,0 +1,116 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; +import { transformContent } from "../markdown-paste/utils"; + +export const MarkdownFileDrop = Extension.create({ + name: "markdownFileDrop", + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("markdownFileDrop"), + props: { + handleDrop: (_view: EditorView, event: DragEvent, _slice, moved) => { + // Don't handle if this is a move within the editor + if (moved) { + return false; + } + + const { editor } = this; + const files = Array.from(event.dataTransfer?.files || []); + + // Check if any files are markdown files + const markdownFiles = files.filter( + (file) => + file.name.endsWith(".md") || + file.name.endsWith(".markdown") || + file.type === "text/markdown" + ); + + if (markdownFiles.length === 0) { + // Let other plugins handle this + return false; + } + + // Prevent default browser behavior + event.preventDefault(); + + // Process all markdown files + for (const file of markdownFiles) { + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result as string; + if (text) { + try { + // Parse markdown to JSON + const json = editor?.markdown?.parse(text); + if (json) { + // Transform Image nodes to Figure nodes + const transformedContent = transformContent(json); + // Insert at drop position + editor.commands.insertContent(transformedContent); + } + } catch (error) { + console.error("Failed to parse markdown file:", error); + } + } + }; + reader.readAsText(file); + } + + // Return true to indicate we handled this event + return true; + }, + + handlePaste: (_view: EditorView, event: ClipboardEvent) => { + const { editor } = this; + const files = Array.from(event.clipboardData?.files || []); + + // Check if any files are markdown files + const markdownFiles = files.filter( + (file) => + file.name.endsWith(".md") || + file.name.endsWith(".markdown") || + file.type === "text/markdown" + ); + + if (markdownFiles.length === 0) { + // Let other plugins handle this + return false; + } + + // Prevent default paste behavior + event.preventDefault(); + + // Process all markdown files + for (const file of markdownFiles) { + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result as string; + if (text) { + try { + // Parse markdown to JSON + const json = editor?.markdown?.parse(text); + if (json) { + // Transform Image nodes to Figure nodes + const transformedContent = transformContent(json); + // Insert at cursor + editor.commands.insertContent(transformedContent); + } + } catch (error) { + console.error("Failed to parse markdown file:", error); + } + } + }; + reader.readAsText(file); + } + + // Return true to indicate we handled this event + return true; + }, + }, + }), + ]; + }, +}); diff --git a/apps/cms/src/components/editor/extensions/markdown-paste/index.ts b/apps/cms/src/components/editor/extensions/markdown-paste/index.ts new file mode 100644 index 00000000..d7d3443a --- /dev/null +++ b/apps/cms/src/components/editor/extensions/markdown-paste/index.ts @@ -0,0 +1,55 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { looksLikeMarkdown, transformContent } from "./utils"; + +export const MarkdownPaste = Extension.create({ + name: "markdownPaste", + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("markdownPaste"), + props: { + handlePaste: (_view, event) => { + const { editor } = this; + + // Get plain text from clipboard + const text = event.clipboardData?.getData("text/plain"); + + if (!text) { + return false; + } + + // Check if it looks like markdown + if (!looksLikeMarkdown(text)) { + return false; + } + + // Prevent default paste behavior + event.preventDefault(); + + try { + // Parse markdown to JSON using Tiptap's markdown extension + const json = editor?.markdown?.parse(text) ?? { + type: "doc", + content: [], + }; + + // Transform Image nodes to Figure nodes + const transformedContent = transformContent(json); + + // Insert the parsed and transformed content + editor.commands.insertContent(transformedContent); + + return true; + } catch (error) { + console.error("Failed to parse markdown:", error); + // Fall back to default paste behavior + return false; + } + }, + }, + }), + ]; + }, +}); diff --git a/apps/cms/src/components/editor/extensions/markdown-paste/utils.ts b/apps/cms/src/components/editor/extensions/markdown-paste/utils.ts new file mode 100644 index 00000000..f83deda4 --- /dev/null +++ b/apps/cms/src/components/editor/extensions/markdown-paste/utils.ts @@ -0,0 +1,161 @@ +import type { JSONContent } from "@tiptap/core"; + +/** + * Checks if text looks like markdown by detecting common markdown patterns + */ +export function looksLikeMarkdown(text: string): boolean { + if (!text || text.trim().length === 0) { + return false; + } + + const markdownPatterns = [ + /^#{1,6}\s+.+/m, // Headings + /\*\*[^*]+\*\*/m, // Bold with ** + /__[^_]+__/m, // Bold with __ + /\*[^*]+\*/m, // Italic with * + /_[^_]+_/m, // Italic with _ + /\[.+\]\(.+\)/m, // Links [text](url) + /^\s*[-*+]\s+/m, // Unordered lists + /^\s*\d+\.\s+/m, // Ordered lists + /```[\s\S]*?```/m, // Code blocks + /`[^`]+`/m, // Inline code + /^\s*>\s+/m, // Blockquotes + /!\[.*\]\(.*\)/m, // Images + /^\s*[-*_]{3,}\s*$/m, // Horizontal rules + /^\|.+\|$/m, // Tables + ]; + + // Check if at least 2 patterns match for better accuracy + const matchCount = markdownPatterns.filter((pattern) => + pattern.test(text) + ).length; + return matchCount >= 2 || /^#{1,6}\s+.+/m.test(text); // Or single heading pattern +} + +/** + * Check if a node type is an inline context where block-level figures can't exist + */ +function isInlineContext(nodeType?: string): boolean { + const inlineTypes = ["text", "strong", "em", "code", "strike", "underline"]; + return nodeType ? inlineTypes.includes(nodeType) : false; +} + +/** + * Recursively transforms Image nodes to Figure nodes in parsed JSON + * Converts markdown image syntax ![caption](url) to Figure nodes with captions + * Handles linked images [![alt](img)](href) by extracting the href + */ +export function transformImageToFigure( + content: JSONContent, + parentType?: string +): JSONContent { + if (!content) { + return content; + } + + // Handle link nodes that contain a single image (linked images) + // Transform: link > image -> figure with href + if (content.type === "link") { + const hasOnlyImage = + content.content && + content.content.length === 1 && + content.content[0]?.type === "image"; + + if (hasOnlyImage && content.content) { + const image = content.content[0]; + const href = content.attrs?.href; + + // Transform to figure with href + return { + type: "figure", + attrs: { + src: image?.attrs?.src || "", + alt: image?.attrs?.alt || "", + caption: image?.attrs?.alt || "", + href: href || null, + }, + }; + } + } + + // Transform the current node if it's an image + if (content.type === "image") { + // Don't transform images in inline contexts (e.g., inside text, strong, etc.) + if (isInlineContext(parentType)) { + return content; // Keep as image + } + + // Transform to figure (without href) + return { + type: "figure", + attrs: { + src: content.attrs?.src || "", + alt: content.attrs?.alt || "", + caption: content.attrs?.alt || "", // Use alt text as caption + href: null, + }, + }; + } + + // Recursively transform children, passing current node type as parent + if (content.content && Array.isArray(content.content)) { + return { + ...content, + content: content.content.map((child) => + transformImageToFigure(child, content.type) + ), + }; + } + + return content; +} + +/** + * Lifts figures out of paragraphs where they're the only child + * Markdown parsers often wrap standalone images in paragraphs, which becomes + * invalid when the image is transformed to a figure (block-level element) + */ +function liftFiguresFromParagraphs(content: JSONContent): JSONContent { + if (!content) { + return content; + } + + // If this is a paragraph with a single figure child, replace the paragraph with the figure + if ( + content.type === "paragraph" && + content.content && + content.content.length === 1 && + content.content[0]?.type === "figure" + ) { + return content.content[0]; // Replace paragraph with the figure + } + + // Recursively process children + if (content.content && Array.isArray(content.content)) { + return { + ...content, + content: content.content.map((child) => liftFiguresFromParagraphs(child)), + }; + } + + return content; +} + +/** + * Transforms an array of JSON content, converting images to figures + * and lifting figures out of invalid contexts + */ +export function transformContent( + json: JSONContent | JSONContent[] +): JSONContent | JSONContent[] { + if (Array.isArray(json)) { + // First transform images to figures + const transformed = json.map((item) => transformImageToFigure(item)); + // Then lift figures out of paragraphs + return transformed.map((item) => liftFiguresFromParagraphs(item)); + } + // First transform images to figures + const transformed = transformImageToFigure(json); + // Then lift figures out of paragraphs + return liftFiguresFromParagraphs(transformed); +} diff --git a/apps/cms/src/components/editor/extensions/multi-column/Column.ts b/apps/cms/src/components/editor/extensions/multi-column/Column.ts new file mode 100644 index 00000000..fd9284b6 --- /dev/null +++ b/apps/cms/src/components/editor/extensions/multi-column/Column.ts @@ -0,0 +1,37 @@ +import { mergeAttributes, Node } from "@tiptap/core"; + +export const Column = Node.create({ + name: "column", + + content: "block+", + + isolating: true, + + addAttributes() { + return { + position: { + default: "", + parseHTML: (element) => element.getAttribute("data-position"), + renderHTML: (attributes) => ({ "data-position": attributes.position }), + }, + }; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes(HTMLAttributes, { "data-type": "column" }), + 0, + ]; + }, + + parseHTML() { + return [ + { + tag: 'div[data-type="column"]', + }, + ]; + }, +}); + +export default Column; diff --git a/apps/cms/src/components/editor/extensions/multi-column/Columns.ts b/apps/cms/src/components/editor/extensions/multi-column/Columns.ts new file mode 100644 index 00000000..3e5ccf06 --- /dev/null +++ b/apps/cms/src/components/editor/extensions/multi-column/Columns.ts @@ -0,0 +1,73 @@ +import type { Editor } from "@tiptap/core"; +import { Node } from "@tiptap/core"; + +export const ColumnLayout = { + SidebarLeft: "sidebar-left", + SidebarRight: "sidebar-right", + TwoColumn: "two-column", +} as const; + +export type ColumnLayout = (typeof ColumnLayout)[keyof typeof ColumnLayout]; + +declare module "@tiptap/core" { + // biome-ignore lint/style/useConsistentTypeDefinitions: Required for TypeScript module augmentation + interface Commands { + columns: { + setColumns: () => ReturnType; + setLayout: (layout: ColumnLayout) => ReturnType; + }; + } +} + +export const Columns = Node.create({ + name: "columns", + + group: "columns", + + content: "column column", + + defining: true, + + isolating: true, + + addAttributes() { + return { + layout: { + default: ColumnLayout.TwoColumn, + }, + }; + }, + + addCommands() { + return { + setColumns: + () => + ({ commands }: { commands: Editor["commands"] }) => + commands.insertContent( + `

` + ), + setLayout: + (layout: ColumnLayout) => + ({ commands }: { commands: Editor["commands"] }) => + commands.updateAttributes("columns", { layout }), + }; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + { "data-type": "columns", class: `layout-${HTMLAttributes.layout}` }, + 0, + ]; + }, + + parseHTML() { + return [ + { + tag: 'div[data-type="columns"]', + }, + ]; + }, +}); + +export default Columns; diff --git a/apps/cms/src/components/editor/extensions/multi-column/index.ts b/apps/cms/src/components/editor/extensions/multi-column/index.ts new file mode 100644 index 00000000..0ca6715d --- /dev/null +++ b/apps/cms/src/components/editor/extensions/multi-column/index.ts @@ -0,0 +1,3 @@ +/* biome-ignore lint/performance/noBarrelFile: Barrel file for organized exports */ +export { Column } from "./Column"; +export { ColumnLayout, Columns } from "./Columns"; diff --git a/apps/cms/src/components/editor/extensions/multi-column/menus/ColumnsMenu.tsx b/apps/cms/src/components/editor/extensions/multi-column/menus/ColumnsMenu.tsx new file mode 100644 index 00000000..10c27112 --- /dev/null +++ b/apps/cms/src/components/editor/extensions/multi-column/menus/ColumnsMenu.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { Button } from "@marble/ui/components/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@marble/ui/components/tooltip"; +import type { Editor } from "@tiptap/react"; +import { useEditorState } from "@tiptap/react"; +import { BubbleMenu as TiptapBubbleMenu } from "@tiptap/react/menus"; +import { Columns2, PanelLeft, PanelRight } from "lucide-react"; +import { type JSX, memo, useCallback } from "react"; +import { ColumnLayout } from "../Columns"; + +type MenuProps = { + editor: Editor; + appendTo?: React.RefObject; +}; + +type ShouldShowProps = { + view: unknown; + state: unknown; + from: number; +}; + +function ColumnsMenuComponent({ editor, appendTo }: MenuProps): JSX.Element { + const shouldShow = useCallback( + ({ state }: ShouldShowProps) => { + if (!state) { + return false; + } + + return editor.isActive("columns"); + }, + [editor] + ); + + const onColumnLeft = useCallback(() => { + editor.chain().focus().setLayout(ColumnLayout.SidebarLeft).run(); + }, [editor]); + + const onColumnRight = useCallback(() => { + editor.chain().focus().setLayout(ColumnLayout.SidebarRight).run(); + }, [editor]); + + const onColumnTwo = useCallback(() => { + editor.chain().focus().setLayout(ColumnLayout.TwoColumn).run(); + }, [editor]); + + const { isColumnLeft, isColumnRight, isColumnTwo } = useEditorState({ + editor, + selector: (ctx) => ({ + isColumnLeft: ctx.editor.isActive("columns", { + layout: ColumnLayout.SidebarLeft, + }), + isColumnRight: ctx.editor.isActive("columns", { + layout: ColumnLayout.SidebarRight, + }), + isColumnTwo: ctx.editor.isActive("columns", { + layout: ColumnLayout.TwoColumn, + }), + }), + }); + + return ( + appendTo?.current ?? 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} + options={{ + placement: "top", + offset: { mainAxis: 18, crossAxis: 0 }, + }} + pluginKey="columnsMenu" + shouldShow={shouldShow} + updateDelay={0} + > + + + + + + +

Sidebar left

+
+
+ + + + + + +

Two columns

+
+
+ + + + + + +

Sidebar right

+
+
+
+
+ ); +} + +export const ColumnsMenu = memo(ColumnsMenuComponent); +export default ColumnsMenu; diff --git a/apps/cms/src/components/editor/extensions/multi-column/menus/index.ts b/apps/cms/src/components/editor/extensions/multi-column/menus/index.ts new file mode 100644 index 00000000..50c3e6de --- /dev/null +++ b/apps/cms/src/components/editor/extensions/multi-column/menus/index.ts @@ -0,0 +1,2 @@ +/* biome-ignore lint/performance/noBarrelFile: Barrel file for organized exports */ +export { ColumnsMenu } from "./ColumnsMenu"; diff --git a/apps/cms/src/components/editor/extensions/table/Table.ts b/apps/cms/src/components/editor/extensions/table/Table.ts new file mode 100644 index 00000000..191f0728 --- /dev/null +++ b/apps/cms/src/components/editor/extensions/table/Table.ts @@ -0,0 +1,8 @@ +import { Table as TiptapTable } from "@tiptap/extension-table"; + +export const Table = TiptapTable.configure({ + resizable: true, + lastColumnResizable: false, +}); + +export default Table; diff --git a/apps/cms/src/components/editor/extensions/table/TableCell.ts b/apps/cms/src/components/editor/extensions/table/TableCell.ts new file mode 100644 index 00000000..2a85d8e9 --- /dev/null +++ b/apps/cms/src/components/editor/extensions/table/TableCell.ts @@ -0,0 +1,134 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { Plugin } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; + +import { getCellsInColumn, isRowSelected, selectRow } from "./utils"; + +export type TableCellOptions = { + HTMLAttributes: Record; +}; + +export const TableCell = Node.create({ + name: "tableCell", + + content: "block+", + + tableRole: "cell", + + isolating: true, + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + parseHTML() { + return [{ tag: "td" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "td", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + 0, + ]; + }, + + addAttributes() { + return { + colspan: { + default: 1, + parseHTML: (element) => { + const colspan = element.getAttribute("colspan"); + const value = colspan ? Number.parseInt(colspan, 10) : 1; + + return value; + }, + }, + rowspan: { + default: 1, + parseHTML: (element) => { + const rowspan = element.getAttribute("rowspan"); + const value = rowspan ? Number.parseInt(rowspan, 10) : 1; + + return value; + }, + }, + colwidth: { + default: null, + parseHTML: (element) => { + const colwidth = element.getAttribute("colwidth"); + const value = colwidth ? [Number.parseInt(colwidth, 10)] : null; + + return value; + }, + }, + style: { + default: null, + }, + }; + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + props: { + decorations: (state) => { + const { isEditable } = this.editor; + + if (!isEditable) { + return DecorationSet.empty; + } + + const { doc, selection } = state; + const decorations: Decoration[] = []; + const cells = getCellsInColumn(0)(selection); + + if (cells) { + let index = 0; + for (const { pos } of cells) { + const currentIndex = index; + decorations.push( + Decoration.widget(pos + 1, () => { + const rowSelected = isRowSelected(currentIndex)(selection); + let className = "grip-row"; + + if (rowSelected) { + className += " selected"; + } + + if (currentIndex === 0) { + className += " first"; + } + + if (currentIndex === cells.length - 1) { + className += " last"; + } + + const grip = document.createElement("a"); + + grip.className = className; + grip.addEventListener("mousedown", (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + + this.editor.view.dispatch( + selectRow(currentIndex)(this.editor.state.tr) + ); + }); + + return grip; + }) + ); + index += 1; + } + } + + return DecorationSet.create(doc, decorations); + }, + }, + }), + ]; + }, +}); diff --git a/apps/cms/src/components/editor/extensions/table/TableHeader.ts b/apps/cms/src/components/editor/extensions/table/TableHeader.ts new file mode 100644 index 00000000..7cf095e8 --- /dev/null +++ b/apps/cms/src/components/editor/extensions/table/TableHeader.ts @@ -0,0 +1,99 @@ +import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table"; +import { Plugin } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; + +import { getCellsInRow, isColumnSelected, selectColumn } from "./utils"; + +export const TableHeader = TiptapTableHeader.extend({ + addAttributes() { + return { + colspan: { + default: 1, + }, + rowspan: { + default: 1, + }, + colwidth: { + default: null, + parseHTML: (element: HTMLElement) => { + const colwidth = element.getAttribute("colwidth"); + const value = colwidth + ? colwidth + .split(",") + .map((item: string) => Number.parseInt(item, 10)) + : null; + + return value; + }, + }, + style: { + default: null, + }, + }; + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + props: { + decorations: (state) => { + const { isEditable } = this.editor; + + if (!isEditable) { + return DecorationSet.empty; + } + + const { doc, selection } = state; + const decorations: Decoration[] = []; + const cells = getCellsInRow(0)(selection); + + if (cells) { + let index = 0; + for (const { pos } of cells) { + const currentIndex = index; + decorations.push( + Decoration.widget(pos + 1, () => { + const colSelected = + isColumnSelected(currentIndex)(selection); + let className = "grip-column"; + + if (colSelected) { + className += " selected"; + } + + if (currentIndex === 0) { + className += " first"; + } + + if (currentIndex === cells.length - 1) { + className += " last"; + } + + const grip = document.createElement("a"); + + grip.className = className; + grip.addEventListener("mousedown", (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + + this.editor.view.dispatch( + selectColumn(currentIndex)(this.editor.state.tr) + ); + }); + + return grip; + }) + ); + index += 1; + } + } + + return DecorationSet.create(doc, decorations); + }, + }, + }), + ]; + }, +}); + +export default TableHeader; diff --git a/apps/cms/src/components/editor/extensions/table/TableRow.ts b/apps/cms/src/components/editor/extensions/table/TableRow.ts new file mode 100644 index 00000000..ae8458d9 --- /dev/null +++ b/apps/cms/src/components/editor/extensions/table/TableRow.ts @@ -0,0 +1,8 @@ +import { TableRow as TiptapTableRow } from "@tiptap/extension-table"; + +export const TableRow = TiptapTableRow.extend({ + allowGapCursor: false, + content: "(tableCell | tableHeader)*", +}); + +export default TableRow; diff --git a/apps/cms/src/components/editor/extensions/table/index.ts b/apps/cms/src/components/editor/extensions/table/index.ts new file mode 100644 index 00000000..0112d090 --- /dev/null +++ b/apps/cms/src/components/editor/extensions/table/index.ts @@ -0,0 +1,5 @@ +/* biome-ignore lint/performance/noBarrelFile: Barrel file for organized exports */ +export { Table } from "./Table"; +export { TableCell } from "./TableCell"; +export { TableHeader } from "./TableHeader"; +export { TableRow } from "./TableRow"; diff --git a/apps/cms/src/components/editor/extensions/table/menus/TableColumn/index.tsx b/apps/cms/src/components/editor/extensions/table/menus/TableColumn/index.tsx new file mode 100644 index 00000000..f16bbcbc --- /dev/null +++ b/apps/cms/src/components/editor/extensions/table/menus/TableColumn/index.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { Button } from "@marble/ui/components/button"; +import { Separator } from "@marble/ui/components/separator"; +import { + ArrowLineLeftIcon, + ArrowLineRightIcon, + TrashIcon, +} from "@phosphor-icons/react"; +import type { Editor } from "@tiptap/react"; +import { BubbleMenu as TiptapBubbleMenu } from "@tiptap/react/menus"; +import { type JSX, memo, useCallback } from "react"; +import { isColumnGripSelected } from "./utils"; + +type MenuProps = { + editor: Editor; + appendTo?: React.RefObject; +}; + +type ShouldShowProps = { + view: unknown; + state: unknown; + from: number; +}; + +function TableColumnMenuComponent({ + editor, + appendTo, +}: MenuProps): JSX.Element { + const shouldShow = useCallback( + ({ view, state, from }: ShouldShowProps) => { + if (!state) { + return false; + } + + return isColumnGripSelected({ + editor, + view, + state, + from: from || 0, + } as Parameters[0]); + }, + [editor] + ); + + const onAddColumnBefore = useCallback(() => { + editor.chain().focus().addColumnBefore().run(); + }, [editor]); + + const onAddColumnAfter = useCallback(() => { + editor.chain().focus().addColumnAfter().run(); + }, [editor]); + + const onDeleteColumn = useCallback(() => { + editor.chain().focus().deleteColumn().run(); + }, [editor]); + + return ( + appendTo?.current ?? document.body} + className="flex flex-row items-center gap-0.5 overflow-hidden rounded-lg border bg-background p-1 shadow-sm" + editor={editor} + options={{ + placement: "top", + offset: { mainAxis: 24, crossAxis: 0 }, + }} + pluginKey="tableColumnMenu" + shouldShow={shouldShow} + updateDelay={0} + > + + + {/* */} + + + + + + + + + ); +} + +export const TableColumnMenu = memo(TableColumnMenuComponent); +TableColumnMenu.displayName = "TableColumnMenu"; + +export default TableColumnMenu; diff --git a/apps/cms/src/components/editor/extensions/table/menus/TableColumn/utils.ts b/apps/cms/src/components/editor/extensions/table/menus/TableColumn/utils.ts new file mode 100644 index 00000000..dc0c1923 --- /dev/null +++ b/apps/cms/src/components/editor/extensions/table/menus/TableColumn/utils.ts @@ -0,0 +1,42 @@ +import type { EditorState } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; +import type { Editor } from "@tiptap/react"; +import { Table } from "../.."; +import { isTableSelected } from "../../utils"; + +export const isColumnGripSelected = ({ + editor, + view, + state, + from, +}: { + editor: Editor; + view: EditorView; + state: EditorState; + from: number; +}) => { + const domAtPos = view.domAtPos(from).node as HTMLElement; + const nodeDOM = view.nodeDOM(from) as HTMLElement; + const node = nodeDOM || domAtPos; + + if ( + !editor.isActive(Table.name) || + !node || + isTableSelected(state.selection) + ) { + return false; + } + + // Find the owning table cell (TD/TH) + const element: Element | null = + node.nodeType === Node.ELEMENT_NODE + ? (node as Element) + : node.parentElement; + const cell = element?.closest?.("td, th") ?? null; + + const gripColumn = cell?.querySelector?.("a.grip-column.selected"); + + return !!gripColumn; +}; + +export default isColumnGripSelected; diff --git a/apps/cms/src/components/editor/extensions/table/menus/TableRow/index.tsx b/apps/cms/src/components/editor/extensions/table/menus/TableRow/index.tsx new file mode 100644 index 00000000..ab8a21ed --- /dev/null +++ b/apps/cms/src/components/editor/extensions/table/menus/TableRow/index.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { Button } from "@marble/ui/components/button"; +import { + ArrowLineDownIcon, + ArrowLineUpIcon, + TrashIcon, +} from "@phosphor-icons/react"; +import type { EditorState } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; +import type { Editor } from "@tiptap/react"; +import { BubbleMenu as TiptapBubbleMenu } from "@tiptap/react/menus"; +import { type JSX, memo, useCallback } from "react"; +import { isRowGripSelected } from "./utils"; + +type MenuProps = { + editor: Editor; + appendTo?: React.RefObject; +}; + +type ShouldShowProps = { + view: EditorView; + state: EditorState; + from: number; +}; + +function TableRowMenuComponent({ editor, appendTo }: MenuProps): JSX.Element { + const shouldShow = useCallback( + ({ view, state, from }: ShouldShowProps) => { + if (!state || !from) { + return false; + } + + return isRowGripSelected({ editor, view, state, from } as Parameters< + typeof isRowGripSelected + >[0]); + }, + [editor] + ); + + const onAddRowBefore = useCallback(() => { + editor.chain().focus().addRowBefore().run(); + }, [editor]); + + const onAddRowAfter = useCallback(() => { + editor.chain().focus().addRowAfter().run(); + }, [editor]); + + const onDeleteRow = useCallback(() => { + editor.chain().focus().deleteRow().run(); + }, [editor]); + + return ( + appendTo?.current ?? document.body} + className="flex flex-col gap-0.5 overflow-hidden rounded-lg border bg-background p-1 shadow-sm" + editor={editor} + options={{ + placement: "left", + offset: { mainAxis: 24, crossAxis: 0 }, + }} + pluginKey="tableRowMenu" + shouldShow={shouldShow} + updateDelay={0} + > + + + + + + + ); +} + +export const TableRowMenu = memo(TableRowMenuComponent); +TableRowMenu.displayName = "TableRowMenu"; + +export default TableRowMenu; diff --git a/apps/cms/src/components/editor/extensions/table/menus/TableRow/utils.ts b/apps/cms/src/components/editor/extensions/table/menus/TableRow/utils.ts new file mode 100644 index 00000000..81c285d4 --- /dev/null +++ b/apps/cms/src/components/editor/extensions/table/menus/TableRow/utils.ts @@ -0,0 +1,41 @@ +import type { EditorState } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; +import type { Editor } from "@tiptap/react"; +import { Table } from "../.."; +import { isTableSelected } from "../../utils"; + +export const isRowGripSelected = ({ + editor, + view, + state, + from, +}: { + editor: Editor; + view: EditorView; + state: EditorState; + from: number; +}) => { + const domAtPos = view.domAtPos(from).node as HTMLElement; + const nodeDOM = view.nodeDOM(from) as HTMLElement; + const node = nodeDOM || domAtPos; + + if ( + !editor.isActive(Table.name) || + !node || + isTableSelected(state.selection) + ) { + return false; + } + + let container = node; + + while (container && !["TD", "TH"].includes(container.tagName)) { + container = container.parentElement ?? container; + } + + const gripRow = container?.querySelector?.("a.grip-row.selected"); + + return !!gripRow; +}; + +export default isRowGripSelected; diff --git a/apps/cms/src/components/editor/extensions/table/menus/index.tsx b/apps/cms/src/components/editor/extensions/table/menus/index.tsx new file mode 100644 index 00000000..0827bcb9 --- /dev/null +++ b/apps/cms/src/components/editor/extensions/table/menus/index.tsx @@ -0,0 +1,3 @@ +/* biome-ignore lint/performance/noBarrelFile: Barrel file for organized exports */ +export * from "./TableColumn"; +export * from "./TableRow"; diff --git a/apps/cms/src/components/editor/extensions/table/utils.ts b/apps/cms/src/components/editor/extensions/table/utils.ts new file mode 100644 index 00000000..735e7934 --- /dev/null +++ b/apps/cms/src/components/editor/extensions/table/utils.ts @@ -0,0 +1,271 @@ +import { findParentNode } from "@tiptap/core"; +import type { Node, ResolvedPos } from "@tiptap/pm/model"; +import type { Selection, Transaction } from "@tiptap/pm/state"; +import { CellSelection, type Rect, TableMap } from "@tiptap/pm/tables"; + +export const isRectSelected = (rect: Rect) => (selection: CellSelection) => { + const map = TableMap.get(selection.$anchorCell.node(-1)); + const start = selection.$anchorCell.start(-1); + const cells = map.cellsInRect(rect); + const selectedCells = map.cellsInRect( + map.rectBetween( + selection.$anchorCell.pos - start, + selection.$headCell.pos - start + ) + ); + + for (let i = 0, count = cells.length; i < count; i += 1) { + const cell = cells[i]; + if (cell !== undefined && selectedCells.indexOf(cell) === -1) { + return false; + } + } + + return true; +}; + +export const findTable = (selection: Selection) => + findParentNode( + (node) => node.type.spec.tableRole && node.type.spec.tableRole === "table" + )(selection); + +export const isCellSelection = ( + selection: Selection +): selection is CellSelection => selection instanceof CellSelection; + +export const isColumnSelected = + (columnIndex: number) => (selection: Selection) => { + if (isCellSelection(selection)) { + const map = TableMap.get(selection.$anchorCell.node(-1)); + + return isRectSelected({ + left: columnIndex, + right: columnIndex + 1, + top: 0, + bottom: map.height, + })(selection); + } + + return false; + }; + +export const isRowSelected = (rowIndex: number) => (selection: Selection) => { + if (isCellSelection(selection)) { + const map = TableMap.get(selection.$anchorCell.node(-1)); + + return isRectSelected({ + left: 0, + right: map.width, + top: rowIndex, + bottom: rowIndex + 1, + })(selection); + } + + return false; +}; + +export const isTableSelected = (selection: Selection) => { + if (isCellSelection(selection)) { + const map = TableMap.get(selection.$anchorCell.node(-1)); + + return isRectSelected({ + left: 0, + right: map.width, + top: 0, + bottom: map.height, + })(selection); + } + + return false; +}; + +export const getCellsInColumn = + (columnIndex: number | number[]) => (selection: Selection) => { + const table = findTable(selection); + if (table) { + const map = TableMap.get(table.node); + const indexes = Array.isArray(columnIndex) + ? columnIndex + : Array.from([columnIndex]); + + return indexes.reduce( + (acc, index) => { + if (index >= 0 && index <= map.width - 1) { + const cells = map.cellsInRect({ + left: index, + right: index + 1, + top: 0, + bottom: map.height, + }); + + return acc.concat( + cells.map((nodePos) => { + const node = table.node.nodeAt(nodePos); + const pos = nodePos + table.start; + + return { pos, start: pos + 1, node }; + }) + ); + } + + return acc; + }, + [] as { pos: number; start: number; node: Node | null | undefined }[] + ); + } + return null; + }; + +export const getCellsInRow = + (rowIndex: number | number[]) => (selection: Selection) => { + const table = findTable(selection); + + if (table) { + const map = TableMap.get(table.node); + const indexes = Array.isArray(rowIndex) + ? rowIndex + : Array.from([rowIndex]); + + return indexes.reduce( + (acc, index) => { + if (index >= 0 && index <= map.height - 1) { + const cells = map.cellsInRect({ + left: 0, + right: map.width, + top: index, + bottom: index + 1, + }); + + return acc.concat( + cells.map((nodePos) => { + const node = table.node.nodeAt(nodePos); + const pos = nodePos + table.start; + return { pos, start: pos + 1, node }; + }) + ); + } + + return acc; + }, + [] as { pos: number; start: number; node: Node | null | undefined }[] + ); + } + + return null; + }; + +export const getCellsInTable = (selection: Selection) => { + const table = findTable(selection); + + if (table) { + const map = TableMap.get(table.node); + const cells = map.cellsInRect({ + left: 0, + right: map.width, + top: 0, + bottom: map.height, + }); + + return cells.map((nodePos) => { + const node = table.node.nodeAt(nodePos); + const pos = nodePos + table.start; + + return { pos, start: pos + 1, node }; + }); + } + + return null; +}; + +export const findParentNodeClosestToPos = ( + $pos: ResolvedPos, + predicate: (node: Node) => boolean +) => { + for (let i = $pos.depth; i > 0; i -= 1) { + const node = $pos.node(i); + + if (predicate(node)) { + return { + pos: i > 0 ? $pos.before(i) : 0, + start: $pos.start(i), + depth: i, + node, + }; + } + } + + return null; +}; + +export const findCellClosestToPos = ($pos: ResolvedPos) => { + const predicate = (node: Node) => + node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole); + + return findParentNodeClosestToPos($pos, predicate); +}; + +const select = + (type: "row" | "column") => (index: number) => (tr: Transaction) => { + const table = findTable(tr.selection); + const isRowSelection = type === "row"; + + if (table) { + const map = TableMap.get(table.node); + + // Check if the index is valid + if (index >= 0 && index < (isRowSelection ? map.height : map.width)) { + const left = isRowSelection ? 0 : index; + const top = isRowSelection ? index : 0; + const right = isRowSelection ? map.width : index + 1; + const bottom = isRowSelection ? index + 1 : map.height; + + const cellsInFirstRow = map.cellsInRect({ + left, + top, + right: isRowSelection ? right : left + 1, + bottom: isRowSelection ? top + 1 : bottom, + }); + + const cellsInLastRow = + bottom - top === 1 + ? cellsInFirstRow + : map.cellsInRect({ + left: isRowSelection ? left : right - 1, + top: isRowSelection ? bottom - 1 : top, + right, + bottom, + }); + + const head = table.start + (cellsInFirstRow[0] ?? 0); + const anchor = table.start + (cellsInLastRow.at(-1) ?? 0); + const $head = tr.doc.resolve(head); + const $anchor = tr.doc.resolve(anchor); + + return tr.setSelection(new CellSelection($anchor, $head)); + } + } + return tr; + }; + +export const selectColumn = select("column"); + +export const selectRow = select("row"); + +export const selectTable = (tr: Transaction) => { + const table = findTable(tr.selection); + + if (table) { + const { map } = TableMap.get(table.node); + + if (map?.length) { + const head = table.start + (map[0] ?? 0); + const anchor = table.start + (map.at(-1) ?? 0); + const $head = tr.doc.resolve(head); + const $anchor = tr.doc.resolve(anchor); + + return tr.setSelection(new CellSelection($anchor, $head)); + } + } + + return tr; +}; diff --git a/apps/cms/src/components/editor/extensions/youtube-upload/index.ts b/apps/cms/src/components/editor/extensions/youtube-upload/index.ts new file mode 100644 index 00000000..62d009af --- /dev/null +++ b/apps/cms/src/components/editor/extensions/youtube-upload/index.ts @@ -0,0 +1,48 @@ +import type { CommandProps } from "@tiptap/core"; +import { Node } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { YouTubeUploadView } from "./youtube-upload-view"; + +declare module "@tiptap/core" { + // biome-ignore lint/style/useConsistentTypeDefinitions: Required for TypeScript module augmentation + interface Commands { + youtubeUpload: { + setYoutubeUpload: () => ReturnType; + }; + } +} + +export const YouTubeUpload = Node.create({ + name: "youtubeUpload", + isolating: true, + defining: true, + group: "block", + draggable: true, + selectable: true, + inline: false, + + parseHTML() { + return [ + { + tag: `div[data-type="${this.name}"]`, + }, + ]; + }, + + renderHTML() { + return ["div", { "data-type": this.name }]; + }, + + addCommands() { + return { + setYoutubeUpload: + () => + ({ commands }: CommandProps) => + commands.insertContent(`
`), + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(YouTubeUploadView); + }, +}); diff --git a/apps/cms/src/components/editor/extensions/youtube-upload/youtube-input.tsx b/apps/cms/src/components/editor/extensions/youtube-upload/youtube-input.tsx new file mode 100644 index 00000000..be80784d --- /dev/null +++ b/apps/cms/src/components/editor/extensions/youtube-upload/youtube-input.tsx @@ -0,0 +1,113 @@ +import { Button } from "@marble/ui/components/button"; +import { Input } from "@marble/ui/components/input"; +import { cn } from "@marble/ui/lib/utils"; +import { YoutubeLogoIcon } from "@phosphor-icons/react"; +import type { ChangeEvent, KeyboardEvent } from "react"; +import { useCallback, useState } from "react"; + +// Extract YouTube video ID from various URL formats +function extractYouTubeVideoId(url: string): string | null { + if (!url) { + return null; + } + + const patterns = [ + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/, + /^([a-zA-Z0-9_-]{11})$/, // Direct video ID + ]; + + for (const pattern of patterns) { + const match = url.match(pattern); + if (match?.[1]) { + return match[1]; + } + } + + return null; +} + +export const YouTubeInput = ({ + onSubmit, + onCancel, +}: { + onSubmit: (url: string) => void; + onCancel: () => void; +}) => { + const [url, setUrl] = useState(""); + const [error, setError] = useState(null); + + const validateAndSubmit = useCallback(() => { + const videoId = extractYouTubeVideoId(url); + if (!videoId) { + setError("Invalid YouTube URL"); + return; + } + + // Construct a clean YouTube URL + const cleanUrl = `https://www.youtube.com/watch?v=${videoId}`; + onSubmit(cleanUrl); + }, [url, onSubmit]); + + const handleInputChange = useCallback((e: ChangeEvent) => { + setUrl(e.target.value); + setError(null); + }, []); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + validateAndSubmit(); + } else if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }, + [validateAndSubmit, onCancel] + ); + + const isValidUrl = extractYouTubeVideoId(url) !== null; + + return ( +
+
+ +
+

Embed YouTube Video

+

+ Paste a YouTube URL or video ID +

+
+
+ +
+ + {error &&

{error}

} +
+ +
+ + +
+
+ ); +}; diff --git a/apps/cms/src/components/editor/extensions/youtube-upload/youtube-upload-view.tsx b/apps/cms/src/components/editor/extensions/youtube-upload/youtube-upload-view.tsx new file mode 100644 index 00000000..c45af8bb --- /dev/null +++ b/apps/cms/src/components/editor/extensions/youtube-upload/youtube-upload-view.tsx @@ -0,0 +1,46 @@ +import type { NodeViewProps } from "@tiptap/core"; +import { NodeViewWrapper } from "@tiptap/react"; +import { useCallback } from "react"; +import { YouTubeInput } from "./youtube-input"; + +export const YouTubeUploadView = ({ getPos, editor }: NodeViewProps) => { + const onSubmit = useCallback( + (url: string) => { + if (url && typeof getPos === "function") { + const pos = getPos(); + if (typeof pos === "number") { + // Replace the youtubeUpload node with an actual YouTube embed + editor + .chain() + .focus() + .deleteRange({ from: pos, to: pos + 1 }) + .setYoutubeVideo({ src: url }) + .run(); + } + } + }, + [getPos, editor] + ); + + const onCancel = useCallback(() => { + if (typeof getPos === "function") { + const pos = getPos(); + if (typeof pos === "number") { + // Remove the placeholder node + editor + .chain() + .focus() + .deleteRange({ from: pos, to: pos + 1 }) + .run(); + } + } + }, [getPos, editor]); + + return ( + +
+ +
+
+ ); +}; diff --git a/apps/cms/src/components/editor/floating-portal-context.tsx b/apps/cms/src/components/editor/floating-portal-context.tsx new file mode 100644 index 00000000..fcee20ff --- /dev/null +++ b/apps/cms/src/components/editor/floating-portal-context.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { createContext, useContext } from "react"; + +type FloatingPortalContextValue = { + container: HTMLElement | null | undefined; +}; + +const FloatingPortalContext = createContext({ + container: typeof document === "undefined" ? null : document.body, +}); + +export function FloatingPortalProvider({ + container, + children, +}: { + container: HTMLElement | null | undefined; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useFloatingPortalContainer() { + return useContext(FloatingPortalContext).container ?? null; +} diff --git a/apps/cms/src/components/editor/image-toolbar.tsx b/apps/cms/src/components/editor/image-toolbar.tsx new file mode 100644 index 00000000..f1aaf9ec --- /dev/null +++ b/apps/cms/src/components/editor/image-toolbar.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { Button } from "@marble/ui/components/button"; +import { Input } from "@marble/ui/components/input"; +import { Label } from "@marble/ui/components/label"; +import { useCurrentEditor } from "@tiptap/react"; +import { BubbleMenu as TiptapBubbleMenu } from "@tiptap/react/menus"; +import { memo, useCallback, useEffect, useState } from "react"; + +function ImageToolbarComponent() { + const { editor } = useCurrentEditor(); + const [alt, setAlt] = useState(""); + const [caption, setCaption] = useState(""); + + // Update local state when selection changes + useEffect(() => { + if (!editor) { + return; + } + + const updateState = () => { + const { selection } = editor.state; + const node = editor.state.doc.nodeAt(selection.from); + + if (node?.type.name === "figure") { + setAlt(node.attrs.alt || ""); + setCaption(node.attrs.caption || ""); + } + }; + + updateState(); + editor.on("selectionUpdate", updateState); + + return () => { + editor.off("selectionUpdate", updateState); + }; + }, [editor]); + + const handleAltChange = useCallback( + (e: React.ChangeEvent) => { + const newAlt = e.target.value; + setAlt(newAlt); + editor?.commands.updateFigure({ alt: newAlt }); + }, + [editor] + ); + + const handleCaptionChange = useCallback( + (e: React.ChangeEvent) => { + const newCaption = e.target.value; + setCaption(newCaption); + editor?.commands.updateFigure({ caption: newCaption }); + }, + [editor] + ); + + const shouldShow = useCallback( + ({ editor: ed }: { editor: typeof editor }) => { + if (!ed) { + return false; + } + + const { selection } = ed.state; + const node = ed.state.doc.nodeAt(selection.from); + + return node?.type.name === "figure"; + }, + [] + ); + + if (!editor) { + return null; + } + + return ( + +
+ + +
+
+ + +
+
+ ); +} + +export const ImageToolbar = memo(ImageToolbarComponent); diff --git a/apps/cms/src/components/editor/image-upload-modal.tsx b/apps/cms/src/components/editor/image-upload-modal.tsx index 8946e6f6..12fc66ed 100644 --- a/apps/cms/src/components/editor/image-upload-modal.tsx +++ b/apps/cms/src/components/editor/image-upload-modal.tsx @@ -18,8 +18,9 @@ import { } from "@marble/ui/components/tabs"; import { ImagesIcon, SpinnerIcon } from "@phosphor-icons/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { Editor } from "@tiptap/core"; +import { useCurrentEditor } from "@tiptap/react"; import Image from "next/image"; -import { useEditor } from "novel"; import { useState } from "react"; import { ImageDropzone } from "@/components/shared/dropzone"; import { AsyncButton } from "@/components/ui/async-button"; @@ -31,21 +32,27 @@ import type { Media, MediaListResponse } from "@/types/media"; type ImageUploadModalProps = { isOpen: boolean; setIsOpen: React.Dispatch>; + editor?: Editor | null; }; -export function ImageUploadModal({ isOpen, setIsOpen }: ImageUploadModalProps) { +export function ImageUploadModal({ + isOpen, + setIsOpen, + editor: editorProp, +}: ImageUploadModalProps) { const [embedUrl, setEmbedUrl] = useState(""); const [file, setFile] = useState(); const [isValidatingUrl, setIsValidatingUrl] = useState(false); const workspaceId = useWorkspaceId(); - const editorInstance = useEditor(); + const { editor: editorFromContext } = useCurrentEditor(); + const editor = editorProp || editorFromContext; const queryClient = useQueryClient(); const { mutate: uploadImage, isPending: isUploading } = useMutation({ mutationFn: (file: File) => uploadFile({ file, type: "media" }), onSuccess: (data: Media) => { if (data?.url) { - editorInstance.editor + editor ?.chain() .focus() .setImage({ src: data.url }) @@ -69,7 +76,7 @@ export function ImageUploadModal({ isOpen, setIsOpen }: ImageUploadModalProps) { }); const handleEmbed = async (url: string) => { - if (!url || !editorInstance.editor) { + if (!url || !editor) { return; } @@ -77,8 +84,8 @@ export function ImageUploadModal({ isOpen, setIsOpen }: ImageUploadModalProps) { setIsValidatingUrl(true); const img = new window.Image(); img.onload = () => { - if (editorInstance.editor) { - editorInstance.editor + if (editor) { + editor .chain() .focus() .setImage({ src: url }) diff --git a/apps/cms/src/components/editor/image-upload.ts b/apps/cms/src/components/editor/image-upload.ts deleted file mode 100644 index 6505c381..00000000 --- a/apps/cms/src/components/editor/image-upload.ts +++ /dev/null @@ -1,67 +0,0 @@ -"use client"; - -import { createImageUpload } from "novel"; -import { toast } from "sonner"; -import { - ALLOWED_RASTER_MIME_TYPES, - MAX_MEDIA_FILE_SIZE, -} from "@/lib/constants"; -import { uploadFile } from "@/lib/media/upload"; - -const onUpload = (file: File) => { - console.log("uploading image", file); - return new Promise((resolve, reject) => { - toast.promise( - uploadFile({ - file, - type: "media", - }).then((response) => { - // Preload the image for better UX - const image = new Image(); - image.src = response.url; - image.onload = () => { - resolve(response.url); - }; - image.onerror = () => { - // Even if preload fails, resolve with the URL - resolve(response.url); - }; - }), - { - loading: "Uploading image...", - success: "Image uploaded successfully.", - error: (e) => { - reject(e); - return e instanceof Error - ? e.message - : "Error uploading image. Please try again."; - }, - } - ); - }); -}; - -export const uploadFn = createImageUpload({ - onUpload, - validateFn: (file) => { - // Only allow raster images for editor content (no SVG or video) - if ( - !ALLOWED_RASTER_MIME_TYPES.includes( - file.type as (typeof ALLOWED_RASTER_MIME_TYPES)[number] - ) - ) { - toast.error( - `File type not supported. Allowed types: ${ALLOWED_RASTER_MIME_TYPES.join(", ")}` - ); - return false; - } - - const maxSizeMB = MAX_MEDIA_FILE_SIZE / 1024 / 1024; - if (file.size > MAX_MEDIA_FILE_SIZE) { - toast.error(`File size too big (max ${maxSizeMB}MB).`); - return false; - } - - return true; - }, -}); diff --git a/apps/cms/src/components/editor/keyboard-key.tsx b/apps/cms/src/components/editor/keyboard-key.tsx new file mode 100644 index 00000000..aa87d79c --- /dev/null +++ b/apps/cms/src/components/editor/keyboard-key.tsx @@ -0,0 +1,29 @@ +import { cn } from "@marble/ui/lib/utils"; +import type { Icon } from "@phosphor-icons/react"; + +type KeyboardKeyProps = { + children?: string; + icon?: Icon; + className?: string; +}; + +export function KeyboardKey({ + children, + icon: IconComponent, + className, +}: KeyboardKeyProps) { + return ( + + {IconComponent ? ( + + ) : ( + children + )} + + ); +} diff --git a/apps/cms/src/components/editor/link-selector.tsx b/apps/cms/src/components/editor/link-selector.tsx index 8d641159..33f6c38d 100644 --- a/apps/cms/src/components/editor/link-selector.tsx +++ b/apps/cms/src/components/editor/link-selector.tsx @@ -1,16 +1,23 @@ +"use client"; + import { Button } from "@marble/ui/components/button"; -import { Label } from "@marble/ui/components/label"; import { Popover, PopoverContent, PopoverTrigger, } from "@marble/ui/components/popover"; import { Separator } from "@marble/ui/components/separator"; -import { Switch } from "@marble/ui/components/switch"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@marble/ui/components/tooltip"; import { cn } from "@marble/ui/lib/utils"; -import { CheckIcon, LinkSimpleIcon, TrashIcon } from "@phosphor-icons/react"; -import { useEditor } from "novel"; +import type { Editor } from "@tiptap/core"; +import { useCurrentEditor, useEditorState } from "@tiptap/react"; +import { Check, ExternalLink, Link, Maximize2, Trash2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { useFloatingPortalContainer } from "@/components/editor/floating-portal-context"; export function isValidUrl(url: string) { try { @@ -41,35 +48,63 @@ type LinkSelectorProps = { export const LinkSelector = ({ open, onOpenChange }: LinkSelectorProps) => { const inputRef = useRef(null); - const { editor } = useEditor(); + const { editor } = useCurrentEditor(); + const [internalOpen, setInternalOpen] = useState(false); const [openInNewTab, setOpenInNewTab] = useState(true); const [inputValue, setInputValue] = useState(""); + const portalContainer = useFloatingPortalContainer(); + + // Use internal state if no props provided + const isOpen = open ?? internalOpen; + const setIsOpen = onOpenChange ?? setInternalOpen; + // Track link active state reactively for proper re-rendering + const isLinkActive = useEditorState({ + editor: editor as Editor, + selector: (ctx) => ctx.editor?.isActive("link") ?? false, + }); + + // Sync input value when popover opens useEffect(() => { - inputRef.current?.focus(); - }, []); + if (isOpen) { + const currentLink = editor?.getAttributes("link").href || ""; + setInputValue(currentLink); + inputRef.current?.focus(); + } + }, [isOpen, editor]); + if (!editor) { return null; } return ( - - - - - - {/** biome-ignore lint/a11y/noNoninteractiveElementInteractions: It's acting as a button */} - {/** biome-ignore lint/a11y/noStaticElementInteractions: It's acting as a button */} + + + + + + + + +

Set link

+
+
+ + {/** biome-ignore lint: Handles Enter key for setting link */}
{ if (e.key === "Enter") { e.preventDefault(); @@ -83,67 +118,119 @@ export const LinkSelector = ({ open, onOpenChange }: LinkSelectorProps) => { target: openInNewTab ? "_blank" : "_self", }) .run(); + setInputValue(""); + setIsOpen(false); } } }} > -
- setInputValue(target.value)} - placeholder="Paste or type link" - ref={inputRef} - type="text" - /> - {editor.getAttributes("link").href ? ( + setInputValue(target.value)} + placeholder="Paste or type link" + ref={inputRef} + type="text" + value={inputValue} + /> + {editor.getAttributes("link").href ? ( + + + + + +

Remove link

+
+
+ ) : ( + + + + + +

Set link

+
+
+ )} + + + - ) : ( + + +

{openInNewTab ? "Opens in new tab" : "Opens in same tab"}

+
+
+ + - )} -
- -
- - -
+ + +

Open link

+
+
diff --git a/apps/cms/src/components/editor/slash-command-items.tsx b/apps/cms/src/components/editor/slash-command-items.tsx deleted file mode 100644 index 721c8a5a..00000000 --- a/apps/cms/src/components/editor/slash-command-items.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { - CheckSquareIcon, - CodeIcon, - ImageIcon, - ListIcon, - ListNumbersIcon, - QuotesIcon, - TextAlignLeftIcon, - TextHFourIcon, - TextHThreeIcon, - TextHTwoIcon, - YoutubeLogoIcon, -} from "@phosphor-icons/react"; -import { Command, createSuggestionItems, renderItems } from "novel"; - -export const suggestionItems = createSuggestionItems([ - { - title: "Text", - description: "Just start typing with plain text.", - searchTerms: ["p", "paragraph"], - icon: , - command: ({ editor, range }) => { - editor - .chain() - .focus() - .deleteRange(range) - .toggleNode("paragraph", "paragraph") - .run(); - }, - }, - { - title: "Heading 2", - description: "Medium section heading.", - searchTerms: ["subtitle", "medium"], - icon: , - command: ({ editor, range }) => { - editor - .chain() - .focus() - .deleteRange(range) - .setNode("heading", { level: 2 }) - .run(); - }, - }, - { - title: "Heading 3", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - command: ({ editor, range }) => { - editor - .chain() - .focus() - .deleteRange(range) - .setNode("heading", { level: 3 }) - .run(); - }, - }, - { - title: "Heading 4", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - command: ({ editor, range }) => { - editor - .chain() - .focus() - .deleteRange(range) - .setNode("heading", { level: 4 }) - .run(); - }, - }, - { - title: "Bullet List", - description: "Create a simple bullet list.", - searchTerms: ["unordered", "point"], - icon: , - command: ({ editor, range }) => { - editor - .chain() - .focus() - .deleteRange(range) - .toggleList("bulletList", "listItem") - .run(); - }, - }, - { - title: "Numbered List", - description: "Create a list with numbering.", - searchTerms: ["ordered"], - icon: , - command: ({ editor, range }) => { - editor - .chain() - .focus() - .deleteRange(range) - .toggleList("orderedList", "listItem") - .run(); - }, - }, - { - title: "Quote", - description: "Capture a quote.", - searchTerms: ["blockquote"], - icon: , - command: ({ editor, range }) => - editor - .chain() - .focus() - .deleteRange(range) - .toggleNode("paragraph", "paragraph") - .toggleNode("blockquote", "blockquote") - .run(), - }, - { - title: "Code Block", - description: "Capture code snippets.", - searchTerms: ["code", "block"], - icon: , - command: ({ editor, range }) => - editor - .chain() - .focus() - .deleteRange(range) - .toggleNode("codeBlock", "codeBlock") - .run(), - }, - { - title: "To-do List", - description: "Track tasks with a to-do list.", - searchTerms: ["todo", "task", "list", "check", "checkbox"], - icon: , - command: ({ editor, range }) => { - editor - .chain() - .focus() - .deleteRange(range) - .toggleList("taskList", "listItem") - .run(); - }, - }, - { - title: "Image", - description: "Upload an image from your device.", - searchTerms: ["photo", "picture", "media"], - icon: , - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).run(); - // upload image - const input = document.createElement("input"); - input.type = "file"; - input.accept = "image/*"; - input.onchange = async () => { - if (input.files?.length) { - const file = input.files[0]; - const pos = editor.view.state.selection.from; - // if (file) uploadFile(file, editor.view, pos); - } - }; - input.click(); - }, - }, - { - title: "YouTube", - description: "Embed a YouTube video", - icon: , - }, -]); - -export const slashCommand = Command.configure({ - suggestion: { - items: () => suggestionItems, - render: renderItems, - }, -}); diff --git a/apps/cms/src/components/editor/slash-command-menu.tsx b/apps/cms/src/components/editor/slash-command-menu.tsx deleted file mode 100644 index b2f7e76c..00000000 --- a/apps/cms/src/components/editor/slash-command-menu.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { - EditorCommand, - EditorCommandEmpty, - EditorCommandItem, - EditorCommandList, -} from "novel"; -import { useState } from "react"; -import { ImageUploadModal } from "./image-upload-modal"; -import { suggestionItems } from "./slash-command-items"; -import { YoutubeEmbedModal } from "./youtube-embed-modal"; - -export function SlashCommandMenu() { - const [showImageModal, setShowImageModal] = useState(false); - const [showYoutubeModal, setShowYoutubeModal] = useState(false); - - return ( - <> - - - No results - - - {suggestionItems.map((item) => ( - { - if (item.title === "Image") { - if (val.editor && val.range) { - val.editor.chain().focus().deleteRange(val.range).run(); - } - setShowImageModal(true); - } else if (item.title === "YouTube") { - if (val.editor && val.range) { - val.editor.chain().focus().deleteRange(val.range).run(); - } - setShowYoutubeModal(true); - } else { - item.command?.(val); - } - }} - value={item.title} - > -
- {item.icon} -
-
-

{item.title}

-
-
- ))} -
-
- - - - ); -} diff --git a/apps/cms/src/components/editor/slash-command/dropdown-button.tsx b/apps/cms/src/components/editor/slash-command/dropdown-button.tsx new file mode 100644 index 00000000..890634ea --- /dev/null +++ b/apps/cms/src/components/editor/slash-command/dropdown-button.tsx @@ -0,0 +1,38 @@ +import { cn } from "@marble/ui/lib/utils"; +import { forwardRef } from "react"; + +/* biome-ignore lint/nursery/noReactForwardRef: forwardRef is used intentionally for ref forwarding */ +export const DropdownButton = forwardRef< + HTMLButtonElement, + { + children: React.ReactNode; + isActive?: boolean; + onClick?: () => void; + disabled?: boolean; + className?: string; + } +>((props, ref) => { + const { children, isActive, onClick, disabled, className } = props; + + const buttonClass = cn( + "flex w-full items-center gap-2 rounded-[6px] bg-transparent px-2 py-1 text-left font-medium text-sm", + !isActive && !disabled && "hover:bg-accent", + isActive && !disabled && "bg-accent", + disabled && "cursor-not-allowed opacity-50", + className + ); + + return ( + + ); +}); + +DropdownButton.displayName = "DropdownButton"; diff --git a/apps/cms/src/components/editor/slash-command/groups.ts b/apps/cms/src/components/editor/slash-command/groups.ts new file mode 100644 index 00000000..13bdcf17 --- /dev/null +++ b/apps/cms/src/components/editor/slash-command/groups.ts @@ -0,0 +1,185 @@ +import { + BookOpen, + Code, + Columns as ColumnsIcon, + Heading1, + Heading2, + Heading3, + Image, + List, + ListOrdered, + ListTodo, + Minus, + Quote, + Table as TableIcon, + Type, + Youtube, +} from "lucide-react"; +import type { Group } from "./types"; + +export const GROUPS: Group[] = [ + { + name: "format", + title: "Format", + commands: [ + { + name: "text", + label: "Text", + description: "Just start typing with plain text.", + aliases: ["p", "paragraph"], + icon: Type, + action: (editor) => { + editor.chain().focus().toggleNode("paragraph", "paragraph").run(); + }, + }, + { + name: "heading1", + label: "Heading 1", + description: "High priority section title", + aliases: ["h1"], + icon: Heading1, + action: (editor) => { + editor.chain().focus().setNode("heading", { level: 1 }).run(); + }, + }, + { + name: "heading2", + label: "Heading 2", + description: "Medium priority section title", + aliases: ["h2"], + icon: Heading2, + action: (editor) => { + editor.chain().focus().setNode("heading", { level: 2 }).run(); + }, + }, + { + name: "heading3", + label: "Heading 3", + description: "Low priority section title", + aliases: ["h3"], + icon: Heading3, + action: (editor) => { + editor.chain().focus().setNode("heading", { level: 3 }).run(); + }, + }, + { + name: "bulletList", + label: "Bullet List", + description: "Unordered list of items", + aliases: ["ul"], + icon: List, + action: (editor) => { + editor.chain().focus().toggleBulletList().run(); + }, + }, + { + name: "numberedList", + label: "Numbered List", + description: "Ordered list of items", + aliases: ["ol"], + icon: ListOrdered, + action: (editor) => { + editor.chain().focus().toggleOrderedList().run(); + }, + }, + { + name: "taskList", + label: "Task List", + description: "Task list with todo items", + aliases: ["todo", "checklist"], + icon: ListTodo, + action: (editor) => { + editor.chain().focus().toggleTaskList().run(); + }, + }, + { + name: "blockquote", + label: "Blockquote", + description: "Element for quoting", + aliases: ["quote"], + icon: Quote, + action: (editor) => { + editor.chain().focus().toggleBlockquote().run(); + }, + }, + { + name: "codeBlock", + label: "Code Block", + description: "Code block with syntax highlighting", + aliases: ["code"], + icon: Code, + action: (editor) => { + editor.chain().focus().toggleCodeBlock().run(); + }, + }, + ], + }, + { + name: "insert", + title: "Insert", + commands: [ + { + name: "table", + label: "Table", + description: "Insert a table", + aliases: ["table"], + icon: TableIcon, + action: (editor) => { + editor + .chain() + .focus() + .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + .run(); + }, + }, + { + name: "columns", + label: "2 Columns", + description: "Add two column layout", + aliases: ["columns", "col", "2col"], + icon: ColumnsIcon, + action: (editor) => { + editor + .chain() + .focus() + .setColumns() + .focus(editor.state.selection.head - 1) + .run(); + }, + shouldBeHidden: (editor) => editor.isActive("columns"), + }, + { + name: "image", + label: "Image", + description: "Upload an image from your device", + aliases: ["img", "photo", "picture", "media"], + icon: Image, + action: (editor) => { + editor.chain().focus().setImageUpload().run(); + }, + }, + { + name: "youtube", + label: "YouTube", + description: "Embed a YouTube video", + aliases: ["video", "yt"], + icon: Youtube, + action: (editor) => { + editor.chain().focus().setYoutubeUpload().run(); + }, + }, + { + name: "horizontalRule", + label: "Horizontal Rule", + description: "Insert a horizontal divider", + aliases: ["hr", "divider"], + icon: Minus, + action: (editor) => { + editor.chain().focus().setHorizontalRule().run(); + }, + }, + ], + }, +]; + +export default GROUPS; diff --git a/apps/cms/src/components/editor/slash-command/index.ts b/apps/cms/src/components/editor/slash-command/index.ts new file mode 100644 index 00000000..40976d88 --- /dev/null +++ b/apps/cms/src/components/editor/slash-command/index.ts @@ -0,0 +1,262 @@ +import { computePosition, flip, offset, shift } from "@floating-ui/dom"; +// import type { Editor } from "@tiptap/core"; +import { Extension } from "@tiptap/core"; +import { PluginKey } from "@tiptap/pm/state"; +import { ReactRenderer } from "@tiptap/react"; +import Suggestion, { + type SuggestionKeyDownProps, + type SuggestionProps, +} from "@tiptap/suggestion"; +import { GROUPS } from "./groups"; +import { MenuList } from "./menu-list"; + +const extensionName = "slashCommand"; + +export const SlashCommand = Extension.create({ + name: extensionName, + + priority: 200, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + char: "/", + allowSpaces: true, + startOfLine: false, + pluginKey: new PluginKey(extensionName), + allow: ({ state }) => { + // Check if cursor is inside a table by examining the document structure + const $from = state.selection.$from; + + // Check the parent node directly + if ( + $from.parent.type.name === "tableCell" || + $from.parent.type.name === "tableHeader" + ) { + return false; // Disable slash command inside tables + } + + // Also check ancestors in case we're nested deeper + for (let d = $from.depth; d > 0; d -= 1) { + const nodeName = $from.node(d).type.name; + if (nodeName === "tableCell" || nodeName === "tableHeader") { + return false; // Disable slash command inside tables + } + } + + return true; // Allow slash command everywhere else + }, + command: ({ editor, props }) => { + const { view, state } = editor; + const { $head, $from } = view.state.selection; + + const end = $from.pos; + const from = $head?.nodeBefore + ? end - + ($head.nodeBefore.text?.substring( + $head.nodeBefore.text?.indexOf("/") + ).length ?? 0) + : $from.start(); + + const tr = state.tr.deleteRange(from, end); + view.dispatch(tr); + + props.action(editor); + view.focus(); + }, + items: ({ query }: { query: string }) => { + const withFilteredCommands = GROUPS.map((group) => ({ + ...group, + commands: group.commands + .filter((item) => { + const labelNormalized = item.label.toLowerCase().trim(); + const queryNormalized = query.toLowerCase().trim(); + + if (item.aliases) { + const aliases = item.aliases.map((alias) => + alias.toLowerCase().trim() + ); + + return ( + labelNormalized.includes(queryNormalized) || + aliases.includes(queryNormalized) + ); + } + + return labelNormalized.includes(queryNormalized); + }) + .filter((command) => + command.shouldBeHidden + ? !command.shouldBeHidden(this.editor) + : true + ), + })); + + const withoutEmptyGroups = withFilteredCommands.filter((group) => { + if (group.commands.length > 0) { + return true; + } + + return false; + }); + + return withoutEmptyGroups; + }, + render: () => { + let component: { + updateProps: (props: SuggestionProps) => void; + destroy: () => void; + element: HTMLElement; + ref?: { + onKeyDown?: (props: { event: KeyboardEvent }) => boolean; + } | null; + } | null = null; + let popup: HTMLElement | null = null; + let popupContainer: HTMLDivElement | null = null; + let isDestroyed = false; + + return { + onStart: (props: SuggestionProps) => { + // Reset destroyed flag for new menu session + isDestroyed = false; + + component = new ReactRenderer(MenuList, { + props, + editor: props.editor, + }); + + if (!props.clientRect) { + return; + } + + const rect = props.clientRect(); + if (!rect) { + return; + } + + // Create a dedicated container div for stability + popupContainer = document.createElement("div"); + popupContainer.setAttribute("contenteditable", "false"); + popupContainer.style.position = "absolute"; + popupContainer.style.top = "0"; + popupContainer.style.left = "0"; + popupContainer.style.zIndex = "50"; + popupContainer.style.opacity = "0"; + popupContainer.style.transition = "opacity 0.1s"; + + // Prevent mousedown from causing editor to lose focus + popupContainer.addEventListener("mousedown", (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + + // Mount the React component inside the container + if (!component) { + return; + } + popup = component.element; + popupContainer.appendChild(popup); + document.body.appendChild(popupContainer); + + // Create virtual element for Floating UI + const virtualElement = { + getBoundingClientRect: () => rect, + }; + + // Use Floating UI for positioning + computePosition(virtualElement, popupContainer, { + placement: "bottom-start", + middleware: [ + offset({ mainAxis: 8, crossAxis: 0 }), + flip(), + shift({ padding: 8 }), + ], + }).then(({ x, y }) => { + if (popupContainer && !isDestroyed) { + Object.assign(popupContainer.style, { + left: `${x}px`, + top: `${y}px`, + opacity: "1", + }); + } + }); + }, + + onUpdate(props: SuggestionProps) { + if (isDestroyed || !component || !popupContainer) { + return; + } + + component.updateProps(props); + + if (!props.clientRect || !popupContainer) { + return; + } + + const rect = props.clientRect(); + if (!rect) { + return; + } + + const virtualElement = { + getBoundingClientRect: () => rect, + }; + + computePosition(virtualElement, popupContainer, { + placement: "bottom-start", + middleware: [ + offset({ mainAxis: 8, crossAxis: 0 }), + flip(), + shift({ padding: 8 }), + ], + }).then(({ x, y }) => { + if (popupContainer && !isDestroyed) { + Object.assign(popupContainer.style, { + left: `${x}px`, + top: `${y}px`, + }); + } + }); + }, + + onKeyDown(props: SuggestionKeyDownProps) { + if (props.event.key === "Escape") { + if (!isDestroyed && popupContainer) { + popupContainer.remove(); + popupContainer = null; + } + if (!isDestroyed) { + component?.destroy(); + component = null; + popup = null; + isDestroyed = true; + } + return true; + } + + return component?.ref?.onKeyDown?.(props) || false; + }, + + onExit() { + if (isDestroyed) { + return; + } + + if (popupContainer) { + popupContainer.remove(); + popupContainer = null; + } + component?.destroy(); + component = null; + popup = null; + isDestroyed = true; + }, + }; + }, + }), + ]; + }, +}); + +export default SlashCommand; diff --git a/apps/cms/src/components/editor/slash-command/menu-list.tsx b/apps/cms/src/components/editor/slash-command/menu-list.tsx new file mode 100644 index 00000000..8e648f97 --- /dev/null +++ b/apps/cms/src/components/editor/slash-command/menu-list.tsx @@ -0,0 +1,180 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; +import { DropdownButton } from "./dropdown-button"; +import { Surface } from "./surface"; +import type { MenuListProps } from "./types"; + +/* biome-ignore lint/nursery/noReactForwardRef: forwardRef is used intentionally for ref forwarding */ +export const MenuList = forwardRef< + { onKeyDown: (props: { event: KeyboardEvent }) => boolean }, + MenuListProps +>((props, ref) => { + const scrollContainer = useRef(null); + const activeItem = useRef(null); + const [selectedGroupIndex, setSelectedGroupIndex] = useState(0); + const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); + + // Reset selection whenever menu items change + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally reset only when items change + useEffect(() => { + setSelectedGroupIndex(0); + setSelectedCommandIndex(0); + }, [props.items]); + + const selectItem = useCallback( + (groupIndex: number, commandIndex: number) => { + const group = props.items[groupIndex]; + if (!group) { + return; + } + + const command = group.commands[commandIndex]; + if (!command) { + return; + } + + props.command(command); + }, + [props] + ); + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: { event: KeyboardEvent }) => { + if (event.key === "ArrowDown") { + if (!props.items.length) { + return false; + } + + const currentGroup = props.items[selectedGroupIndex]; + if (!currentGroup) { + return false; + } + + const commands = currentGroup.commands; + let newCommandIndex = selectedCommandIndex + 1; + let newGroupIndex = selectedGroupIndex; + + if (newCommandIndex >= commands.length) { + newCommandIndex = 0; + newGroupIndex = (selectedGroupIndex + 1) % props.items.length; + } + + setSelectedCommandIndex(newCommandIndex); + setSelectedGroupIndex(newGroupIndex); + return true; + } + + if (event.key === "ArrowUp") { + if (!props.items.length) { + return false; + } + + let newCommandIndex = selectedCommandIndex - 1; + let newGroupIndex = selectedGroupIndex; + + if (newCommandIndex < 0) { + newGroupIndex = + selectedGroupIndex - 1 < 0 + ? props.items.length - 1 + : selectedGroupIndex - 1; + const newGroup = props.items[newGroupIndex]; + if (!newGroup) { + return false; + } + newCommandIndex = newGroup.commands.length - 1; + } + + setSelectedCommandIndex(newCommandIndex); + setSelectedGroupIndex(newGroupIndex); + return true; + } + + if (event.key === "Enter") { + if (!props.items.length) { + return false; + } + selectItem(selectedGroupIndex, selectedCommandIndex); + return true; + } + + return false; + }, + })); + + // biome-ignore lint/correctness/useExhaustiveDependencies: selectItem is stable + useEffect(() => { + if (activeItem.current && scrollContainer.current) { + const offsetTop = activeItem.current.offsetTop; + const offsetHeight = activeItem.current.offsetHeight; + scrollContainer.current.scrollTop = offsetTop - offsetHeight; + } + }, [selectedCommandIndex, selectedGroupIndex]); + + const createCommandClickHandler = useCallback( + (groupIndex: number, commandIndex: number) => () => + selectItem(groupIndex, commandIndex), + [selectItem] + ); + + return ( + + {props.items.length ? ( +
+ {props.items.map((group, groupIndex) => ( +
+
+ {group.title} +
+
+ {group.commands.map((command, commandIndex) => { + const Icon = command.icon; + const isActive = + selectedGroupIndex === groupIndex && + selectedCommandIndex === commandIndex; + + return ( + + + {command.label} + + ); + })} +
+
+ ))} +
+ ) : ( +
+ No results +
+ )} +
+ ); +}); + +MenuList.displayName = "MenuList"; + +export default MenuList; diff --git a/apps/cms/src/components/editor/slash-command/surface.tsx b/apps/cms/src/components/editor/slash-command/surface.tsx new file mode 100644 index 00000000..6609e393 --- /dev/null +++ b/apps/cms/src/components/editor/slash-command/surface.tsx @@ -0,0 +1,31 @@ +import { cn } from "@marble/ui/lib/utils"; +import type { HTMLProps } from "react"; +import { forwardRef } from "react"; + +export type SurfaceProps = HTMLProps & { + withShadow?: boolean; + withBorder?: boolean; +}; + +/* biome-ignore lint/nursery/noReactForwardRef: forwardRef is used intentionally for ref forwarding */ +export const Surface = forwardRef( + ( + { children, className, withShadow = true, withBorder = true, ...props }, + ref + ) => { + const surfaceClass = cn( + className, + "rounded-lg bg-background", + withShadow ? "shadow-xs" : "", + withBorder ? "border" : "" + ); + + return ( +
+ {children} +
+ ); + } +); + +Surface.displayName = "Surface"; diff --git a/apps/cms/src/components/editor/slash-command/types.ts b/apps/cms/src/components/editor/slash-command/types.ts new file mode 100644 index 00000000..699ab02c --- /dev/null +++ b/apps/cms/src/components/editor/slash-command/types.ts @@ -0,0 +1,24 @@ +import type { Editor } from "@tiptap/core"; +import type { LucideIcon } from "lucide-react"; + +export type Group = { + name: string; + title: string; + commands: Command[]; +}; + +export type Command = { + name: string; + label: string; + description: string; + aliases?: string[]; + icon: LucideIcon; + action: (editor: Editor) => void; + shouldBeHidden?: (editor: Editor) => boolean; +}; + +export type MenuListProps = { + editor: Editor; + items: Group[]; + command: (command: Command) => void; +}; diff --git a/apps/cms/src/components/editor/tabs/analysis-tab.tsx b/apps/cms/src/components/editor/tabs/analysis-tab.tsx index 57ac73e7..acd3329b 100644 --- a/apps/cms/src/components/editor/tabs/analysis-tab.tsx +++ b/apps/cms/src/components/editor/tabs/analysis-tab.tsx @@ -8,7 +8,7 @@ import { TooltipTrigger, } from "@marble/ui/components/tooltip"; import { ArrowClockwiseIcon, InfoIcon } from "@phosphor-icons/react"; -import type { EditorInstance } from "novel"; +import type { Editor } from "@tiptap/core"; import { useEffect, useState } from "react"; import { useReadability } from "@/hooks/use-readability"; import { useUnsavedChanges } from "@/providers/unsaved-changes"; @@ -18,7 +18,7 @@ import { ReadabilitySuggestions } from "../ai/readability-suggestions"; import { HiddenScrollbar } from "../hidden-scrollbar"; type AnalysisTabProps = { - editor?: EditorInstance | null; + editor?: Editor | null; aiSuggestions?: ReadabilitySuggestion[]; aiLoading?: boolean; onRefreshAi?: () => void; diff --git a/apps/cms/src/components/editor/text-buttons.tsx b/apps/cms/src/components/editor/text-buttons.tsx index f9843270..a15cd8b6 100644 --- a/apps/cms/src/components/editor/text-buttons.tsx +++ b/apps/cms/src/components/editor/text-buttons.tsx @@ -1,103 +1,413 @@ +"use client"; + import { Button } from "@marble/ui/components/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@marble/ui/components/popover"; +import { Separator } from "@marble/ui/components/separator"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@marble/ui/components/tooltip"; import { cn } from "@marble/ui/lib/utils"; +import { ArrowUpIcon } from "@phosphor-icons/react"; +import type { Editor } from "@tiptap/core"; +import { useCurrentEditor, useEditorState } from "@tiptap/react"; import { - TextBIcon as Bold, - type Icon, - TextItalicIcon as Italic, - TextStrikethroughIcon as StrikethroughIcon, - TextAlignCenterIcon, - TextAlignJustifyIcon, - TextAlignLeftIcon, - TextAlignRightIcon, - TextUnderlineIcon as UnderlineIcon, -} from "@phosphor-icons/react"; -import { useEditor } from "novel"; + AlignCenter, + AlignJustify, + AlignLeft, + AlignRight, + Bold, + Code, + EllipsisVertical, + FileCode, + Highlighter, + Italic, + Palette, + Strikethrough, + Subscript, + Superscript, + Underline, +} from "lucide-react"; +import { memo } from "react"; +import { useFloatingPortalContainer } from "@/components/editor/floating-portal-context"; +import { ColorPicker } from "./color-picker"; +import { ContentTypePicker } from "./content-type-picker"; +import { KeyboardKey } from "./keyboard-key"; +import { getModifierKey } from "./utils/platform"; export type SelectorItem = { name: string; - icon: Icon; - command: ( - editor: NonNullable["editor"]> - ) => void; - isActive: ( - editor: NonNullable["editor"]> - ) => boolean; + icon: typeof Bold; + command: (editor: Editor) => void; + isActive: (editor: Editor) => boolean; + tooltip: string; + shortcut?: string[]; }; -export const TextButtons = () => { - const { editor } = useEditor(); +// Define items array outside component to avoid recreation on every render +const BASIC_FORMATTING: SelectorItem[] = [ + { + name: "bold", + isActive: (editor) => editor.isActive("bold"), + command: (editor) => editor.chain().focus().toggleBold().run(), + icon: Bold, + tooltip: "Bold", + shortcut: ["Mod", "B"], + }, + { + name: "italic", + isActive: (editor) => editor.isActive("italic"), + command: (editor) => editor.chain().focus().toggleItalic().run(), + icon: Italic, + tooltip: "Italic", + shortcut: ["Mod", "I"], + }, + { + name: "underline", + isActive: (editor) => editor.isActive("underline"), + command: (editor) => editor.chain().focus().toggleUnderline().run(), + icon: Underline, + tooltip: "Underline", + shortcut: ["Mod", "U"], + }, + { + name: "strike", + isActive: (editor) => editor.isActive("strike"), + command: (editor) => editor.chain().focus().toggleStrike().run(), + icon: Strikethrough, + tooltip: "Strikethrough", + shortcut: ["Mod", "Shift", "X"], + }, + { + name: "code", + isActive: (editor) => editor.isActive("code"), + command: (editor) => editor.chain().focus().toggleCode().run(), + icon: Code, + tooltip: "Code", + shortcut: ["Mod", "E"], + }, + { + name: "codeBlock", + isActive: (editor) => editor.isActive("codeBlock"), + command: (editor) => editor.chain().focus().toggleCodeBlock().run(), + icon: FileCode, + tooltip: "Code block", + shortcut: ["Mod", "Alt", "C"], + }, +]; + +const SUBSCRIPT_SUPERSCRIPT: SelectorItem[] = [ + { + name: "subscript", + isActive: (editor) => editor.isActive("subscript"), + command: (editor) => editor.chain().focus().toggleSubscript().run(), + icon: Subscript, + tooltip: "Subscript", + shortcut: ["Mod", ","], + }, + { + name: "superscript", + isActive: (editor) => editor.isActive("superscript"), + command: (editor) => editor.chain().focus().toggleSuperscript().run(), + icon: Superscript, + tooltip: "Superscript", + shortcut: ["Mod", "."], + }, +]; + +const ALIGNMENT: SelectorItem[] = [ + { + name: "alignLeft", + isActive: (editor) => editor.isActive({ textAlign: "left" }), + command: (editor) => editor.chain().focus().setTextAlign("left").run(), + icon: AlignLeft, + tooltip: "Align left", + }, + { + name: "alignCenter", + isActive: (editor) => editor.isActive({ textAlign: "center" }), + command: (editor) => editor.chain().focus().setTextAlign("center").run(), + icon: AlignCenter, + tooltip: "Align center", + }, + { + name: "alignRight", + isActive: (editor) => editor.isActive({ textAlign: "right" }), + command: (editor) => editor.chain().focus().setTextAlign("right").run(), + icon: AlignRight, + tooltip: "Align right", + }, + { + name: "justify", + isActive: (editor) => editor.isActive({ textAlign: "justify" }), + command: (editor) => editor.chain().focus().setTextAlign("justify").run(), + icon: AlignJustify, + tooltip: "Justify", + }, +]; + +// Helper function to render keyboard shortcuts +function renderShortcut(shortcut: string[]) { + return shortcut.map((key) => { + let displayKey = key; + + // Replace "Mod" with platform-specific modifier + if (key === "Mod") { + displayKey = getModifierKey(); + } + + // Render Shift as icon, others as text + if (key === "Shift") { + return ; + } + + return {displayKey}; + }); +} + +function TextButtonsComponent() { + const { editor } = useCurrentEditor(); + const portalContainer = useFloatingPortalContainer(); + + // Track all active states reactively for proper re-rendering + const activeStates = useEditorState({ + editor: editor as Editor, + selector: (ctx) => ({ + bold: ctx.editor.isActive("bold"), + italic: ctx.editor.isActive("italic"), + underline: ctx.editor.isActive("underline"), + strike: ctx.editor.isActive("strike"), + code: ctx.editor.isActive("code"), + codeBlock: ctx.editor.isActive("codeBlock"), + highlight: ctx.editor.isActive("highlight"), + textColor: !!ctx.editor.getAttributes("textStyle").color, + subscript: ctx.editor.isActive("subscript"), + superscript: ctx.editor.isActive("superscript"), + alignLeft: ctx.editor.isActive({ textAlign: "left" }), + alignCenter: ctx.editor.isActive({ textAlign: "center" }), + alignRight: ctx.editor.isActive({ textAlign: "right" }), + justify: ctx.editor.isActive({ textAlign: "justify" }), + }), + }); + if (!editor) { return null; } - const items: SelectorItem[] = [ - { - name: "bold", - isActive: (editor) => editor.isActive("bold"), - command: (editor) => editor.chain().focus().toggleBold().run(), - icon: Bold, - }, - { - name: "italic", - isActive: (editor) => editor.isActive("italic"), - command: (editor) => editor.chain().focus().toggleItalic().run(), - icon: Italic, - }, - { - name: "underline", - isActive: (editor) => editor.isActive("underline"), - command: (editor) => editor.chain().focus().toggleUnderline().run(), - icon: UnderlineIcon, - }, - { - name: "strike", - isActive: (editor) => editor.isActive("strike"), - command: (editor) => editor.chain().focus().toggleStrike().run(), - icon: StrikethroughIcon, - }, - { - name: "alignLeft", - isActive: (editor) => editor.isActive({ textAlign: "left" }), - command: (editor) => editor.chain().focus().setTextAlign("left").run(), - icon: TextAlignLeftIcon, - }, - { - name: "alignRight", - isActive: (editor) => editor.isActive({ textAlign: "right" }), - command: (editor) => editor.chain().focus().setTextAlign("right").run(), - icon: TextAlignRightIcon, - }, - { - name: "alignCenter", - isActive: (editor) => editor.isActive({ textAlign: "center" }), - command: (editor) => editor.chain().focus().setTextAlign("center").run(), - icon: TextAlignCenterIcon, - }, - { - name: "justify", - isActive: (editor) => editor.isActive({ textAlign: "justify" }), - command: (editor) => editor.chain().focus().setTextAlign("justify").run(), - icon: TextAlignJustifyIcon, - }, - ]; - return ( -
- {items.map((item) => ( - + + +
+ {item.tooltip} + {item.shortcut && ( + + {renderShortcut(item.shortcut)} + + )} +
+
+ + ))} + + + + {/* Highlight Color */} + + + + + + + + +

Highlight text

+
+
+ - + editor.chain().focus().setHighlight({ color }).run() + } + onClear={() => editor.chain().focus().unsetHighlight().run()} /> - - ))} + +
+ + {/* Text Color */} + + + + + + + + +

Text color

+
+
+ + editor.chain().setColor(color).run()} + onClear={() => editor.chain().focus().unsetColor().run()} + /> + +
+ + + + {/* More Options */} + + + + + + + + +

More options

+
+
+ + {/* Subscript & Superscript */} + {SUBSCRIPT_SUPERSCRIPT.map((item) => ( + + + + + +
+ {item.tooltip} + {item.shortcut && ( + + {renderShortcut(item.shortcut)} + + )} +
+
+
+ ))} + + + + {/* Alignment buttons */} + {ALIGNMENT.map((item) => ( + + + + + +
+ {item.tooltip} + {item.shortcut && ( + + {renderShortcut(item.shortcut)} + + )} +
+
+
+ ))} +
+
); -}; +} + +// Memoize component to prevent unnecessary rerenders +export const TextButtons = memo(TextButtonsComponent); diff --git a/apps/cms/src/components/editor/utils/platform.ts b/apps/cms/src/components/editor/utils/platform.ts new file mode 100644 index 00000000..e9fd4606 --- /dev/null +++ b/apps/cms/src/components/editor/utils/platform.ts @@ -0,0 +1,17 @@ +/** + * Detects if the user is on a Mac + */ +export function isMac(): boolean { + if (typeof window === "undefined") { + return false; + } + return /Mac|iPod|iPhone|iPad/.test(window.navigator.platform); +} + +/** + * Returns the appropriate modifier key based on platform + * "Cmd" for Mac, "Ctrl" for other platforms + */ +export function getModifierKey(): string { + return isMac() ? "Cmd" : "Ctrl"; +} diff --git a/apps/cms/src/components/editor/youtube-embed-modal.tsx b/apps/cms/src/components/editor/youtube-embed-modal.tsx index aabfc8da..8e9347f2 100644 --- a/apps/cms/src/components/editor/youtube-embed-modal.tsx +++ b/apps/cms/src/components/editor/youtube-embed-modal.tsx @@ -8,28 +8,28 @@ import { DialogTitle, } from "@marble/ui/components/dialog"; import { Input } from "@marble/ui/components/input"; -import { useEditor } from "novel"; +import type { Editor } from "@tiptap/core"; +import { useCurrentEditor } from "@tiptap/react"; import { useState } from "react"; type YoutubeEmbedModalProps = { isOpen: boolean; setIsOpen: React.Dispatch>; + editor?: Editor | null; }; export function YoutubeEmbedModal({ isOpen, setIsOpen, + editor: editorProp, }: YoutubeEmbedModalProps) { const [url, setUrl] = useState(""); - const editorInstance = useEditor(); + const { editor: editorFromContext } = useCurrentEditor(); + const editor = editorProp || editorFromContext; const handleEmbed = (url: string) => { - if (editorInstance) { - editorInstance.editor - ?.chain() - .focus() - .setYoutubeVideo({ src: url }) - .run(); + if (editor) { + editor.chain().focus().setYoutubeVideo({ src: url }).run(); setIsOpen(false); setUrl(""); } diff --git a/apps/cms/src/hooks/use-readability.ts b/apps/cms/src/hooks/use-readability.ts index b2870812..9849115a 100644 --- a/apps/cms/src/hooks/use-readability.ts +++ b/apps/cms/src/hooks/use-readability.ts @@ -1,7 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import type { EditorInstance } from "novel"; +import type { Editor } from "@tiptap/core"; import { useMemo } from "react"; import { useDebounce } from "@/hooks/use-debounce"; import { fetchAiReadabilitySuggestionsStrings } from "@/lib/ai/readability"; @@ -14,7 +14,7 @@ import { } from "@/utils/readability"; type UseReadabilityParams = { - editor?: EditorInstance | null; + editor?: Editor | null; text: string; }; @@ -46,7 +46,7 @@ function countSyllablesForWord(word: string): number { return matches ? matches.length : 1; } -function computeMetrics(text: string, editor?: EditorInstance | null) { +function computeMetrics(text: string, editor?: Editor | null) { if (!text || text.trim().length === 0) { return { wordCount: 0, diff --git a/apps/cms/src/lib/utils/getRenderContainer.ts b/apps/cms/src/lib/utils/getRenderContainer.ts new file mode 100644 index 00000000..b6462e0d --- /dev/null +++ b/apps/cms/src/lib/utils/getRenderContainer.ts @@ -0,0 +1,47 @@ +import type { Editor } from "@tiptap/react"; + +export const getRenderContainer = (editor: Editor, nodeType: string) => { + const { + view, + state: { + selection: { from }, + }, + } = editor; + + const elements = document.querySelectorAll(".has-focus"); + const elementCount = elements.length; + const innermostNode = elements[elementCount - 1]; + const element = innermostNode; + + if ( + (element?.getAttribute("data-type") && + element.getAttribute("data-type") === nodeType) || + element?.classList?.contains(nodeType) + ) { + return element; + } + + const node = view.domAtPos(from).node; + let container: HTMLElement | null = null; + + if (node instanceof HTMLElement) { + container = node; + } else { + container = node.parentElement; + } + + while ( + container && + !( + container.getAttribute("data-type") && + container.getAttribute("data-type") === nodeType + ) && + !container.classList.contains(nodeType) + ) { + container = container.parentElement; + } + + return container; +}; + +export default getRenderContainer; diff --git a/apps/cms/src/styles/editor.css b/apps/cms/src/styles/editor.css index e8df7814..0c0a6152 100644 --- a/apps/cms/src/styles/editor.css +++ b/apps/cms/src/styles/editor.css @@ -1,7 +1,49 @@ +/* Color picker full width */ +.react-colorful { + width: 100% !important; +} + +/* Slash command menu theme */ +[data-theme="slash-command"] { + width: max(16rem, min(100%, 48rem)); + max-width: 90vw; +} + +/* Slash command menu scrollbar - thin grey aesthetic */ +.slash-command-scrollbar::-webkit-scrollbar { + width: 4px !important; +} + +.slash-command-scrollbar::-webkit-scrollbar-track { + background: hsl(var(--muted) / 0.3) !important; + border-radius: 2px !important; +} + +.slash-command-scrollbar::-webkit-scrollbar-thumb { + background: hsl(var(--muted-foreground) / 0.5) !important; + border-radius: 2px !important; +} + +.slash-command-scrollbar::-webkit-scrollbar-thumb:hover { + background: hsl(var(--muted-foreground) / 0.7) !important; +} + +.slash-command-scrollbar::-webkit-scrollbar-button { + display: none !important; + height: 0 !important; + width: 0 !important; +} + +/* Firefox scrollbar */ +.slash-command-scrollbar { + scrollbar-width: thin !important; + scrollbar-color: hsl(var(--muted-foreground) / 0.5) hsl(var(--muted) / 0.3) !important; +} + .ProseMirror .is-editor-empty:first-child::before { content: attr(data-placeholder); float: left; - color: hsl(var(--muted-foreground)); + color: hsl(from var(--muted-foreground) h s l / 0.5); pointer-events: none; height: 0; font-size: 16px; @@ -9,7 +51,7 @@ .ProseMirror .is-empty::before { content: attr(data-placeholder); float: left; - color: hsl(var(--muted-foreground)); + color: hsl(from var(--muted-foreground) h s l / 0.5); pointer-events: none; height: 0; } @@ -29,6 +71,26 @@ } } +/* Figure and caption styles */ +.ProseMirror figure { + margin: 1.5rem 0; + display: block; +} + +.ProseMirror figure img { + display: block; + width: 100%; + margin: 0; +} + +.ProseMirror figure figcaption { + margin-top: 0.5rem; + font-size: 0.875rem; + color: hsl(var(--muted-foreground)); + font-style: italic; + text-align: center; +} + .img-placeholder { position: relative; @@ -123,49 +185,6 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { box-shadow: none; } -.drag-handle { - position: fixed; - opacity: 1; - transition: opacity ease-in 0.2s; - border-radius: 0.25rem; - - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(0, 0, 0, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E"); - background-size: calc(0.5em + 0.375rem) calc(0.5em + 0.375rem); - background-repeat: no-repeat; - background-position: center; - width: 1.2rem; - height: 1.5rem; - z-index: 50; - cursor: grab; - - /* biome-ignore lint/style/noDescendingSpecificity: <> */ - &:hover { - background-color: var(--novel-stone-100); - transition: background-color 0.2s; - } - - /* biome-ignore lint/style/noDescendingSpecificity: <> */ - &:active { - background-color: var(--novel-stone-200); - transition: background-color 0.2s; - cursor: grabbing; - } - - &.hide { - opacity: 0; - pointer-events: none; - } - - @media screen and (max-width: 600px) { - display: none; - pointer-events: none; - } -} - -.dark .drag-handle { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(255, 255, 255, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E"); -} - /* Custom Youtube Video CSS */ iframe { border: 8px solid #ffd00027; @@ -208,6 +227,105 @@ mark[style] > strong { color: inherit; } +/* Columns */ +[data-type="columns"] { + display: grid; + gap: 1rem; + margin-top: 3.5rem; + margin-bottom: 3rem; + + &.layout-sidebar-left { + grid-template-columns: 40fr 60fr; + } + + &.layout-sidebar-right { + grid-template-columns: 60fr 40fr; + } + + &.layout-two-column { + grid-template-columns: 1fr 1fr; + } + + &.has-focus [data-type="column"], + &:hover [data-type="column"] { + border-color: rgb(212 212 212); /* neutral-300 */ + } + + [data-type="column"].has-focus { + border-color: rgb(163 163 163); /* neutral-400 */ + } + + /* Hide placeholder on the columns container itself */ + &.is-empty::before, + &.is-editor-empty::before { + display: none !important; + } + + /* Hide placeholders that are direct children of columns (they shouldn't exist) */ + & > .is-empty::before, + & > .is-editor-empty::before, + & > p.is-empty::before, + & > p.is-editor-empty::before { + display: none !important; + } + + /* Completely hide any paragraph that's a direct child of columns */ + /* biome-ignore lint/style/noDescendingSpecificity: Specific override intentional for nested paragraph */ + & > p { + display: none !important; + } +} + +/* biome-ignore lint/style/noDescendingSpecificity: Specific override intentional for nested paragraph */ +[data-type="column"] { + overflow: auto; + border-radius: 0.25rem; + border-width: 2px; + border-style: dotted; + border-color: rgb(64 64 64); /* neutral-700 */ + padding: 0.25rem; + transition: border 160ms cubic-bezier(0.45, 0.05, 0.55, 0.95); + + /* biome-ignore lint/style/noDescendingSpecificity: Specific override intentional for nested element */ + &:hover { + border-color: rgb(245 245 245); /* neutral-100 */ + } + + &:has(.is-active), + &.has-focus { + border-color: rgb(163 163 163); /* neutral-400 */ + } +} + +@media (prefers-color-scheme: dark) { + [data-type="columns"] { + &.has-focus [data-type="column"], + &:hover [data-type="column"] { + border-color: rgb(64 64 64); /* neutral-700 */ + } + + [data-type="column"].has-focus { + border-color: rgb(82 82 82); /* neutral-600 */ + } + } + + /* biome-ignore lint/style/noDescendingSpecificity: Specific override intentional for nested element */ + [data-type="column"] { + /* biome-ignore lint/style/noDescendingSpecificity: Specific override intentional for nested element */ + &:hover { + border-color: rgb(23 23 23); /* neutral-900 */ + } + + &:has(.is-active), + &.has-focus { + border-color: rgb(82 82 82); /* neutral-600 */ + } + } +} + +.prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) { + margin: 0 !important; +} /* codeblocks */ pre { diff --git a/apps/cms/src/styles/globals.css b/apps/cms/src/styles/globals.css index 03383cca..501780f5 100644 --- a/apps/cms/src/styles/globals.css +++ b/apps/cms/src/styles/globals.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "./table.css"; /* UI component utilities */ @source "../../../../packages/ui/src/components/**/*.{ts,tsx}"; @@ -237,6 +238,9 @@ body { @apply bg-background text-foreground; } + button { + @apply cursor-pointer; + } .darkbackground { background-color: hsl(150, 4%, 9%); } diff --git a/apps/cms/src/styles/table.css b/apps/cms/src/styles/table.css new file mode 100644 index 00000000..1436a839 --- /dev/null +++ b/apps/cms/src/styles/table.css @@ -0,0 +1,132 @@ +.ProseMirror { + .tableWrapper { + @apply my-12; + } + + table { + @apply border-collapse border-black/10 rounded box-border w-full; + @apply dark:border-white/20; + + td, + th { + @apply border border-black/10 min-w-[100px] p-2 relative text-left align-top; + @apply dark:border-white/20; + + &:first-of-type:not(a) { + @apply mt-0; + } + + p { + @apply m-0; + + & + p { + @apply mt-3; + } + } + } + + th { + @apply font-bold; + } + + .column-resize-handle { + @apply -bottom-[2px] flex pointer-events-none absolute -right-1 top-0 w-2; + + &::before { + @apply bg-black/20 h-full w-[1px] ml-2; + @apply dark:bg-white/20; + content: ""; + } + } + + .selectedCell { + @apply bg-black/5 border-black/20 border-double; + @apply dark:bg-white/10 dark:border-white/20; + } + + .grip-column, + .grip-row { + @apply items-center bg-black/5 cursor-pointer flex justify-center absolute z-10; + @apply dark:bg-white/10; + } + + .grip-column { + @apply w-[calc(100%+1px)] border-l border-black/20 h-3 left-0 -ml-[1px] -top-3; + @apply dark:border-white/20; + + &:hover, + &.selected { + &::before { + content: ""; + @apply w-2.5; + } + } + + &:hover { + @apply bg-black/10; + @apply dark:bg-white/20; + + &::before { + @apply border-b-2 border-dotted border-black/60; + @apply dark:border-white/60; + } + } + + &.first { + @apply border-transparent rounded-tl-sm; + } + + &.last { + @apply rounded-tr-sm; + } + + &.selected { + @apply bg-black/30 border-black/30 shadow-sm; + @apply dark:bg-white/30 dark:border-white/30; + + &::before { + @apply border-b-2 border-dotted; + } + } + } + + .grip-row { + @apply h-[calc(100%+1px)] border-t border-black/20 -left-3 w-3 top-0 -mt-[1px]; + @apply dark:border-white/20; + + &:hover, + &.selected { + &::before { + @apply h-2.5; + content: ""; + } + } + &:hover { + @apply bg-black/10; + @apply dark:bg-white/20; + + &::before { + @apply border-l-2 border-dotted border-black/60; + @apply dark:border-white/60; + } + } + + &.first { + @apply border-transparent rounded-tl-sm; + } + + &.last { + @apply rounded-bl-sm; + } + + &.selected { + @apply bg-black/30 border-black/30 shadow-sm; + @apply dark:bg-white/30 dark:border-white/30; + + &::before { + @apply border-l-2 border-dotted; + } + } + } + } +} diff --git a/apps/cms/src/utils/readability.ts b/apps/cms/src/utils/readability.ts index 8cf87a64..1b30a286 100644 --- a/apps/cms/src/utils/readability.ts +++ b/apps/cms/src/utils/readability.ts @@ -1,6 +1,6 @@ -import type { EditorInstance } from "novel"; +import type { Editor } from "@tiptap/core"; -export function calculateReadabilityScore(editor: EditorInstance): number { +export function calculateReadabilityScore(editor: Editor): number { const text = editor?.getText(); if (!text || text.trim().length === 0) { return 0; diff --git a/apps/web/src/components/BlogHeader.astro b/apps/web/src/components/BlogHeader.astro index 13533c08..6d4c587d 100644 --- a/apps/web/src/components/BlogHeader.astro +++ b/apps/web/src/components/BlogHeader.astro @@ -1,7 +1,7 @@ --- - import { SITE } from "@/lib/constants"; - import Container from "./Container.astro"; - import WordMark from "./icons/WordMark.astro"; +import { SITE } from "@/lib/constants"; +import Container from "./Container.astro"; +import WordMark from "./icons/WordMark.astro"; ---