mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user