fix(studio): a hovered menu row fills its row, and a host can hide the clip menu tools (#4921)

* fix(studio): a hovered menu row fills its row, with no band around it

* refactor(studio): one shared menu style owns every context menu's panel, rows and dividers

* fix(studio): menus clip their rows to the panel's corners and cover every hand-built panel

* fix(studio): asset menu Delete shows red, trim menu owner comments, test submenu placement

* docs(studio): shorten a submenu comment

* feat(studio): a host can hide the clip menu tools without losing the edits fades need

* test(studio): the clip menu tools fixture is splittable

* test(studio): the default clip menu shows Voice, the control for hiding it

* docs(studio): shorten the menu owner comment

* docs(studio): drop comment lines the ratchet counts

* docs(studio): the menu owner carries one comment

* style(studio): format the files the repo formatter listed

* test(studio): the menu style scan names files with forward slashes on Windows too
This commit is contained in:
Miguel Ángel
2026-10-03 11:55:09 +00:00
committed by GitHub
parent afe020ae59
commit ce08f2042e
17 changed files with 262 additions and 87 deletions
@@ -6,6 +6,7 @@ import { useMenuKeyboardNav } from "../player/components/menuKeyboardNav";
import { useLinkedClipPreferences } from "../utils/linkedClipPreferences";
import { Tooltip } from "./ui";
import { flatActive, flatIdle } from "./timelineToolbarStyles";
import { menuClasses } from "./ui/menuStyle";
function SyncIndicatorMenu({ x, y, onClose }: { x: number; y: number; onClose: () => void }) {
const menuRef = useContextMenuDismiss(onClose);
@@ -17,14 +18,14 @@ function SyncIndicatorMenu({ x, y, onClose }: { x: number; y: number; onClose: (
ref={menuRef}
role="menu"
aria-label="Linked clips"
className="fixed z-200 min-w-[200px] rounded-sm border border-neutral-700 bg-neutral-900 py-1 shadow-lg"
className={`${menuClasses.panel} fixed z-200 min-w-[200px]`}
style={{ left: x, top: y }}
>
<button
type="button"
role="menuitemcheckbox"
aria-checked={visible}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-neutral-300 outline-hidden hover:bg-neutral-800 focus-visible:bg-neutral-800"
className={`${menuClasses.row} ${menuClasses.rowEnabled} flex items-center gap-2`}
onClick={() => {
setVisible(!visible);
onClose();
+5 -7
View File
@@ -31,6 +31,7 @@ import {
type PanelId,
} from "./panelRegistry";
import "./dock.css";
import { menuClasses } from "../ui/menuStyle";
const PERSIST_DEBOUNCE_MS = 250;
@@ -288,10 +289,7 @@ function WindowMenu() {
Window
</button>
{open && (
<div
role="menu"
className="absolute right-0 top-8 z-50 w-44 rounded-md border border-neutral-800 bg-neutral-900 py-1 shadow-lg"
>
<div role="menu" className={`${menuClasses.panel} absolute right-0 top-8 z-50 w-44`}>
{panels.map((id) => (
<button
key={id}
@@ -299,13 +297,13 @@ function WindowMenu() {
role="menuitemcheckbox"
aria-checked={openPanels.has(id)}
onClick={() => togglePanel(id)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-neutral-300 hover:bg-neutral-800"
className={`${menuClasses.row} ${menuClasses.rowEnabled} flex items-center gap-2`}
>
<span className="w-3 text-accent-ink">{openPanels.has(id) ? "✓" : ""}</span>
{PANEL_DEFINITIONS[id].title}
</button>
))}
<div className="my-1 border-t border-neutral-800" />
<div className={menuClasses.divider} />
<button
type="button"
role="menuitem"
@@ -313,7 +311,7 @@ function WindowMenu() {
resetLayout();
setOpen(false);
}}
className="w-full px-3 py-1.5 text-left text-[11px] text-neutral-300 hover:bg-neutral-800"
className={`${menuClasses.row} ${menuClasses.rowEnabled}`}
>
Reset layout
</button>
@@ -33,6 +33,7 @@ import {
type ZOrderAction,
type ZOrderPatch,
} from "./canvasContextMenuZOrder";
import { menuClasses } from "../ui/menuStyle";
interface CanvasContextMenuProps {
/** Viewport x of the right-click event. */
@@ -209,7 +210,7 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
return createPortal(
<div
ref={menuRef}
className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
className={`${menuClasses.panel} fixed z-200 min-w-[180px]`}
style={{ left: adjustedX, top: adjustedY }}
onPointerDown={stopBubble}
onMouseDown={stopBubble}
@@ -252,7 +253,7 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
);
})}
{hasDivider && <div className="my-1 border-t border-neutral-700/60" />}
{hasDivider && <div className={menuClasses.divider} />}
{hasDelete && (
<button
@@ -18,11 +18,14 @@ import {
type InlineInputState,
DIM,
} from "./FileTreeIcons";
import { menuClasses } from "../ui/menuStyle";
export type { ContextMenuState, InlineInputState };
export { buildTree, sortChildren, isActiveInSubtree } from "./FileTreeIcons";
const SZ_ICON = 14;
const ROW = `${menuClasses.row} ${menuClasses.rowEnabled} flex items-center gap-2 active:bg-neutral-700`;
const DANGER_ROW = `${menuClasses.row} ${menuClasses.rowDanger} flex items-center gap-2 active:bg-danger/25`;
// ── Context Menu Component ──
@@ -101,14 +104,14 @@ export function ContextMenu({
ref={menuRef}
role="menu"
onKeyDown={handleMenuKeyDown}
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[160px]"
className={`${menuClasses.panel} fixed z-50 min-w-[160px]`}
style={{ left: adjustedX, top: adjustedY }}
>
{state.targetIsFolder && (
<>
<button
role="menuitem"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-hidden cursor-pointer text-left"
className={ROW}
onClick={() => {
onNewFile(state.targetPath);
onClose();
@@ -119,7 +122,7 @@ export function ContextMenu({
</button>
<button
role="menuitem"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-hidden cursor-pointer text-left"
className={ROW}
onClick={() => {
onNewFolder(state.targetPath);
onClose();
@@ -128,14 +131,14 @@ export function ContextMenu({
<FolderSimplePlus size={12} weight="duotone" className="text-neutral-500" />
New Folder
</button>
<div className="border-t border-neutral-700 my-1" />
<div className={menuClasses.divider} />
</>
)}
{!state.targetIsFolder && (
<>
<button
role="menuitem"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-hidden cursor-pointer text-left"
className={ROW}
onClick={() => {
onNewFile(parentPath);
onClose();
@@ -144,12 +147,12 @@ export function ContextMenu({
<FilePlus size={12} weight="duotone" className="text-neutral-500" />
New File
</button>
<div className="border-t border-neutral-700 my-1" />
<div className={menuClasses.divider} />
</>
)}
<button
role="menuitem"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-hidden cursor-pointer text-left"
className={ROW}
onClick={() => {
onRename(state.targetPath);
onClose();
@@ -161,7 +164,7 @@ export function ContextMenu({
{!state.targetIsFolder && (
<button
role="menuitem"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-hidden cursor-pointer text-left"
className={ROW}
onClick={() => {
onDuplicate(state.targetPath);
onClose();
@@ -171,10 +174,10 @@ export function ContextMenu({
Duplicate
</button>
)}
<div className="border-t border-neutral-700 my-1" />
<div className={menuClasses.divider} />
<button
role="menuitem"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-danger-ink hover:bg-danger/25 focus-visible:bg-danger/25 active:bg-danger/25 outline-hidden cursor-pointer text-left"
className={DANGER_ROW}
onClick={() => {
onDelete(state.targetPath);
onClose();
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { filename } from "./assetHelpers";
import { menuClasses } from "../ui/menuStyle";
/** Reject names that would escape the asset directory or break paths. */
function isValidAssetName(name: string): boolean {
@@ -94,8 +95,7 @@ export function ContextMenu({
onClose();
}, [renameDraft, asset, onRename, onClose]);
const itemCls =
"w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden active:bg-neutral-700/70 transition-colors";
const itemCls = `${menuClasses.row} ${menuClasses.rowEnabled} active:bg-neutral-700/70 transition-colors`;
return (
<div
@@ -110,7 +110,7 @@ export function ContextMenu({
ref={menuRef}
role="menu"
aria-label={`Actions for ${filename(asset)}`}
className="absolute bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl py-1 min-w-[160px] text-xs"
className={`${menuClasses.panel} absolute min-w-[160px] text-xs`}
style={{ left: pos.x, top: pos.y }}
onClick={(e) => e.stopPropagation()}
>
@@ -147,7 +147,7 @@ export function ContextMenu({
<button
role="menuitem"
onClick={() => setMode("confirm-delete")}
className={`${itemCls} text-danger-ink`}
className={`${menuClasses.row} ${menuClasses.rowDanger} active:bg-neutral-700/70 transition-colors`}
>
Delete
</button>
@@ -0,0 +1,61 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
import { menuClasses } from "./menuStyle";
const spacing = /(^| )-?(p|py|pt|pb|m|my|mt|mb)-\d/;
const root = join(__dirname, "../..");
/** Panels that are not a column of rows: a dialog, a crop toolbar, a preset card and a bare speed list. */
const NOT_ROW_MENUS = new Set([
"player/components/AudioGainDialog.tsx",
"components/editor/CropPresetBar.tsx",
"components/editor/EaseCurveSection.tsx",
"player/components/SpeedMenu.tsx",
]);
function sources(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) return sources(path);
return /\.tsx$/.test(name) && !/\.test\./.test(name) ? [path] : [];
});
}
const files = sources(root).map((path) => ({
name: relative(root, path).replaceAll("\\", "/"),
text: readFileSync(path, "utf8"),
}));
describe("menu style", () => {
it("adds no padding or margin around a menu's rows", () => {
for (const key of ["panel", "group", "divider"] as const)
expect(menuClasses[key], key).not.toMatch(spacing);
});
it("clips the panel to its own rounded corners, so a highlight never pokes out of them", () => {
expect(menuClasses.panel).toContain("overflow-hidden");
expect(menuClasses.panel).toMatch(/(^| )rounded-/);
});
it("is the only place a menu panel's fill, border and shadow are written", () => {
const handBuilt = files
.filter(({ name }) => !NOT_ROW_MENUS.has(name))
.filter(({ text }) =>
[...text.matchAll(/["'`]([^"'`]*)["'`]/g)].some(
([, literal]) =>
/shadow-(lg|xl)/.test(literal!) && /bg-(neutral-900|panel-bg-2)\b/.test(literal!),
),
)
.map(({ name }) => name);
expect(handBuilt).toEqual([]);
});
it("is the panel of every role=menu, wherever its element is written", () => {
const unowned = files
.filter(({ text }) => text.includes('role="menu"'))
.filter(({ name, text }) => !NOT_ROW_MENUS.has(name) && !text.includes("menuClasses.panel"))
.map(({ name }) => name);
expect(unowned).toEqual([]);
});
});
@@ -0,0 +1,10 @@
/** The one owner of context menu spacing: rows touch the panel, which clips to its rounded corners. */
export const menuClasses = {
panel: "overflow-hidden rounded-md border border-neutral-700 bg-neutral-900 shadow-lg",
group: "empty:hidden border-b border-neutral-700/60",
divider: "border-t border-neutral-700/60",
row: "w-full px-3 py-1.5 text-left text-xs outline-hidden",
rowEnabled: "text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 cursor-pointer",
rowDanger: "text-danger-ink hover:bg-danger/25 focus-visible:bg-danger/25 cursor-pointer",
rowDisabled: "text-neutral-600 cursor-not-allowed",
} as const;
@@ -46,6 +46,7 @@ const EDIT_CALLBACK_KEY_SET: Record<keyof TimelineEditCallbacks, true> = {
onRazorSplit: true,
onRazorSplitAll: true,
onFreezeFrame: true,
clipMenuTools: true,
onNotice: true,
onDeleteKeyframe: true,
onDeleteAllKeyframes: true,
@@ -8,6 +8,7 @@ import { memo } from "react";
import { createPortal } from "react-dom";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
import { AUTOMATION_SHAPES, type AutomationShapeId } from "./automationShapes";
import { menuClasses } from "../../components/ui/menuStyle";
interface AutomationSelectionMenuProps {
x: number;
@@ -45,7 +46,7 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
// portaled to `document.body`, but the ruler's sticky header sits at z-70
// in the SAME root stacking context, so a z-50 menu opened near the top of
// the timeline is painted through by the ruler and the playhead.
className="hf-automation-menu fixed z-200 min-w-[140px] rounded-sm border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
className={`${menuClasses.panel} hf-automation-menu fixed z-200 min-w-[140px]`}
style={{ left: adjustedX, top: adjustedY }}
>
{AUTOMATION_SHAPES.map((shape) => (
@@ -61,7 +62,7 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
{shape.label}
</button>
))}
<div className="my-1 border-t border-panel-border-input" />
<div className={menuClasses.divider} />
<button
type="button"
className={row}
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { ClipContextMenu } from "./ClipContextMenu";
import type { TimelineClipMenuItem } from "./TimelineTypes";
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -117,6 +118,67 @@ describe("ClipContextMenu host items", () => {
});
});
describe("ClipContextMenu tools", () => {
const video = {
id: "talk",
domId: "talk",
tag: "video",
start: 0,
duration: 4,
track: 0,
hasAudio: true,
} as unknown as TimelineElement;
function menuWith(clipMenuTools: boolean | undefined) {
const host = document.createElement("div");
document.body.appendChild(host);
const root = createRoot(host);
act(() =>
root.render(
<TimelineEditProvider
value={{
onFreezeFrame: vi.fn(),
onSetElementAttributeQuiet: vi.fn(),
onLinkEdit: vi.fn(),
...(clipMenuTools === undefined ? {} : { clipMenuTools }),
}}
>
<ClipContextMenu
x={10}
y={10}
element={video}
currentTime={1}
onClose={vi.fn()}
onSplit={vi.fn()}
onDelete={vi.fn()}
/>
</TimelineEditProvider>,
),
);
const labels = () =>
Array.from(document.body.querySelectorAll("[role=menuitem]")).map((item) => item.textContent);
return { labels, unmount: () => act(() => root.unmount()) };
}
it("offers Freeze frame and the attribute tools by default", () => {
const { labels, unmount } = menuWith(undefined);
expect(labels().some((label) => label?.startsWith("Freeze frame"))).toBe(true);
expect(labels().some((label) => label?.startsWith("Look"))).toBe(true);
expect(labels().some((label) => label?.startsWith("Voice"))).toBe(true);
unmount();
});
it("hides every tool when the host turns them off, keeping Split and Delete", () => {
const { labels, unmount } = menuWith(false);
expect(labels().some((label) => label?.startsWith("Freeze frame"))).toBe(false);
expect(labels().some((label) => label?.startsWith("Look"))).toBe(false);
expect(labels().some((label) => label?.startsWith("Voice"))).toBe(false);
expect(labels().some((label) => label?.startsWith("Split"))).toBe(true);
expect(labels().some((label) => label?.startsWith("Delete"))).toBe(true);
unmount();
});
});
/** A clip button that opens the real menu, whose Ask moves the focus the way `focus` says. */
function FocusHost({ focus }: { focus: "existing" | "mounted" }) {
const [open, setOpen] = useState(false);
@@ -186,3 +248,14 @@ describe("ClipContextMenu focus on close", () => {
unmount();
});
});
describe("ClipContextMenu row fill", () => {
it("leaves no padding or margin between its rows and the panel edge or a divider", () => {
renderMenu([{ id: "ask", label: "Ask", onSelect: vi.fn() }]);
const menu = document.body.querySelector<HTMLElement>("[role=menu]")!;
const spacing = /(^| )(p|py|pt|pb|m|my|mt|mb)-\d/;
expect(menu.className).not.toMatch(spacing);
for (const group of menu.querySelectorAll<HTMLElement>("[role=group]"))
expect(group.className).not.toMatch(spacing);
});
});
@@ -8,10 +8,10 @@ import type { TimelineClipMenuItem } from "./TimelineTypes";
import { ClipMenuToolItems } from "./clipMenuToolItems";
import { ClipMenuAudioItems } from "./clipMenuAudioItems";
import { ClipMenuLinkItems } from "./clipMenuLinkItems";
import { menuClasses } from "../../components/ui/menuStyle";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
const MENU_MARGIN = 8;
// Empty groups collapse; every non-empty group before the always-present Delete group ends in a divider.
const GROUP_CLASS = "empty:hidden mb-1 pb-1 border-b border-neutral-700/60";
function useMeasuredHeight(ref: RefObject<HTMLDivElement | null>, anchorKey: string): number {
const [height, setHeight] = useState(0);
@@ -42,13 +42,8 @@ interface ClipContextMenuProps {
hostItems?: readonly TimelineClipMenuItem[] | undefined;
}
// Same enabled/disabled menu-item pattern as the sibling TrackGapContextMenu.
const itemClass = (enabled: boolean) =>
`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none${
enabled
? " focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer"
: " text-neutral-600 cursor-not-allowed"
}`;
`${menuClasses.row} flex items-center justify-between ${enabled ? menuClasses.rowEnabled : menuClasses.rowDisabled}`;
/** The host's items, above Studio's. A pick closes the menu; focus the item moves stays where it went. */
function HostItems({
@@ -103,6 +98,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
}: ClipContextMenuProps) {
const menuRef = useContextMenuDismiss(onClose);
useMenuKeyboardNav(menuRef);
const tools = useTimelineEditContextOptional().clipMenuTools !== false;
// The right-clicked clip's own id: a member of the live multi-selection
// means Copy/Duplicate act on the whole group, matching onContextMenuClip's
// selection-preserving behaviour for a right-click inside it.
@@ -134,15 +130,15 @@ export const ClipContextMenu = memo(function ClipContextMenu({
ref={menuRef}
role="menu"
aria-label="Clip actions"
className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
className={`${menuClasses.panel} fixed z-200 min-w-[180px]`}
style={{ left: adjustedX, top: adjustedY }}
>
{hostItems.length > 0 && (
<div role="group" aria-label="Host" className={GROUP_CLASS}>
<div role="group" aria-label="Host" className={menuClasses.group}>
<HostItems items={hostItems} onClose={onClose} />
</div>
)}
<div role="group" aria-label="Time" className={GROUP_CLASS}>
<div role="group" aria-label="Time" className={menuClasses.group}>
{splitLabel && (
<button
type="button"
@@ -164,7 +160,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
<span className="text-neutral-500 text-[10px] ml-3">S</span>
</button>
)}
{splitLabel && (
{tools && splitLabel && (
<ClipMenuToolItems
group="time"
element={element}
@@ -174,28 +170,34 @@ export const ClipContextMenu = memo(function ClipContextMenu({
)}
</div>
<div role="group" aria-label="Sound" className={GROUP_CLASS}>
<ClipMenuAudioItems part="gain" element={element} onClose={onClose} />
<ClipMenuToolItems
group="sound"
element={element}
currentTime={currentTime}
onClose={onClose}
/>
<ClipMenuLinkItems part="link" element={element} onClose={onClose} />
<ClipMenuAudioItems part="duck" element={element} onClose={onClose} />
<div role="group" aria-label="Sound" className={menuClasses.group}>
{tools && (
<>
<ClipMenuAudioItems part="gain" element={element} onClose={onClose} />
<ClipMenuToolItems
group="sound"
element={element}
currentTime={currentTime}
onClose={onClose}
/>
<ClipMenuLinkItems part="link" element={element} onClose={onClose} />
<ClipMenuAudioItems part="duck" element={element} onClose={onClose} />
</>
)}
</div>
<div role="group" aria-label="Picture" className={GROUP_CLASS}>
<ClipMenuToolItems
group="picture"
element={element}
currentTime={currentTime}
onClose={onClose}
/>
<div role="group" aria-label="Picture" className={menuClasses.group}>
{tools && (
<ClipMenuToolItems
group="picture"
element={element}
currentTime={currentTime}
onClose={onClose}
/>
)}
</div>
<div role="group" aria-label="Clipboard" className={GROUP_CLASS}>
<div role="group" aria-label="Clipboard" className={menuClasses.group}>
{onCopy && (
<button
type="button"
@@ -255,7 +257,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
<span>Delete</span>
<span className="text-neutral-500 text-[10px] ml-3">⌫</span>
</button>
<ClipMenuLinkItems part="delete" element={element} onClose={onClose} />
{tools && <ClipMenuLinkItems part="delete" element={element} onClose={onClose} />}
</div>
</div>,
document.body,
@@ -4,6 +4,7 @@ import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
import { useMenuKeyboardNav } from "./menuKeyboardNav";
import type { TimelineElement } from "../store/playerStore";
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
import { menuClasses } from "../../components/ui/menuStyle";
export interface KeyframeDiamondContextMenuState {
x: number;
@@ -39,10 +40,8 @@ interface KeyframeDiamondContextMenuProps {
onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
}
const ITEM_CLS =
"w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-200 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left";
const DESTRUCTIVE_ITEM_CLS =
"w-full flex items-center gap-2 px-3 py-1.5 text-xs text-danger-ink hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left";
const ITEM_CLS = `${menuClasses.row} ${menuClasses.rowEnabled} flex items-center gap-2`;
const DESTRUCTIVE_ITEM_CLS = `${menuClasses.row} text-danger-ink hover:bg-neutral-800 focus-visible:bg-neutral-800 cursor-pointer flex items-center gap-2`;
export function KeyframeDiamondContextMenu({
state,
@@ -96,7 +95,7 @@ export function KeyframeDiamondContextMenu({
ref={menuRef}
role="menu"
aria-label="Keyframe actions"
className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px] overflow-y-auto"
className={`${menuClasses.panel} fixed z-200 min-w-[180px] overflow-y-auto`}
style={{ left: adjustedX, top: adjustedY, maxHeight: `calc(100vh - ${adjustedY + 8}px)` }}
>
{onMoveToPlayhead && (
@@ -165,14 +164,14 @@ export function KeyframeDiamondContextMenu({
{/* Deleting every keyframe sat adjacent to the single delete and styled
identically. Separate and mark it so the two cannot be misread. */}
<div className="my-1 border-t border-neutral-700/60" role="separator" />
<div className="my-1 border-t border-neutral-700/60" role="separator" />
<div className={menuClasses.divider} role="separator" />
<div className="h-1" aria-hidden="true" />
<div className={menuClasses.divider} role="separator" />
<button
type="button"
role="menuitem"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-danger-ink hover:bg-danger/25 focus-visible:bg-danger/25 outline-hidden cursor-pointer text-left"
className={`${menuClasses.row} ${menuClasses.rowDanger} flex items-center gap-2`}
onClick={() => {
onDeleteAll(state.element, state.animationId);
onClose();
@@ -7,11 +7,11 @@ import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { clipSyncState, type ClipSyncState } from "./clipSync";
import { useMenuKeyboardNav } from "./menuKeyboardNav";
import { menuClasses } from "../../components/ui/menuStyle";
const stop = (e: SyntheticEvent) => e.stopPropagation();
const MENU_ROW =
"block w-full px-3 py-1.5 text-left text-xs text-neutral-300 outline-hidden hover:bg-neutral-800 focus-visible:bg-neutral-800 disabled:opacity-40";
const ROW = `${menuClasses.row} ${menuClasses.rowEnabled} block disabled:cursor-not-allowed disabled:opacity-40`;
function SyncMenu({
x,
@@ -39,13 +39,13 @@ function SyncMenu({
ref={menuRef}
role="menu"
aria-label="Out of sync"
className="fixed z-200 min-w-[170px] rounded-sm border border-neutral-700 bg-neutral-900 py-1 shadow-lg"
className={`${menuClasses.panel} fixed z-200 min-w-[170px]`}
style={{ left, top }}
>
<button
type="button"
role="menuitem"
className={MENU_ROW}
className={ROW}
disabled={!onLinkEdit || moveStart === null}
title={moveStart === null ? "Would move the clip before 0:00" : undefined}
onClick={() => {
@@ -59,7 +59,7 @@ function SyncMenu({
<button
type="button"
role="menuitem"
className={MENU_ROW}
className={ROW}
disabled={!onLinkEdit || slipMediaStart === null}
title={slipMediaStart === null ? "Would slip before the start of the file" : undefined}
onClick={() => {
@@ -1,6 +1,7 @@
import { memo } from "react";
import { createPortal } from "react-dom";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
import { menuClasses } from "../../components/ui/menuStyle";
interface TrackGapContextMenuProps {
x: number;
@@ -53,11 +54,7 @@ export const TrackGapContextMenu = memo(function TrackGapContextMenu({
const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
const itemClass = (enabled: boolean) =>
`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left ${
enabled
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
: "text-neutral-600 cursor-not-allowed"
}`;
`${menuClasses.row} flex items-center justify-between ${enabled ? menuClasses.rowEnabled : menuClasses.rowDisabled}`;
// Disabled reasons: no gap under the pointer beats the lock reason — a
// pointer not on a gap has nothing to close regardless of movability.
@@ -75,7 +72,7 @@ export const TrackGapContextMenu = memo(function TrackGapContextMenu({
return createPortal(
<div
ref={menuRef}
className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
className={`${menuClasses.panel} fixed z-200 min-w-[180px]`}
style={{ left: adjustedX, top: adjustedY }}
onPointerLeave={() => onHoverAction(null)}
>
@@ -99,6 +99,29 @@ describe("ClipMenuToolItems", () => {
expect(onClose).toHaveBeenCalled();
});
it("opens its submenu at the row's edge in window pixels, and flips left near the window's right edge", () => {
const rowAt = (rect: { top: number; left: number; right: number }) =>
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect as DOMRect);
const spy = rowAt({ top: 50, left: 100, right: 300 });
renderItems("sound", video);
openSubmenu("Voice");
let menu = document.querySelector<HTMLElement>('[role="menu"]')!;
expect(menu.className).toContain("fixed");
expect([menu.style.top, menu.style.left]).toEqual(["50px", "300px"]);
act(() => root?.unmount());
document.body.innerHTML = "";
spy.mockReturnValue({
top: 50,
left: window.innerWidth - 200,
right: window.innerWidth - 10,
} as DOMRect);
renderItems("sound", video);
openSubmenu("Voice");
menu = document.querySelector<HTMLElement>('[role="menu"]')!;
expect([menu.style.top, menu.style.right, menu.style.left]).toEqual(["50px", "200px", ""]);
spy.mockRestore();
});
it("Voice None drops the attribute", () => {
const { setQuiet } = renderItems("sound", video);
openSubmenu("Voice");
@@ -15,6 +15,7 @@ import {
type ClipToolChoice,
} from "./clipToolAttrs";
import { useClipToolState } from "./useClipToolState";
import { menuClasses } from "../../components/ui/menuStyle";
export type ClipMenuToolGroup = "time" | "sound" | "picture";
@@ -25,10 +26,8 @@ interface ClipMenuToolItemsProps {
onClose: () => void;
}
const ROW_CLASS =
"w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer";
const DISABLED_ROW_CLASS =
"w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none text-neutral-600 cursor-not-allowed";
const ROW_CLASS = `${menuClasses.row} flex items-center justify-between ${menuClasses.rowEnabled}`;
const DISABLED_ROW_CLASS = `${menuClasses.row} flex items-center justify-between ${menuClasses.rowDisabled}`;
const SUBMENU_WIDTH = 170;
const HOVER_PREVIEW_DELAY_MS = 80;
@@ -98,7 +97,7 @@ function ChoiceSubmenu({
preview?: HoverPreview;
}) {
const [open, setOpen] = useState(false);
const [flipLeft, setFlipLeft] = useState(false);
const [anchor, setAnchor] = useState<{ top: number; left?: number; right?: number }>({ top: 0 });
const rowRef = useRef<HTMLButtonElement | null>(null);
const submenuRef = useRef<HTMLDivElement | null>(null);
const hover = useHoverPreview(preview);
@@ -111,7 +110,12 @@ function ChoiceSubmenu({
const show = (focusFirst: boolean) => {
const rect = rowRef.current?.getBoundingClientRect();
setFlipLeft(rect ? rect.right + SUBMENU_WIDTH > window.innerWidth : false);
if (rect)
setAnchor(
rect.right + SUBMENU_WIDTH > window.innerWidth
? { top: rect.top, right: window.innerWidth - rect.left }
: { top: rect.top, left: rect.right },
);
setOpen(true);
if (!focusFirst) return;
openingFocusRef.current = true;
@@ -184,8 +188,8 @@ function ChoiceSubmenu({
ref={submenuRef}
role="menu"
aria-label={label}
className="absolute top-0 z-10 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1"
style={{ width: SUBMENU_WIDTH, ...(flipLeft ? { right: "100%" } : { left: "100%" }) }}
className={`${menuClasses.panel} fixed z-10`}
style={{ width: SUBMENU_WIDTH, ...anchor }}
onKeyDown={onSubmenuKeyDown}
>
{choiceRow(null, "None")}
@@ -193,7 +197,7 @@ function ChoiceSubmenu({
<div key={section.heading ?? index}>
{section.heading && (
<>
<div className="my-1 border-t border-neutral-700/60" />
<div className={menuClasses.divider} />
<div className="px-3 py-1 text-[9px] uppercase tracking-wide text-neutral-500">
{section.heading}
</div>
@@ -137,6 +137,7 @@ export interface TimelineEditCallbacks {
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
onFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
clipMenuTools?: boolean;
onNotice?: (message: string, tone?: "error" | "info") => void;
onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
onDeleteAllKeyframes?: (element: TimelineElement, animationId?: string) => void;