From accfbf3d6a7735c093a17feef957a38845e66051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Lun=C4=97nas?= <34475426+lunenas@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:08:30 +0200 Subject: [PATCH 01/31] fix: enable publish button on all routes Made-with: Cursor --- app/ycode/components/HeaderBar.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/ycode/components/HeaderBar.tsx b/app/ycode/components/HeaderBar.tsx index d926ea2b..0f702dd7 100644 --- a/app/ycode/components/HeaderBar.tsx +++ b/app/ycode/components/HeaderBar.tsx @@ -599,7 +599,6 @@ export default function HeaderBar({ setIsPublishing={setIsPublishing} baseUrl={baseUrl} publishedUrl={publishedUrl} - isDisabled={isSettingsRoute} onPublishSuccess={onPublishSuccess} /> From 36995920c8a60b0046f509d13318db61788f0220 Mon Sep 17 00:00:00 2001 From: Tristan Mouchet Date: Thu, 19 Mar 2026 14:56:04 +0100 Subject: [PATCH 02/31] fix: render link tags consistently on canvas and generated pages - Remove !isEditMode guard from isButtonWithLink and shouldWrapWithLink - Add w-fit class to edit mode className for buttons rendered as - Extract shared layerLinkContext to eliminate 3 duplicate constructions - Merge button-link and -layer-link blocks into single block - Add resolveLinkAttrs utility to deduplicate href/target/rel/download --- components/LayerRenderer.tsx | 140 +++++++++++------------------------ lib/link-utils.ts | 26 +++++++ 2 files changed, 71 insertions(+), 95 deletions(-) diff --git a/components/LayerRenderer.tsx b/components/LayerRenderer.tsx index 77483f34..863f4edd 100644 --- a/components/LayerRenderer.tsx +++ b/components/LayerRenderer.tsx @@ -44,7 +44,7 @@ import FilterableCollection from '@/components/FilterableCollection'; import LocaleSelector from '@/components/layers/LocaleSelector'; import { usePagesStore } from '@/stores/usePagesStore'; import { useSettingsStore } from '@/stores/useSettingsStore'; -import { generateLinkHref, type LinkResolutionContext } from '@/lib/link-utils'; +import { generateLinkHref, resolveLinkAttrs, type LinkResolutionContext } from '@/lib/link-utils'; import { collectEditorHiddenLayerIds, type HiddenLayerInfo } from '@/lib/animation-utils'; import AnimationInitializer from '@/components/AnimationInitializer'; import { transformLayerIdsForInstance, resolveVariableLinks } from '@/lib/resolve-components'; @@ -599,7 +599,6 @@ const LayerItem: React.FC<{ // Buttons with link settings render as directly instead of being // wrapped in which is invalid HTML const isButtonWithLink = layer.name === 'button' - && !isEditMode && !isInsideForm && isValidLinkSettings(layer.variables?.link); if (isButtonWithLink) { @@ -1421,6 +1420,7 @@ const LayerItem: React.FC<{ paragraphClasses, SWIPER_CLASS_MAP[layer.name], isSlideChild && 'swiper-slide', + buttonNeedsFit && 'w-fit', enableDragDrop && !isEditing && !isLockedByOther && 'cursor-default', isDragging && 'opacity-30', showProjection && 'outline outline-1 outline-dashed outline-blue-400 bg-blue-50/10', @@ -1463,6 +1463,25 @@ const LayerItem: React.FC<{ return null; } + // Shared link resolution context — only built once, reused by button links, + // layer links, and link wrappers. Skipped in edit mode (no resolution needed). + const layerLinkContext: LinkResolutionContext | undefined = isEditMode ? undefined : { + pages, + folders, + collectionItemSlugs, + collectionItemId: collectionLayerItemId, + pageCollectionItemId, + collectionItemData: collectionLayerData, + pageCollectionItemData: pageCollectionItemData || undefined, + isPreview, + locale: currentLocale, + translations, + getAsset, + anchorMap, + resolvedAssets, + layerDataMap: effectiveLayerDataMap, + }; + // Render element-specific content const renderContent = () => { // Component instances in EDIT MODE: render component's layers directly @@ -1623,65 +1642,15 @@ const LayerItem: React.FC<{ ...(!isEditMode && { suppressHydrationWarning: true }), }; - // When a button is rendered as , apply link attributes directly - if (isButtonWithLink && layer.variables?.link) { - const btnLinkSettings = layer.variables.link; - const btnLinkContext: LinkResolutionContext = { - pages, - folders, - collectionItemSlugs, - collectionItemId: collectionLayerItemId, - pageCollectionItemId, - collectionItemData: collectionLayerData, - pageCollectionItemData: pageCollectionItemData || undefined, - isPreview, - locale: currentLocale, - translations, - getAsset, - anchorMap, - resolvedAssets, - layerDataMap: effectiveLayerDataMap, - }; - const btnLinkHref = generateLinkHref(btnLinkSettings, btnLinkContext); - if (btnLinkHref) { - elementProps.href = btnLinkHref; - elementProps.target = btnLinkSettings.target || '_self'; - const btnLinkRel = btnLinkSettings.rel || (btnLinkSettings.target === '_blank' ? 'noopener noreferrer' : undefined); - if (btnLinkRel) elementProps.rel = btnLinkRel; - if (btnLinkSettings.download) elementProps.download = btnLinkSettings.download; + // Apply link attributes for elements rendered as (buttons with links or layers) + if (htmlTag === 'a' && layer.variables?.link) { + if (isButtonWithLink) { + elementProps.role = 'button'; + delete elementProps.type; } - elementProps.role = 'button'; - delete elementProps.type; - } - - // When an layer has link settings, apply href/target/rel directly - if (htmlTag === 'a' && !isButtonWithLink && !isEditMode && layer.variables?.link) { - const aLinkSettings = layer.variables.link; - if (isValidLinkSettings(aLinkSettings)) { - const aLinkContext: LinkResolutionContext = { - pages, - folders, - collectionItemSlugs, - collectionItemId: collectionLayerItemId, - pageCollectionItemId, - collectionItemData: collectionLayerData, - pageCollectionItemData: pageCollectionItemData || undefined, - isPreview, - locale: currentLocale, - translations, - getAsset, - anchorMap, - resolvedAssets, - layerDataMap: effectiveLayerDataMap, - }; - const aLinkHref = generateLinkHref(aLinkSettings, aLinkContext); - if (aLinkHref) { - elementProps.href = aLinkHref; - elementProps.target = aLinkSettings.target || '_self'; - const aLinkRel = aLinkSettings.rel || (aLinkSettings.target === '_blank' ? 'noopener noreferrer' : undefined); - if (aLinkRel) elementProps.rel = aLinkRel; - if (aLinkSettings.download) elementProps.download = aLinkSettings.download; - } + if (layerLinkContext && isValidLinkSettings(layer.variables.link)) { + const linkAttrs = resolveLinkAttrs(layer.variables.link, layerLinkContext); + if (linkAttrs) Object.assign(elementProps, linkAttrs); } } @@ -2886,53 +2855,34 @@ const LayerItem: React.FC<{ // Don't wrap layers inside component instances (they're not directly editable) let content = renderContent(); - // Wrap with link if layer has link settings (published mode only) - // In edit mode, links are not interactive to allow layer selection + // Wrap with link if layer has link settings // Skip for buttons — they render as directly (see isButtonWithLink) // Skip for layers — they already render as and nesting inside is invalid HTML const linkSettings = layer.variables?.link; - const shouldWrapWithLink = !isEditMode - && !isButtonWithLink + const shouldWrapWithLink = !isButtonWithLink && htmlTag !== 'a' && !subtreeHasInteractiveDescendants && isValidLinkSettings(linkSettings); if (shouldWrapWithLink && linkSettings) { - // Build link context for layer-level link resolution - const layerLinkContext: LinkResolutionContext = { - pages, - folders, - collectionItemSlugs, - collectionItemId: collectionLayerItemId, - pageCollectionItemId, - collectionItemData: collectionLayerData, - pageCollectionItemData: pageCollectionItemData || undefined, - isPreview, - locale: currentLocale, - translations, - getAsset, - anchorMap, - resolvedAssets, - layerDataMap: effectiveLayerDataMap, - }; - const linkHref = generateLinkHref(linkSettings, layerLinkContext); - - if (linkHref) { - const linkTarget = linkSettings.target || '_self'; - const linkRel = linkSettings.rel || (linkTarget === '_blank' ? 'noopener noreferrer' : undefined); - const linkDownload = linkSettings.download; - + if (isEditMode) { content = ( - + {content} ); + } else if (layerLinkContext) { + const linkAttrs = resolveLinkAttrs(linkSettings, layerLinkContext); + if (linkAttrs) { + content = ( + + {content} + + ); + } } } diff --git a/lib/link-utils.ts b/lib/link-utils.ts index 7568d51a..a9a40ea3 100644 --- a/lib/link-utils.ts +++ b/lib/link-utils.ts @@ -533,3 +533,29 @@ export function looksLikePhone(value: string): boolean { const digitCount = (trimmed.match(/\d/g) || []).length; return /^[\d\s\-()+.]*$/.test(trimmed) && digitCount >= 7; } + +export interface ResolvedLinkAttrs { + href: string; + target: string; + rel?: string; + download?: boolean; +} + +/** Resolve link settings to HTML anchor attributes (href, target, rel, download) */ +export function resolveLinkAttrs( + linkSettings: LinkSettings, + context: LinkResolutionContext +): ResolvedLinkAttrs | null { + const href = generateLinkHref(linkSettings, context); + if (!href) return null; + + const target = linkSettings.target || '_self'; + const rel = linkSettings.rel || (target === '_blank' ? 'noopener noreferrer' : undefined); + + return { + href, + target, + ...(rel && { rel }), + ...(linkSettings.download && { download: linkSettings.download }), + }; +} From 329183ab709d708b34f3d0b7a12910a1a5095ec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Lun=C4=97nas?= <34475426+lunenas@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:02:22 +0200 Subject: [PATCH 03/31] feat: add image alt text popover to rich text editor Click an image in the rich text editor to select it and auto-open an alt text popover anchored to the image toolbar button. Made-with: Cursor --- app/ycode/components/RichTextEditor.tsx | 211 +++++++++++++----- app/ycode/components/RichTextImageBlock.tsx | 33 +++ app/ycode/components/RichTextImagePopover.tsx | 118 ++++++++++ 3 files changed, 310 insertions(+), 52 deletions(-) create mode 100644 app/ycode/components/RichTextImageBlock.tsx create mode 100644 app/ycode/components/RichTextImagePopover.tsx diff --git a/app/ycode/components/RichTextEditor.tsx b/app/ycode/components/RichTextEditor.tsx index 78f2f006..ffd7feef 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'; @@ -61,8 +61,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'; @@ -250,6 +252,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 @@ -326,6 +390,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 @@ -374,7 +439,7 @@ const RichTextEditor = forwardRef(({ ListItem, Blockquote, Code, - RichTextImage, + RichTextImageWithNodeView, ]; // Always include heading extension so content with headings is preserved @@ -634,6 +699,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; @@ -975,33 +1059,45 @@ const RichTextEditor = forwardRef(({ variant="secondary" spacing={1} > - - - + + + + } + /> (({ {/* Insert Image / Component */}
- + trigger={ + + } + />
+ ); +} diff --git a/app/ycode/components/RichTextImagePopover.tsx b/app/ycode/components/RichTextImagePopover.tsx new file mode 100644 index 00000000..4d6eacb3 --- /dev/null +++ b/app/ycode/components/RichTextImagePopover.tsx @@ -0,0 +1,118 @@ +'use client'; + +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { Editor } from '@tiptap/core'; + +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import Icon from '@/components/ui/icon'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import SettingsPanel from './SettingsPanel'; + +export interface RichTextImagePopoverProps { + editor: Editor; + trigger: React.ReactNode; + open: boolean; + onOpenChange: (open: boolean) => void; + disabled?: boolean; +} + +export default function RichTextImagePopover({ + editor, + trigger, + open, + onOpenChange, + disabled = false, +}: RichTextImagePopoverProps) { + const [altText, setAltText] = useState(''); + const [savedPos, setSavedPos] = useState(null); + const inputRef = useRef(null); + + const saveAlt = useCallback(() => { + if (savedPos === null) return; + const node = editor.state.doc.nodeAt(savedPos); + if (node?.type.name === 'richTextImage' && node.attrs.alt !== altText) { + const tr = editor.state.tr.setNodeMarkup(savedPos, undefined, { + ...node.attrs, + alt: altText, + }); + editor.view.dispatch(tr); + } + }, [editor, altText, savedPos]); + + const handleOpenChange = useCallback((newOpen: boolean) => { + if (newOpen && disabled) return; + + if (newOpen) { + const { selection } = editor.state; + const node = editor.state.doc.nodeAt(selection.from); + if (node?.type.name === 'richTextImage') { + setAltText(node.attrs.alt || ''); + setSavedPos(selection.from); + } + } else { + saveAlt(); + } + + onOpenChange(newOpen); + }, [editor, onOpenChange, disabled, saveAlt]); + + useEffect(() => { + if (open) { + requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + } + }, [open]); + + return ( + + + {trigger} + + + e.preventDefault()} + > + {}} + action={ + + } + > +
+ +
+ setAltText(e.target.value)} + placeholder="Image description" + /> +
+
+
+
+
+ ); +} From ed493182fcda6c2e037e09e609e91d48d42cb70c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Lun=C4=97nas?= <34475426+lunenas@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:10:58 +0200 Subject: [PATCH 04/31] ui: matching UI with LinkSettings in right sidebar --- app/ycode/components/RichTextImagePopover.tsx | 12 -- app/ycode/components/RichTextLinkPopover.tsx | 122 +++++------------- app/ycode/components/RichTextLinkSettings.tsx | 76 +++++------ components/ui/select.tsx | 17 ++- 4 files changed, 78 insertions(+), 149 deletions(-) diff --git a/app/ycode/components/RichTextImagePopover.tsx b/app/ycode/components/RichTextImagePopover.tsx index 4d6eacb3..bcc6535b 100644 --- a/app/ycode/components/RichTextImagePopover.tsx +++ b/app/ycode/components/RichTextImagePopover.tsx @@ -87,18 +87,6 @@ export default function RichTextImagePopover({ title="Image" isOpen={true} onToggle={() => {}} - action={ - - } >
diff --git a/app/ycode/components/RichTextLinkPopover.tsx b/app/ycode/components/RichTextLinkPopover.tsx index 854cbb03..ef2e94d9 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'; @@ -158,55 +158,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(() => { @@ -248,64 +228,28 @@ export default function RichTextLinkPopover({ -
-
-

Link settings

-
- -
- -
- - - -
- {hadLinkOnOpen && ( - - )} - -
- - - - -
-
+ {}} + > + + + ); diff --git a/app/ycode/components/RichTextLinkSettings.tsx b/app/ycode/components/RichTextLinkSettings.tsx index 297b9651..8f675a75 100644 --- a/app/ycode/components/RichTextLinkSettings.tsx +++ b/app/ycode/components/RichTextLinkSettings.tsx @@ -508,53 +508,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/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()} > - - +
+ +
+ ) : ( From 893f147ac6dba1d7b98c758ed18a8263496da33c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Lun=C4=97nas?= <34475426+lunenas@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:36:07 +0200 Subject: [PATCH 05/31] fix: don't preselect URL type for new rich text links Made-with: Cursor --- app/ycode/components/RichTextLinkPopover.tsx | 6 +----- components/ui/button.tsx | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/app/ycode/components/RichTextLinkPopover.tsx b/app/ycode/components/RichTextLinkPopover.tsx index ef2e94d9..2ba1c34c 100644 --- a/app/ycode/components/RichTextLinkPopover.tsx +++ b/app/ycode/components/RichTextLinkPopover.tsx @@ -133,11 +133,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); } } 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', From 12becbfbeace0c0f403b4b0d23778f8a1379a9e5 Mon Sep 17 00:00:00 2001 From: Tristan Mouchet Date: Fri, 20 Mar 2026 11:33:25 +0100 Subject: [PATCH 06/31] feat: change the way the preview is displayed so it doesn't force the canvas to re-render --- app/ycode/components/CenterCanvas.tsx | 553 +++++++++++----------- app/ycode/components/YCodeBuilderMain.tsx | 38 +- components/ui/icon.tsx | 10 +- hooks/use-zoom.ts | 70 ++- 4 files changed, 372 insertions(+), 299 deletions(-) diff --git a/app/ycode/components/CenterCanvas.tsx b/app/ycode/components/CenterCanvas.tsx index 9aabb3b3..f0107510 100644 --- a/app/ycode/components/CenterCanvas.tsx +++ b/app/ycode/components/CenterCanvas.tsx @@ -110,6 +110,83 @@ const viewportSizes: Record void; + onZoomIn: () => void; + onZoomOut: () => void; + onResetZoom: () => void; + onZoomToFit: () => void; + onAutofit: () => void; +} + +/** Shared viewport toggle + zoom dropdown used in both the canvas and preview toolbars. */ +function ViewportZoomControls({ + viewportMode, + zoom, + onViewportChange, + onZoomIn, + onZoomOut, + onResetZoom, + onZoomToFit, + onAutofit, +}: ViewportZoomControlsProps) { + return ( +
+ onViewportChange(v as ViewportMode)}> + + Desktop + Tablet + Phone + + + + + + + + + Zoom in + ⌘+ + + + Zoom out + ⌘- + + + + Zoom to 100% + ⌘0 + + + Fit height + ⌘1 + + + Fit width + ⌘2 + + + +
+ ); +} + // Component editing canvas sizing const COMPONENT_CANVAS_PADDING = 0; @@ -503,6 +580,8 @@ const CenterCanvas = React.memo(function CenterCanvas({ const [showAddBlockPanel, setShowAddBlockPanel] = useState(false); const iframeRef = useRef(null); const canvasContainerRef = useRef(null); + const previewContainerRef = useRef(null); + const [previewContentHeight, setPreviewContentHeight] = useState(0); const scrollContainerRef = useRef(null); // State for iframe element (for SelectionOverlay) @@ -725,6 +804,27 @@ const CenterCanvas = React.memo(function CenterCanvas({ minZoom: 10, maxZoom: 1000, zoomStep: 10, + shortcutsEnabled: !isPreviewMode, + }); + + // Independent zoom for the preview (second useZoom instance, active only in preview mode) + const previewContentWidth = parseInt(viewportSizes[viewportMode].width); + const { + zoom: previewZoom, + zoomIn: previewZoomIn, + zoomOut: previewZoomOut, + resetZoom: previewResetZoom, + zoomToFit: previewZoomToFit, + autofit: previewAutofit, + } = useZoom({ + containerRef: previewContainerRef, + contentWidth: previewContentWidth, + contentHeight: previewContentHeight || previewContentWidth, + minZoom: 10, + maxZoom: 1000, + zoomStep: 10, + shortcutsEnabled: isPreviewMode, + iframeRef, }); // Determine if we should center (zoomed out beyond "zoom to fit" level) @@ -755,11 +855,16 @@ const CenterCanvas = React.memo(function CenterCanvas({ // Small delay to ensure container dimensions are updated setTimeout(() => { - autofit(); + if (isPreviewMode) { + previewAutofit(); + } else { + autofit(); + } }, 50); + prevViewportMode.current = viewportMode; } - }, [viewportMode, autofit]); + }, [viewportMode, autofit, isPreviewMode, previewAutofit]); // Scroll canvas to selected element if it's off-screen const prevCanvasLayerIdRef = useRef(null); @@ -793,6 +898,9 @@ const CenterCanvas = React.memo(function CenterCanvas({ scrollEl.scrollTo({ top: Math.max(0, targetScroll), behavior: smooth ? 'smooth' : 'auto' }); }, [canvasIframeElement, zoom]); + const scrollCanvasToLayerRef = useRef(scrollCanvasToLayer); + scrollCanvasToLayerRef.current = scrollCanvasToLayer; + useEffect(() => { if (!selectedLayerId) { prevCanvasLayerIdRef.current = null; @@ -849,27 +957,16 @@ const CenterCanvas = React.memo(function CenterCanvas({ return () => clearTimeout(timeout); }, [reportedContentHeight, selectedLayerId, canvasIframeElement, isCanvasReady, scrollCanvasToLayer]); - // Recalculate zoom when content height becomes ready in preview mode - const hasRecalculatedForContent = useRef(false); + // Scroll to selected layer after breakpoint change (uses ref to avoid stale zoom closure) useEffect(() => { - // In preview mode, wait for meaningful content height then recalculate once - if (isPreviewMode && !hasRecalculatedForContent.current && iframeContentHeight > 600) { - hasRecalculatedForContent.current = true; - // Delay to ensure everything is ready - setTimeout(() => { - if (zoomMode === 'autofit') { - autofit(); - } else if (zoomMode === 'fit') { - zoomToFit(); - } - }, 150); - } - }, [isPreviewMode, iframeContentHeight, zoomMode, autofit, zoomToFit]); + if (isPreviewMode || !selectedLayerId || !canvasIframeElement || !isCanvasReady) return; - // Reset flag when preview mode changes - useEffect(() => { - hasRecalculatedForContent.current = false; - }, [isPreviewMode]); + const timeout = setTimeout(() => { + scrollCanvasToLayerRef.current(selectedLayerId, true, true); + }, 300); + return () => clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewportMode]); // Track container dimensions for dynamic alignment useEffect(() => { @@ -1532,13 +1629,37 @@ const CenterCanvas = React.memo(function CenterCanvas({ return `/ycode/preview${path === '/' ? '' : path}`; }, [currentPage, folders, currentPageCollectionItemId, collectionItemsFromStore, collectionFieldsFromStore, selectedLocale, localeTranslations]); - // Generate a stable preview key that changes when layers are actually modified - const previewKey = useMemo(() => { - // Use JSON.stringify of layer structure to detect changes - // This is more efficient than Date.now() which would refresh constantly - const layerHash = JSON.stringify(layers); - return `preview-${currentPageId}-${layerHash.length}`; - }, [currentPageId, layers]); + // Reload preview iframe every time preview mode opens (covers all change sources: + // layer edits, component updates, CMS, layer styles, color variables, etc.) + const [isPreviewLoading, setIsPreviewLoading] = useState(false); + useEffect(() => { + if (!isPreviewMode || !previewUrl) return; + const iframe = iframeRef.current; + if (!iframe) return; + setIsPreviewLoading(true); + iframe.src = previewUrl; + }, [isPreviewMode, previewUrl]); + + // Autofit when entering preview mode (not on every breakpoint change) + const prevIsPreviewMode = useRef(false); + useEffect(() => { + if (isPreviewMode && !prevIsPreviewMode.current) { + previewAutofit(); + } + prevIsPreviewMode.current = isPreviewMode; + }, [isPreviewMode, previewAutofit]); + + const handlePreviewLoad = useCallback(() => { + setIsPreviewLoading(false); + try { + const doc = iframeRef.current?.contentDocument; + if (doc) { + setPreviewContentHeight(doc.documentElement.scrollHeight); + } + } catch { + // Cross-origin — fall back to 0 + } + }, []); // Load collection items when dynamic page is selected useEffect(() => { @@ -1685,124 +1806,6 @@ const CenterCanvas = React.memo(function CenterCanvas({ return () => document.removeEventListener('keydown', handleKeyDown); }, [isPreviewMode, handleUndo, handleRedo]); - // Add zoom gesture handlers for preview mode (when iframe doesn't have them) - useEffect(() => { - if (!isPreviewMode) return; // Editor iframe handles its own zoom gestures - - const container = canvasContainerRef.current; - const iframe = iframeRef.current; - if (!container) return; - - // Get iframe's window and document for event listening - let iframeWindow: Window | null = null; - let iframeDocument: Document | null = null; - - // Wait for iframe to load before attaching listeners - const setupIframeListeners = () => { - try { - iframeWindow = iframe?.contentWindow || null; - iframeDocument = iframe?.contentDocument || null; - - if (!iframeWindow || !iframeDocument) return; - - // Attach listeners to iframe's document - iframeDocument.addEventListener('wheel', handleWheel, { passive: false, capture: true }); - iframeDocument.addEventListener('touchstart', handleTouchStart, { passive: true }); - iframeDocument.addEventListener('touchmove', handleTouchMove, { passive: true }); - iframeDocument.addEventListener('touchend', handleTouchEnd, { passive: true }); - } catch (e) { - // Cross-origin iframe - fall back to container listeners only - console.warn('Cannot access iframe document for zoom gestures:', e); - } - }; - - // Wheel event for Ctrl/Cmd + wheel zoom (includes trackpad pinch on Mac) - const handleWheel = (e: WheelEvent) => { - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - e.stopPropagation(); - - // Positive deltaY means zoom out, negative means zoom in - const delta = -e.deltaY; - handleZoomGesture(delta); - - return false; - } - }; - - // Touch events for pinch zoom on mobile/tablet - let lastTouchDistance: number | null = null; - - const handleTouchStart = (e: TouchEvent) => { - if (e.touches.length === 2) { - const touch1 = e.touches[0]; - const touch2 = e.touches[1]; - const dx = touch2.clientX - touch1.clientX; - const dy = touch2.clientY - touch1.clientY; - lastTouchDistance = Math.sqrt(dx * dx + dy * dy); - } - }; - - const handleTouchMove = (e: TouchEvent) => { - if (e.touches.length === 2 && lastTouchDistance !== null) { - const touch1 = e.touches[0]; - const touch2 = e.touches[1]; - const dx = touch2.clientX - touch1.clientX; - const dy = touch2.clientY - touch1.clientY; - const currentDistance = Math.sqrt(dx * dx + dy * dy); - - // Calculate delta and send zoom gesture - const delta = (currentDistance - lastTouchDistance) * 2; - handleZoomGesture(delta); - - lastTouchDistance = currentDistance; - } - }; - - const handleTouchEnd = () => { - lastTouchDistance = null; - }; - - // Add event listeners to container (fallback for when cursor is outside iframe) - container.addEventListener('wheel', handleWheel, { passive: false, capture: true }); - container.addEventListener('touchstart', handleTouchStart, { passive: true }); - container.addEventListener('touchmove', handleTouchMove, { passive: true }); - container.addEventListener('touchend', handleTouchEnd, { passive: true }); - - // Setup iframe listeners when iframe loads - if (iframe) { - iframe.addEventListener('load', setupIframeListeners); - // Try to set up immediately in case iframe is already loaded - if (iframe.contentDocument?.readyState === 'complete') { - setupIframeListeners(); - } - } - - return () => { - // Remove container listeners - container.removeEventListener('wheel', handleWheel); - container.removeEventListener('touchstart', handleTouchStart); - container.removeEventListener('touchmove', handleTouchMove); - container.removeEventListener('touchend', handleTouchEnd); - - // Remove iframe listeners if they were added - if (iframeDocument) { - try { - iframeDocument.removeEventListener('wheel', handleWheel); - iframeDocument.removeEventListener('touchstart', handleTouchStart); - iframeDocument.removeEventListener('touchmove', handleTouchMove); - iframeDocument.removeEventListener('touchend', handleTouchEnd); - } catch (e) { - // Ignore errors when removing listeners - } - } - - if (iframe) { - iframe.removeEventListener('load', setupIframeListeners); - } - }; - }, [isPreviewMode, handleZoomGesture]); - return (
{/* Top Bar */} @@ -1894,61 +1897,16 @@ const CenterCanvas = React.memo(function CenterCanvas({ )} {/* Viewport Controls */} -
- setViewportMode(value as ViewportMode)}> - - - Desktop - - - Tablet - - - Phone - - - - - - - - - - Zoom in - ⌘+ - - - Zoom out - ⌘- - - - - Zoom to 100% - ⌘0 - - - Fit height - ⌘1 - - - Fit width - ⌘2 - - - -
+ {/* Undo/Redo Buttons (hidden in preview mode) */} {!isPreviewMode && ( @@ -2264,86 +2222,36 @@ const CenterCanvas = React.memo(function CenterCanvas({ {/* Element picker SVG connector overlay */} - {/* Scrollable container with hidden scrollbars */} + {/* Scrollable container with hidden scrollbars (editor canvas) */}
- {/* Hide scrollbars for Webkit browsers (editor mode only) */} - {!isPreviewMode && ( - - )} - - {/* Preview mode: Scaled iframe with internal scrolling */} - {isPreviewMode ? ( -
-
- {layers.length > 0 ? ( -