"use client"; import { ArrowLeft, ArrowRight, ExternalLink, MoreHorizontal, Pin, PinOff, Plus, X } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { Button } from "@/components/motion/button/base"; import { MorphPopover, MorphPopoverTrigger, MorphPopoverContent } from "@/components/motion/popover-morph"; import { SPRING_LAYOUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; type MenuPoint = { x: number; y: number }; const LONG_PRESS_DELAY = 520; const LONG_PRESS_TOLERANCE = 10; export interface WorkspaceTab { id: string; title: string; icon?: ReactNode; pinned?: boolean; dirty?: boolean; closable?: boolean; detachable?: boolean; tabId?: string; panelId?: string; } export interface WorkspaceTabBarProps { tabs: WorkspaceTab[]; activeTabId: string; onSelect: (id: string) => void; onClose?: (id: string) => void; onReorder?: (sourceId: string, targetId: string) => void; onPinnedChange?: (id: string, pinned: boolean) => void; onOpenInNewWindow?: (id: string) => void; onNewTab?: () => void; actions?: ReactNode; closeOnMiddleClick?: boolean; closeOnDoubleClick?: boolean; className?: string; } interface TabAction { key: string; label: string; icon: ReactNode; disabled?: boolean; run: () => void } /** Per-tab action menu. One beUI MorphPopover serves every invocation: the ⋯ * button, right-click, long-press and Shift+F10 all open this same panel. * `openAt` positions without moving focus; a real trigger click just toggles. */ function TabActions({ title, items, restore, openRequest, onOpenHandled, anchorTabId }: { title: string; items: TabAction[]; restore: () => void; openRequest: { point: MenuPoint; invocation: number } | null; onOpenHandled: () => void; anchorTabId: string; }) { const [open, setOpen] = useState(false); const panel = useRef(null); const popover = useRef(null); const anchor = useRef(null); useLayoutEffect(() => { if (!open) return; // beUI portals mount in an effect. Wait one frame for the actual actions. const frame = requestAnimationFrame(() => panel.current?.querySelector("button:not(:disabled)")?.focus()); return () => cancelAnimationFrame(frame); }, [open]); useLayoutEffect(() => { if (!openRequest) return; anchor.current = popover.current?.querySelector(`[data-tab-menu-anchor="${anchorTabId}"]`) ?? null; setOpen(false); setOpen(true); onOpenHandled(); }, [openRequest, anchorTabId, onOpenHandled]); return { setOpen(next); if (!next) restore(); }}> {!openRequest && }
{items.map((item) => )}
; } /** Browser-like tabs joined to the content surface. Only the selected surface * moves; controls stay in place. Selection/order/dirty confirmation are host-owned. */ interface TabMenuHostProps { tab: WorkspaceTab; index: number; selected: boolean; items: TabAction[]; instance: string; tabs: WorkspaceTab[]; refs: React.MutableRefObject>; onSelect: (id: string) => void; close: (tab: WorkspaceTab) => void; closeOnMiddleClick: boolean; closeOnDoubleClick: boolean; onReorder: boolean; move: (tab: WorkspaceTab, target?: WorkspaceTab) => void; selectAt: (index: number) => void; restore: (id: string) => void; canClose: (tab: WorkspaceTab) => boolean; onClose: boolean; } /** One tab plus its sibling controls. Right-click, touch long-press and * Shift+F10/CopyAll open the same MorphPopover as the ⋯ button — the * ContextMenu copy is gone, so there is a single menu to maintain. */ function TabMenuHost({ tab, index, selected, items, instance, tabs, refs, onSelect, close, closeOnMiddleClick, closeOnDoubleClick, onReorder, move, selectAt, restore, canClose, onClose }: TabMenuHostProps) { const reduce = useReducedMotion(); const longPressTimer = useRef | null>(null); const touchOrigin = useRef(null); const [openRequest, setOpenRequest] = useState<{ point: MenuPoint; invocation: number } | null>(null); const cancelLongPress = useCallback(() => { if (longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null; } touchOrigin.current = null; }, []); useEffect(() => cancelLongPress, [cancelLongPress]); const openMenuAt = useCallback((point: MenuPoint) => { setOpenRequest((current) => ({ point, invocation: (current?.invocation ?? 0) + 1 })); }, []); const onContextMenu = (event: ReactMouseEvent) => { if (!items.length) return; event.preventDefault(); cancelLongPress(); openMenuAt({ x: event.clientX, y: event.clientY }); }; // Touch/pen: hold to open, like the platform context menu. A drag cancels. const onPointerDown = (event: ReactPointerEvent) => { if (!items.length) return; const pressToOpen = event.pointerType === "touch" || event.pointerType === "pen"; if (pressToOpen) { const origin = { x: event.clientX, y: event.clientY }; touchOrigin.current = origin; longPressTimer.current = setTimeout(() => { longPressTimer.current = null; touchOrigin.current = null; openMenuAt(origin); }, LONG_PRESS_DELAY); } }; const onPointerMove = (event: ReactPointerEvent) => { const origin = touchOrigin.current; if (origin && Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > LONG_PRESS_TOLERANCE) cancelLongPress(); }; // Keyboard: Menu key or Shift+F10 opens at the tab, matching ContextMenu's contract. const onKeyDown = (event: ReactKeyboardEvent) => { if (event.key === "Delete") { event.preventDefault(); close(tab); } if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) { if (!items.length) return; event.preventDefault(); const rect = event.currentTarget.getBoundingClientRect(); openMenuAt({ x: rect.left + Math.min(24, rect.width / 2), y: rect.top + rect.height / 2 }); } if (event.key === "ArrowLeft" || event.key === "ArrowRight") { event.preventDefault(); const step = (event.key === "ArrowLeft" ? -1 : 1) * (getComputedStyle(event.currentTarget).direction === "rtl" ? -1 : 1); if (event.altKey) move(tab, tabs[index + step]); else selectAt(index + step); } if (event.key === "Home" || event.key === "End") { event.preventDefault(); selectAt(event.key === "Home" ? 0 : tabs.length - 1); } }; return ( /* biome-ignore lint/a11y/useSemanticElements: This groups a tab and sibling actions, not form fields. */
{ event.dataTransfer.setData("text/plain", `${instance}:${tab.id}`); event.dataTransfer.effectAllowed = "move"; }} onDragOver={(event) => { if (onReorder) event.preventDefault(); }} onDrop={(event) => { event.preventDefault(); const key = event.dataTransfer.getData("text/plain"); const source = tabs.find((item) => `${instance}:${item.id}` === key); if (source) move(source, tab); }} className="group relative isolate flex h-10 shrink-0 items-center rounded-t-[var(--workspace-radius,0.75rem)] px-1 pb-0.5 outline-none"> {selected &&
); } export function WorkspaceTabBar({ tabs, activeTabId, onSelect, onClose, onReorder, onPinnedChange, onOpenInNewWindow, onNewTab, actions, closeOnMiddleClick = true, closeOnDoubleClick = false, className }: WorkspaceTabBarProps) { const instance = useId(); const refs = useRef(new Map()); const scroller = useRef(null); const closing = useRef(null); const currentId = tabs.some((tab) => tab.id === activeTabId) ? activeTabId : tabs[0]?.id; const canClose = (tab: WorkspaceTab) => Boolean(onClose && !tab.pinned && tab.closable !== false && tabs.length > 1); const restore = (id: string) => (refs.current.get(id) ?? refs.current.get(currentId) ?? refs.current.get(tabs[0]?.id))?.focus({ preventScroll: true }); useLayoutEffect(() => { if (closing.current && !tabs.some((tab) => tab.id === closing.current)) { closing.current = null; (refs.current.get(currentId) ?? refs.current.get(tabs[0]?.id))?.focus({ preventScroll: true }); } const node = scroller.current; const active = refs.current.get(currentId)?.closest("[data-slot='workspace-tab']"); if (!node || !active) return; const bounds = node.getBoundingClientRect(); const rect = active.getBoundingClientRect(); if (rect.left < bounds.left) node.scrollLeft += rect.left - bounds.left; else if (rect.right > bounds.right) node.scrollLeft += rect.right - bounds.right; }, [currentId, tabs]); const close = (tab: WorkspaceTab) => { if (canClose(tab)) { closing.current = tab.id; onClose?.(tab.id); } }; const move = (tab: WorkspaceTab, target?: WorkspaceTab) => { if (!target || tab.id === target.id || Boolean(tab.pinned) !== Boolean(target.pinned)) return; onReorder?.(tab.id, target.id); restore(tab.id); }; const selectAt = (index: number) => { const target = tabs[(index + tabs.length) % tabs.length]; if (target) { onSelect(target.id); restore(target.id); } }; const menuItems = (tab: WorkspaceTab, index: number) => { const items: TabAction[] = []; if (onOpenInNewWindow) items.push({ key: "detach", label: "在独立窗口打开", icon: , disabled: tab.detachable === false, run: () => onOpenInNewWindow(tab.id) }); if (onPinnedChange) items.push({ key: "pin", label: tab.pinned ? "取消固定" : "固定标签", icon: tab.pinned ? : , run: () => onPinnedChange(tab.id, !tab.pinned) }); if (onReorder) { items.push({ key: "left", label: "向左移动", icon: , disabled: !tabs[index - 1] || Boolean(tabs[index - 1].pinned) !== Boolean(tab.pinned), run: () => move(tab, tabs[index - 1]) }); items.push({ key: "right", label: "向右移动", icon: , disabled: !tabs[index + 1] || Boolean(tabs[index + 1].pinned) !== Boolean(tab.pinned), run: () => move(tab, tabs[index + 1]) }); } if (onClose) items.push({ key: "close", label: "关闭标签", icon: , disabled: !canClose(tab), run: () => close(tab) }); return items; }; return
{/* Each tab owns sibling action buttons; never put controls inside a tab button. */}
{tabs.map((tab, index) => { const selected = tab.id === currentId; const items = menuItems(tab, index); return ; })}
{(onNewTab || actions) &&
{onNewTab && }{actions}
}
; }