diff --git a/src/main.js b/src/main.js index 38b96dfd..1d51771d 100644 --- a/src/main.js +++ b/src/main.js @@ -16,7 +16,7 @@ import { import { _installModalKeyboard, setStatus } from './ui.js'; import { EditHistory } from './history.js'; import { - AddNoteCmd, AddStringCmd, RemoveStringCmd, _ROLL_REFUSE_REASONS, + AddNoteCmd, _ROLL_REFUSE_REASONS, _commitAddResolved } from './commands.js'; import { editorApplyCreateResult, editorArtSearch, @@ -93,6 +93,10 @@ import { editorRunShortcutCommand, editorToggleShortcutPanel, editorWaveformVisible, onContextMenu, onKeyDown, } from './input.js'; +import { + editorAddString, editorHideStringsModal, editorRemoveString, + editorSetStringTuning, editorShowStringsModal, +} from './strings.js'; import { setHostHooks } from './host.js'; import { MIN_MEASURE, TempoGridCmd, TempoMapCmd, _r3, _refreshTempoMapButton, _refreshTempoSyncInspector, _respaceWithLocksPure, @@ -2235,273 +2239,6 @@ function _editorMovePart(dir) { } window.editorMovePart = _editorMovePart; -// ════════════════════════════════════════════════════════════════════ -// Strings (tuning) editor — add/remove strings on the active arrangement -// ════════════════════════════════════════════════════════════════════ - -/* @pure:string-tuning:start */ -// Range per role. Bass 4–6 (add low B, then high C). Guitar 6–8 (add low -// B, then low F#). These floors/ceilings are NOT free policy: the pitch -// and label model (`_openMidiForArr` / `laneLabels`) can only represent a -// FIXED set of extended shapes — guitar strings are prepended at the low -// end, bass adds low B at the 5th then high C at the 6th. A guitar below -// 6 or a string added at the "wrong" end has no consistent open-pitch or -// label, so `_stringCountFor` would re-snap the count and silently -// re-interpret every note index. The modal therefore offers each add/ -// remove only at the end the model supports (see `_addPositionPure` / -// `_removePositionPure`); direct per-string tuning entry below covers the -// exotic tunings (drop/open/re-entrant) that changing the COUNT cannot. -function _stringsRangePure(isBass) { - return isBass ? { min: 4, max: 6 } : { min: 6, max: 8 }; -} - -// The only END an add may touch for a given role + current count, or null -// when the arrangement is at its ceiling. Mirrors the fixed extension -// order baked into `_openMidiForArr`/`laneLabels`: bass grows low (4→5) -// then high (5→6); guitar grows low (6→7→8). Adding at any other end -// yields a count/label/pitch shape the renderer can't represent, so the -// modal never offers it. Pure — role + count in, position out. -function _addPositionPure(isBass, cur) { - if (isBass) { - if (cur === 4) return 'low'; // 4→5 adds low B - if (cur === 5) return 'high'; // 5→6 adds high C - return null; // 6-string bass is the ceiling - } - if (cur === 6 || cur === 7) return 'low'; // 6→7 low B, 7→8 low F# - return null; // 8-string guitar is the ceiling -} - -// The only END a remove may touch — the inverse of `_addPositionPure`, so -// removing always peels the string the last add appended and the count -// collapses back to a shape the model can represent. null at the floor. -function _removePositionPure(isBass, cur) { - if (isBass) { - if (cur === 6) return 'high'; // 6→5 peels high C - if (cur === 5) return 'low'; // 5→4 peels low B - return null; // 4-string bass is the floor - } - if (cur === 7 || cur === 8) return 'low'; // 8→7, 7→6 peel the low ext - return null; // 6-string guitar is the floor -} - -// Clamp a per-string tuning offset (semitones from that lane's standard -// pitch). ±36 covers everything real — a re-entrant banjo drone sits far -// above its lane position, an octave-down 8-string far below — while a -// junk value can never author NaN into the wire tuning array. -function _stringTuningClampPure(v) { - const n = Math.round(Number(v)); - if (!Number.isFinite(n)) return 0; - return Math.max(-36, Math.min(36, n)); -} - -// Undoable per-string tuning edit — the modal's direct-entry rows. Holds -// the target arrangement INDEX (undo can fire after an arrangement -// switch) plus the exact old offset; lane count never changes, so no -// resize is involved. -class SetStringTuningCmd { - constructor(arrIdx, stringIdx, newOffset) { - this.arrIdx = arrIdx; - this.stringIdx = stringIdx; - this.newOffset = _stringTuningClampPure(newOffset); - const arr = S.arrangements[arrIdx]; - const t = (arr && arr.tuning) || []; - this.oldOffset = Number.isFinite(Number(t[stringIdx])) ? Number(t[stringIdx]) : 0; - } - _arr() { return S.arrangements[this.arrIdx]; } - _set(v) { - const arr = this._arr(); - if (!arr) return; - if (!Array.isArray(arr.tuning)) arr.tuning = []; - while (arr.tuning.length <= this.stringIdx) arr.tuning.push(0); - arr.tuning[this.stringIdx] = v; - } - exec() { this._set(this.newOffset); } - rollback() { this._set(this.oldOffset); } -} -/* @pure:string-tuning:end */ - -function _stringsRangeForActive() { - const arr = S.arrangements[S.currentArr]; - const isBass = arr && /bass/i.test(arr.name || ''); - return _stringsRangePure(!!isBass); -} - -function _notesOnString(arr, idx) { - let count = 0; - for (const n of arr.notes || []) if (n.string === idx) count += 1; - for (const ch of arr.chords || []) { - for (const cn of ch.notes || []) if (cn.string === idx) count += 1; - } - return count; -} - -function _renderStringsModal() { - const arr = S.arrangements[S.currentArr]; - if (!arr) return; - const labels = laneLabels(); // low → high, length === lanes() - // Normalize the display tuning to the real string count so we don't - // surface RS-XML padding zeros as if they were real strings. - const tuning = (arr.tuning || []).slice(0, labels.length); - while (tuning.length < labels.length) tuning.push(0); - const { min, max } = _stringsRangeForActive(); - const isBass = /bass/i.test(arr.name || ''); - - const summary = document.getElementById('editor-strings-summary'); - if (summary) { - summary.textContent = `${arr.name || 'Arrangement'} — ${labels.length} string${labels.length === 1 ? '' : 's'} (${isBass ? 'bass' : 'guitar'}; range ${min}–${max})`; - } - - const list = document.getElementById('editor-strings-list'); - if (list) { - // Build rows with createElement / textContent rather than - // innerHTML — `tuning[i]` arrives from imported/edited JSON - // and could be non-numeric, so interpolating it raw would - // open a DOM-injection vector. Coercing to Number defends - // both against bad input AND against future code that may - // surface `lbl` values that aren't already HTML-safe. - // Display low → high so it reads naturally; `tuning` is also - // low → high in RS XML order, so iterating tuning matches. - // Each row carries a DIRECT-ENTRY offset input (semitones from - // that lane's standard pitch), so any tuning — drop, open, - // banjo's re-entrant drone — is typable, not just reachable - // through presets. Edits go through SetStringTuningCmd (undoable). - list.textContent = ''; - for (let i = 0; i < labels.length; i++) { - const lbl = labels[i]; - const rawOff = tuning[i]; - const off = Number.isFinite(Number(rawOff)) ? Number(rawOff) : 0; - const row = document.createElement('div'); - row.className = 'flex items-center justify-between bg-dark-800 rounded px-2 py-1'; - const left = document.createElement('span'); - left.textContent = `String ${i} (${lbl})`; - const right = document.createElement('label'); - right.className = 'flex items-center gap-1 text-gray-500'; - const input = document.createElement('input'); - input.type = 'number'; - input.min = '-36'; - input.max = '36'; - input.step = '1'; - // The wrapping