Skip to content
Merged
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Track regions — import a drum part into an existing project as a placed,
movable region.** The Add-Drums import (New Track ▸ Drums ▸ from a file, or
Track ▸ Drums…) now lands the imported part in the Tracks view **selected as
a region**, ready to drag — and a new **"Place at"** choice in the dialog
says where: **Keep source timing** (the default — the file's own timing, no
content motion), **Bar 1**, or **Playhead** (the whole part slides there as
one undoable step, snapped to the bar, beats preserved across tempo
changes). **Delete/Backspace** on a selected region block removes the block
and the notes it owns as one undoable step. Under the hood:
`PlaceRegionCmd` / `DeleteRegionCmd` (bounded window + verbatim-snapshot
undo, mirroring the move command) and a `placeImportedPartAsRegion`
orchestrator the import flows reach through the host table. Also fixes the region commands and the Tracks drag-arming for
**multi-drum songs**: a region on a drum part's track now always acts on
that part's own tab — never whichever part happens to be active in the drum
grid (dragging an extra part's block used to move nothing at all).

- **Multiple drum parts now work when creating a new song too.** The several-
drum-parts feature previously covered only songs you re-open and Save; a
*create-mode* session (New Song from a Guitar Pro / MIDI import) was capped at
Expand Down
14 changes: 14 additions & 0 deletions screen.html
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,20 @@
<label class="text-xs font-medium text-gray-400 mb-1 block">Select Drum Track</label>
<div id="editor-add-drums-track-list" class="space-y-1 max-h-32 overflow-y-auto bg-dark-800 rounded p-2"></div>
</div>
<!-- Where the imported part lands on the timeline (its region).
Keep = the source file's own timing; Bar 1 / Playhead slide the
whole part there as one undoable placement. Hidden in create
mode (single-tab replace semantics — no region to place). -->
<div id="editor-add-drums-place-row">
<label class="text-xs font-medium text-gray-400 mb-1 block" for="editor-add-drums-place">Place at</label>
<select id="editor-add-drums-place"
class="w-full bg-dark-700 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300 outline-none"
title="Where the imported drum part lands on the timeline — keep the file's own timing, or slide the whole part to bar 1 / the playhead (undoable)">
<option value="keep" selected>Keep source timing</option>
<option value="bar1">Bar 1</option>
<option value="playhead">Playhead</option>
</select>
</div>
<button onclick="editorDoAddDrums()" id="editor-add-drums-go"
class="w-full px-4 py-2 bg-red-700 hover:bg-red-600 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed" disabled>
Import Drum Track
Expand Down
24 changes: 23 additions & 1 deletion src/arrangement.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { _editorEscHtml, _editorPromptText, setStatus } from './ui.js';
import { flattenChords } from './chords.js';
import { KEYS_PATTERN } from './keys.js';
import { _arrTypeKind, _typeKind } from './instrument.js';
import { addDrumArrangement, clampAwayFromDrums, findDrumArrangement, isDrumArrangement, pitchedArrangementCount, pitchedIndexOf, syncDrumArrangement } from './drum-arrangement.js';
import { activeDrumArrangementIndex, addDrumArrangement, clampAwayFromDrums, findDrumArrangement, isDrumArrangement, pitchedArrangementCount, pitchedIndexOf, syncDrumArrangement } from './drum-arrangement.js';
import { _recState } from './midi-record.js';
import { _maybeOfferMidiTempoMap, _showDrumImportUnmappedModal } from './import.js';
import { host } from './host.js';
Expand Down Expand Up @@ -313,6 +313,14 @@ export function editorShowAddDrumsModal() {
: 'This song already has a drum track — importing will replace it.';
}
}
// "Place at": default to the file's own timing on every open. Hidden in
// create mode — a create session can hold several drum parts now (#343),
// but region PLACEMENT there stays deferred until the create build path
// proves it round-trips regions[]; the import still lands selected.
const placeRow = document.getElementById('editor-add-drums-place-row');
if (placeRow) placeRow.classList.toggle('hidden', !!S.createMode);
const placeSel = document.getElementById('editor-add-drums-place');
if (placeSel) placeSel.value = 'keep';
}

