---
title: Note edit menu
description: "A menu of commands over a piano roll's notes, with their keys: split, join, quantize, legato, time commands, fit to scale, chance and velocity."
component: true
base: base
---

```tsx
"use client";

import type { Note, NoteId } from "@audio-ui/core/piano-roll";
import { useCallback, useRef, useState } from "react";
import {
  DropdownMenuItem,
  DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import {
  NoteEditMenu,
  NoteEditMenuContent,
  NoteEditMenuSub,
  NoteEditMenuTrigger,
} from "@/components/audio-ui/note-edit-menu";
import {
  PianoRoll,
  PianoRollGrid,
  PianoRollKeys,
  PianoRollStrip,
  PianoRollVelocity,
  PianoRollVelocityScale,
  PianoRollViewport,
} from "@/components/audio-ui/piano-roll";
import { Timeline } from "@/components/audio-ui/timeline";
import {
  applyNoteEdit,
  canApplyNoteEdit,
  type NoteEdit,
  PITCH_EDITS,
  TIME_EDITS,
  VELOCITY_EDITS,
} from "@/lib/note-edits";

const BOUNDS = { duration: 16, maxPitch: 72, minLength: 1, minPitch: 60 };

const PHRASE: Note[] = [
  { id: "1", length: 2, pitch: 60, start: 0, velocity: 0.9 },
  { id: "2", length: 2, pitch: 64, start: 2, velocity: 0.5 },
  { id: "3", length: 2, pitch: 67, start: 4, velocity: 0.8 },
  { id: "4", length: 2, pitch: 72, start: 6, velocity: 1 },
  { id: "5", length: 1, pitch: 71, start: 9, velocity: 0.4 },
  { id: "6", length: 3, pitch: 67, start: 10, velocity: 0.7 },
  { id: "7", length: 3, pitch: 62, start: 13, velocity: 0.6 },
];

/** Edit the selection, or every note with none selected; Reset is an item of the demo's own, beside the edits. */
export function NoteEditMenuDemo() {
  const [notes, setNotes] = useState(PHRASE);
  const [selection, setSelection] = useState<readonly NoteId[]>([]);
  const nextIdRef = useRef(0);

  const edit = useCallback(
    (item: NoteEdit) =>
      setNotes(
        applyNoteEdit(item, notes, selection, {
          at: 0,
          bounds: BOUNDS,
          loopEnd: BOUNDS.duration,
          nextId: () => {
            nextIdRef.current += 1;
            return `edit-${nextIdRef.current}`;
          },
          scale: null,
        })
      ),
    [notes, selection]
  );
  const isDisabled = useCallback(
    (item: NoteEdit) =>
      !canApplyNoteEdit(item, {
        hasNotes: notes.length > 0,
        hasScale: false,
        hasSelection: selection.length > 0,
      }),
    [notes.length, selection.length]
  );
  const reset = useCallback(() => setNotes(PHRASE), []);

  return (
    <div className="flex w-full flex-col gap-3">
      <div className="flex justify-end">
        <NoteEditMenu disabled={isDisabled} onEdit={edit}>
          <NoteEditMenuTrigger />
          <NoteEditMenuContent>
            <NoteEditMenuSub edits={TIME_EDITS} label="Time" />
            <NoteEditMenuSub edits={PITCH_EDITS} label="Pitch" />
            <NoteEditMenuSub edits={VELOCITY_EDITS} label="Velocity" />
            <DropdownMenuSeparator />
            <DropdownMenuItem onClick={reset}>Reset</DropdownMenuItem>
          </NoteEditMenuContent>
        </NoteEditMenu>
      </div>
      <Timeline duration={BOUNDS.duration} snapStep={1}>
        <PianoRoll
          className="h-72 rounded-md border"
          maxPitch={BOUNDS.maxPitch}
          minPitch={BOUNDS.minPitch}
          onSelectionChange={setSelection}
          onValueChange={setNotes}
          selection={selection}
          value={notes}
        >
          <PianoRollViewport>
            <PianoRollStrip className="flex-1" lead={<PianoRollKeys />}>
              <PianoRollGrid accentStep={4} aria-label="Notes" gridStep={1} />
            </PianoRollStrip>
            <PianoRollStrip
              className="border-t"
              lead={<PianoRollVelocityScale />}
              sticky="bottom"
            >
              <PianoRollVelocity accentStep={4} gridStep={1} />
            </PianoRollStrip>
          </PianoRollViewport>
        </PianoRoll>
      </Timeline>
    </div>
  );
}
```

