Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

118 lines
4.6 KiB
TypeScript

'use client';
import { Fragment } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { FloatingAction } from '@/lib/edit/scene-editor-surface';
interface FloatingToolbarProps {
readonly actions: readonly FloatingAction[];
}
/**
* Contextual mini-bar shown above the canvas when something is selected.
* Each action is either a button (onInvoke) or a popover trigger
* (popoverContent) — used by the slide surface for color pickers, font
* select, etc. so properties live here instead of in a fixed right panel.
*/
export function FloatingToolbar({ actions }: FloatingToolbarProps) {
if (actions.length === 0) return null;
const grouped = groupByGroup(actions);
return (
<div className="pointer-events-none absolute left-1/2 top-5 z-20 -translate-x-1/2">
<div className="pointer-events-auto flex items-center gap-0.5 rounded-2xl bg-white/95 p-1 shadow-[0_4px_24px_-8px_rgba(0,0,0,0.12),0_1px_2px_rgba(0,0,0,0.04)] ring-1 ring-zinc-200/60 backdrop-blur dark:bg-zinc-900/95 dark:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.5)] dark:ring-zinc-800/60">
{grouped.map((group, groupIndex) => (
<Fragment key={groupIndex}>
{groupIndex > 0 && <div className="mx-0.5 h-5 w-px bg-zinc-200 dark:bg-zinc-800" />}
{group.map((action) => (
<ActionButton key={action.id} action={action} />
))}
</Fragment>
))}
</div>
</div>
);
}
function ActionButton({ action }: { readonly action: FloatingAction }) {
const isDanger = action.id === 'delete';
const button = (
<button
type="button"
disabled={action.disabled}
onClick={action.popoverContent ? undefined : action.onInvoke}
className={`flex h-8 items-center gap-1.5 rounded-xl px-2 transition-colors disabled:pointer-events-none disabled:opacity-40 ${
isDanger
? 'text-zinc-500 hover:bg-rose-50 hover:text-rose-600 dark:text-zinc-400 dark:hover:bg-rose-950/40 dark:hover:text-rose-400'
: 'text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100'
}`}
>
{action.icon}
{!action.icon && <span className="text-xs">{action.label}</span>}
</button>
);
const triggerWithTooltip = (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>{action.tooltip ?? action.label}</TooltipContent>
</Tooltip>
);
if (!action.popoverContent) return triggerWithTooltip;
// Chain both triggers' asChild Slots directly onto the real <button>.
// Wrapping PopoverTrigger around <Tooltip> (a provider, not a DOM node)
// dropped the popover trigger handler, so the popover never opened —
// this is the first popoverContent consumer to exercise that path.
return (
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>{button}</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{action.tooltip ?? action.label}</TooltipContent>
</Tooltip>
{/* w-auto: let the action's own content size the popover (the text
property bar is a wide single row); max-w-[92vw] keeps it on-screen
and Radix handles edge collision. Avoids the fixed-w-72 clip.
onOpenAutoFocus prevented: opening the bar must NOT pull focus off
the canvas selection (commands apply to the live selection).
onFocusOutside prevented: format commands refocus the editor
(execCommand → editorView.focus()); without this the bar would
dismiss after every single click. Escape and pointer-down truly
outside still close it (defaults untouched), so it behaves like a
contextual bar that stays up across consecutive formatting steps. */}
<PopoverContent
side="bottom"
align="center"
className="w-auto max-w-[92vw] p-2"
onOpenAutoFocus={(e) => e.preventDefault()}
onFocusOutside={(e) => e.preventDefault()}
>
{action.popoverContent()}
</PopoverContent>
</Popover>
);
}
function groupByGroup(items: readonly FloatingAction[]): FloatingAction[][] {
const groups: FloatingAction[][] = [];
let current: FloatingAction[] = [];
let currentKey: string | undefined;
for (const item of items) {
const key = item.group;
if (key !== currentKey && current.length > 0) {
groups.push(current);
current = [];
}
currentKey = key;
current.push(item);
}
if (current.length > 0) groups.push(current);
return groups;
}