diff --git a/packages/studio/src/components/LinkedSelectionToggle.tsx b/packages/studio/src/components/LinkedSelectionToggle.tsx index 8f01a2d144..59f7e32a09 100644 --- a/packages/studio/src/components/LinkedSelectionToggle.tsx +++ b/packages/studio/src/components/LinkedSelectionToggle.tsx @@ -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 }} > {open && ( -
+
{panels.map((id) => ( ))} -
+
diff --git a/packages/studio/src/components/editor/CanvasContextMenu.tsx b/packages/studio/src/components/editor/CanvasContextMenu.tsx index b29b11149a..1c13c50e45 100644 --- a/packages/studio/src/components/editor/CanvasContextMenu.tsx +++ b/packages/studio/src/components/editor/CanvasContextMenu.tsx @@ -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(
} + {hasDivider &&
} {hasDelete && ( -
+
)} {!state.targetIsFolder && ( <> -
+
)} )} -
+
diff --git a/packages/studio/src/components/ui/menuStyle.test.ts b/packages/studio/src/components/ui/menuStyle.test.ts new file mode 100644 index 0000000000..8e762be259 --- /dev/null +++ b/packages/studio/src/components/ui/menuStyle.test.ts @@ -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([]); + }); +}); diff --git a/packages/studio/src/components/ui/menuStyle.ts b/packages/studio/src/components/ui/menuStyle.ts new file mode 100644 index 0000000000..2ab1f607f4 --- /dev/null +++ b/packages/studio/src/components/ui/menuStyle.ts @@ -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; diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx index 48d7989111..bdc54003f9 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -46,6 +46,7 @@ const EDIT_CALLBACK_KEY_SET: Record = { onRazorSplit: true, onRazorSplitAll: true, onFreezeFrame: true, + clipMenuTools: true, onNotice: true, onDeleteKeyframe: true, onDeleteAllKeyframes: true, diff --git a/packages/studio/src/player/components/AutomationSelectionMenu.tsx b/packages/studio/src/player/components/AutomationSelectionMenu.tsx index f55cd2fb9e..fe79a28312 100644 --- a/packages/studio/src/player/components/AutomationSelectionMenu.tsx +++ b/packages/studio/src/player/components/AutomationSelectionMenu.tsx @@ -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} ))} -
+
)} - {splitLabel && ( + {tools && splitLabel && ( -
- - - - +
+ {tools && ( + <> + + + + + + )}
-
- +
+ {tools && ( + + )}
-
+
{onCopy && ( - + {tools && }
, document.body, diff --git a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx index ae398bfb01..ba837124ac 100644 --- a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx +++ b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx @@ -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. */} -
- -
+
+