## Installation

<CodeTabs>

<TabsList>
  <TabsTrigger value="cli">CLI</TabsTrigger>
  <TabsTrigger value="manual">Manual</TabsTrigger>
</TabsList>

<TabsContent value="cli">

```bash component
npx shadcn@latest add @audio-ui/note-edit-menu
```

</TabsContent>

<TabsContent value="manual">

<Steps>

<Step>Install the following dependencies:</Step>

```bash
npm install @audio-ui/core
```

<Step>Copy and paste the following code into your project.</Step>

```tsx
"use client";

import {
  type ComponentProps,
  createContext,
  type ReactNode,
  use,
  useCallback,
  useMemo,
} from "react";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuShortcut,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { NoteEdit } from "@/lib/note-edits";

interface NoteEditMenuContextValue {
  isDisabled: (edit: NoteEdit) => boolean;
  onEdit: (edit: NoteEdit) => void;
}

const NoteEditMenuContext = createContext<NoteEditMenuContextValue | null>(
  null
);

function useNoteEditMenu() {
  const context = use(NoteEditMenuContext);
  if (!context) {
    throw new Error("NoteEditMenu parts must be placed inside a NoteEditMenu.");
  }
  return context;
}

const NEVER_DISABLED = () => false;

interface NoteEditMenuProps {
  children: ReactNode;
  /** Whether an edit can't run now, such as `!canApplyNoteEdit(edit, state)`. */
  disabled?: (edit: NoteEdit) => boolean;
  /** An edit was picked: run it with `applyNoteEdit`. */
  onEdit: (edit: NoteEdit) => void;
}

/** A menu of commands over notes: put `NoteEditMenuItems` and `NoteEditMenuSub` in its content, beside items of your own. */
function NoteEditMenu({
  children,
  disabled = NEVER_DISABLED,
  onEdit,
}: NoteEditMenuProps) {
  const value = useMemo(
    () => ({ isDisabled: disabled, onEdit }),
    [disabled, onEdit]
  );
  return (
    <NoteEditMenuContext value={value}>
      <DropdownMenu>{children}</DropdownMenu>
    </NoteEditMenuContext>
  );
}

/** The button that opens the menu, "Notes" with a caret unless given children. */
function NoteEditMenuTrigger({
  children = "Notes",
  ...props
}: ComponentProps<typeof Button>) {
  return (
    <DropdownMenuTrigger
      render={
        <Button
          data-slot="note-edit-menu-trigger"
          variant="outline"
          {...props}
        />
      }
    >
      {children}
      <IconPlaceholder
        hugeicons="ArrowDown01Icon"
        lucide="ChevronDownIcon"
        phosphor="CaretDownIcon"
        remixicon="RiArrowDownSLine"
        tabler="IconChevronDown"
      />
    </DropdownMenuTrigger>
  );
}

function NoteEditMenuContent({
  align = "end",
  className,
  ...props
}: ComponentProps<typeof DropdownMenuContent>) {
  return (
    <DropdownMenuContent
      align={align}
      className={cn("w-auto min-w-52", className)}
      data-slot="note-edit-menu-content"
      {...props}
    />
  );
}

/** A menu item per edit, with its key beside it. */
function NoteEditMenuItems({ edits }: { edits: readonly NoteEdit[] }) {
  const { isDisabled, onEdit } = useNoteEditMenu();
  const handleClick = useCallback(
    (event: { currentTarget: HTMLElement }) => {
      const item = edits.find(
        (edit) => edit.id === event.currentTarget.dataset.edit
      );
      if (item) {
        onEdit(item);
      }
    },
    [edits, onEdit]
  );
  return edits.map((item) => (
    <DropdownMenuItem
      data-edit={item.id}
      disabled={isDisabled(item)}
      key={item.id}
      onClick={handleClick}
    >
      {item.label}
      {item.shortcut ? (
        <DropdownMenuShortcut>{item.shortcut}</DropdownMenuShortcut>
      ) : null}
    </DropdownMenuItem>
  ));
}

/** A submenu named `label`, holding a menu item per edit. */
function NoteEditMenuSub({
  edits,
  label,
}: {
  edits: readonly NoteEdit[];
  label: ReactNode;
}) {
  return (
    <DropdownMenuSub>
      <DropdownMenuSubTrigger>{label}</DropdownMenuSubTrigger>
      <DropdownMenuSubContent className="w-auto min-w-48">
        <NoteEditMenuItems edits={edits} />
      </DropdownMenuSubContent>
    </DropdownMenuSub>
  );
}

export {
  NoteEditMenu,
  NoteEditMenuContent,
  NoteEditMenuItems,
  NoteEditMenuSub,
  NoteEditMenuTrigger,
};
```