// Can a SECOND (third, …) drum part be added — i.e. does a drum import ADD
Expand Down Expand Up @@ -500,6 +508,20 @@ export async function editorDoAddDrums() {
// part only (a drums-only session must not put drums at index 0).
if (pitchedArrangementCount(S.arrangements) > 0) syncDrumArrangement(S);

// Land the fresh part in the Tracks view as a selected region — placed
// at bar 1 / the playhead when the dialog said so, else left at the
// source file's own timing (R3b import-into-existing). Create mode
// forces 'keep' (its Place-at row is hidden — placement is deferred
// until the create build path proves it round-trips regions[]), so
// there the import lands selected at source timing.
const placeSel = document.getElementById('editor-add-drums-place');
host.placeImportedPartAsRegion({
kind: 'drums',
arrIdx: activeDrumArrangementIndex(S.arrangements, tab),
placeAt: (placeSel && !S.createMode) ? placeSel.value : 'keep',
items: Array.isArray(tab.hits) ? tab.hits.slice() : [],
});

editorHideAddDrumsModal();
const hitCount = Array.isArray(data.drum_tab.hits)
? data.drum_tab.hits.length : 0;
Expand Down
13 changes: 13 additions & 0 deletions src/host.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,19 @@ export const host = {
partMixChanged: () => {},
/** The visible audio-source roster changed (remove/restore/import/rename). */
audioSourcesChanged: () => {},
/**
* Land a freshly-imported part in the Tracks view as a selected region,
* optionally placed at bar 1 / the playhead (R3b). Owned by
* src/track-session.js; the import flows (arrangement.js) reach it here
* because track-session imports arrangement — the usual seam inversion.
*/
placeImportedPartAsRegion: () => false,
/**
* Del/Backspace in the Tracks view: delete the selected region block
* (content + window, one undoable step). Owned by src/parts-view.js;
* input.js's Delete ladder asks here. False = key not consumed.
*/
partsViewRegionDelete: () => false,
stripUiChanged: () => {},
/** The persisted band-mode pref, read by the panel's header toggle. */
playAllTracksEnabled: () => false,
Expand Down
13 changes: 13 additions & 0 deletions src/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -1769,6 +1769,17 @@ export function onKeyDown(e) {
if (S.partsViewMode
&& _editorFeedbackCommandForKeyPure(e, 'note') !== 'togglePartsView'
&& _editorEofCommandForKeyPure(e, 'note') !== 'togglePartsView') {
// The Tracks overview owns exactly one editing key: Delete/Backspace
// removes the SELECTED REGION BLOCK (its window AND the content it
// owns, one undoable step) — a region op on this surface, not a
// note-edit on the hidden chart. parts-view owns the resolution
// (which part, which kind) through the host table; false = no region
// selected, and the key stays ignored like everything else here.
if ((e.key === 'Delete' || e.key === 'Backspace')
&& !e.target.matches('input, select, textarea')
&& host.partsViewRegionDelete()) {
e.preventDefault();
}
return;
}

Expand Down Expand Up @@ -1889,6 +1900,8 @@ export function onKeyDown(e) {
}

if (e.key === 'Delete' || e.key === 'Backspace') {
// (Region-block delete lives in the partsViewMode gate above — this
// ladder is only reachable outside the Tracks overview.)
// Tempo-map mode: delete the selected barline(s) — bulk when a
// multi-selection exists (PR 5a), else the single focus.
if (S.tempoMapMode && (S.tempoSel >= 0 || (S.tempoSelMulti && S.tempoSelMulti.size)) &&
Expand Down
10 changes: 7 additions & 3 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@
editorShowNewTrackModal
} from './new-track.js';
import {
_editorTogglePartsView, _partsViewDraw, _partsViewOnDblClick,
_partsViewOnMouseDown, _partsViewRegionDrag, _partsViewRegionDrop, _refreshPartsViewButton
_editorTogglePartsView, _partsViewDraw, _partsViewOnDblClick, _partsViewOnMouseDown,
_partsViewRegionDelete, _partsViewRegionDrag, _partsViewRegionDrop, _refreshPartsViewButton
} from './parts-view.js';
import { drawWaveform } from './waveform.js';
import {
Expand All @@ -132,7 +132,7 @@
import { initToolbars } from './toolbars.js';
import { _applyToolCursor, editorToolPaletteClick } from './tools.js';
import { editorStartTour, editorTourEscape, editorTourSkip, _tourAdvance, _tourNoteAction } from './tour.js';
import { _trackSessionTargetsPure, initTrackSession, installCreatedTrackSession, refreshTrackSession, scrollTrackSessionBy, trackSessionOrderedMixKeys } from './track-session.js';
import { _trackSessionTargetsPure, initTrackSession, installCreatedTrackSession, placeImportedPartAsRegion, refreshTrackSession, scrollTrackSessionBy, trackSessionOrderedMixKeys } from './track-session.js';
import { editorDismissSignpost } from './signposts.js';
import { _editorSongFit } from './song-fit.js';
import { _transportBarTick, initTransportBar } from './transport-bar.js';
Expand Down Expand Up @@ -554,6 +554,7 @@
partsViewOnDblClick: (...a) => _partsViewOnDblClick(...a),
partsViewRegionDrag: (...a) => _partsViewRegionDrag(...a),
partsViewRegionDrop: (...a) => _partsViewRegionDrop(...a),
partsViewRegionDelete: () => _partsViewRegionDelete(),
resizeCanvas: (...a) => resizeCanvas(...a),
editorCycleViewMode: (...a) => _editorCycleViewMode(...a),
editorMovePart: (...a) => _editorMovePart(...a),
Expand All @@ -578,6 +579,9 @@
},
playAllTracksEnabled: () => editorPlayAllTracksEnabled(),
stripUiChanged: () => _mixerPanelRefresh(),
// Import-into-existing (R3b): the import flows land a fresh part in the
// Tracks view as a selected (optionally placed) region.
placeImportedPartAsRegion: (opts) => placeImportedPartAsRegion(opts),
// The stem-mixer capability signal: its PRESENCE flips stemMixerAvailable()
// true (lighting up Solo-my-source and the audio-row strips). Re-ramps the
// stem gains off S.partMix and repaints the surfaces.
Expand Down Expand Up @@ -2025,7 +2029,7 @@
// the same save path as the Save button (in-place sloppak write, not the
// heavy create-mode build).
if (S.sessionId) {
try { await saveCDLC(); } catch (e) { /* surfaced via setStatus */ }

Check warning on line 2032 in src/main.js

View workflow job for this annotation

GitHub Actions / lint

'e' is defined but never used. Allowed unused caught errors must match /^_/u
}
// Capture where we are so the return trip lands on the same spot.
const returnCtx = {
Expand Down
35 changes: 32 additions & 3 deletions src/parts-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { DRUM_PIECE_META, _refreshDrumEditButton } from './drum.js';
import { beatOf, timeOf } from './beats.js';
import { LABEL_W, TIMELINE_TOP, timeToX, xToTime } from './geometry.js';
import { _regionBlockRectPure, _regionHitPure, _regionSnapStartPure, _regionTimeSpanPure, _trackRegionsResolvePure } from './region.js';
import { MoveRegionCmd } from './region-commands.js';
import { DeleteRegionCmd, MoveRegionCmd } from './region-commands.js';
import { isDrumArrangement } from './drum-arrangement.js';
import { arrKind } from './instrument.js';
import { _stringCountFor } from './lanes.js';
Expand Down Expand Up @@ -104,6 +104,9 @@ function _arrIndexForTarget(targetId) {
.find(item => item.id === targetId);
return target && target.mixKey.startsWith('arr:') ? Number(target.mixKey.slice(4)) : -1;
}
function _isDrumRow(arrIdx, targetId) {
return (arrIdx >= 0 && isDrumArrangement(S.arrangements[arrIdx])) || targetId === 'drums';
}

// An audio lane's waveform, when the host has one cached for the source
// (today: the master mix via S.waveformPeaks; stems light up with the
Expand Down Expand Up @@ -388,7 +391,7 @@ export function _partsViewOnMouseDown(e, x, y) {
host.selectTrackSessionSource(row.sourceId);
} else {
const idx = _arrIndexForTarget(row.targetId);
if ((idx >= 0 && isDrumArrangement(S.arrangements[idx])) || row.targetId === 'drums') {
if (_isDrumRow(idx, row.targetId)) {
// A drum part (any of them) — arming is a no-op (currentArr never
// moves onto a drums arrangement); double-click opens its grid.
setStatus('Drum transcription selected — double-click to open the drum editor');
Expand All @@ -415,7 +418,11 @@ export function _partsViewOnMouseDown(e, x, y) {
trackId: row.id,
regionId: hitRegion,
region,
kind: row.targetId === 'drums' ? 'drums' : 'notation',
// Any drum part's row (its own arrangement, or the legacy
// unmaterialized 'drums' target) moves DRUM content — the
// command resolves the part's own tab from arrIdx, so dragging
// a non-active part never shifts the active grid's hits.
kind: _isDrumRow(rArrIdx, row.targetId) ? 'drums' : 'notation',
arrIdx: rArrIdx,
origStart: span.t0,
spanW: Math.max(0, span.t1 - span.t0),
Expand Down Expand Up @@ -458,6 +465,28 @@ export function _partsViewRegionDrop() {
setStatus(`Moved “${d.region.name || d.region.id}” ${dBeat > 0 ? 'later' : 'earlier'}`);
}

// Delete the selected region block — the Del/Backspace surface for the Tracks
// view (input.js routes here through the host table). Removes the window AND
// the content it owns as one undoable DeleteRegionCmd. Transcription rows only
// — an audio region carries a two-clock media pointer and stays select-only
// until the audio-region PR. Same kind/arrIdx resolution as the drag arming,
// so a drum part's region always deletes ITS OWN hits. Returns true when it
// consumed the key (a caller must then preventDefault).
export function _partsViewRegionDelete() {
if (!S.partsViewMode || !S.selectedTrackId || !S.selectedRegionId || !S.history) return false;
const row = _unifiedRows().find(r => r.id === S.selectedTrackId);
if (!row || row.type !== 'transcription') return false;
const region = _trackRegionsResolvePure(row.regions).find(r => r.id === S.selectedRegionId);
if (!region) return false;
const arrIdx = _arrIndexForTarget(row.targetId);
const kind = _isDrumRow(arrIdx, row.targetId) ? 'drums' : 'notation';
S.history.exec(new DeleteRegionCmd({ kind, arrIdx, trackId: row.id, region }));
host.draw();
host.updateStatus();
setStatus(`Deleted region “${region.name || region.id}” and its notes — Undo restores it`);
return true;
}

export function _partsViewOnDblClick(e) {
const { x, y } = getMousePos(e);
_partsViewOnMouseDown(e, x, y);
Expand Down
Loading
Loading