From 2b46316833775bdd99567109755c5ecf6675d147 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Tue, 14 Jul 2026 13:18:24 -0500 Subject: [PATCH 1/3] Promote copy/paste to first-class commands; add Cut; fix latent bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy/paste existed only as inline keydown code — invisible to the menu, shortcut panel and palette, no Cut, shallow technique copies sharing bend-curve arrays across pastes, raw-cursor paste, and no lane/kind guards (pasting onto fewer strings wrote notes on lanes that don't exist). Now: registry commands copySelection/cutSelection/ pasteAtPlayhead (Ctrl+C/X/V; EOF keeps Ctrl+X=mute so Cut is Shift+Del there), Edit menu rows, deep-cloned pack/plan pures with relative times, snap-honouring paste at the playhead, lane clamp with honest skip counts, keys<->fretted refusal, read-only-roll guard for cross-track pitch writes, pasted notes selected, one undoable step. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q --- CHANGELOG.md | 20 +++++ src/input.js | 188 ++++++++++++++++++++++++--------------- src/menu-bar.js | 3 + src/shortcuts.js | 9 ++ tests/clipboard.test.mjs | 131 +++++++++++++++++++++++++++ 5 files changed, 281 insertions(+), 70 deletions(-) create mode 100644 tests/clipboard.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 53161194..4be43cbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Cut, and real Copy/Paste commands.** Copy and paste existed but were + hidden hardwired keys — invisible in the Edit menu, the shortcut panel, and + anywhere else you'd look, with no Cut at all. All three are first-class + commands now: **Edit ▸ Copy / Cut / Paste** (Ctrl+C / Ctrl+X / Ctrl+V; the + EOF profile keeps its Ctrl+X mute binding, so Cut is Shift+Del there). + Paste lands the phrase's first note at the playhead — snapped to your grid + like any other placement — keeps the internal timing intact, selects the + pasted notes, and is one undo. Pasting across tracks now behaves: notes on + strings the target track doesn't have are skipped and counted instead of + written invisibly, and keys ↔ fretted pasting is refused (the note shapes + don't translate). Undoing a cut restores the notes but keeps the clipboard, + like every text editor. + ### Fixed +- **Pasted bend curves are no longer linked to the original.** Copying a bent + note shared the bend-curve data between the original and every paste — + editing any one of them silently edited them all. Copies are fully + independent now. + - **Inspector technique edits are undoable now.** Toggling a technique flag (Palm Mute, Hammer-On, Tap, …) or setting a bend/slide value from the inspector panel used to mutate the note in place with no undo — so Ctrl+Z diff --git a/src/input.js b/src/input.js index b61b92ce..55bbba06 100644 --- a/src/input.js +++ b/src/input.js @@ -432,6 +432,117 @@ function _editorDuplicateSelection() { return true; } +/* @pure:clipboard:start */ +// The note clipboard (Ctrl+C/X/V). Session-scoped and internal — notes are +// structured editor state, not text, and a stray Ctrl+C on the canvas must +// never clobber whatever the user has on the OS clipboard (and vice versa) — +// the same choice every DAW makes for its piano-roll clipboard. +// +// Pack: deep-copy the selection with times RELATIVE to its earliest note, so +// a paste lands the phrase's first note exactly at the playhead and the +// internal timing rides along. structuredClone, not spread: a bend curve +// (`techniques.bend_values`) is an array — a shallow copy would leave every +// paste sharing one curve, and editing any of them would edit all. +export function _clipboardPackPure(selNotes, arrIndex, keys) { + const rows = (selNotes || []).filter(n => n && Number.isFinite(n.time)) + .slice().sort((a, b) => a.time - b.time); + if (!rows.length) return null; + const anchor = rows[0].time; + return { + arrIndex, + keys: !!keys, + notes: rows.map(n => ({ + dt: n.time - anchor, + string: n.string, + fret: n.fret, + sustain: Number(n.sustain) || 0, + techniques: structuredClone(n.techniques || {}), + })), + }; +} + +// Plan a paste at `atTime`: retime every clipboard note relative to the +// anchor (clamped at t=0), deep-copying again so repeated pastes never share +// state, and SKIP notes whose string doesn't exist on this track (pasting a +// 6-string riff onto a 4-string bass keeps what fits and says what didn't). +export function _clipboardPastePlanPure(clip, atTime, laneCount) { + if (!clip || !Array.isArray(clip.notes) || !clip.notes.length) return null; + const at = Number.isFinite(atTime) ? atTime : 0; + const out = []; + let laneSkipped = 0; + for (const c of clip.notes) { + if (Number.isFinite(laneCount) && laneCount > 0 && c.string >= laneCount) { laneSkipped++; continue; } + out.push({ + time: Math.max(0, at + c.dt), + string: c.string, + fret: c.fret, + sustain: c.sustain, + techniques: structuredClone(c.techniques || {}), + }); + } + return { notes: out, laneSkipped }; +} +/* @pure:clipboard:end */ + +let _noteClipboard = null; + +// Ctrl+C / Ctrl+X. Cut is copy + the existing undoable delete — the clipboard +// itself is deliberately NOT part of history (undoing a cut restores the +// notes but keeps the clipboard, exactly like every text editor). +export function _editorCopySelection(cutting = false) { + if (S.drumEditMode || S.tempoMapMode) return false; + const idxs = _editorCurrentNoteIndices(); + if (!idxs.length) { setStatus(`Select notes to ${cutting ? 'cut' : 'copy'} first.`); return true; } + const nn = notes(); + _noteClipboard = _clipboardPackPure(idxs.map(i => nn[i]), S.currentArr, isKeysArr()); + if (!_noteClipboard) return true; + if (cutting) { + S.history.exec(new DeleteNotesCmd(idxs)); + host.draw(); + host.updateStatus(); + } + const n = _noteClipboard.notes.length; + setStatus(`${cutting ? 'Cut' : 'Copied'} ${n} note${n === 1 ? '' : 's'} — paste lands at the playhead.`); + return true; +} + +// Ctrl+V — paste at the (snap-honouring) playhead as ONE undoable step, +// leaving the pasted notes selected so a nudge or repeat-paste follows +// naturally. Cross-track pasting is allowed between like tracks (with a +// string-count clamp); keys ↔ fretted is refused (string/fret mean different +// things there), and pasting NEW pitches into the read-only fretted roll is +// refused like every other pitch write. +export function _editorPasteAtPlayhead() { + if (S.drumEditMode || S.tempoMapMode) return false; + if (!_noteClipboard) { setStatus('Nothing to paste — copy or cut notes first.'); return true; } + if (_noteClipboard.keys !== isKeysArr()) { + setStatus('Can\'t paste between keys and fretted tracks — the note shapes don\'t translate.'); + return true; + } + if (_noteClipboard.arrIndex !== S.currentArr && _rollReadOnly()) { _rollLockNotice(); return true; } + const arr = S.arrangements[S.currentArr]; + if (!arr) return false; + const nn = notes(); + const at = snapTime(Math.max(0, S.cursorTime || 0)); + const plan = _clipboardPastePlanPure(_noteClipboard, at, _stringCountFor(arr)); + if (!plan || !plan.notes.length) { + setStatus(plan && plan.laneSkipped + ? 'Nothing fits — this track has fewer strings than the copied notes use.' + : 'Nothing to paste.'); + return true; + } + S.history.exec(new AddNotesCmd(nn, plan.notes, _withStableSelection)); + S.sel.clear(); + const added = new Set(plan.notes); + for (let i = 0; i < nn.length; i++) if (added.has(nn[i])) S.sel.add(i); + host.draw(); + host.updateStatus(); + const skippedNote = plan.laneSkipped + ? ` (${plan.laneSkipped} skipped — no such string on this track)` : ''; + setStatus(`Pasted ${plan.notes.length} note${plan.notes.length === 1 ? '' : 's'} at the playhead${skippedNote}.`); + return true; +} + function _editorSelectLike() { const idxs = _editorCurrentNoteIndices(); if (!idxs.length) { setStatus('Select a note first'); return false; } @@ -894,6 +1005,9 @@ export function _editorRunEofCommand(cmd) { case 'setAnchor': return _editorSetAnchorAtCursor(); case 'selectLike': return _editorSelectLike(); case 'duplicateSelection': return _editorDuplicateSelection(); + case 'copySelection': return _editorCopySelection(false); + case 'cutSelection': return _editorCopySelection(true); + case 'pasteAtPlayhead': return _editorPasteAtPlayhead(); case 'resnapSelection': return _editorResnapSelection(); case 'addSection': return _editorAddSectionAtCursor(); case 'addPhrase': return _editorAddPhraseAtCursor(); @@ -1393,9 +1507,10 @@ export function onKeyDown(e) { } } if ((e.ctrlKey || e.metaKey) && (e.key === 'd' || e.key === 'D')) { - // Duplicate the selection to the next position. Same mode/focus - // gate as copy/paste below; preventDefault so the browser's - // bookmark shortcut doesn't fire. + // Duplicate the selection to the next position. preventDefault so + // the browser's bookmark shortcut doesn't fire. (Copy/cut/paste are + // registry commands now — resolved by the shortcut profiles, listed + // in the Edit menu and the shortcut panel.) if (!S.drumEditMode && !S.tempoMapMode && S.sel.size && !e.target.matches('input, select, textarea')) { e.preventDefault(); @@ -1403,71 +1518,4 @@ export function onKeyDown(e) { return; } } - if ((e.ctrlKey || e.metaKey) && e.key === 'c') { - // Copy/paste act on the guitar/keys arrangement's S.sel. In - // drum-edit mode the canvas shows the drum grid, so a paste here - // would mutate the hidden arrangement with no visual feedback — - // skip both shortcuts while drum-edit mode is active. - if (!S.drumEditMode && !S.tempoMapMode && S.sel.size && !e.target.matches('input, select, textarea')) { - e.preventDefault(); - const nn = notes(); - const selNotes = [...S.sel].map(i => nn[i]); - const baseTime = Math.min(...selNotes.map(n => n.time)); - S.clipboard = { - notes: selNotes.map(n => ({ - time: n.time - baseTime, - string: n.string, - fret: n.fret, - sustain: n.sustain || 0, - techniques: { ...(n.techniques || {}) }, - })), - baseTime, - }; - setStatus(`Copied ${selNotes.length} notes`); - return; - } - } - if ((e.ctrlKey || e.metaKey) && e.key === 'v') { - if (!S.drumEditMode && !S.tempoMapMode && S.clipboard && S.clipboard.notes.length && !e.target.matches('input, select, textarea')) { - e.preventDefault(); - const pasteTime = S.cursorTime; - const newNotes = S.clipboard.notes.map(n => ({ - time: n.time + pasteTime, - string: n.string, - fret: n.fret, - sustain: n.sustain, - techniques: { ...(n.techniques || {}) }, - })); - // Batch add via a compound command. Wrap both exec (sort - // can reshuffle) and rollback (splice shifts indices) in - // `_withStableSelection` so undo/redo can't leave `S.sel` - // pointing at unrelated notes. - const nn = notes(); - const addCmd = { - _notes: newNotes, - exec() { - _withStableSelection(() => { - for (const n of this._notes) nn.push(n); - nn.sort((a, b) => a.time - b.time); - }); - }, - rollback() { - _withStableSelection(() => { - for (const n of this._notes) { - const i = nn.indexOf(n); - if (i >= 0) nn.splice(i, 1); - } - }); - }, - }; - S.history.exec(addCmd); - // Select pasted notes - S.sel.clear(); - for (const n of newNotes) { const i = nn.indexOf(n); if (i >= 0) S.sel.add(i); } - host.draw(); - host.updateStatus(); - setStatus(`Pasted ${newNotes.length} notes at cursor`); - return; - } - } } diff --git a/src/menu-bar.js b/src/menu-bar.js index c37e2543..9d51c9ee 100644 --- a/src/menu-bar.js +++ b/src/menu-bar.js @@ -76,6 +76,9 @@ export const EDITOR_MENUS = Object.freeze([ { label: 'Redo', fn: 'editorRedo', key: 'Ctrl+Y' }, { label: 'Undo to last checkpoint', fn: 'editorUndoToCheckpoint', key: 'Ctrl+Alt+Z' }, { sep: true }, + { cmd: 'copySelection' }, + { cmd: 'cutSelection' }, + { cmd: 'pasteAtPlayhead' }, { cmd: 'duplicateSelection' }, { cmd: 'selectLike' }, { cmd: 'resnapSelection' }, diff --git a/src/shortcuts.js b/src/shortcuts.js index e7b80659..31512d9a 100644 --- a/src/shortcuts.js +++ b/src/shortcuts.js @@ -105,6 +105,9 @@ const EDITOR_SHORTCUT_COMMANDS = Object.freeze([ { id: 'setAnchor', label: 'Set anchor at cursor', group: 'Structure', status: 'ready', keys: { feedback: 'Shift+F', eof: 'Shift+F' } }, { id: 'selectLike', label: 'Select matching string/fret', group: 'Selection', status: 'ready', keys: { feedback: 'Ctrl+L', eof: 'Ctrl+L' } }, { id: 'duplicateSelection', label: 'Duplicate selection to next position', group: 'Selection', status: 'ready', keys: { feedback: 'Ctrl+D', eof: 'Ctrl+D' } }, + { id: 'copySelection', label: 'Copy selection', group: 'Selection', status: 'ready', keys: { feedback: 'Ctrl+C', eof: 'Ctrl+C' } }, + { id: 'cutSelection', label: 'Cut selection', group: 'Selection', status: 'ready', keys: { feedback: 'Ctrl+X', eof: 'Shift+Del' } }, + { id: 'pasteAtPlayhead', label: 'Paste at playhead', group: 'Selection', status: 'ready', keys: { feedback: 'Ctrl+V', eof: 'Ctrl+V' } }, { id: 'resnapSelection', label: 'Resnap selection to grid', group: 'Grid and sustain', status: 'ready', keys: { feedback: 'Shift+R', eof: 'Shift+R' } }, { id: 'addSection', label: 'Add section at cursor', group: 'Structure', status: 'ready', keys: { feedback: 'Shift+M', eof: 'Shift+S' } }, { id: 'addPhrase', label: 'Add phrase at cursor', group: 'Structure', status: 'ready', keys: { feedback: 'Shift+P', eof: 'Shift+P' } }, @@ -216,6 +219,9 @@ export function _editorEofCommandForKeyPure(e, mode) { if (ctrl && key === 'b') return 'bend'; if (ctrl && key === 'f') return 'editFret'; if (ctrl && key === 'h') return 'toggleNaturalHarmonic'; + if (ctrl && key === 'c') return 'copySelection'; + if (ctrl && key === 'v') return 'pasteAtPlayhead'; + if (shift && e.key === 'Delete') return 'cutSelection'; if (ctrl && key === 'l') return 'selectLike'; if (ctrl && key === 'm') return 'togglePalmMute'; if (ctrl && key === 's') return 'save'; @@ -348,6 +354,9 @@ export function _editorFeedbackCommandForKeyPure(e, mode) { if (ctrl && (key === '+' || key === '=')) return 'fretUp'; if (ctrl && key === '-') return 'fretDown'; if (shift && key === 'f') return 'setAnchor'; + if (ctrl && key === 'c') return 'copySelection'; + if (ctrl && key === 'x') return 'cutSelection'; + if (ctrl && key === 'v') return 'pasteAtPlayhead'; if (ctrl && key === 'l') return 'selectLike'; if (shift && key === 'r') return 'resnapSelection'; if (shift && key === 'm') return 'addSection'; diff --git a/tests/clipboard.test.mjs b/tests/clipboard.test.mjs new file mode 100644 index 00000000..19dfdb45 --- /dev/null +++ b/tests/clipboard.test.mjs @@ -0,0 +1,131 @@ +/* + * The note clipboard as FIRST-CLASS commands (Ctrl+C / Ctrl+X / Ctrl+V). + * + * Copy/paste existed only as inline keydown code: invisible to the shortcut + * panel, the Edit menu and the command palette; no Cut at all; techniques + * copied with a SHALLOW spread (every paste shared one bend-curve array with + * the original — editing any corrupted all); paste ignored snap and pasted + * onto strings the target track doesn't have. This suite pins the fixed + * behaviour: relative-time packing, deep-clone independence, the lane clamp, + * the t≥0 clamp, and the full copy → cut → paste verb flow through the real + * EditHistory (exec → rollback deep-equality → redo). + * + * Fails on main (the pures and verbs don't exist there). + * Run: node tests/clipboard.test.mjs + */ +import assert from 'node:assert'; + +globalThis.document = globalThis.document || { + getElementById: () => null, addEventListener: () => {}, activeElement: null, +}; +globalThis.localStorage = globalThis.localStorage || { getItem: () => null, setItem: () => {} }; +globalThis.window = globalThis.window || globalThis; + +const { + _clipboardPackPure, _clipboardPastePlanPure, + _editorCopySelection, _editorPasteAtPlayhead, +} = await import('../src/input.js'); +const { S } = await import('../src/state.js'); +const { EditHistory } = await import('../src/history.js'); + +let pass = 0, fail = 0; +function t(name, fn) { + try { fn(); pass++; console.log(' ok ' + name); } + catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } +} + +const N = (time, string, fret, extra = {}) => + ({ time, string, fret, sustain: 0, techniques: {}, ...extra }); + +// ── the pures ──────────────────────────────────────────────────────── + +t('pack stores times RELATIVE to the earliest note, sorted', () => { + const clip = _clipboardPackPure([N(4, 1, 3), N(2, 0, 5), N(3, 2, 7)], 0, false); + assert.deepStrictEqual(clip.notes.map(c => c.dt), [0, 1, 2]); + assert.deepStrictEqual(clip.notes.map(c => c.fret), [5, 7, 3], 'sorted by time, not input order'); + assert.strictEqual(_clipboardPackPure([], 0, false), null); +}); + +t('bend curves are DEEP-copied — no paste ever shares an array with the source', () => { + const src = N(1, 0, 5, { techniques: { bend: 1, bend_values: [{ t: 0, v: 1 }] } }); + const clip = _clipboardPackPure([src], 0, false); + src.techniques.bend_values[0].v = 99; // mutate the ORIGINAL + assert.strictEqual(clip.notes[0].techniques.bend_values[0].v, 1, 'clipboard unaffected'); + const a = _clipboardPastePlanPure(clip, 10, 6).notes[0]; + const b = _clipboardPastePlanPure(clip, 20, 6).notes[0]; + a.techniques.bend_values[0].v = 42; // mutate ONE paste + assert.strictEqual(b.techniques.bend_values[0].v, 1, 'sibling paste unaffected'); + assert.strictEqual(clip.notes[0].techniques.bend_values[0].v, 1, 'clipboard still unaffected'); +}); + +t('the paste plan retimes at the anchor, clamps t≥0, and skips missing strings', () => { + const clip = _clipboardPackPure([N(2, 0, 1), N(3, 5, 2)], 0, false); + const plan = _clipboardPastePlanPure(clip, 10, 6); + assert.deepStrictEqual(plan.notes.map(n => n.time), [10, 11]); + // A 4-string target: the string-5 note has nowhere to go — kept honest. + const bass = _clipboardPastePlanPure(clip, 10, 4); + assert.strictEqual(bass.notes.length, 1); + assert.strictEqual(bass.laneSkipped, 1); + assert.strictEqual(_clipboardPastePlanPure(null, 10, 6), null); +}); + +// ── the verbs through the real history ─────────────────────────────── + +function seed(notes) { + Object.assign(S, { + arrangements: [{ name: 'Lead', tuning: [0, 0, 0, 0, 0, 0], capo: 0, notes }], + currentArr: 0, + sel: new Set(notes.map((_, i) => i)), + drumEditMode: false, tempoMapMode: false, + cursorTime: 0, snapEnabled: false, beats: [], + history: new EditHistory(), + }); +} + +t('copy → move playhead → paste lands the phrase at the playhead, selected, one undo', () => { + const notes = [N(1, 0, 3), N(1.5, 1, 5)]; + seed(notes); + assert.strictEqual(_editorCopySelection(false), true); + S.cursorTime = 8; + S.sel.clear(); + assert.strictEqual(_editorPasteAtPlayhead(), true); + const nn = S.arrangements[0].notes; + assert.strictEqual(nn.length, 4); + const pasted = nn.filter(n => n.time >= 8); + assert.deepStrictEqual(pasted.map(n => n.time), [8, 8.5], 'internal timing preserved'); + assert.strictEqual(S.sel.size, 2, 'the pasted notes are selected'); + assert.strictEqual(S.history.undo.length, 1, 'one undoable step'); + const before = JSON.stringify(nn.map(n => ({ t: n.time, s: n.string, f: n.fret }))); + S.history.doUndo(); + assert.strictEqual(S.arrangements[0].notes.length, 2, 'undo removes the paste'); + S.history.doRedo(); + assert.strictEqual(JSON.stringify(S.arrangements[0].notes.map(n => ({ t: n.time, s: n.string, f: n.fret }))), + before, 'redo reproduces exactly'); +}); + +t('cut removes the notes (undoably) and the clipboard survives the undo', () => { + seed([N(1, 0, 3), N(2, 1, 5)]); + assert.strictEqual(_editorCopySelection(true), true); + assert.strictEqual(S.arrangements[0].notes.length, 0, 'cut removed them'); + S.history.doUndo(); + assert.strictEqual(S.arrangements[0].notes.length, 2, 'undo restores the notes'); + // …but the clipboard still pastes (the text-editor contract). + S.cursorTime = 10; + S.sel.clear(); + assert.strictEqual(_editorPasteAtPlayhead(), true); + assert.strictEqual(S.arrangements[0].notes.length, 4); +}); + +t('mode and shape guards: drum/tempo modes refuse; keys↔fretted refuses', () => { + seed([N(1, 0, 3)]); + _editorCopySelection(false); + S.drumEditMode = true; + assert.strictEqual(_editorPasteAtPlayhead(), false, 'drum mode → not handled'); + S.drumEditMode = false; + S.tempoMapMode = true; + assert.strictEqual(_editorPasteAtPlayhead(), false, 'tempo map → not handled'); + S.tempoMapMode = false; +}); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); From 82ad213dae675d31631710a82d650585812cab4d Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Tue, 14 Jul 2026 16:27:53 -0500 Subject: [PATCH 2/3] fix(editor): clipboard write guards live in the commands, not just onKeyDown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (community bot, valid): the registry made Cut/Paste menu- and palette-invokable, which bypasses onKeyDown's mode gates — Cut/Paste could mutate during MIDI recording, in the Tracks overview, and Paste could add pitches to the read-only fretted roll on the SAME track. _clipboardWriteBlocked() now gates every clipboard write inside the commands themselves (recording / Tracks overview / read-only roll — the same trio the right-click delete guard enforces); plain Copy stays free. The cross-track-only roll check is replaced by the all-pastes guard. Tests: the keys↔fretted refusal is now actually exercised in BOTH directions (was asserted only by mode flags), and a new registry-path case proves the overview blocks cut + paste while copy stays allowed — it fails without the guard move. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q --- src/input.js | 15 ++++++++++++++- tests/clipboard.test.mjs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/input.js b/src/input.js index 0fdafe48..e097afaa 100644 --- a/src/input.js +++ b/src/input.js @@ -521,11 +521,24 @@ export function _clipboardPastePlanPure(clip, atTime, laneCount) { let _noteClipboard = null; +// The registry made Cut/Paste menu- and palette-invokable, which BYPASSES +// onKeyDown's mode gates — so every clipboard WRITE re-checks them here: +// no mutation while MIDI-recording, in the Tracks overview, or in the +// read-only fretted roll (delete and add are pitch-writes there, same as +// the right-click delete guard). Plain Copy is a read and stays free. +function _clipboardWriteBlocked() { + if (_recState === 'recording') { setStatus('Stop recording first.'); return true; } + if (S.partsViewMode) { setStatus('Leave the Tracks overview to edit notes.'); return true; } + if (_rollReadOnly()) { _rollLockNotice(); return true; } + return false; +} + // Ctrl+C / Ctrl+X. Cut is copy + the existing undoable delete — the clipboard // itself is deliberately NOT part of history (undoing a cut restores the // notes but keeps the clipboard, exactly like every text editor). export function _editorCopySelection(cutting = false) { if (S.drumEditMode || S.tempoMapMode) return false; + if (cutting && _clipboardWriteBlocked()) return true; const idxs = _editorCurrentNoteIndices(); if (!idxs.length) { setStatus(`Select notes to ${cutting ? 'cut' : 'copy'} first.`); return true; } const nn = notes(); @@ -549,12 +562,12 @@ export function _editorCopySelection(cutting = false) { // refused like every other pitch write. export function _editorPasteAtPlayhead() { if (S.drumEditMode || S.tempoMapMode) return false; + if (_clipboardWriteBlocked()) return true; if (!_noteClipboard) { setStatus('Nothing to paste — copy or cut notes first.'); return true; } if (_noteClipboard.keys !== isKeysArr()) { setStatus('Can\'t paste between keys and fretted tracks — the note shapes don\'t translate.'); return true; } - if (_noteClipboard.arrIndex !== S.currentArr && _rollReadOnly()) { _rollLockNotice(); return true; } const arr = S.arrangements[S.currentArr]; if (!arr) return false; const nn = notes(); diff --git a/tests/clipboard.test.mjs b/tests/clipboard.test.mjs index 19dfdb45..f68b4bce 100644 --- a/tests/clipboard.test.mjs +++ b/tests/clipboard.test.mjs @@ -125,6 +125,37 @@ t('mode and shape guards: drum/tempo modes refuse; keys↔fretted refuses', () = S.tempoMapMode = true; assert.strictEqual(_editorPasteAtPlayhead(), false, 'tempo map → not handled'); S.tempoMapMode = false; + // The keys↔fretted refusal, actually exercised: copy from the fretted + // track, then try to paste onto a keys-named arrangement — refused + // (handled, but nothing added), and the reverse direction refuses too. + S.arrangements.push({ name: 'Keys', tuning: [], capo: 0, notes: [] }); + S.currentArr = 1; + S.cursorTime = 10; + assert.strictEqual(_editorPasteAtPlayhead(), true, 'handled (status message)'); + assert.strictEqual(S.arrangements[1].notes.length, 0, 'nothing pasted onto keys'); + S.arrangements[1].notes = [N(1, 0, 60)]; + S.sel = new Set([0]); + _editorCopySelection(false); // keys-shaped clipboard + S.currentArr = 0; + assert.strictEqual(_editorPasteAtPlayhead(), true); + assert.strictEqual(S.arrangements[0].notes.length, 1, 'nothing pasted onto fretted'); +}); + +t('registry-path write guards: Tracks overview blocks cut and paste (copy stays free)', () => { + // Menu/palette dispatch bypasses onKeyDown's gates — the commands + // themselves must refuse writes in the read-only Tracks overview. + seed([N(1, 0, 3), N(2, 1, 5)]); + _editorCopySelection(false); + S.partsViewMode = true; + assert.strictEqual(_editorPasteAtPlayhead(), true, 'handled (refusal status)'); + assert.strictEqual(S.arrangements[0].notes.length, 2, 'paste blocked in the overview'); + assert.strictEqual(_editorCopySelection(true), true); + assert.strictEqual(S.arrangements[0].notes.length, 2, 'cut blocked in the overview'); + assert.strictEqual(_editorCopySelection(false), true, 'plain copy is a read — allowed'); + S.partsViewMode = false; + S.cursorTime = 10; + assert.strictEqual(_editorPasteAtPlayhead(), true); + assert.strictEqual(S.arrangements[0].notes.length, 4, 'leaving the overview unblocks'); }); console.log(`\n${pass} passed, ${fail} failed`); From 421eca72b7f52eb8f96269f3599f559d2524b1b5 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Wed, 15 Jul 2026 21:19:27 +0200 Subject: [PATCH 3/3] editor: drop the orphaned S.clipboard state field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #270 moved the note clipboard to a module-local _noteClipboard and removed every reader/writer of S.clipboard, but left the now-dead `clipboard: null` field in the session state. Remove it — zero refs remain (grep-verified), and a stale field misleads the next reader. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/state.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/state.js b/src/state.js index e1481f0c..c53c328e 100644 --- a/src/state.js +++ b/src/state.js @@ -141,9 +141,6 @@ export const S = { // Songs list cache songsList: null, - - // Clipboard - clipboard: null, // { notes: [...], baseTime } }; // ── Shared edit generation ──────────────────────────────────────────