<Step>Copy and paste the commands the menu lists.</Step>

```tsx
"use client";

import {
  type ComponentProps,
  createContext,
  type ReactNode,
  use,
  useCallback,
  useMemo,
} from "react";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuShortcut,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { NoteEdit } from "@/lib/note-edits";

interface NoteEditMenuContextValue {
  isDisabled: (edit: NoteEdit) => boolean;
  onEdit: (edit: NoteEdit) => void;
}

const NoteEditMenuContext = createContext<NoteEditMenuContextValue | null>(
  null
);

function useNoteEditMenu() {
  const context = use(NoteEditMenuContext);
  if (!context) {
    throw new Error("NoteEditMenu parts must be placed inside a NoteEditMenu.");
  }
  return context;
}

const NEVER_DISABLED = () => false;

interface NoteEditMenuProps {
  children: ReactNode;
  /** Whether an edit can't run now, such as `!canApplyNoteEdit(edit, state)`. */
  disabled?: (edit: NoteEdit) => boolean;
  /** An edit was picked: run it with `applyNoteEdit`. */
  onEdit: (edit: NoteEdit) => void;
}

/** A menu of commands over notes: put `NoteEditMenuItems` and `NoteEditMenuSub` in its content, beside items of your own. */
function NoteEditMenu({
  children,
  disabled = NEVER_DISABLED,
  onEdit,
}: NoteEditMenuProps) {
  const value = useMemo(
    () => ({ isDisabled: disabled, onEdit }),
    [disabled, onEdit]
  );
  return (
    <NoteEditMenuContext value={value}>
      <DropdownMenu>{children}</DropdownMenu>
    </NoteEditMenuContext>
  );
}

/** The button that opens the menu, "Notes" with a caret unless given children. */
function NoteEditMenuTrigger({
  children = "Notes",
  ...props
}: ComponentProps<typeof Button>) {
  return (
    <DropdownMenuTrigger
      render={
        <Button
          data-slot="note-edit-menu-trigger"
          variant="outline"
          {...props}
        />
      }
    >
      {children}
      <IconPlaceholder
        hugeicons="ArrowDown01Icon"
        lucide="ChevronDownIcon"
        phosphor="CaretDownIcon"
        remixicon="RiArrowDownSLine"
        tabler="IconChevronDown"
      />
    </DropdownMenuTrigger>
  );
}

function NoteEditMenuContent({
  align = "end",
  className,
  ...props
}: ComponentProps<typeof DropdownMenuContent>) {
  return (
    <DropdownMenuContent
      align={align}
      className={cn("w-auto min-w-52", className)}
      data-slot="note-edit-menu-content"
      {...props}
    />
  );
}

/** A menu item per edit, with its key beside it. */
function NoteEditMenuItems({ edits }: { edits: readonly NoteEdit[] }) {
  const { isDisabled, onEdit } = useNoteEditMenu();
  const handleClick = useCallback(
    (event: { currentTarget: HTMLElement }) => {
      const item = edits.find(
        (edit) => edit.id === event.currentTarget.dataset.edit
      );
      if (item) {
        onEdit(item);
      }
    },
    [edits, onEdit]
  );
  return edits.map((item) => (
    <DropdownMenuItem
      data-edit={item.id}
      disabled={isDisabled(item)}
      key={item.id}
      onClick={handleClick}
    >
      {item.label}
      {item.shortcut ? (
        <DropdownMenuShortcut>{item.shortcut}</DropdownMenuShortcut>
      ) : null}
    </DropdownMenuItem>
  ));
}

/** A submenu named `label`, holding a menu item per edit. */
function NoteEditMenuSub({
  edits,
  label,
}: {
  edits: readonly NoteEdit[];
  label: ReactNode;
}) {
  return (
    <DropdownMenuSub>
      <DropdownMenuSubTrigger>{label}</DropdownMenuSubTrigger>
      <DropdownMenuSubContent className="w-auto min-w-48">
        <NoteEditMenuItems edits={edits} />
      </DropdownMenuSubContent>
    </DropdownMenuSub>
  );
}

export {
  NoteEditMenu,
  NoteEditMenuContent,
  NoteEditMenuItems,
  NoteEditMenuSub,
  NoteEditMenuTrigger,
};
```

