diff --git a/app/ycode/components/RichTextEditor.tsx b/app/ycode/components/RichTextEditor.tsx index f30a29b5..bae516d9 100644 --- a/app/ycode/components/RichTextEditor.tsx +++ b/app/ycode/components/RichTextEditor.tsx @@ -16,7 +16,7 @@ import Document from '@tiptap/extension-document'; import Text from '@tiptap/extension-text'; import Paragraph from '@tiptap/extension-paragraph'; import History from '@tiptap/extension-history'; -import { EditorState } from '@tiptap/pm/state'; +import { EditorState, NodeSelection } from '@tiptap/pm/state'; import Placeholder from '@tiptap/extension-placeholder'; import Bold from '@tiptap/extension-bold'; import Italic from '@tiptap/extension-italic'; @@ -62,8 +62,10 @@ import { RichTextComponent } from '@/lib/tiptap-extensions/rich-text-component'; import { RichTextLink, getLinkSettingsFromMark } from '@/lib/tiptap-extensions/rich-text-link'; import { RichTextImage } from '@/lib/tiptap-extensions/rich-text-image'; import RichTextLinkPopover from './RichTextLinkPopover'; +import RichTextImagePopover from './RichTextImagePopover'; import RichTextComponentPicker from './RichTextComponentPicker'; import RichTextComponentBlock from './RichTextComponentBlock'; +import RichTextImageBlock from './RichTextImageBlock'; import type { CollectionFieldType, Layer, LinkSettings, LinkType, Asset } from '@/types'; import { DEFAULT_TEXT_STYLES } from '@/lib/text-format-utils'; import { useEditorStore } from '@/stores/useEditorStore'; @@ -253,6 +255,68 @@ const RichTextComponentWithNodeView = RichTextComponent.extend({ }, }); +/** + * RichTextImage with React node view for inline image editing. + * Renders the image with a selection ring; alt editing is handled by the toolbar popover. + */ +const RichTextImageWithNodeView = RichTextImage.extend({ + addNodeView() { + return ({ node: initialNode, getPos, editor }) => { + const container = document.createElement('div'); + container.contentEditable = 'false'; + + let currentNode = initialNode; + let isSelected = false; + + const root = createRoot(container); + + const renderBlock = () => { + root.render( + , + ); + }; + + container.addEventListener('click', () => { + const pos = getPos(); + if (typeof pos === 'number' && editor.isEditable) { + const tr = editor.state.tr.setSelection( + NodeSelection.create(editor.state.doc, pos) + ); + editor.view.dispatch(tr); + } + }); + + queueMicrotask(renderBlock); + + return { + dom: container, + stopEvent: () => true, + selectNode: () => { + isSelected = true; + renderBlock(); + }, + deselectNode: () => { + isSelected = false; + renderBlock(); + }, + update: (updatedNode) => { + if (updatedNode.type.name !== 'richTextImage') return false; + currentNode = updatedNode; + renderBlock(); + return true; + }, + destroy: () => { + setTimeout(() => root.unmount(), 0); + }, + }; + }; + }, +}); + /** * Custom Tiptap mark for dynamic text styles * Preserves the style keys from canvas text editor without applying visual styling @@ -330,6 +394,7 @@ const RichTextEditor = forwardRef(({ const isFullVariant = variant === 'full'; const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [linkPopoverOpen, setLinkPopoverOpen] = useState(false); + const [imagePopoverOpen, setImagePopoverOpen] = useState(false); const [componentPickerOpen, setComponentPickerOpen] = useState(false); const openFileManager = useEditorStore((s) => s.openFileManager); // Track if update is coming from editor to prevent infinite loop @@ -378,6 +443,7 @@ const RichTextEditor = forwardRef(({ ListItem, Blockquote, Code, + RichTextImageWithNodeView, HorizontalRule, RichTextImage, ]; @@ -639,6 +705,25 @@ const RichTextEditor = forwardRef(({ } }, [value, fields, allFields, editor, withFormatting]); + // Auto-open image popover when an image node is selected + useEffect(() => { + if (!editor || !withFormatting) return; + + const handleSelectionUpdate = () => { + const { selection } = editor.state; + const node = editor.state.doc.nodeAt(selection.from); + const isImage = node?.type.name === 'richTextImage'; + if (isImage && !imagePopoverOpen) { + setImagePopoverOpen(true); + } else if (!isImage && imagePopoverOpen) { + setImagePopoverOpen(false); + } + }; + + editor.on('selectionUpdate', handleSelectionUpdate); + return () => { editor.off('selectionUpdate', handleSelectionUpdate); }; + }, [editor, withFormatting, imagePopoverOpen]); + // Internal function to add a field variable const addFieldVariableInternal = useCallback((variableData: FieldVariable) => { if (!editor) return; @@ -763,7 +848,7 @@ const RichTextEditor = forwardRef(({
{/* Formatting toolbar - Full variant (CMS style like original TiptapEditor) */} {withFormatting && showFormattingToolbar && isFullVariant && ( -
+
setAltText(e.target.value)} + placeholder="Image description" + /> +
+
+ + + + ); +} diff --git a/app/ycode/components/RichTextLinkPopover.tsx b/app/ycode/components/RichTextLinkPopover.tsx index f8a6f868..29d0172c 100644 --- a/app/ycode/components/RichTextLinkPopover.tsx +++ b/app/ycode/components/RichTextLinkPopover.tsx @@ -13,8 +13,8 @@ import { Editor } from '@tiptap/core'; import { Button } from '@/components/ui/button'; import Icon from '@/components/ui/icon'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { Separator } from '@/components/ui/separator'; import RichTextLinkSettings from './RichTextLinkSettings'; +import SettingsPanel from './SettingsPanel'; import { getLinkSettingsFromMark } from '@/lib/tiptap-extensions/rich-text-link'; import type { Layer, CollectionField, Collection, LinkSettings, LinkType } from '@/types'; import type { FieldGroup } from './CollectionFieldSelector'; @@ -136,11 +136,7 @@ export default function RichTextLinkPopover({ const attrs = editor.getAttributes('richTextLink'); setLinkSettings(getLinkSettingsFromMark(attrs)); } else { - // Default to URL type for new links - setLinkSettings({ - type: 'url', - url: { type: 'dynamic_text', data: { content: '' } }, - }); + setLinkSettings(null); } } @@ -161,55 +157,35 @@ export default function RichTextLinkPopover({ } }, [isControlled, controlledOnOpenChange]); - // Handle settings change - const handleSettingsChange = useCallback((settings: LinkSettings | null) => { - setLinkSettings(settings); - }, []); - - // Apply link to selection - const handleApply = useCallback(() => { - if (!savedSelection) { - closePopover(); - return; - } + // Apply link settings to the editor immediately + const applyToEditor = useCallback((settings: LinkSettings | null, selection: { from: number; to: number } | null) => { + if (!selection) return; - const { from, to } = savedSelection; + const { from, to } = selection; + const markType = editor.schema.marks.richTextLink; + if (!markType) return; - if (!linkSettings) { - // Remove link if settings are null + if (!settings) { editor.chain() .focus() .setTextSelection({ from, to }) .unsetRichTextLink() .run(); - closePopover(); return; } - // Get the mark type from schema - const markType = editor.schema.marks.richTextLink; - if (!markType) { - closePopover(); - return; - } - - // Use a direct transaction to update/add the mark - editor.chain().focus().setTextSelection({ from, to }).run(); - - // Create and dispatch a transaction that removes old mark (if any) and adds new one const { state } = editor; const tr = state.tr; - - // Remove any existing richTextLink marks in the range tr.removeMark(from, to, markType); - // Add the new mark with updated settings - tr.addMark(from, to, markType.create(linkSettings as any)); - - // Dispatch the transaction + tr.addMark(from, to, markType.create(settings as any)); editor.view.dispatch(tr); + }, [editor]); - closePopover(); - }, [editor, linkSettings, savedSelection, closePopover]); + // Handle settings change — apply immediately + const handleSettingsChange = useCallback((settings: LinkSettings | null) => { + setLinkSettings(settings); + applyToEditor(settings, savedSelection); + }, [applyToEditor, savedSelection]); // Remove link from selection const handleRemove = useCallback(() => { @@ -251,65 +227,29 @@ export default function RichTextLinkPopover({ -
-
-

Link settings

-
- -
- -
- - - -
- {hadLinkOnOpen && ( - - )} - -
- - - - -
-
+ {}} + > + + + ); diff --git a/app/ycode/components/RichTextLinkSettings.tsx b/app/ycode/components/RichTextLinkSettings.tsx index 86dae949..d9e4a059 100644 --- a/app/ycode/components/RichTextLinkSettings.tsx +++ b/app/ycode/components/RichTextLinkSettings.tsx @@ -513,53 +513,45 @@ export default function RichTextLinkSettings({ ); return ( -
+
{/* Link Type */}
-
- handleLinkTypeChange(newVal as LinkType | 'none')} + > + handleLinkTypeChange('none') + : undefined} > - - - - - {linkTypeOptions.map((option, index) => { - if ('type' in option && option.type === 'separator') { - return ; - } - if ('value' in option) { - return ( - -
- - {option.label} -
-
- ); - } - return null; - })} -
- - {linkType !== 'none' && ( - handleLinkTypeChange('none')} - > - - - )} -
+ + + + {linkTypeOptions.map((option, index) => { + if ('type' in option && option.type === 'separator') { + return ; + } + if ('value' in option) { + return ( + +
+ + {option.label} +
+
+ ); + } + return null; + })} +
+
diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 9dbca355..80accedc 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -15,7 +15,7 @@ const buttonVariants = cva( secondary: 'bg-secondary text-muted-foreground hover:bg-secondary/70 backdrop-blur', purple: 'bg-purple-500/20 text-purple-300 hover:bg-purple-500/30', data: 'bg-blue-500/20 text-blue-300 hover:bg-blue-500/30', - ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 text-muted-foreground', + ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-secondary/70 text-muted-foreground', link: 'text-primary underline-offset-4 hover:underline', input: 'bg-input hover:bg-input/60 text-muted-foreground', white: 'bg-white text-neutral-900', diff --git a/components/ui/select.tsx b/components/ui/select.tsx index 6e3e3972..02f2f7fd 100644 --- a/components/ui/select.tsx +++ b/components/ui/select.tsx @@ -7,6 +7,7 @@ import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' import Icon from '@/components/ui/icon'; +import { Button } from '@/components/ui/button'; function Select({ ...props @@ -27,11 +28,11 @@ function SelectValue({ } const selectVariants = cva( - "border-transparent data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-[0px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex items-center justify-between gap-1 rounded-lg border bg-transparent px-2 py-1 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none cursor-pointer disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "w-full border-transparent data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-[0px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex items-center justify-between gap-1 rounded-lg border bg-transparent px-2 py-1 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none cursor-pointer disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { - default: 'bg-input hover:bg-input/60', + default: 'bg-input', ghost: 'hover:bg-input dark:hover:bg-input/70 border-transparent shadow-none backdrop-blur', overlay: 'bg-white/90 text-neutral-800 hover:bg-white dark:bg-neutral-800/90 dark:text-white dark:hover:bg-neutral-800 disabled:opacity-80', }, @@ -68,10 +69,12 @@ function SelectTrigger({ > {children} {onClear ? ( - { e.preventDefault(); e.stopPropagation(); @@ -86,8 +89,10 @@ function SelectTrigger({ }} onPointerDown={(e) => e.stopPropagation()} > - - +
+ +
+ ) : (