diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a74986..43549695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 durations read from the spacing between notes, and anything outside the barline span is counted in the status rather than silently dropped. Fretted tracks only for now; standard notation is the follow-up. +- **Standard notation in the score view — View ▸ Score staff.** Pick what + the live score engraves: **tablature only** (the default), **standard + notation only**, or **both staves together**. Clicking a beat selects its + notes on every staff choice, the pick is remembered per browser, and + choosing a staff while the score view is off switches you into it. ### Fixed diff --git a/src/menu-bar.js b/src/menu-bar.js index 1aee549e..a1c6110d 100644 --- a/src/menu-bar.js +++ b/src/menu-bar.js @@ -43,6 +43,7 @@ import { applyToolbarPreset, getToolbarCtx, resetToolbarLayout, toggleToolbar, } from './toolbars.js'; import { _clearBarSelection, editorLoopSnapMode, editorSetLoopSnapMode } from './loop.js'; +import { editorSetTabViewStaff, editorTabViewStaff } from './tab-view-live.js'; /* @pure:menu-model:start */ // The nine menus (charrette §2.2). Item kinds: @@ -150,6 +151,14 @@ export const EDITOR_MENUS = Object.freeze([ { cmd: 'toggleTabView' }, { cmd: 'showTabPreview' }, { sep: true }, + // Score-staff radio (rides the live Tab/Score view): which staves + // the engraving shows. Checkmarks resolve from ctx.scoreStaff at + // open time, same as the loop-snap trio. + { hdr: 'Score staff' }, + { scoreStaff: 'tab', label: 'Tablature only' }, + { scoreStaff: 'notation', label: 'Standard notation only' }, + { scoreStaff: 'both', label: 'Notation + tablature' }, + { sep: true }, { label: 'Theme: Dark → Medium → Light', fn: 'editorCycleTheme', v3Only: true }, { sep: true }, { hdr: 'Panels' }, @@ -287,6 +296,19 @@ export function _menuModelPure(menus, rows, ctx) { }); continue; } + if (it.scoreStaff) { + // Score-staff radio: ctx.scoreStaff is absent in older + // callers -> unchecked (same degradation as the loop trio). + const on = ctx.scoreStaff === it.scoreStaff; + items.push({ + label: (on ? '✓ ' : ' ') + it.label, + key: '', + dispatch: { scoreStaff: it.scoreStaff }, + disabled: false, + planned: false, + }); + continue; + } if (it.loopSnap || it.loopClear) { // Loop rows (B3). The snap trio renders like a radio group; // ctx.loopSnapMode is absent in older callers -> unchecked. @@ -402,6 +424,7 @@ function currentModel() { v3: !!(window.slopsmith && window.slopsmith.uiVersion === 'v3'), toolbars: getToolbarCtx(), loopSnapMode: editorLoopSnapMode(), + scoreStaff: editorTabViewStaff(), gmGuide: _gmGuideMenuCtx(), }); } @@ -413,6 +436,7 @@ function dispatch(d) { if (d.tbPreset) { applyToolbarPreset(d.tbPreset); return; } if (d.tbReset) { resetToolbarLayout(); return; } if (d.loopSnap) { editorSetLoopSnapMode(d.loopSnap); return; } + if (d.scoreStaff) { editorSetTabViewStaff(d.scoreStaff); return; } if (d.loopClear) { _clearBarSelection(); return; } if (d.guideVoice) { _editorSetGuideVoiceMode(d.guideVoice); return; } if (d.gmVoice != null) { editorSetGmVoice(d.gmKind, d.gmVoice); return; } diff --git a/src/state.js b/src/state.js index 2e4d2b1c..71cd21e2 100644 --- a/src/state.js +++ b/src/state.js @@ -107,6 +107,8 @@ export const S = { // Live Tab view (view-modality): an orthogonal lens flag like // partsViewMode — mode toggles clear it, the draw pass enforces it. tabViewMode: false, + // Which staves the score view engraves: 'tab' | 'notation' | 'both'. + tabViewStaff: 'tab', // True when this editor session was opened from the 3D highway's // "Edit region" action. Used to make the preview button read as a // return trip instead of a fresh action. diff --git a/src/tab-view-live.js b/src/tab-view-live.js index 72f93150..219d3de6 100644 --- a/src/tab-view-live.js +++ b/src/tab-view-live.js @@ -6,10 +6,13 @@ * plugin (contrast tab-preview.js, the saved-pack proofreading modal, which * stays for checking what actually shipped to disk). * - * v1 contract (Christian's calls): tab staff only (standard notation is the - * follow-up), read + CLICK-TO-SELECT — clicking an engraved beat selects its - * source notes and seeks the playhead; editing stays in the timeline views, - * whose shortcuts keep working while the score refreshes live. + * Contract (Christian's calls): read + CLICK-TO-SELECT — clicking an + * engraved beat selects its source notes and seeks the playhead; editing + * stays in the timeline views, whose shortcuts keep working while the score + * refreshes live. The engraved STAFF is a per-browser reading preference + * (View ▸ Score staff): tab only (default), standard notation only, or both + * — same generated alphaTex either way, alphaTab derives pitch from + * tuning + fret. * * Lifecycle: `S.tabViewMode` is an orthogonal lens flag like partsViewMode — * the drum / tempo-map / parts toggles clear it, and the draw pass is the @@ -30,20 +33,46 @@ import { TAB_RENDERER_FONT_DIR, _tabPreviewLoadScript } from './tab-preview.js'; let _api = null; let _apiMount = null; // the DOM node the api was built on (re-injection guard) +let _apiStaff = ''; // the staff profile the api was built with +let _domHandler = null; // the capture-phase mousedown fallback (removed on destroy) let _beatMap = null; let _renderedKey = ''; // editGen|arr|session — regen only on real change let _debounce = 0; const $mount = () => document.getElementById('editor-tabview-mount'); +// Restore the staff preference — a READING preference, never chart data, so +// it lives in localStorage like the loop-snap mode does. +try { + const m = localStorage.getItem('editorTabViewStaff'); + if (m === 'notation' || m === 'both') S.tabViewStaff = m; +} catch (_) { /* storage unavailable — the 'tab' default stands */ } + +// The staff preference → alphaTab's StaveProfile enum KEY. A pure string map +// (no alphaTab needed) so the contract is pinnable in node, and so an +// unknown/legacy stored value degrades to tab instead of throwing. +export function _scoreStaffProfilePure(staff) { + if (staff === 'notation') return 'Score'; + if (staff === 'both') return 'ScoreTab'; + return 'Tab'; +} + function _keyNow() { return `${editGen}|${S.currentArr}|${S.sessionId}`; } function _destroyApi() { if (_api) { try { _api.destroy(); } catch (_) { /* best-effort */ } } + // Our capture-phase DOM listener is our own closure — alphaTab.destroy() + // never touches it, so remove it here or a same-node rebuild (e.g. a + // staff switch) accumulates a live listener on the surviving mount. + if (_apiMount && _domHandler) { + try { _apiMount.removeEventListener('mousedown', _domHandler, true); } catch (_) { /* best-effort */ } + } + _domHandler = null; _api = null; _apiMount = null; + _apiStaff = ''; _beatMap = null; _renderedKey = ''; } @@ -51,15 +80,19 @@ function _destroyApi() { // Build (or rebuild after a screen re-injection replaced the mount) and wire // the click-to-select: alphaTab reports the clicked beat's bar + in-bar index, // which is exactly how the generator's beatMap is keyed. -function _ensureApi(mount) { - if (_api && _apiMount === mount) return _api; +export function _ensureApi(mount) { + if (_api && _apiMount === mount && _apiStaff === S.tabViewStaff) return _api; _destroyApi(); /* global alphaTab */ _api = new alphaTab.AlphaTabApi(mount, { // The music font MUST come from the same pinned CDN — a null/missing // fontDirectory engraves an invisible score (glyphs never load). core: { fontDirectory: TAB_RENDERER_FONT_DIR, includeNoteBounds: true }, - display: { layoutMode: alphaTab.LayoutMode.Page, scale: 0.85 }, + display: { + layoutMode: alphaTab.LayoutMode.Page, + scale: 0.85, + staveProfile: alphaTab.StaveProfile[_scoreStaffProfilePure(S.tabViewStaff)], + }, // The interaction layer (beat clicks + the bounds lookup behind them) // is gated on the player flag in this alphaTab line — enable it with // NO soundfont and no cursor: nothing downloads, nothing sounds, the @@ -67,6 +100,7 @@ function _ensureApi(mount) { player: { enablePlayer: true, enableCursor: false, enableUserInteraction: true }, }); _apiMount = mount; + _apiStaff = S.tabViewStaff; const select = (beat) => { try { const barIdx = beat.voice.bar.index; @@ -89,7 +123,7 @@ function _ensureApi(mount) { // the events it consumes — so this fallback listens in CAPTURE phase (it // runs before alphaTab's own handlers, whatever they swallow) and pairs // the plain DOM click with the bounds lookup. - mount.addEventListener('mousedown', (e) => { + _domHandler = (e) => { try { const lookup = _api && _api.renderer && _api.renderer.boundsLookup; if (!lookup || !lookup.getBeatAtPos) return; @@ -99,7 +133,8 @@ function _ensureApi(mount) { e.clientY - r.top + mount.scrollTop); if (beat) select(beat); } catch (_) { /* no lookup yet — ignore */ } - }, true); + }; + mount.addEventListener('mousedown', _domHandler, true); return _api; } @@ -208,6 +243,28 @@ export function editorToggleTabView(force) { return true; } +// The menu's Score-staff radio: getter feeds the checkmarks, setter applies +// + persists. Picking a staff while the score view is OFF also enters it — +// choosing what to read implies wanting to read. +export function editorTabViewStaff() { return S.tabViewStaff; } + +export function editorSetTabViewStaff(staff) { + const v = (staff === 'notation' || staff === 'both') ? staff : 'tab'; + const label = v === 'notation' ? 'standard notation' + : v === 'both' ? 'notation + tab' : 'tablature'; + if (v !== S.tabViewStaff) { + S.tabViewStaff = v; + try { localStorage.setItem('editorTabViewStaff', v); } catch (_) { /* preference just won't persist */ } + // A different staff needs a rebuilt renderer (staveProfile is a + // construction-time setting) AND a re-render: _ensureApi sees the + // staff mismatch, _renderedKey forces the ping to regenerate. + _renderedKey = ''; + } + if (!S.tabViewMode) { editorToggleTabView(true); return; } + setStatus(`Score staff: ${label}.`); + host.draw(); +} + // Session teardown: the mount is being replaced wholesale — drop the api so // the next entry rebuilds against the fresh DOM. export function teardownTabView() { diff --git a/tests/menu_model.test.mjs b/tests/menu_model.test.mjs index bba99360..538d2b21 100644 --- a/tests/menu_model.test.mjs +++ b/tests/menu_model.test.mjs @@ -102,6 +102,20 @@ t('v3-only theme cycling hides outside the v3 layout', () => { assert.strictEqual(labels({ ...CTX, v3: true }).includes('Theme: Dark → Medium → Light'), true); }); +t('Score-staff trio renders as a radio from ctx.scoreStaff; absent ctx unchecks all', () => { + const staffItems = (ctx) => _menuModelPure(EDITOR_MENUS, rows, ctx) + .find((m) => m.title === 'View').items + .filter((i) => i.dispatch && i.dispatch.scoreStaff); + const withPref = staffItems({ ...CTX, scoreStaff: 'notation' }); + assert.strictEqual(withPref.length, 3, 'tab / notation / both'); + assert.deepStrictEqual(withPref.map((i) => i.label[0] === '✓'), + [false, true, false], 'exactly the active staff is checked'); + assert.deepStrictEqual(withPref.map((i) => i.dispatch.scoreStaff), + ['tab', 'notation', 'both']); + const older = staffItems(CTX); // ctx without scoreStaff (older caller) + assert.ok(older.every((i) => i.label[0] !== '✓'), 'degrades unchecked, never crashes'); +}); + t('Help ▸ User Guide surfaces, enabled, and dispatches to its toggle', () => { const model = _menuModelPure(EDITOR_MENUS, rows, CTX); const help = model.find((m) => m.title === 'Help'); diff --git a/tests/tab_view_staff.test.mjs b/tests/tab_view_staff.test.mjs new file mode 100644 index 00000000..e0d4938b --- /dev/null +++ b/tests/tab_view_staff.test.mjs @@ -0,0 +1,121 @@ +/* + * The Score-staff preference (live Tab/Score view): the staff → alphaTab + * StaveProfile mapping, the localStorage restore-on-load, and the setter's + * validate + persist + re-render-arming behavior. + * + * Pinned here: unknown/legacy stored values degrade to 'tab' (never throw + * into the renderer), the preference is a READING preference restored at + * module load, and picking a staff while the score view is off ENTERS it. + * + * Fails on main (the exports don't exist there). + * Run: node tests/tab_view_staff.test.mjs + */ +import assert from 'node:assert'; + +// localStorage stub BEFORE the module import — the restore-on-load read +// happens at import time, which is exactly what the first test pins. +const stored = { editorTabViewStaff: 'both' }; +const setCalls = []; +globalThis.localStorage = { + getItem: (k) => (k in stored ? stored[k] : null), + setItem: (k, v) => { stored[k] = v; setCalls.push([k, v]); }, +}; +globalThis.document = globalThis.document || { + getElementById: () => null, addEventListener: () => {}, activeElement: null, +}; +globalThis.window = globalThis.window || globalThis; + +// Minimal alphaTab stub so _ensureApi can build without the CDN bundle. Only +// the surface _ensureApi touches at construction time is stubbed. +globalThis.alphaTab = { + LayoutMode: { Page: 'Page' }, + StaveProfile: { Tab: 'Tab', Score: 'Score', ScoreTab: 'ScoreTab' }, + AlphaTabApi: class { constructor(mount, opts) { this.mount = mount; this.opts = opts; this.renderer = {}; } destroy() {} }, +}; + +const { S } = await import('../src/state.js'); +const { _scoreStaffProfilePure, editorSetTabViewStaff, editorTabViewStaff, _ensureApi } = + await import('../src/tab-view-live.js'); + +// A mount that records add/removeEventListener so we can count live listeners. +function fakeMount() { + const listeners = []; + return { + listeners, + addEventListener(type, fn, capture) { listeners.push({ type, fn, capture }); }, + removeEventListener(type, fn, capture) { + const i = listeners.findIndex((l) => l.type === type && l.fn === fn && l.capture === capture); + if (i >= 0) listeners.splice(i, 1); + }, + getBoundingClientRect: () => ({ left: 0, top: 0 }), + scrollLeft: 0, scrollTop: 0, + }; +} + +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); } +} + +t('a stored preference is restored at module load', () => { + assert.strictEqual(S.tabViewStaff, 'both'); + assert.strictEqual(editorTabViewStaff(), 'both'); +}); + +t('staff → StaveProfile key: the three real values', () => { + assert.strictEqual(_scoreStaffProfilePure('tab'), 'Tab'); + assert.strictEqual(_scoreStaffProfilePure('notation'), 'Score'); + assert.strictEqual(_scoreStaffProfilePure('both'), 'ScoreTab'); +}); + +t('unknown/legacy values degrade to Tab, never throw into the renderer', () => { + assert.strictEqual(_scoreStaffProfilePure('score'), 'Tab'); + assert.strictEqual(_scoreStaffProfilePure(''), 'Tab'); + assert.strictEqual(_scoreStaffProfilePure(undefined), 'Tab'); + assert.strictEqual(_scoreStaffProfilePure(null), 'Tab'); +}); + +t('the setter validates, applies, and persists', () => { + S.tabViewMode = true; // stay off the enter-the-view path + editorSetTabViewStaff('notation'); + assert.strictEqual(S.tabViewStaff, 'notation'); + assert.deepStrictEqual(setCalls.pop(), ['editorTabViewStaff', 'notation']); + editorSetTabViewStaff('nonsense'); + assert.strictEqual(S.tabViewStaff, 'tab', 'junk input degrades to tab'); + assert.deepStrictEqual(setCalls.pop(), ['editorTabViewStaff', 'tab']); +}); + +t('re-picking the current staff does not re-persist', () => { + S.tabViewMode = true; + const n = setCalls.length; + editorSetTabViewStaff('tab'); + assert.strictEqual(setCalls.length, n); +}); + +t('picking a staff while the score view is off enters it (guard: needs a song)', () => { + S.tabViewMode = false; + Object.assign(S, { arrangements: [{ name: 'Lead' }], currentArr: 0, sel: new Set() }); + editorSetTabViewStaff('both'); + assert.strictEqual(S.tabViewStaff, 'both'); + assert.strictEqual(S.tabViewMode, true, 'choosing what to read implies wanting to read'); +}); + +t('a staff-change rebuild on the same mount does not leak DOM mousedown listeners', () => { + const mount = fakeMount(); + const count = () => mount.listeners.filter((l) => l.type === 'mousedown').length; + S.tabViewStaff = 'tab'; + _ensureApi(mount); + assert.strictEqual(count(), 1, 'first build wires exactly one capture listener'); + // Same node, different staff -> _ensureApi rebuilds (staveProfile is a + // construction-time setting). Pre-fix the old listener stayed bound. + S.tabViewStaff = 'notation'; + _ensureApi(mount); + assert.strictEqual(count(), 1, 'rebuild removes the stale listener instead of stacking a second'); + S.tabViewStaff = 'both'; + _ensureApi(mount); + assert.strictEqual(count(), 1, 'still one after a third staff switch'); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0);