<Step>Update the import paths to match your project setup.</Step>

</Steps>

</TabsContent>

</CodeTabs>

## Anatomy

```tsx
<NoteEditMenu>
  <NoteEditMenuTrigger />
  <NoteEditMenuContent>
    <NoteEditMenuItems />
    <NoteEditMenuSub />
  </NoteEditMenuContent>
</NoteEditMenu>
```

## Usage

```tsx
import {
  NoteEditMenu,
  NoteEditMenuContent,
  NoteEditMenuSub,
  NoteEditMenuTrigger,
} from "@/components/audio-ui/note-edit-menu";
import {
  applyNoteEdit,
  canApplyNoteEdit,
  PITCH_EDITS,
  TIME_EDITS,
  VELOCITY_EDITS,
} from "@/lib/note-edits";

<NoteEditMenu
  disabled={(edit) =>
    !canApplyNoteEdit(edit, {
      hasNotes: notes.length > 0,
      hasScale: scale !== null,
      hasSelection: selection.length > 0,
    })
  }
  onEdit={(edit) =>
    setNotes(
      applyNoteEdit(edit, notes, selection, {
        at: playhead, // in steps: where Split cuts with nothing selected
        bounds: { duration: 64, maxPitch: 84, minLength: 1, minPitch: 36 },
        loopEnd: 64, // what Legato stretches the last notes to
        nextId: () => crypto.randomUUID(), // an id for each note an edit makes
        scale, // what Fit to scale fits to, or null
      })
    )
  }
>
  <NoteEditMenuTrigger />
  <NoteEditMenuContent>
    <NoteEditMenuSub edits={TIME_EDITS} label="Time" />
    <NoteEditMenuSub edits={PITCH_EDITS} label="Pitch" />
    <NoteEditMenuSub edits={VELOCITY_EDITS} label="Velocity" />
  </NoteEditMenuContent>
</NoteEditMenu>;
```

The item installs two files: the menu, and `lib/note-edits.ts`, the commands it lists. A command is data, `{ id, label, shortcut?, edit }`, so the menu needs no prop per command: `NoteEditMenuItems` lists commands as items, `NoteEditMenuSub` lists them in a submenu, and you pick which go where. Each item shows its key beside it (`⌘E`), and `disabled` greys out the commands that can't run: there must be notes, a time command needs a selection and Fit to scale a scale.

`onEdit` gets the command picked. `applyNoteEdit` runs it on the selected notes, or on every note with none selected, and returns the new notes. Hand them to `useUndoHistory`'s `edit` and every command undoes in one step (see [Piano roll](/docs/components/base/piano-roll#undo-and-redo)).

### The commands

- `TIME_EDITS`: Split (⌘E), Chop in two, Join (⌘J), Quantize (⌘U), Legato, Reverse, Twice as long, Half as long, Duplicate time (⇧⌘D), Delete time (⇧⌘⌫), Insert time (⌘I).
- `PITCH_EDITS`: Invert, Fit to scale.
- `PLAY_EDITS`: Deactivate (0), Always play, Play 75%, 50% or 25% of the time.
- `VELOCITY_EDITS`: Crescendo, Decrescendo, Humanize.

Split cuts the selected notes on every grid step, or, with nothing selected, the notes under the playhead at `at`. The work is done by the edits in `@audio-ui/core/piano-roll` (`splitNotes`, `quantizeNotes`, `fitNotesToScale`…); `note-edits` names them, groups them and gives them keys.

### Items of your own

