diff --git a/scripts/rtl-baseline.json b/scripts/rtl-baseline.json index f4c88162..4330391c 100644 --- a/scripts/rtl-baseline.json +++ b/scripts/rtl-baseline.json @@ -30,7 +30,7 @@ "src/components/CreateInvoiceModal/CreateInvoiceModal.tsx": 3, "src/components/CreateReferralModal/CreateReferralModal.tsx": 4, "src/components/DashboardWidget/DashboardWidget.tsx": 9, - "src/components/DateInput/DateInput.tsx": 3, + "src/components/DateInput/DateInput.tsx": 2, "src/components/DateRangePicker/DateRangePicker.tsx": 7, "src/components/DocumentScanner/DocumentDetectionOverlay.tsx": 16, "src/components/DocumentScanner/FilePreview.tsx": 1, diff --git a/src/components/Dropdown/Dropdown.stories.tsx b/src/components/Dropdown/Dropdown.stories.tsx index bf3112fc..63f5fdcf 100644 --- a/src/components/Dropdown/Dropdown.stories.tsx +++ b/src/components/Dropdown/Dropdown.stories.tsx @@ -3,6 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Dropdown, DropdownItem, + DropdownSubmenu, DropdownSeparator, DropdownLabel, } from './Dropdown'; @@ -112,6 +113,42 @@ export const Default: Story = { ), }; +export const WithSubmenu: Story = { + render: () => ( + Export}> + console.warn('Copy clicked')}> + Copy + + + console.warn('Rich text clicked')}> + Rich text + + console.warn('Markdown clicked')}> + Markdown + + console.warn('Plain text clicked')}> + Plain text + + + + console.warn('Delete clicked')} + > + Delete + + + ), + parameters: { + docs: { + description: { + story: + 'A `DropdownSubmenu` opens a nested flyout on hover, click, or ArrowRight; ArrowLeft/Escape closes just the flyout.', + }, + }, + }, +}; + export const WithIcons: Story = { render: () => ( Actions}> diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx index 67c50269..7d9a51ed 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { createPortal } from 'react-dom'; +import { ChevronRight as ChevronRightIcon } from 'lucide-react'; import { cn } from '../../utils/cn'; import { useClickOutside } from '../../hooks/useClickOutside'; import { useEscapeKey } from '../../hooks/useEscapeKey'; @@ -62,6 +63,12 @@ interface DropdownContextValue { multiSelect: boolean; selectedValues: string[]; toggleSelectedValue: (value: string) => void; + /** + * Registers a portaled descendant (submenu panel) with the root dropdown's + * click-outside detection so clicks inside it don't close the menu. + * Returns an unregister function. + */ + registerOutsideRef: (ref: React.RefObject) => () => void; } const DropdownContext = React.createContext(null); @@ -226,6 +233,31 @@ function filterDropdownChildren( return searchText.includes(normalizedQuery) ? child : null; } + if (isDropdownElement(child, DropdownSubmenu)) { + const searchText = [ + getNodeText(child.props.label), + child.props.searchText, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + // Keep the whole submenu when its label matches; otherwise filter its + // items and keep it only when something inside still matches. + if (searchText.includes(normalizedQuery)) { + return child; + } + + const submenuChildren = filterDropdownChildren( + child.props.children, + normalizedQuery + ); + + return hasVisibleDropdownContent(submenuChildren) + ? React.cloneElement(child, undefined, submenuChildren) + : null; + } + if (isDropdownElement(child, DropdownContent)) { const contentChildren = filterDropdownChildren( child.props.children, @@ -326,6 +358,9 @@ function Dropdown({ const [uncontrolledSelectedValues, setUncontrolledSelectedValues] = React.useState(defaultSelectedValues); const [searchQuery, setSearchQuery] = React.useState(''); + const [extraOutsideRefs, setExtraOutsideRefs] = React.useState< + ReadonlyArray> + >([]); const containerRef = React.useRef(null); const searchInputRef = React.useRef(null); const menuId = React.useId(); @@ -382,13 +417,22 @@ function Dropdown({ [multiSelect, selectedValues, setSelectedValues] ); + const registerOutsideRef = React.useCallback( + (ref: React.RefObject) => { + setExtraOutsideRefs((prev) => [...prev, ref]); + return () => setExtraOutsideRefs((prev) => prev.filter((r) => r !== ref)); + }, + [] + ); + const dropdownContext = React.useMemo( () => ({ multiSelect, selectedValues, toggleSelectedValue, + registerOutsideRef, }), - [multiSelect, selectedValues, toggleSelectedValue] + [multiSelect, selectedValues, toggleSelectedValue, registerOutsideRef] ); useEscapeKey(handleClose, isOpen); @@ -405,8 +449,8 @@ function Dropdown({ }); const outsideRefs = React.useMemo( - () => [containerRef, floatingRef], - [floatingRef] + () => [containerRef, floatingRef, ...extraOutsideRefs], + [floatingRef, extraOutsideRefs] ); useClickOutside(outsideRefs, handleClose, isOpen); @@ -840,6 +884,208 @@ const DropdownItem = React.forwardRef( DropdownItem.displayName = 'DropdownItem'; +// ============================================================================ +// Dropdown Submenu Component +// ============================================================================ + +export interface DropdownSubmenuProps { + /** The parent item's label */ + label: React.ReactNode; + /** Icon to display before the label */ + icon?: React.ReactNode; + /** Whether the submenu trigger is disabled */ + disabled?: boolean; + /** Optional text used when filtering searchable dropdown items */ + searchText?: string; + /** Additional class name for the submenu trigger item */ + className?: string; + /** Submenu items */ + children: React.ReactNode; +} + +/** Delay before a hover-opened submenu closes, allowing diagonal travel. */ +const submenuCloseDelay = 150; + +/** + * A dropdown item that opens a nested flyout menu. + * + * Opens on hover or click, and via ArrowRight/Enter/Space from the keyboard; + * ArrowLeft or Escape closes just the submenu (Escape is stopped so the root + * menu stays open) and returns focus to the trigger. + * + * @example + * ```tsx + * Options}> + * Copy + * + * Markdown + * Plain text + * + * + * ``` + */ +function DropdownSubmenu({ + label, + icon, + disabled = false, + className, + children, +}: DropdownSubmenuProps) { + const [open, setOpen] = React.useState(false); + const menuId = React.useId(); + const dropdownContext = React.useContext(DropdownContext); + const closeTimer = React.useRef | undefined>( + undefined + ); + + const { anchorRef, floatingRef, style } = useAnchoredPosition< + HTMLButtonElement, + HTMLDivElement + >({ open, placement: 'right-start', offset: 4 }); + + // Let the root dropdown treat clicks inside the portaled flyout as "inside" + // so they don't dismiss the whole menu. + const registerOutsideRef = dropdownContext?.registerOutsideRef; + React.useEffect(() => { + if (!open || !registerOutsideRef) return; + return registerOutsideRef(floatingRef); + }, [open, registerOutsideRef, floatingRef]); + + React.useEffect(() => () => clearTimeout(closeTimer.current), []); + + const cancelClose = () => clearTimeout(closeTimer.current); + const openNow = () => { + if (disabled) return; + cancelClose(); + setOpen(true); + }; + const scheduleClose = () => { + cancelClose(); + closeTimer.current = setTimeout(() => setOpen(false), submenuCloseDelay); + }; + const closeAndRefocus = () => { + cancelClose(); + setOpen(false); + anchorRef.current?.focus(); + }; + + const focusFirstItem = () => { + // The flyout positions in a layout effect; wait a frame so it's measurable + // and visible before moving focus. + requestAnimationFrame(() => { + floatingRef.current + ?.querySelector('[data-slot="dropdown-item"]') + ?.focus(); + }); + }; + + return ( + <> + + {open && + createPortal( + , + document.body + )} + + ); +} + +DropdownSubmenu.displayName = 'DropdownSubmenu'; + // ============================================================================ // Dropdown Separator Component // ============================================================================ @@ -896,6 +1142,7 @@ export { DropdownHeader, DropdownContent, DropdownItem, + DropdownSubmenu, DropdownSeparator, DropdownLabel, }; diff --git a/src/components/Dropdown/index.ts b/src/components/Dropdown/index.ts index bc208942..eb8a1994 100644 --- a/src/components/Dropdown/index.ts +++ b/src/components/Dropdown/index.ts @@ -3,11 +3,13 @@ export { DropdownHeader, DropdownContent, DropdownItem, + DropdownSubmenu, DropdownSeparator, DropdownLabel, type DropdownProps, type DropdownHeaderProps, type DropdownContentProps, type DropdownItemProps, + type DropdownSubmenuProps, type DropdownPlacement, } from './Dropdown'; diff --git a/src/components/SuperChat/SuperChat.test.tsx b/src/components/SuperChat/SuperChat.test.tsx index 0979004d..6da12de0 100644 --- a/src/components/SuperChat/SuperChat.test.tsx +++ b/src/components/SuperChat/SuperChat.test.tsx @@ -892,7 +892,7 @@ describe('SuperChat', () => { expect(copyButtons).toHaveLength(2); }); - it('copies the message source as Markdown via the copy menu', async () => { + it('copies the message source as Markdown via the footer copy menu', async () => { const { default: userEvent } = await import('@testing-library/user-event'); const user = userEvent.setup(); const writeText = vi.fn(async () => {}); @@ -908,7 +908,8 @@ describe('SuperChat', () => { ); - // Open the menu on the first message (m1, authored by u1) and pick Markdown. + // The footer copy button opens the format menu (like the original copy + // control); pick Markdown. const [firstCopy] = screen.getAllByRole('button', { name: 'Copy message', }); @@ -921,6 +922,72 @@ describe('SuperChat', () => { vi.unstubAllGlobals(); }); + it('Ctrl/Cmd-click on the footer copy button copies in the default format', async () => { + const { default: userEvent } = await import('@testing-library/user-event'); + const user = userEvent.setup(); + const writeText = vi.fn(async () => {}); + const write = vi.fn(async () => {}); + vi.stubGlobal('navigator', { + ...globalThis.navigator, + clipboard: { writeText, write }, + }); + + render( +
+ +
+ ); + + const [firstCopy] = screen.getAllByRole('button', { + name: 'Copy message', + }); + await user.keyboard('{Control>}'); + await user.click(firstCopy); + await user.keyboard('{/Control}'); + + // Copies immediately in the configured format without opening the menu. + expect(writeText).toHaveBeenCalledWith(conversation.thread[0].text); + expect( + screen.queryByRole('menuitem', { name: 'Copy as Markdown' }) + ).not.toBeInTheDocument(); + vi.unstubAllGlobals(); + }); + + it('copies the message source as Markdown via the overflow menu', async () => { + const { default: userEvent } = await import('@testing-library/user-event'); + const user = userEvent.setup(); + const writeText = vi.fn(async () => {}); + const write = vi.fn(async () => {}); + vi.stubGlobal('navigator', { + ...globalThis.navigator, + clipboard: { writeText, write }, + }); + + render( +
+ +
+ ); + + // Open the overflow menu on the first message (m1, authored by u1), then + // the "Copy as" submenu, and pick Markdown. + const [firstOverflow] = screen.getAllByRole('button', { + name: 'Message actions', + }); + await user.click(firstOverflow); + await user.click(screen.getByRole('menuitem', { name: 'Copy as' })); + await user.click( + await screen.findByRole('menuitem', { name: 'Copy as Markdown' }) + ); + + expect(writeText).toHaveBeenCalledWith(conversation.thread[0].text); + vi.unstubAllGlobals(); + }); + it('renders AI content blocks of type code', () => { const withCode: SuperChatConversation = { ...conversation, diff --git a/src/components/SuperChat/SuperChat.tsx b/src/components/SuperChat/SuperChat.tsx index 5ac13fc2..02fae710 100644 --- a/src/components/SuperChat/SuperChat.tsx +++ b/src/components/SuperChat/SuperChat.tsx @@ -31,6 +31,7 @@ import type { ComposerAttachment, Participant, SuperChatConversation, + SuperChatCopyFormat, SuperChatLinkBuilder, SuperChatRef, SuperChatRenderPlugin, @@ -74,6 +75,12 @@ export interface SuperChatProps { virtualized?: boolean; /** Build hrefs for `ref` thread items. */ linkBuilder?: SuperChatLinkBuilder; + /** + * Format for a message's default copy action (Ctrl/Cmd-click on the footer + * copy button). All formats stay reachable per message via the copy menus. + * Defaults to `'rich'`. + */ + defaultCopyFormat?: SuperChatCopyFormat; /** Additional class name. */ className?: string; @@ -121,6 +128,7 @@ export function SuperChat({ order = 'asc', virtualized = false, linkBuilder, + defaultCopyFormat, className, onMessageSent, onMessageEdited, @@ -291,6 +299,7 @@ export function SuperChat({ onReferenceClick={onReferenceClick} editable={editable} onMessageEdited={handleMessageEdited} + defaultCopyFormat={defaultCopyFormat} order={order} conversationId={conversation.id} containerProps={{ @@ -329,6 +338,7 @@ export function SuperChat({ onReferenceClick={onReferenceClick} editable={editable} onMessageEdited={handleMessageEdited} + defaultCopyFormat={defaultCopyFormat} /> ))} diff --git a/src/components/SuperChat/SuperChatInbox.tsx b/src/components/SuperChat/SuperChatInbox.tsx index 0ddc674f..265b9580 100644 --- a/src/components/SuperChat/SuperChatInbox.tsx +++ b/src/components/SuperChat/SuperChatInbox.tsx @@ -19,6 +19,7 @@ import type { AttachmentKind, ComposerAttachment, SuperChatConversation, + SuperChatCopyFormat, SuperChatLinkBuilder, SuperChatRef, SuperChatRenderPlugin, @@ -58,6 +59,8 @@ export interface SuperChatInboxProps { showSidebar?: boolean; /** Build hrefs for `ref` thread items. */ linkBuilder?: SuperChatLinkBuilder; + /** Format for a message's default copy action (Ctrl/Cmd-click on copy). */ + defaultCopyFormat?: SuperChatCopyFormat; /** Additional class name. */ className?: string; @@ -101,6 +104,7 @@ export function SuperChatInbox({ virtualized, showSidebar = true, linkBuilder, + defaultCopyFormat, className, onMessageSent, onMessageEdited, @@ -165,6 +169,7 @@ export function SuperChatInbox({ order={order} virtualized={virtualized} linkBuilder={linkBuilder} + defaultCopyFormat={defaultCopyFormat} onMessageSent={onMessageSent} onMessageEdited={onMessageEdited} onConversationClosed={onConversationClosed} diff --git a/src/components/SuperChat/VirtualThread.tsx b/src/components/SuperChat/VirtualThread.tsx index 18cdf796..92655fe2 100644 --- a/src/components/SuperChat/VirtualThread.tsx +++ b/src/components/SuperChat/VirtualThread.tsx @@ -18,6 +18,7 @@ import { MessageRow } from './parts'; import type { AIRenderTextContent, Participant, + SuperChatCopyFormat, SuperChatLinkBuilder, SuperChatMessage, SuperChatRef, @@ -40,6 +41,8 @@ export interface VirtualThreadProps { editable?: boolean; /** Save handler for an inline message edit (bound to the message's id). */ onMessageEdited?: (messageId: string, text: string) => void; + /** Format for a message's default copy action (Ctrl/Cmd-click on copy). */ + defaultCopyFormat?: SuperChatCopyFormat; /** * Thread ordering. `'asc'` anchors new messages to the bottom; `'desc'` * anchors them to the top (feed style). @@ -62,6 +65,7 @@ export function VirtualThread({ onReferenceClick, editable, onMessageEdited, + defaultCopyFormat, order, conversationId, containerProps, @@ -134,6 +138,7 @@ export function VirtualThread({ onReferenceClick={onReferenceClick} editable={editable} onMessageEdited={onMessageEdited} + defaultCopyFormat={defaultCopyFormat} /> diff --git a/src/components/SuperChat/index.ts b/src/components/SuperChat/index.ts index 329fe649..ee9188ea 100644 --- a/src/components/SuperChat/index.ts +++ b/src/components/SuperChat/index.ts @@ -42,6 +42,7 @@ export type { SuperChatMessage, SuperChatItemType, SuperChatChannel, + SuperChatCopyFormat, SuperChatRef, SuperChatLinkBuilder, ComposerAttachment, diff --git a/src/components/SuperChat/parts.tsx b/src/components/SuperChat/parts.tsx index 7290f6dd..2910124d 100644 --- a/src/components/SuperChat/parts.tsx +++ b/src/components/SuperChat/parts.tsx @@ -11,12 +11,13 @@ import { cva } from 'class-variance-authority'; import { Check as CheckIcon, Clipboard as ClipboardIcon, + Ellipsis as EllipsisIcon, Pencil as PencilIcon, } from 'lucide-react'; import { cn } from '../../utils/cn'; import { Avatar } from '../Avatar'; import { Badge } from '../Badge'; -import { Dropdown, DropdownItem } from '../Dropdown'; +import { Dropdown, DropdownItem, DropdownSubmenu } from '../Dropdown'; import { MCPToolCallDisplay } from '../AI/MCPToolCall'; import { ChatBubble, AITypingIndicator } from '../AI/AIMessage'; import { SparklesIcon } from '../AI/icons'; @@ -26,6 +27,7 @@ import type { ComposerAttachment, Participant, SuperChatConversation, + SuperChatCopyFormat, SuperChatLinkBuilder, SuperChatMessage, SuperChatRef, @@ -295,26 +297,68 @@ export function filesToComposerAttachments( ); } -interface CopyMenuProps { - /** Aligns the popover to the outer margin (right for self, left otherwise). */ - isSelf: boolean; +// ============================================================================ +// Message actions (footer bar + sticky overflow menu) +// ============================================================================ + +/** + * Reveal-on-hover/focus for message action affordances. Hover doesn't exist on + * touch devices, so coarse pointers always show the actions. + */ +const actionRevealClass = + 'opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100 [@media(pointer:coarse)]:opacity-100'; + +const actionButtonClass = + 'rounded p-1 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200'; + +interface MessageActionItem { + id: string; + label: string; + /** Secondary line shown under the label in the overflow menu. */ + description?: string; + onSelect: () => void; +} + +/** + * One per-message action, rendered twice from the same definition: as an icon + * button in the footer bar and as an item in the sticky overflow (⋯) menu. + */ +interface MessageAction { + id: string; + /** Accessible name (footer button) and menu label. */ + label: string; + icon: React.ReactNode; + /** Default behavior (footer click / top-level menu item). */ + onSelect: () => void; + /** Explicit variants, rendered as a flyout submenu in the overflow menu. */ + submenu?: { label: string; items: MessageActionItem[] }; +} + +interface UseMessageCopyOptions { /** Markdown source for this message (plain-text / Markdown copy). */ markdown: string; /** Read the rendered bubble HTML at copy time (rich-text copy). */ getHtml: () => string; /** Read the rendered bubble plain text at copy time. */ getText: () => string; + /** One-click format for the default copy action. */ + defaultFormat: SuperChatCopyFormat; } /** - * Per-message copy control. The primary **Copy** writes *both* a rich-text + * Per-message clipboard writes. The **rich** format writes *both* a rich-text * (`text/html`) and a Markdown (`text/plain`) representation in a single * clipboard write, so the paste target decides: rich editors get formatting, - * plain editors get Markdown. Explicit "as Markdown" / "as plain text" options - * are also offered. + * plain editors get Markdown. Explicit "as Markdown" / "as plain text" + * variants are also exposed, and `copyDefault` honors the host-configured + * {@link SuperChatCopyFormat}. */ -function CopyMenu({ isSelf, markdown, getHtml, getText }: CopyMenuProps) { - const [open, setOpen] = React.useState(false); +function useMessageCopy({ + markdown, + getHtml, + getText, + defaultFormat, +}: UseMessageCopyOptions) { const [copied, setCopied] = React.useState(false); const copiedTimer = React.useRef | undefined>( undefined @@ -361,25 +405,195 @@ function CopyMenu({ isSelf, markdown, getHtml, getText }: CopyMenuProps) { }; const run = (fn: () => Promise) => { - setOpen(false); // Only flash "Copied" when the write actually succeeds; swallow failures // (insecure context / denied permission) instead of leaking an unhandled // rejection or showing a false success. void fn().then(flash, () => {}); }; + const copyRich = () => run(writeBoth); + const copyMarkdown = () => run(() => writeText(markdown || getText())); + const copyPlain = () => run(() => writeText(getText())); + const copyDefault = + defaultFormat === 'markdown' + ? copyMarkdown + : defaultFormat === 'plain' + ? copyPlain + : copyRich; + + return { copied, copyDefault, copyRich, copyMarkdown, copyPlain }; +} + +interface MessageActionsBarProps { + actions: MessageAction[]; + isSelf: boolean; +} + +/** A submenu item as a Dropdown row, with an optional secondary line. */ +function ActionMenuItem({ + item, + onSelect, +}: { + item: MessageActionItem; + onSelect: (fn: () => void) => void; +}) { + return ( + onSelect(item.onSelect)}> + {item.description ? ( + + {item.label} + + {item.description} + + + ) : ( + item.label + )} + + ); +} + +/** + * One footer-bar affordance. Plain actions run their default on click; actions + * with variants (e.g. copy formats) open a small menu of those variants, like + * the original per-message copy menu. Ctrl/Cmd-click skips the menu and runs + * the action's default (e.g. the host-configured {@link SuperChatCopyFormat}). + */ +function FooterActionButton({ + action, + isSelf, +}: { + action: MessageAction; + isSelf: boolean; +}) { + const [open, setOpen] = React.useState(false); + + // Capture-phase so it runs before the Dropdown's trigger onClick: a + // modifier click fires the default action instead of opening the menu. + const onClickCapture = action.submenu + ? (e: React.MouseEvent) => { + if (e.metaKey || e.ctrlKey) { + e.preventDefault(); + e.stopPropagation(); + action.onSelect(); + } + } + : undefined; + + const button = ( + + ); + + if (!action.submenu) return button; + + return ( + + {action.submenu.items.map((item) => ( + { + setOpen(false); + fn(); + }} + /> + ))} + + ); +} + +/** + * The hover-revealed row of action icon buttons under a message bubble. Each + * button runs its action's default behavior; explicit variants live in the + * sticky overflow menu ({@link MessageOverflowMenu}). + */ +const MessageActionsBar = React.forwardRef< + HTMLDivElement, + MessageActionsBarProps +>(function MessageActionsBar({ actions, isSelf }, ref) { + return ( +
+ {actions.map((action) => ( + + ))} +
+ ); +}); + +interface MessageOverflowMenuProps { + /** Aligns the popover to the outer margin (right for self, left otherwise). */ + isSelf: boolean; + actions: MessageAction[]; + /** + * Whether the footer action bar is currently in view. While it is, the + * overflow trigger hides on fine pointers (the footer already offers the + * actions); coarse pointers always show both. + */ + footerVisible: boolean; +} + +/** + * The sticky overflow (⋯) control beside the bubble. On long messages it + * follows the scroll (sticky within the thread) so actions stay reachable, and + * hands off to the footer bar once the message end scrolls into view. + */ +function MessageOverflowMenu({ + isSelf, + actions, + footerVisible, +}: MessageOverflowMenuProps) { + const [open, setOpen] = React.useState(false); + + const select = (fn: () => void) => { + setOpen(false); + fn(); + }; + return ( ); @@ -433,6 +645,9 @@ interface MessageRowProps { editable?: boolean; /** Save handler for an inline message edit (bound to this message's id). */ onMessageEdited?: (messageId: string, text: string) => void; + /** Format for the default copy action — Ctrl/Cmd-click on the footer copy + * button (defaults to `'rich'`). */ + defaultCopyFormat?: SuperChatCopyFormat; } /** @@ -453,6 +668,7 @@ export const MessageRow = React.memo(function MessageRow({ onReferenceClick, editable, onMessageEdited, + defaultCopyFormat = 'rich', }: MessageRowProps) { const streaming = message.status === 'streaming'; const hasBody = !!message.text || (message.content?.length ?? 0) > 0; @@ -479,6 +695,72 @@ export const MessageRow = React.memo(function MessageRow({ autosize(); }, [isEditing, autosize]); + // --- Message actions (copy / edit) --------------------------------------- + // Computed before the early returns below so the hooks run unconditionally + // (system/ref rows simply never render the action surfaces). + + // Inline editing applies only to the local user's own plain-text messages + // (rich content blocks / streaming messages are not inline-editable). + const canEdit = + !!editable && + isSelf && + !streaming && + typeof message.text === 'string' && + !message.content?.length; + + // The Markdown source for copying: prefer the raw `text`, otherwise assemble + // it from the message's text/code content blocks. + const markdownSource = + typeof message.text === 'string' && message.text + ? message.text + : (message.content + ?.map((block) => { + if (block.type === 'code' && block.text) { + return `\`\`\`${block.language ?? ''}\n${block.text}\n\`\`\``; + } + if ( + (block.type === 'text' || block.type === 'thinking') && + block.text + ) { + return block.text; + } + return ''; + }) + .filter(Boolean) + .join('\n\n') ?? ''); + + // A copy affordance appears on every message that has a body (not while it + // is being edited). + const canCopy = + !isEditing && + (!!message.content?.length || + (typeof message.text === 'string' && message.text.length > 0)); + + const hasActions = canCopy || (canEdit && !isEditing); + + const copy = useMessageCopy({ + markdown: markdownSource, + getHtml: () => bubbleRef.current?.innerHTML ?? '', + getText: () => bubbleRef.current?.textContent ?? '', + defaultFormat: defaultCopyFormat, + }); + + // Hand-off between the footer action bar and the sticky overflow (⋯): the + // overflow only shows (on fine pointers) while the footer bar is scrolled + // out of view. Clipping by the scrollable thread counts as "not + // intersecting", so the default viewport root is sufficient. + const actionsBarRef = React.useRef(null); + const [footerVisible, setFooterVisible] = React.useState(false); + React.useEffect(() => { + const el = actionsBarRef.current; + if (!el || typeof globalThis.IntersectionObserver === 'undefined') return; + const observer = new globalThis.IntersectionObserver(([entry]) => { + setFooterVisible(entry?.isIntersecting ?? false); + }); + observer.observe(el); + return () => observer.disconnect(); + }, [hasActions]); + if (message.type === 'system') { return (
{ setDraft(message.text ?? ''); setIsEditing(true); @@ -577,33 +850,55 @@ export const MessageRow = React.memo(function MessageRow({ }); }; - // The Markdown source for copying: prefer the raw `text`, otherwise assemble - // it from the message's text/code content blocks. - const markdownSource = - typeof message.text === 'string' && message.text - ? message.text - : (message.content - ?.map((block) => { - if (block.type === 'code' && block.text) { - return `\`\`\`${block.language ?? ''}\n${block.text}\n\`\`\``; - } - if ( - (block.type === 'text' || block.type === 'thinking') && - block.text - ) { - return block.text; - } - return ''; - }) - .filter(Boolean) - .join('\n\n') ?? ''); - - // A copy affordance appears on every message that has a body (not while it is - // being edited). - const canCopy = - !isEditing && - (!!message.content?.length || - (typeof message.text === 'string' && message.text.length > 0)); + // The same action definitions power both surfaces: the footer icon bar and + // the sticky overflow menu. + const actions: MessageAction[] = [ + ...(canCopy + ? [ + { + id: 'copy', + label: 'Copy message', + icon: copy.copied ? ( +
); diff --git a/src/components/SuperChat/types.ts b/src/components/SuperChat/types.ts index 107bd855..8e868a6a 100644 --- a/src/components/SuperChat/types.ts +++ b/src/components/SuperChat/types.ts @@ -66,6 +66,18 @@ export type SuperChatChannel = | 'auto' | (string & {}); +/** + * Format used when a message's copy action is triggered without picking an + * explicit variant (Ctrl/Cmd-click on the footer copy button). + * - `'rich'` (default): writes rich text (`text/html`) *and* Markdown + * (`text/plain`) in one clipboard write — the paste target decides. + * - `'markdown'`: writes the Markdown source as plain text. + * - `'plain'`: writes the rendered plain text. + * + * All formats stay reachable per message via the copy menus. + */ +export type SuperChatCopyFormat = 'rich' | 'markdown' | 'plain'; + /** Reference attachment carried by a `ref` thread item. */ export interface SuperChatRef { /** Kind of referenced entity. */ diff --git a/src/hooks/useAnchoredPosition.ts b/src/hooks/useAnchoredPosition.ts index 58980af8..2f954f5e 100644 --- a/src/hooks/useAnchoredPosition.ts +++ b/src/hooks/useAnchoredPosition.ts @@ -8,7 +8,15 @@ export type AnchoredPlacement = | 'bottom' | 'top-start' | 'top-end' - | 'top'; + | 'top' + | 'right-start' + | 'right-end' + | 'right' + | 'left-start' + | 'left-end' + | 'left'; + +export type AnchoredSide = 'top' | 'bottom' | 'left' | 'right'; export interface UseAnchoredPositionOptions { /** Whether the floating element is currently shown */ @@ -45,8 +53,8 @@ export interface UseAnchoredPositionReturn< floatingRef: React.RefObject; /** Fixed-position style for the floating element — spread onto it */ style: React.CSSProperties; - /** Vertical side in use after flipping ('top' or 'bottom') */ - actualSide: 'top' | 'bottom'; + /** Side of the anchor in use after flipping */ + actualSide: AnchoredSide; /** Recompute the position (e.g. after async content loads) */ update: () => void; } @@ -107,8 +115,14 @@ export function useAnchoredPosition< const anchorRef = React.useRef(null); const floatingRef = React.useRef(null); const [style, setStyle] = React.useState(HIDDEN_STYLE); - const [actualSide, setActualSide] = React.useState<'top' | 'bottom'>( - placement.startsWith('top') ? 'top' : 'bottom' + const [actualSide, setActualSide] = React.useState( + placement.startsWith('top') + ? 'top' + : placement.startsWith('left') + ? 'left' + : placement.startsWith('right') + ? 'right' + : 'bottom' ); const update = React.useCallback(() => { @@ -151,6 +165,61 @@ export function useAnchoredPosition< const availableWidth = Math.max(rightLimit - leftLimit, 0); const positionedWidth = Math.min(floatingWidth, availableWidth); + // --- Horizontal-axis placements (left/right flyouts, e.g. submenus) ----- + // The floating element sits beside the anchor: flip horizontally when the + // preferred side is cramped, align vertically (-start = top edges, + // -end = bottom edges, bare = centered), and clamp to the boundary. + if (placement.startsWith('left') || placement.startsWith('right')) { + const contentWidth = floating.offsetWidth; + const spaceRight = rightLimit - rect.right - offset; + const spaceLeft = rect.left - leftLimit - offset; + const preferLeft = placement.startsWith('left'); + let hSide: 'left' | 'right' = preferLeft ? 'left' : 'right'; + if (allowFlip && preferLeft) { + hSide = + spaceLeft < contentWidth && spaceRight > spaceLeft ? 'right' : 'left'; + } else if (allowFlip) { + hSide = + spaceRight < contentWidth && spaceLeft > spaceRight + ? 'left' + : 'right'; + } + const sideWidth = Math.max(hSide === 'left' ? spaceLeft : spaceRight, 0); + + const boundaryHeight = Math.max(bottomLimit - topLimit, 0); + const positionedHeight = Math.min(contentHeight, boundaryHeight); + const alignV = placement.endsWith('-start') + ? 'top' + : placement.endsWith('-end') + ? 'bottom' + : 'center'; + let top = + alignV === 'top' + ? rect.top + : alignV === 'bottom' + ? rect.bottom - positionedHeight + : rect.top + rect.height / 2 - positionedHeight / 2; + top = Math.min( + Math.max(top, topLimit), + Math.max(bottomLimit - positionedHeight, topLimit) + ); + + setActualSide(hSide); + setStyle({ + position: 'fixed', + top, + // Anchor the edge nearest the trigger so the flyout grows away from it. + ...(hSide === 'left' + ? { right: viewportWidth - rect.left + offset } + : { left: rect.right + offset }), + maxWidth: sideWidth, + maxHeight: Math.min(boundaryHeight, maxHeight ?? Infinity), + zIndex: 9999, + transition: 'none', + }); + return; + } + // --- Vertical: preferred side, flip when out of space ------------------- const spaceBelow = bottomLimit - rect.bottom - offset; const spaceAbove = rect.top - topLimit - offset; diff --git a/src/tailwind-preset.ts b/src/tailwind-preset.ts index 9d0ca914..fa7459cc 100644 --- a/src/tailwind-preset.ts +++ b/src/tailwind-preset.ts @@ -543,6 +543,14 @@ export const miewebUISafelist = [ 'focus:border-primary-500', 'focus:ring-primary-500', 'text-[10px]', + // SuperChat message actions (footer bar + sticky overflow menu) — arbitrary + // touch-pointer variants and stacking above rich content (z-50 internals). + '[@media(pointer:coarse)]:opacity-100', + '[@media(pointer:coarse)]:pointer-events-auto', + '[@media(pointer:coarse)]:visible', + 'z-[60]', + // Dropdown submenu flyout — preferred width clamped to the viewport. + 'min-w-[min(10rem,calc(100vw-1rem))]', // SuperChat mermaid diagram wrapper — arbitrary variants applied to the // injected so the diagram sizes naturally instead of collapsing. '[&_svg]:h-auto',