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

123 lines
4.1 KiB
TypeScript

'use client';
/**
* ActionPicker — portal popover listing the cue types insertable at a given
* timeline slot (旁白/聚光/激光/讨论). Shared by the header "add" pill and the
* inline "+" affordance (wired in Task 5); this component only renders the
* list and reports the chosen type back via `onSelect`.
*
* Mirrors `CueTooltip`'s `createPortal` pattern in ActionsBar.tsx: a small
* card anchored to a DOMRect, clamped to the viewport, dismissed by a
* backdrop click or Esc.
*/
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import type { Action } from '@/lib/types/action';
import type { SceneType } from '@/lib/types/stage';
import { useI18n } from '@/lib/hooks/use-i18n';
import { cn } from '@/lib/utils/cn';
import { cueLabel, cueMeta } from './cue-meta';
import { pickerOptions, type PickerType } from './picker-options';
const DESC_KEY: Record<PickerType, string> = {
speech: 'edit.picker.speechDesc',
spotlight: 'edit.picker.spotlightDesc',
laser: 'edit.picker.laserDesc',
discussion: 'edit.picker.discussionDesc',
};
export function ActionPicker({
anchor,
sceneType,
actions,
onSelect,
onClose,
}: {
anchor: DOMRect;
sceneType: SceneType;
actions: Action[];
onSelect: (type: PickerType) => void;
onClose: () => void;
}) {
const { t } = useI18n();
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
if (typeof document === 'undefined') return null;
const W = 240;
const left = Math.min(Math.max(8, anchor.left), window.innerWidth - W - 8);
const below = anchor.bottom + 8;
const opensUp = below + 220 > window.innerHeight;
const options = pickerOptions(sceneType, actions);
return createPortal(
<>
<div className="fixed inset-0 z-[70]" onClick={onClose} />
<div
role="menu"
style={{
position: 'fixed',
left,
top: opensUp ? undefined : below,
bottom: opensUp ? window.innerHeight - anchor.top + 8 : undefined,
width: W,
zIndex: 71,
}}
className="overflow-hidden rounded-xl border border-border/80 bg-popover p-1.5 text-popover-foreground shadow-2xl shadow-black/20"
>
<div className="px-2 pb-1.5 pt-1 text-[11px] font-medium tracking-wide text-muted-foreground">
{t('edit.picker.title')}
</div>
{options.map((opt, i) => {
const meta = cueMeta(opt.type);
const Icon = meta.icon;
return (
<div key={opt.type}>
{opt.type === 'discussion' && i > 0 && <div className="my-1 h-px bg-border/70" />}
<button
type="button"
role="menuitem"
disabled={opt.disabled}
title={opt.disabled ? t('edit.timeline.addDiscussionExists') : undefined}
onClick={() => {
onSelect(opt.type);
onClose();
}}
className={cn(
'flex w-full items-start gap-2.5 rounded-lg px-2 py-1.5 text-left transition-colors',
opt.disabled
? 'cursor-not-allowed opacity-40'
: 'hover:bg-muted/70 active:bg-muted',
)}
>
<span
className={cn(
'mt-0.5 grid size-[30px] shrink-0 place-items-center rounded-lg',
meta.glyph,
)}
>
<Icon className="size-4" />
</span>
<span className="min-w-0">
<span className="block text-[12.5px] font-medium text-foreground">
{cueLabel(opt.type, t)}
</span>
<span className="block text-[11px] leading-snug text-muted-foreground">
{t(DESC_KEY[opt.type])}
</span>
</span>
</button>
</div>
);
})}
</div>
</>,
document.body,
);
}