'use client'; /** * ElementPickLayer — canvas-side target picker for the timeline. * * When the ActionsBar arms "pick" mode (useCanvasStore.pickTarget, keyed by * actionId), this layer covers the slide canvas and lets the user bind a cue's * target either by clicking the element on the slide (hit-tested live) or by * clicking a row in the floating element panel (draggable + collapsible). Every * selectable element is outlined; the hovered one gets a solid ring + live * spotlight/laser preview. Click empty canvas or press Esc to cancel. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ChevronDown, GripHorizontal, MousePointerClick } from 'lucide-react'; import { useCanvasStore } from '@/lib/store/canvas'; import { useStageStore } from '@/lib/store/stage'; import { useI18n } from '@/lib/hooks/use-i18n'; import { setElementIdById } from '@/components/edit/ActionsBar/actions-edit'; import { cueLabel, elementLabel } from '@/components/edit/ActionsBar/cue-meta'; import { clearCuePreview, previewCueEffect } from '@/components/edit/ActionsBar/cue-preview'; const PREFIX = 'editable-element-'; const PANEL_W = 232; interface Box { left: number; top: number; width: number; height: number; } interface ElementLite { id: string; type: string; content?: string; } function elementHostAt(x: number, y: number): HTMLElement | null { for (const node of document.elementsFromPoint(x, y)) { const host = (node as HTMLElement).closest?.(`[id^="${PREFIX}"]`) as HTMLElement | null; if (host?.id?.startsWith(PREFIX)) return host; } return null; } export function ElementPickLayer() { const { t } = useI18n(); const pickTarget = useCanvasStore.use.pickTarget(); // Reactive scene lookup so the panel/binding state tracks store updates. const scene = useStageStore((s) => pickTarget ? (s.scenes.find((x) => x.id === pickTarget.sceneId) ?? null) : null, ); const rootRef = useRef(null); const [hover, setHover] = useState<{ id: string; box: Box } | null>(null); const [outlines, setOutlines] = useState>([]); const [panel, setPanel] = useState<{ x: number; y: number }>({ x: 0, y: 16 }); const [collapsed, setCollapsed] = useState(false); const dragRef = useRef<{ px: number; py: number; ox: number; oy: number } | null>(null); const moveRafRef = useRef(null); const cueType = pickTarget?.cueType; const elements = useMemo( () => (scene?.content as { canvas?: { elements?: ElementLite[] } } | undefined)?.canvas?.elements ?? [], [scene], ); const currentBound = ( scene?.actions?.find((a) => a.id === pickTarget?.actionId) as | { elementId?: string } | undefined )?.elementId ?? ''; const preview = useCallback( (elementId: string) => { if (cueType) previewCueEffect(cueType, elementId); }, [cueType], ); const finish = useCallback(() => { clearCuePreview(); useCanvasStore.getState().setPickTarget(null); setHover(null); }, []); const bind = useCallback( (elementId: string) => { const pt = useCanvasStore.getState().pickTarget; if (!pt) return; const sc = useStageStore.getState().scenes.find((s) => s.id === pt.sceneId); if (sc) { // Bind by actionId — index-stale-safe against concurrent reorder/delete. useStageStore.getState().updateScene(pt.sceneId, { actions: setElementIdById(sc.actions ?? [], pt.actionId, elementId), }); } finish(); }, [finish], ); // Local (canvas-relative) box for a viewport rect. const toLocal = useCallback((r: DOMRect): Box | null => { const cr = rootRef.current?.getBoundingClientRect(); if (!cr) return null; return { left: r.left - cr.left, top: r.top - cr.top, width: r.width, height: r.height }; }, []); const measureOutlines = useCallback(() => { const boxes: Array<{ id: string; box: Box }> = []; for (const el of elements) { const host = document.getElementById(`${PREFIX}${el.id}`); if (!host) continue; const b = toLocal(host.getBoundingClientRect()); if (b) boxes.push({ id: el.id, box: b }); } setOutlines(boxes); }, [elements, toLocal]); // On entering pick mode: outline every selectable element, dock panel top-right. useEffect(() => { if (!pickTarget) return; measureOutlines(); const cr = rootRef.current?.getBoundingClientRect(); if (cr) setPanel({ x: Math.max(8, cr.width - PANEL_W - 16), y: 16 }); setCollapsed(false); const onResize = () => measureOutlines(); window.addEventListener('resize', onResize); window.addEventListener('scroll', onResize, true); return () => { window.removeEventListener('resize', onResize); window.removeEventListener('scroll', onResize, true); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [pickTarget?.sceneId, pickTarget?.actionId, elements.length]); useEffect(() => { if (!pickTarget) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') finish(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [pickTarget, finish]); // Unmount cleanup: cancel any pending rAF, and — if this layer unmounts while // still armed (scene switch / leaving the slide surface before the user picks) // — clear the global pick target + preview. pickTarget lives in the canvas // store, so without this it survives the unmount and the next slide mount // renders a stale picker bound to the old scene/action. A normal finish() // already nulled pickTarget, so this is a no-op in that case. useEffect( () => () => { if (moveRafRef.current != null) cancelAnimationFrame(moveRafRef.current); if (useCanvasStore.getState().pickTarget) { clearCuePreview(); useCanvasStore.getState().setPickTarget(null); } }, [], ); if (!pickTarget) return null; const typeLabel = cueLabel(pickTarget.cueType, t); // Hit-test on mousemove, coalesced to one rAF per frame. const onCanvasMove = (e: React.MouseEvent) => { const { clientX, clientY } = e; if (moveRafRef.current != null) return; moveRafRef.current = requestAnimationFrame(() => { moveRafRef.current = null; const host = elementHostAt(clientX, clientY); if (!host) { if (hover) { setHover(null); preview(''); } return; } const id = host.id.slice(PREFIX.length); if (id !== hover?.id) { const box = toLocal(host.getBoundingClientRect()); setHover(box ? { id, box } : null); preview(id); } }); }; const onCanvasClick = () => { if (hover) bind(hover.id); else finish(); }; const highlightById = (id: string) => { const host = document.getElementById(`${PREFIX}${id}`); const box = host ? toLocal(host.getBoundingClientRect()) : null; setHover(box ? { id, box } : { id, box: { left: 0, top: 0, width: 0, height: 0 } }); preview(id); }; const onPanelDown = (e: React.PointerEvent) => { dragRef.current = { px: e.clientX, py: e.clientY, ox: panel.x, oy: panel.y }; try { (e.target as HTMLElement).setPointerCapture(e.pointerId); } catch { /* best effort */ } }; const onPanelMove = (e: React.PointerEvent) => { const d = dragRef.current; if (!d) return; const cr = rootRef.current?.getBoundingClientRect(); const maxX = cr ? cr.width - PANEL_W - 8 : 9999; const maxY = cr ? cr.height - 40 : 9999; setPanel({ x: Math.min(Math.max(8, d.ox + (e.clientX - d.px)), Math.max(8, maxX)), y: Math.min(Math.max(8, d.oy + (e.clientY - d.py)), Math.max(8, maxY)), }); }; const onPanelUp = () => { dragRef.current = null; }; return (
{/* click-catcher (sibling of the panel, so panel clicks never reach it) */}
{/* every selectable element gets a faint outline → "this is clickable" */} {outlines.map((o) => (
))} {/* hovered element — solid ring */} {hover && hover.box.width > 0 && (
)} {/* instruction banner */}
{t('edit.pick.pickFor', { label: typeLabel })} {' '} · {t('edit.pick.pickHint')}
{/* draggable + collapsible element panel, inside the canvas */}
e.stopPropagation()} style={{ left: panel.x, top: panel.y, width: PANEL_W }} className="absolute flex max-h-[70%] flex-col overflow-hidden rounded-2xl border border-border bg-popover/95 shadow-xl shadow-black/15 backdrop-blur" >
{t('edit.pick.pageElements', { count: elements.length })}
{!collapsed && (
{elements.length === 0 ? (

{t('edit.pick.noElements')}

) : ( elements.map((el) => ( )) )}
)} {collapsed && (
{t('edit.pick.bindHint')}
)}
); }