`NoteEditMenuContent` is a dropdown menu's content, so commands that are not note edits sit beside the parts as plain items. The piano roll block puts its file commands there:

```tsx
<NoteEditMenuContent>
  <NoteEditMenuItems edits={KEYED_EDITS} />
  <DropdownMenuSeparator />
  <NoteEditMenuSub edits={TIME_EDITS} label="Time" />
  <DropdownMenuSeparator />
  <DropdownMenuItem onClick={importFile}>Import a MIDI file</DropdownMenuItem>
  <DropdownMenuItem onClick={exportFile}>Export as a MIDI file</DropdownMenuItem>
  <DropdownMenuItem onClick={clear} variant="destructive">
    Clear
  </DropdownMenuItem>
</NoteEditMenuContent>
```

A command of your own is a `NoteEdit` too:

```tsx
import { moveNotes } from "@audio-ui/core/piano-roll";
import type { NoteEdit } from "@/lib/note-edits";

const OCTAVE_UP: NoteEdit = {
  edit: (notes, ids, { bounds }) => moveNotes(notes, ids, 0, 12, bounds),
  id: "octave-up",
  label: "Octave up",
};
```

### Without the menu

The same commands run from the keyboard, a toolbar or an agent's tool. `noteEditForKey(key, shiftKey)` returns the command Cmd/Ctrl with `key` runs (Shift too for the time commands), or nothing. `key` is lower case:

```tsx
const onKeyDown = (event: KeyboardEvent) => {
  if (!(event.metaKey || event.ctrlKey)) return;
  const edit = noteEditForKey(event.key.toLowerCase(), event.shiftKey);
  if (edit && canApplyNoteEdit(edit, state)) {
    event.preventDefault();
    setNotes(applyNoteEdit(edit, notes, selection, context));
  }
};
```

## API reference

### NoteEditMenu

<TypeTable>
<TypeRow name={"onEdit"} type={"(edit: NoteEdit) => void"}>A command was picked: run it with `applyNoteEdit`.</TypeRow>
<TypeRow name={"disabled"} type={"(edit: NoteEdit) => boolean"}>Whether a command can't run now. Every command can when left out.</TypeRow>
</TypeTable>

### Parts

<TypeTable title="Components">
<TypeRow name={"NoteEditMenuTrigger"}>The button that opens the menu, "Notes" with a caret unless given children. Takes the button's props.</TypeRow>
<TypeRow name={"NoteEditMenuContent"}>The menu. Aligned to the trigger's end unless given `align`.</TypeRow>
<TypeRow name={"NoteEditMenuItems"}>A menu item per command in `edits`, with its key beside it.</TypeRow>
<TypeRow name={"NoteEditMenuSub"}>A submenu named `label`, holding a menu item per command in `edits`.</TypeRow>
</TypeTable>

### note-edits

<TypeTable>
<TypeRow name={"applyNoteEdit"} type={"(edit, notes, selection, context) => Note[]"}>Runs `edit` on the selected notes, or on every note with none selected.</TypeRow>
<TypeRow name={"canApplyNoteEdit"} type={"(edit, { hasNotes, hasScale, hasSelection }) => boolean"}>Whether `edit` can run now.</TypeRow>
<TypeRow name={"noteEditForKey"} type={"(key, shiftKey) => NoteEdit | undefined"}>The command Cmd/Ctrl (and Shift) with `key` runs.</TypeRow>
<TypeRow name={"TIME_EDITS, PITCH_EDITS, PLAY_EDITS, VELOCITY_EDITS"} type={"NoteEdit[]"}>The commands, by what they change.</TypeRow>
</TypeTable>

The context `applyNoteEdit` takes:

<TypeTable>
<TypeRow name={"at"} type={"number"}>Where the playhead is, in steps.</TypeRow>
<TypeRow name={"bounds"} type={"{ duration, minPitch, maxPitch, minLength }"}>The roll's length and pitches, and the shortest a note gets from a command.</TypeRow>
<TypeRow name={"loopEnd"} type={"number"}>Where the loop ends, in steps.</TypeRow>
<TypeRow name={"nextId"} type={"() => NoteId"}>A new id for each note a command makes. Each call must return a different one.</TypeRow>
<TypeRow name={"scale"} type={"Scale | null"}>The scale marked on the roll.</TypeRow>
</TypeTable>
