Files
openmaic/OpenMAIC/components/edit/EditShell/FloatingInsertToolbar.tsx
2026-08-16 14:58:47 +08:00

115 lines
4.2 KiB
TypeScript

'use client';
import { motion, useDragControls, type MotionValue } from 'motion/react';
import { GripHorizontal } from 'lucide-react';
import { useRef, useState, type KeyboardEvent } from 'react';
import { useI18n } from '@/lib/hooks/use-i18n';
import type { InsertPaletteItem } from '@/lib/edit/scene-editor-surface';
import { cn } from '@/lib/utils';
import { InsertButton } from './InsertButton';
interface Props {
readonly items: readonly InsertPaletteItem[];
readonly x: MotionValue<number>;
readonly y: MotionValue<number>;
}
/**
* Persistent insert toolbar — floats inside the center-left edge of the studio
* canvas. Replaces the inline insert slot in CommandBar so the global stage
* controls (back, undo
* /redo, title, settings, Pro, Download) aren't visually mixed with
* content-insertion affordances ("text box / image / shape ..." live
* with the content, not with stage controls).
*
* Labels stay in tooltips so the vertical strip remains compact. A low-profile
* grip lets authors move the strip anywhere inside the studio without shifting
* the centered slide viewport or dedicating permanent layout space to it.
*/
export function FloatingInsertToolbar({ items, x, y }: Props) {
const { t } = useI18n();
const constraintsRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const dragControls = useDragControls();
const [keyboardDragging, setKeyboardDragging] = useState(false);
const handleDragKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setKeyboardDragging((active) => !active);
return;
}
if (event.key === 'Escape') {
setKeyboardDragging(false);
return;
}
if (!keyboardDragging || !event.key.startsWith('Arrow')) return;
const bounds = constraintsRef.current?.getBoundingClientRect();
const toolbar = toolbarRef.current?.getBoundingClientRect();
if (!bounds || !toolbar) return;
event.preventDefault();
const step = event.shiftKey ? 24 : 8;
const dx = event.key === 'ArrowLeft' ? -step : event.key === 'ArrowRight' ? step : 0;
const dy = event.key === 'ArrowUp' ? -step : event.key === 'ArrowDown' ? step : 0;
const clampedDx = Math.max(
bounds.left - toolbar.left,
Math.min(dx, bounds.right - toolbar.right),
);
const clampedDy = Math.max(
bounds.top - toolbar.top,
Math.min(dy, bounds.bottom - toolbar.bottom),
);
x.set(x.get() + clampedDx);
y.set(y.get() + clampedDy);
};
if (items.length === 0) return null;
return (
<div
ref={constraintsRef}
className="pointer-events-none absolute inset-2 z-30 flex items-center justify-start"
>
<motion.div
ref={toolbarRef}
drag
dragListener={false}
dragControls={dragControls}
dragConstraints={constraintsRef}
dragElastic={0.04}
dragMomentum={false}
style={{ x, y }}
whileDrag={{ scale: 1.02 }}
className={cn(
'pointer-events-auto flex flex-col items-center gap-1 p-1',
'bg-white/90 dark:bg-zinc-900/90 backdrop-blur-md',
'ring-1 ring-zinc-200/80 dark:ring-zinc-700/80',
'rounded-xl shadow-md',
)}
>
<button
type="button"
data-testid="insert-toolbar-drag-handle"
aria-label={t('edit.insert.dragToolbarKeyboard')}
aria-pressed={keyboardDragging}
title={t('edit.insert.dragToolbar')}
onPointerDown={(event) => {
setKeyboardDragging(false);
dragControls.start(event);
}}
onKeyDown={handleDragKeyDown}
onBlur={() => setKeyboardDragging(false)}
className="flex h-6 w-9 touch-none cursor-grab items-center justify-center rounded-md text-zinc-300 hover:bg-zinc-100 hover:text-zinc-500 focus-visible:outline-2 focus-visible:outline-violet-500 active:cursor-grabbing dark:text-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-400"
>
<GripHorizontal className="h-3 w-3" strokeWidth={2} />
</button>
{items.map((item) => (
<InsertButton key={item.id} item={item} iconOnly popoverSide="right" />
))}
</motion.div>
</div>
);
}