feat(media): let a host extend Undo, and add the Redo it never had - #354
Conversation
MediaEditor's undo is word-level, which is the right grain for typing and the wrong one for a host that also versions the document: undoing a batch change one word at a time is not undoing it. A host that wants both ends up putting a second Undo somewhere else on screen, and then there are two Undos and no way to tell which one you want. So the existing control learns to hand over. `onUndoBeyond` is called when Undo is pressed and the editor's own stack is empty, and `canUndoBeyond` keeps the control alive at depth 0 instead of letting it disappear. Word-level steps are always spent first, so nothing about the existing behaviour changes for a host that passes neither. Redo is new and host-only. The editor cannot implement it: `undo()` pops and discards, so there is nothing to redo from. Rather than rework that stack, the button renders only when `onRedo` is provided and is disabled unless `canRedo`. Both now carry their shortcut in a `kbd` the same way Del, Cut and Paste already do, which is also the first time Undo advertised ⌘Z anywhere but a tooltip. Five tests: no Undo when neither side has one, host-only Undo before any edit, the editor spending its own history before handing over, Redo appearing only when provided, and Redo disabled when the host has nothing forward.
The version Undo/Redo sat in the page header while the editor kept its own word-level Undo down in the stats bar. Two Undos in two places, and the one you wanted depended on what you had just done — which is not something anyone should have to reason about. They are one control now, in the editor toolbar, using the host hooks added in mieweb/ui#354: word-level steps are spent first and the same button carries on into the version timeline once they run out. Exactly the layering ⌘Z already had, now visible instead of implied. Redo moves there too, and both show their shortcut in a kbd like Del, Cut and Paste do. The header keeps only the History toggle, which opens a panel rather than performing an edit.
The consolidated Undo needs the MediaEditor props added in mieweb/ui#354. Reverts to a mieweb/ui commit once that merges — same temporary arrangement this branch already uses for the media components.
There was a problem hiding this comment.
Pull request overview
This PR extends MediaEditor’s editing toolbar to support host-level history controls by keeping Undo available after the editor’s internal undo stack is exhausted (delegating via onUndoBeyond), and adds an optional host-provided Redo button.
Changes:
- Added host-extension props for Undo-beyond (
onUndoBeyond,canUndoBeyond,undoBeyondLabel) and host-only Redo (onRedo,canRedo,redoLabel). - Updated the toolbar to keep Undo visible at depth 0 when the host can undo further, improve tooltips/ARIA labels, and render a Redo button only when provided.
- Added tests covering host-only Undo, editor-first Undo behavior, Redo rendering/disabled behavior, and absence of controls when unavailable.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/components/MediaEditor/MediaEditor.tsx | Adds host-level undo/redo props and updates toolbar rendering/labels to support host-extensible Undo and optional Redo. |
| src/components/MediaEditor/MediaEditor.test.tsx | Adds coverage for host-extensible Undo behavior and host-only Redo rendering/disabled states. |
Suppressed comments (1)
src/components/MediaEditor/MediaEditor.tsx:1417
- Undo beyond only works via the button click right now: the keyboard handler always calls the editor’s
undo()(which no-ops whenundoStackis empty) and never invokesonUndoBeyond, so keyboard users can’t reach host-level undo.
// Word-level first; the host only gets it once the
// editor's own history is spent.
onClick={undoStack.length > 0 ? undo : onUndoBeyond}
aria-label={
The version Undo/Redo sat in the page header while the editor kept its own word-level Undo down in the stats bar. Two Undos in two places, and the one you wanted depended on what you had just done — which is not something anyone should have to reason about. They are one control now, in the editor toolbar, using the host hooks added in mieweb/ui#354: word-level steps are spent first and the same button carries on into the version timeline once they run out. Exactly the layering ⌘Z already had, now visible instead of implied. Redo moves there too, and both show their shortcut in a kbd like Del, Cut and Paste do. The header keeps only the History toggle, which opens a panel rather than performing an edit.
The consolidated Undo needs the MediaEditor props from mieweb/ui#354, which is not merged yet. Both this URL and the pin revert to mieweb/ui once it lands.
Undo vanished at the end of history while Redo merely greyed out, so the pair followed two different rules. Worse, a host that passes onRedo got the Redo button unconditionally: on a document with no history at all that left a lone, permanently grey Redo and no Undo beside it, which reads as broken rather than inapplicable. Whichever of the two cannot act is now disabled rather than removed, and both are absent only when neither has anything to offer. The disabled Undo also stops advertising a target it cannot reach — with canUndoBeyond false it says "Nothing to undo" instead of naming the label it was handed.
A permanently greyed Redo on a pulse that has never been edited reads as a broken button rather than an inapplicable one. Gated on the same condition as the History button, and paired with mieweb/ui#354 keeping Undo and Redo on screen together so neither disappears out from under the other.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/components/MediaEditor/MediaEditor.tsx:1422
- Undo can become enabled but do nothing when
canUndoBeyondis true butonUndoBeyondis not provided. The current logic treatscanUndoBeyondas sufficient to enable the button and to advertiseundoBeyondLabel, but the click handler will beundefinedin that case.
disabled={undoStack.length === 0 && !canUndoBeyond}
// Word-level first; the host only gets it once the
// editor's own history is spent.
onClick={undoStack.length > 0 ? undo : onUndoBeyond}
aria-label={
src/components/MediaEditor/MediaEditor.tsx:89
- The new
onUndoBeyondbehavior is only wired to the toolbar button click. The keyboard shortcut path still always calls the editor’s destructiveundo()(MediaEditor.tsx:1065-1067), so Cmd/Ctrl+Z will not “hand over” toonUndoBeyondwhen the editor stack is empty, which contradicts the prop docs/PR description.
* change one word at a time is not undoing it. When a host provides this, the
* Undo control stays available once the editor's own stack is empty and hands
* over instead of going dead, so there is one Undo rather than two.
*/
onUndoBeyond?: () => void;
src/components/MediaEditor/MediaEditor.test.tsx:301
- The new tests cover click behavior for host-extensible Undo/Redo, but there’s no coverage for the documented keyboard Undo path (Cmd/Ctrl+Z) handing over to
onUndoBeyondwhen the editor stack is empty. Adding a keydown test would prevent regressions once the keyboard path is updated.
it('spends its own history before handing over to the host', () => {
const onUndoBeyond = vi.fn();
render(
<MediaEditor
src="clip.mp3"
A permanently greyed Redo on a pulse that has never been edited reads as a broken button rather than an inapplicable one. Gated on the same condition as the History button, and paired with mieweb/ui#354 keeping Undo and Redo on screen together so neither disappears out from under the other.
…hrome eats Two things made Redo look broken. The editor had no word-level redo at all: undo() popped and discarded. So with one word deleted you got a live "Undo (1)" next to a dead Redo, and taking the undo back was impossible. useTranscriptEdits now keeps a redo stack alongside the undo one — speed snapshots included, since they already rode along with undo — cleared by any new edit and by a transcript change, the same way the undo stack is. Redo spends the editor's own steps first and only then calls a host's onRedo, mirroring how Undo already hands over. And the shortcut was wrong for the platform. ⌘Y is Chrome's own History on macOS, so pressing it opened a browser tab instead of redoing. The button now advertises ⇧⌘Z. Hosts may still accept ⌘Y for people arriving from Windows, but nothing should tell a Mac user to press it. Undo and Redo are now rendered under one condition and each disabled on its own ability, so they cannot appear, vanish, or shift position independently. Four tests: the word-level round trip, the editor spending its own redo before the host's, Redo present-but-inert beside a live Undo, and the pair staying together at both ends of the history.
⌘Y is Chrome's History shortcut on macOS, so pressing it opened a browser tab rather than redoing. Still accepted for anyone arriving from Windows, but no longer advertised. Picks up the word-level redo added in mieweb/ui#354, which is what makes Redo live after an ordinary word undo.
⌘Y is Chrome's History shortcut on macOS, so pressing it opened a browser tab rather than redoing. Still accepted for anyone arriving from Windows, but no longer advertised. Picks up the word-level redo added in mieweb/ui#354, which is what makes Redo live after an ordinary word undo.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (5)
src/components/MediaEditor/MediaEditor.tsx:1438
- The Undo button’s aria-label/title logic uses
canUndoBeyondeven ifonUndoBeyondis missing. After gating the actual enabled state, this can still present a host-undo tooltip/label for a control that cannot do anything. Use the gatedcanUndoBeyondEffectivewhen deciding whether to show host-specific labeling.
aria-label={
undoStack.length > 0
? `Undo (${undoStack.length} available)`
: canUndoBeyond && undoBeyondLabel
? `Undo: ${undoBeyondLabel}`
src/components/MediaEditor/MediaEditor.tsx:1467
- Similarly, the Redo button’s aria-label/title uses
canRedoeven ifonRedois not provided, which can advertise a redo target when the button cannot actually perform a host redo. Use the gatedcanRedoBeyondEffectivefor host-specific labeling.
aria-label={
!canRedoWords && canRedo && redoLabel
? `Redo: ${redoLabel}`
: 'Redo'
}
src/components/MediaEditor/MediaEditor.tsx:350
canUndoBeyond/canRedocan enable the buttons even when the corresponding handler (onUndoBeyond/onRedo) is not provided. In that case the button becomes clickable butonClickisundefined, so clicking does nothing. Gate the host capability booleans by the presence of the handler when computingcanUndoAnything/canRedoAnything(and reuse those gated values elsewhere).
This issue also appears in the following locations of the same file:
- line 1434
- line 1463
const canUndoAnything = undoStack.length > 0 || canUndoBeyond;
const canRedoAnything = canRedoWords || canRedo;
const showUndoRedo = canUndoAnything || canRedoAnything || !!onRedo;
src/hooks/useTranscriptEdits.ts:578
redo()forcessetHasEdits(true), but redo can legitimately land back on the baseline state (e.g. when the initial state already had edits and the redone step returns to the untouched baseline, especially for speed markers/default speed). This makeshasEditsinaccurate after redo. RecomputehasEditsthe same wayundo()does, based on the state being restored.
// Redoing always lands on a state that was reached by editing, so it is by
// definition not the untouched original.
setHasEdits(true);
src/hooks/useTranscriptEdits.ts:474
- Now that redo history exists, any operation that invalidates/drops undo history should also clear
redoStack/speedRedoStackto avoid redoing into snapshots captured under an old model (e.g.setSilenceThresholdscurrently dropsundoStack/speedUndoStackbut leaves redo history intact).
// Helper to save current state to undo stack before making changes
const pushUndo = useCallback(() => {
setUndoStack((prev) => [...prev, editedWords]);
setSpeedUndoStack((prev) => [...prev, { speedMarkers, defaultSpeed }]);
// A fresh edit abandons the redo branch, as it does in every editor.
setRedoStack([]);
setSpeedRedoStack([]);
}, [editedWords, speedMarkers, defaultSpeed]);
… no handler Both from Copilot review on mieweb#354. The Redo button advertised a shortcut the component did not implement. It worked in PulseClip only because that app wires its own document-level listener, so for any other host the hint was a lie. MediaEditor already handled ⌘Z for undo; it now handles ⇧⌘Z beside it, spending word-level steps first and then the host's, exactly as the button does. ⌘Y is accepted for people arriving from Windows but stays unadvertised — on macOS it is Chrome's own History shortcut and the browser wins. And a host could set canUndoBeyond or canRedo without the matching handler, which rendered an enabled button whose onClick was undefined: a control that looks live and silently does nothing. Both capability flags now require their handler to count. Three tests: the disabled-without-handler case, the advertised shortcut actually firing, and the keyboard path spending word-level redo before the host's.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/components/MediaEditor/MediaEditor.tsx:1082
- The Cmd/Ctrl+Z keyboard handler always calls the editor’s
undo()and never falls through toonUndoBeyondwhen the editor undo stack is empty. This makes host-extensible undo work via the button click but not via the advertised shortcut, so hosts still need to add their own listener to get version-level undo from the keyboard.
} else if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
undo();
handled = true;
} else if (
src/hooks/useTranscriptEdits.ts:578
redo()unconditionally setshasEditsto true, but it can legally redo to the baseline/original state (e.g., a user edits back to the original text/speed, then undoes, then redoes). In that case the UI will incorrectly report there are edits. Consider computinghasEditsthe same wayundo()does (compare against the silence-inserted baseline + speed snapshot).
// Redoing always lands on a state that was reached by editing, so it is by
// definition not the untouched original.
setHasEdits(true);
src/hooks/useTranscriptEdits.ts:294
- The PR description says Redo is “host-only” and that the editor “cannot implement it”, but this change set adds a word-level redo stack in
useTranscriptEditsandMediaEditoruses it (e.g.redo/canRedo) even whenonRedois not provided. Please update the PR description (and any external docs/release notes) to reflect the new behavior: the editor now supports word-level redo, whileonRedo/canRedoare for host-level redo beyond the editor’s own history.
/** Step forward again after an undo. Cleared by any new edit. */
redo: () => void;
/** Whether there is anything to redo */
canRedo: boolean;
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/components/MediaEditor/MediaEditor.tsx:1464
- Undo’s aria-label/title logic uses
canUndoBeyondto describe a host undo target even whenonUndoBeyondis missing. In that misconfigured host case the button is correctly disabled, but the tooltip/label can still claim it would undo to a specific label, which is inconsistent with the component’s own “handler required” rule. Gate the label/tooltip text oncanUndoBeyond && !!onUndoBeyond(not justcanUndoBeyond).
undoStack.length > 0
? `Undo (⌘Z) — ${undoStack.length} step${undoStack.length === 1 ? '' : 's'}`
: canUndoBeyond
? undoBeyondLabel
? `Undo (⌘Z) — back to "${undoBeyondLabel}"`
src/components/MediaEditor/MediaEditor.tsx:1485
- Redo’s aria-label/title logic also uses
canRedo/redoLabeleven whenonRedois missing. The button is disabled in that case, but the tooltip/label can still suggest a redo target exists. Gate these strings oncanRedo && !!onRedoso the UI copy matches the actionable capability the component actually exposes.
aria-label={
!canRedoWords && canRedo && redoLabel
? `Redo: ${redoLabel}`
: 'Redo'
}
src/components/MediaEditor/MediaEditor.tsx:1084
- The ⌘Z/ctrl+Z keyboard handler always calls the editor’s word-level
undo()and never falls through toonUndoBeyondwhen the editor undo stack is empty. That means host-only undo-beyond works via the button but not via the advertised shortcut, and it also diverges from the PR’s stated behavior (hand over once the editor stack is exhausted). Update the keybinding branch to callonUndoBeyond()whenundoStack.length === 0andcanUndoBeyond && onUndoBeyondare true (and include the new dependencies in theuseCallbackdependency list).
This issue also appears in the following locations of the same file:
- line 1460
- line 1481
} else if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
undo();
handled = true;
} else if (
(e.metaKey || e.ctrlKey) &&
mieweb/ui#354 merged, so the MediaEditor undo/redo props this branch needed are on main. Pinned at the merge commit rather than main's head — that is exactly main at the moment #354 landed, without pulling in seven unrelated commits. Reverts the temporary fork pin. Nothing on a mieweb branch should point at a personal fork: it is fragile if the branch is ever deleted, and it means anyone cloning pulseclip fetches ui from somewhere other than the org.
mieweb/ui#354 merged, so this branch no longer needs the temporary fork pin.
MediaEditor's undo is word-level. That is the right grain for typing and the wrong one for a host that also versions the document — undoing a batch change one word at a time is not undoing it.A host that wants both ends up putting a second Undo somewhere else on screen, which is exactly what happened in PulseClip: a version-level Undo/Redo pair in the page header, and the editor's own
Undo (3)down in the stats bar. Two Undos, no way to tell which one you want.What this adds
The existing Undo learns to hand over.
onUndoBeyondfires when Undo is pressed and the editor's own stack is empty;canUndoBeyondkeeps the control alive at depth 0 instead of letting it vanish. Word-level steps are always spent first, so for a host that passes neither prop nothing changes.Redo is new, and host-only. The editor cannot implement it —
undo()pops and discards, so there is nothing to redo from. Rather than rework that stack, the button renders only whenonRedois given and is disabled unlesscanRedo.Both carry their shortcut in a
<kbd>, the wayDel ⌫/Cut ⌘X/Paste ⌘Valready do. This is also the first time Undo advertises ⌘Z anywhere but a tooltip.The labels are optional and feed the tooltip, so Undo can say back to "Trim to 45 seconds" rather than just "Undo".
Deliberately generic
No "version" or "checkpoint" vocabulary in the props — the library should not learn a host's model. It only knows that someone else may be able to undo further, which is true of any host with its own history stack.
Testing
Five new tests: no Undo when neither side has one · host-only Undo before any edit exists · the editor spending its own history before handing over · Redo appearing only when provided · Redo disabled when the host has nothing forward.
420/420 across the suite.
pnpm lint,pnpm typecheckandpnpm formatall clean.Consumer
Used by mieweb/pulseclip#33, which versions every edit — the agent's and the user's — on one timeline. That is where the second Undo came from, and this removes it.