diff --git a/CHANGELOG.md b/CHANGELOG.md index 4baa092f..bd13f0d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **The inspector panel now lives in `src/inspector.js` (R2, step 24).** 639 + lines: note attributes on one face, chord name/voicing/fingering/function on + the other. `src/main.js` is down to 9,144. + Most of its edits commit through a command and are undoable; the technique + toggles and boolean flags still mutate in place, which is a deliberate scope + limit from PR3b and unchanged here. All of them honour the read-only-roll lock. + Its 19 `window.editor*` handlers — the ones the panel's own `innerHTML` calls + by name — are exported plain functions that `main.js` re-attaches. `main.js` + keeps the bend-curve dialog and the canvas-resize scheduler. + + - **The MIDI keyboard recorder now lives in `src/midi-record.js` (R2, step 23), and the transport clock in `src/transport.js`.** `src/main.js` is down to 9,779 — under ten thousand for the first time. diff --git a/src/host.js b/src/host.js index 87e6c495..d9246be2 100644 --- a/src/host.js +++ b/src/host.js @@ -113,6 +113,12 @@ export const host = { * to none of them and stays in main.js. */ finalizeActiveDrag: () => {}, + + // ── Dialogs and canvas geometry, for src/inspector.js ──────────── + /** Open the bend-curve editor for a note. Async: resolves when it closes. */ + promptBend: async () => {}, + /** Re-measure the canvas on the next frame (a lane count changed). */ + scheduleCanvasResize: () => {}, }; export function setHostHooks(hooks) { Object.assign(host, hooks); } diff --git a/src/inspector.js b/src/inspector.js new file mode 100644 index 00000000..b4bd2be3 --- /dev/null +++ b/src/inspector.js @@ -0,0 +1,690 @@ +// ════════════════════════════════════════════════════════════════════ +// The inspector panel — the right-hand editor for whatever is selected. +// +// Two faces over one selection: note attributes (fret, string, time, sustain, +// techniques, bend intent, teaching marks) and, when the selection is a chord, +// its name / voicing / fingering / function. +// +// MOST edits commit through a command in src/commands.js and are undoable: +// time/sustain (MoveNoteCmd, ResizeSustainGroupCmd), bend intent, the chord +// patches, and everything routed through _applyTeachingMark — fret finger, +// scale degree, strum grouping. +// +// The technique toggles (editorInspectorSetTech) and the boolean flags +// (editorInspectorSetFlag) are the exception: they still mutate n.techniques in +// place and are NOT undoable, a deliberate scope limit from PR3b. Both DO honour +// the read-only-roll lock, so they cannot silently write a chart the roll is +// showing read-only. +// +// It renders innerHTML and reads back through the window.editorInspector* and +// window.editorChord* handlers that markup calls. Those are exported as plain +// functions and re-attached by main.js: a module cannot own `window.x =`. +// +// main.js keeps the bend-curve dialog and the canvas-resize scheduler; they +// arrive through the shared `host` object in src/host.js. +// ════════════════════════════════════════════════════════════════════ +import { EditChordTemplateCmd } from './annotation-lanes.js'; +import { + _fretKeyForL, _groupFn, _normFingers, _parseGuideTones, _sanitizeCaged, _sanitizeGuideTones, +} from './chords.js'; +import { + EditChordFnCmd, MoveNoteCmd, ResizeSustainGroupCmd, SetBendIntentCmd, SetTeachingMarkCmd, +} from './commands.js'; +import { host } from './host.js'; +import { _rollLockNotice, _rollReadOnly } from './keys.js'; +import { lanes } from './lanes.js'; +import { + BEND_INTENTS, FRET_FINGER_OPTIONS, nextUnusedStrumGroup, notes, rescaleBendCurveToPeak, + sanitizeBendCurve, +} from './notes.js'; +import { S } from './state.js'; + +// ════════════════════════════════════════════════════════════════════ +// Inspector panel — right-side note attribute editor (PR3b of the +// tones+notation UI follow-up). Reflects S.sel; mutations apply to +// every selected note so multi-select bulk edits work without a new +// command class. +// ════════════════════════════════════════════════════════════════════ + +// All boolean technique flags the inspector exposes. The label is what +// the UI shows; the key matches the `techniques` dict on a note. +const _INSPECTOR_FLAGS = [ + { key: 'hammer_on', label: 'Hammer-On' }, + { key: 'pull_off', label: 'Pull-Off' }, + { key: 'palm_mute', label: 'Palm Mute' }, + { key: 'fret_hand_mute', label: 'Fret-Hand Mute' }, + { key: 'mute', label: 'String Mute' }, + { key: 'harmonic', label: 'Harmonic' }, + { key: 'harmonic_pinch', label: 'Pinch Harmonic' }, + { key: 'accent', label: 'Accent' }, + { key: 'vibrato', label: 'Vibrato' }, + { key: 'tremolo', label: 'Tremolo' }, + { key: 'tap', label: 'Tap' }, + { key: 'slap', label: 'Slap' }, + { key: 'pluck', label: 'Pop (Pluck)' }, + { key: 'link_next', label: 'Link Next' }, + { key: 'ignore', label: 'Ignore' }, +]; + +export function _selectedNotes() { + if (!S.sel || S.sel.size === 0) return []; + const nn = notes(); + return [...S.sel].map(i => nn[i]).filter(Boolean); +} + +// Reduce a getter across the selection: returns the shared value, or +// `null` when the selection is mixed. Used to render either a concrete +// value or the "(mixed)" placeholder. +function _selSharedValue(sel, getter, eq) { + eq = eq || ((a, b) => a === b); + if (sel.length === 0) return null; + const first = getter(sel[0]); + for (let i = 1; i < sel.length; i++) { + if (!eq(getter(sel[i]), first)) return null; + } + return first; +} + +export function _renderInspector() { + const el = document.getElementById('editor-inspector'); + if (!el) return; + const sel = _selectedNotes(); + const wasVisible = !el.classList.contains('hidden'); + if (sel.length === 0) { + if (wasVisible) { + el.classList.add('hidden'); + el.innerHTML = ''; + // Hiding the panel grows the canvas wrap back to full + // width — without a resize the canvas backing buffer keeps + // the old narrower width and we render into a stale region. + host.scheduleCanvasResize(); + } + return; + } + if (!wasVisible) { + el.classList.remove('hidden'); + // Showing the panel shrinks the canvas wrap; refresh the canvas + // backing dimensions so notes stay inside the visible region + // instead of being clipped past the panel's left edge. + host.scheduleCanvasResize(); + } + + // Header: condensed summary of the selection. + const sharedString = _selSharedValue(sel, n => n.string); + const sharedFret = _selSharedValue(sel, n => n.fret); + const sharedTime = _selSharedValue(sel, n => n.time); + const sharedSustain = _selSharedValue(sel, n => n.sustain || 0); + const headerCount = sel.length === 1 + ? '1 note selected' + : `${sel.length} notes selected`; + const mixed = '(mixed)'; + // Escape every note-derived value before it reaches innerHTML. The load + // path already coerces string/fret to ints server-side (_note in routes.py), + // so today nothing hostile survives to here — but that coupling is implicit, + // and a persisted note that ever reached the panel un-coerced would inject + // markup. Defence in depth at the trust boundary, not a defect being patched. + const fmtStr = v => v === null ? mixed : _chordAttrEsc(v); + const fmtTime = v => v === null ? mixed : v.toFixed(3); + const fmtSus = v => v === null ? mixed : (v || 0).toFixed(3); + + // Numeric inputs — when the selection has a shared value, prefill + // it; when mixed, leave blank and let the user supply a new value + // that applies to all. + const sharedBend = _selSharedValue(sel, n => (n.techniques && n.techniques.bend) || 0); + const sharedBt = _selSharedValue(sel, n => (n.techniques && n.techniques.bend_intent) || 0); + const sharedSlide = _selSharedValue(sel, n => { + const v = n.techniques && n.techniques.slide_to; + return v === undefined ? -1 : v; + }); + const sharedSlideU = _selSharedValue(sel, n => { + const v = n.techniques && n.techniques.slide_unpitch_to; + return v === undefined ? -1 : v; + }); + // Teaching marks (§6.2.2): fret-hand finger, scale-degree override, strum + // group. Default to -1 (unset) so a note that never authored them reads as + // unset rather than "mixed" against an authored sibling. + const sharedFinger = _selSharedValue(sel, n => { + const v = n.techniques && n.techniques.fret_finger; + return Number.isInteger(v) ? v : -1; + }); + const sharedScaleDeg = _selSharedValue(sel, n => { + const v = n.techniques && n.techniques.scale_degree; + return Number.isInteger(v) ? v : -1; + }); + const sharedStrum = _selSharedValue(sel, n => { + const v = n.techniques && n.techniques.strum_group; + return Number.isInteger(v) ? v : -1; + }); + const inputVal = v => v === null ? '' : _chordAttrEsc(v); + + // Chord inspector (E1): when the selection is a chord (>=2 notes sharing a + // time), author the shared chord template — name / displayName / per-string + // fingering / arp. Edits land on the matching `arr.chord_templates` entry + // (created if this chord hasn't been saved yet), which reconstructChords() + // carries through save via relinkChordTemplate. + const chordHtml = _chordInspectorHtml(_selectedChordContext(sel)); + + let html = ` +
+
${headerCount}
+
string: ${fmtStr(sharedString)}
+
fret: ${fmtStr(sharedFret)}
+
time: ${fmtTime(sharedTime)}
+
sustain: ${fmtSus(sharedSustain)}
+
+ ${chordHtml} +
+ + + + +
+ Bend curve + +
+ + +
+
+
Teaching marks
+ + +
+ Strum grp ${sharedStrum === null ? '(mixed)' : (sharedStrum >= 0 ? '#' + sharedStrum : '—')} + + +
+
+
`; + + for (const f of _INSPECTOR_FLAGS) { + const sharedFlag = _selSharedValue(sel, n => !!(n.techniques && n.techniques[f.key])); + // Three states: true / false / null (mixed). HTML's `indeterminate` + // is only set via property, not attribute — handle it after + // injecting via the post-mount pass below. + const checked = sharedFlag === true; + const indeterminate = sharedFlag === null; + html += ` + `; + } + html += `
`; + el.innerHTML = html; + + // Apply indeterminate state to the inputs that need it — the + // attribute alone doesn't work; the JS property does. + for (const cb of el.querySelectorAll('input[type=checkbox][data-indeterminate="1"]')) { + cb.indeterminate = true; + } +} + +// Inspector mutators. All operate on the full S.sel so a multi-select edit +// applies bulk-style. +// +// The TECHNIQUE toggles below (editorInspectorSetTech, editorInspectorSetFlag) +// skip the undo history — PR3b kept that scope tight, and a TechBulkCmd lands +// when the inspector grows to need richer per-edit undo. Everything else in this +// file commits through a command: setField, setBendIntent, the chord patches, +// and _applyTeachingMark's SetTeachingMarkCmd. (This comment used to say "edits" +// without qualification, which stopped being true once those landed.) + +// Bounds for the inspector's numeric inputs. Mirrors the limits the +// prompt-based editors (`promptFret`, `promptSlide`, `promptBend`) +// enforce — `type="number" min/max` on the inputs is only a UI hint; +// users can paste / type out-of-range values, so we clamp here too. +export const _INSPECTOR_BOUNDS = { + // Time (start position, seconds): non-negative, no upper clamp (a note + // can't sit before the song start; the duration bound is soft). Lets an + // author type a precise onset to align a note to the recording. + time: { min: 0, max: Infinity, integer: false }, + // Sustain has no hard upper bound elsewhere (drag-resize / add-note + // dialog leave it unconstrained), so the inspector matches — only + // the lower clamp matters for input sanity. + sustain: { min: 0, max: Infinity, integer: false }, + bend: { min: 0, max: 3, integer: false }, // half-steps, 3 = +3 semitones + // `emptyAs: -1` matches the prompt semantic ("-1 or empty = no + // slide") so the inspector and `promptSlide` / `promptSlideUnpitch` + // accept the same set of inputs. Without it, deleting the input + // value would be treated as a parse error and silently bounce back. + slide_to: { min: -1, max: 24, integer: true, emptyAs: -1 }, + slide_unpitch_to: { min: -1, max: 24, integer: true, emptyAs: -1 }, +}; + +export function _coerceInspectorNumber(rawValue, bounds) { + if (rawValue === null || rawValue === undefined) return null; + const s = String(rawValue).trim(); + if (s === '') { + // Some fields (slide_to, slide_unpitch_to) interpret an empty + // input as a "clear" affordance — match the prompt-based path. + return bounds.emptyAs !== undefined ? bounds.emptyAs : null; + } + let v; + if (bounds.integer) { + // Strict plain-decimal integer regex — matches the + // prompt-based path's `_parseFretInput`. Rejects `1e1`, `1.9`, + // `12abc` so the inspector and the right-click prompt produce + // the same accept/reject decision on identical input. + if (!/^[-+]?\d+$/.test(s)) return null; + v = Number(s); + } else { + // `Number('1e1abc')` is NaN; `parseFloat('1e1abc')` would + // partial-parse to 10. Use `Number(...)` so junk-tail input + // rejects instead of coercing. + v = Number(s); + } + if (!Number.isFinite(v)) return null; + if (v < bounds.min) v = bounds.min; + if (v > bounds.max) v = bounds.max; + return v; +} + +export function editorInspectorSetField(field, raw) { + const idxs = host.editorCurrentNoteIndices(); + if (!idxs.length) return; + const bounds = _INSPECTOR_BOUNDS[field]; + if (!bounds) return; + const v = _coerceInspectorNumber(raw, bounds); + if (v === null) { + // Reject silently — but re-render so the input snaps back to + // the current shared value instead of leaving the user looking + // at an unapplied edit. + _renderInspector(); + return; + } + // Route through the undo history: the sustain edit used to mutate + // notes in place with no undo, and Time is new. Both apply to every + // selected note (matching the field's "set all" semantics) as one + // command, so a numeric edit is a single Ctrl+Z. + const nn = notes(); + if (field === 'sustain') { + S.history.exec(new ResizeSustainGroupCmd(idxs, idxs.map(() => v))); + } else if (field === 'time') { + // MoveNoteCmd applies per-note deltas; convert the absolute target + // time to a delta per note (no re-sort — same as _editorResnapSelection, + // and hitNote is a linear scan, so order isn't load-bearing). + const dtimes = idxs.map(i => v - (nn[i] ? nn[i].time : 0)); + S.history.exec(new MoveNoteCmd(idxs, dtimes, idxs.map(() => 0), null)); + } else { + return; + } + host.draw(); + host.updateStatus(); +} + +export function editorInspectorSetTech(key, raw) { + const sel = _selectedNotes(); + if (sel.length === 0) return; + // Read-only roll (V4): scalar technique edits mutate n.techniques in + // place (no EditHistory command), so the exec lock never sees them. + // Refuse and bounce the input back to the model value. + if (_rollReadOnly()) { _rollLockNotice(); _renderInspector(); return; } + const bounds = _INSPECTOR_BOUNDS[key]; + if (!bounds) return; + const v = _coerceInspectorNumber(raw, bounds); + if (v === null) { + // Same as `editorInspectorSetField` — bounce the input back + // to the current shared value on rejection so the panel can't + // drift visually from the underlying model. + _renderInspector(); + return; + } + for (const n of sel) { + if (!n.techniques) n.techniques = {}; + n.techniques[key] = v; + // Editing the scalar peak must keep any authored curve consistent + // (renderers/graders read bnv as authoritative): rescale the curve to + // the new peak, or drop it when the peak is 0 / the curve is unscalable. + if (key === 'bend' && sanitizeBendCurve(n.techniques.bend_values)) { + const scaled = v > 0 + ? rescaleBendCurveToPeak(n.techniques.bend_values, v) + : null; + n.techniques.bend_values = scaled; + // bnv rounds points to 0.1, so a non-0.1 `v` (e.g. 0.25) would leave + // bn disagreeing with the curve's real peak. Snap bn to the curve. + if (scaled) n.techniques.bend = scaled.reduce((m, p) => Math.max(m, p.v), 0); + } + } + host.draw(); + host.updateStatus(); +} + +export function editorInspectorSetBendIntent(raw) { + const idxs = [...(S.sel || [])]; + if (!idxs.length) return; + const bt = Number(raw) || 0; + S.history.exec(new SetBendIntentCmd(idxs, bt)); + host.draw(); + host.updateStatus(); + _renderInspector(); +} + +export function editorOpenBendCurve() { + const idxs = [...(S.sel || [])]; + if (!idxs.length) return; + // promptBend re-derives the target set from S.sel; pass any selected index. + host.promptBend(idxs[0]); +} + +export function editorInspectorSetFlag(key, on) { + const sel = _selectedNotes(); + if (sel.length === 0) return; + // Read-only roll (V4): flag toggles mutate n.techniques directly — same + // bypass as editorInspectorSetTech. Refuse and re-render to reset the box. + if (_rollReadOnly()) { _rollLockNotice(); _renderInspector(); return; } + for (const n of sel) { + if (!n.techniques) n.techniques = {}; + n.techniques[key] = !!on; + } + host.draw(); + host.updateStatus(); +} + +// ─── Teaching marks (§6.2.2) ──────────────────────────────────────── +// Author fg (fret-hand finger), sd (scale-degree override) and ch (strum +// group) on the current selection. Each is one undoable batch edit +// (SetTeachingMarkCmd). Display only — these never affect grading. +function _applyTeachingMark(key, value) { + const idxs = [...(S.sel || [])]; + if (!idxs.length) return; + S.history.exec(new SetTeachingMarkCmd(idxs, key, value)); + host.draw(); + host.updateStatus(); + _renderInspector(); +} + +export function editorInspectorSetFretFinger(raw) { + const v = Math.trunc(Number(raw)); + if (!Number.isFinite(v)) return; + _applyTeachingMark('fret_finger', Math.max(-1, Math.min(4, v))); +} + +export function editorInspectorSetScaleDegree(raw) { + const s = String(raw).trim(); + // Empty input clears the override back to -1 (auto/unset). + const v = s === '' ? -1 : Math.trunc(Number(s)); + if (!Number.isFinite(v)) { _renderInspector(); return; } + _applyTeachingMark('scale_degree', Math.max(-1, Math.min(11, v))); +} + +// "Group as strum": assign every selected note a shared, unused ch key so the +// highway renders them as one strum/rake gesture (pkd gives direction). +export function editorGroupAsStrum() { + if (!(S.sel && S.sel.size)) return; + _applyTeachingMark('strum_group', nextUnusedStrumGroup(notes())); +} + +// "Ungroup": clear the strum-group key on the selection (-1 = not grouped). +export function editorUngroupStrum() { + if (!(S.sel && S.sel.size)) return; + _applyTeachingMark('strum_group', -1); +} + +// ─── Chord inspector (E1) ─────────────────────────────────────────── +// Resolve the current selection to a chord and its width-L fret pattern + +// matching chord template, or null when the selection isn't a chord. +// +// The fret pattern is built from the FULL save-time group — every note sharing +// the selection's `time.toFixed(4)` key — not just the selected subset, and +// using the same key reconstructChords() groups by. That way a partial +// selection (e.g. rectangle-selecting 2 of a 3-note chord) still authors the +// triad's fret key, so the metadata survives the save-time rebuild instead of +// being dropped onto a dyad key reconstructChords() never produces. +export function _selectedChordContext(sel) { + sel = sel || _selectedNotes(); + if (sel.length < 2) return null; + if (!S.arrangements.length) return null; + const arr = S.arrangements[S.currentArr]; + if (!arr) return null; + // The selection must fall within a single save-time group; reconstructChords + // keys groups on `time.toFixed(4)`, so match that exactly. + const key = sel[0].time.toFixed(4); + for (const n of sel) { if (n.time.toFixed(4) !== key) return null; } + const L = lanes(); + const frets = new Array(L).fill(-1); + const group = []; + for (const n of notes()) { + if (n.time.toFixed(4) !== key) continue; + group.push(n); + if (n.string >= 0 && n.string < L) frets[n.string] = n.fret; + } + if (group.length < 2) return null; // single note at this time isn't a chord + const fretKey = _fretKeyForL(frets, L); + let tmpl = null; + for (const ct of (arr.chord_templates || [])) { + if (ct && Array.isArray(ct.frets) && _fretKeyForL(ct.frets, L) === fretKey) { tmpl = ct; break; } + } + // Harmony function rides the instance — carried on the chord's notes (_fn). + const fn = _groupFn(group); + return { arr, L, frets, fretKey, tmpl, key, fn, group }; +} + +function _chordAttrEsc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(/"/g, '"') + .replace(//g, '>'); +} + +// Build the chord-section HTML, or '' when the selection isn't a chord. Finger +// pickers are shown only for sounding strings (fret >= 0); unused strings carry +// no finger. +function _chordInspectorHtml(ctx) { + if (!ctx) return ''; + const t = ctx.tmpl; + const name = t && typeof t.name === 'string' ? t.name : ''; + const displayName = t && typeof t.displayName === 'string' ? t.displayName : ''; + const arp = !!(t && t.arp); + const voicing = t && typeof t.voicing === 'string' ? t.voicing : ''; + // Harmony function (§6.3.1) rides the chord instance, not the template. + const fn = ctx.fn || {}; + const fnRn = typeof fn.rn === 'string' ? fn.rn : ''; + const fnQ = typeof fn.q === 'string' ? fn.q : ''; + const fnDeg = Number.isInteger(fn.deg) ? String(fn.deg) : ''; + const VOICINGS = ['', 'open', 'triad', 'shell', 'drop2', 'drop3', 'barre']; + const voicingOpts = VOICINGS.map(v => + ``).join(''); + // §6.6 CAGED shape + guide tones (template fields, display only). + const caged = _sanitizeCaged(t && t.caged); + const CAGED_SHAPES = ['', 'C', 'A', 'G', 'E', 'D']; + const cagedOpts = CAGED_SHAPES.map(v => + ``).join(''); + const guideTonesStr = _sanitizeGuideTones(t && t.guideTones).join(', '); + + let fingersHtml = ''; + for (let i = 0; i < ctx.L; i++) { + const fr = ctx.frets[i]; + if (fr < 0) continue; + const cur = (t && Array.isArray(t.fingers) && Number.isFinite(t.fingers[i])) ? t.fingers[i] : -1; + const opt = (v, label) => ``; + fingersHtml += ` + `; + } + + return ` +
+
Chord
+ + + ${fingersHtml} + + + + +
+
Function
+ + + +
+
`; +} + +// Apply a patch (subset of {name, displayName, fingers, arp}) to the selected +// chord's template via the undo history. +function _editorChordPatch(patch) { + const ctx = _selectedChordContext(); + if (!ctx) return; + S.history.exec(new EditChordTemplateCmd(S.currentArr, ctx.L, ctx.frets, patch)); + host.draw(); + _renderInspector(); +} + +export function editorChordSetName(raw) { return _editorChordPatch({ name: String(raw == null ? '' : raw).trim() }); } +export function editorChordSetDisplayName(raw) { return _editorChordPatch({ displayName: String(raw == null ? '' : raw).trim() }); } +export function editorChordToggleArp(on) { return _editorChordPatch({ arp: !!on }); } +export function editorChordSetVoicing(raw) { return _editorChordPatch({ voicing: String(raw == null ? '' : raw).trim() }); } +// §6.6 CAGED shape + guide tones — enum/range-guarded, routed as one undoable +// template patch like voicing (sanitizers live in the @pure:chord-relink block). +export function editorChordSetCaged(raw) { return _editorChordPatch({ caged: _sanitizeCaged(raw) }); } +export function editorChordSetGuideTones(raw) { return _editorChordPatch({ guideTones: _parseGuideTones(raw) }); } + +// Apply a partial harmony-function patch ({rn?|q?|deg?}) to the selected +// chord's instance (the notes at its time), merged onto the current fn, via the +// undo history. fn rides the instance, so it is NOT a template patch. +function _editorChordFnPatch(patch) { + const ctx = _selectedChordContext(); + if (!ctx) return; + S.history.exec(new EditChordFnCmd(S.currentArr, ctx.key, ctx.fn, patch)); + host.draw(); + _renderInspector(); +} + +export function editorChordSetFnRn(raw) { return _editorChordFnPatch({ rn: String(raw == null ? '' : raw).trim() }); } +export function editorChordSetFnQuality(raw) { return _editorChordFnPatch({ q: String(raw == null ? '' : raw).trim() }); } +export function editorChordSetFnDeg(raw) { + const s = String(raw == null ? '' : raw).trim(); + // Blank clears deg; otherwise parse and clamp-validate to 0..11 (else clear). + const d = s === '' ? null : parseInt(s, 10); + _editorChordFnPatch({ deg: (Number.isInteger(d) && d >= 0 && d <= 11) ? d : null }); +} +export function editorChordSetFinger(stringIdx, raw) { + const ctx = _selectedChordContext(); + if (!ctx) return; + const i = Number(stringIdx); + if (!Number.isInteger(i) || i < 0 || i >= ctx.L) return; + const v = parseInt(raw, 10); + if (![-1, 0, 1, 2, 3, 4].includes(v)) { _renderInspector(); return; } + // Fingers persist as one width-L array — start from the current template + // (or a blank width-L array) and change just this string. + const base = _normFingers(ctx.tmpl && ctx.tmpl.fingers, ctx.L); + base[i] = v; + _editorChordPatch({ fingers: base }); +} + diff --git a/src/main.js b/src/main.js index a8c8706c..ffd6b311 100644 --- a/src/main.js +++ b/src/main.js @@ -20,12 +20,11 @@ import { _editorEscHtml, _editorPromptText, _installModalKeyboard, setStatus } f import { hitNote, hitNoteEdge } from './hit-test.js'; import { EditHistory } from './history.js'; import { - AddNoteCmd, AddStringCmd, ChangeFretCmd, ChangeFretGroupCmd, - DeleteNotesCmd, EditChordFnCmd, MoveNoteCmd, RemoveStringCmd, - ReplaceArrangementChartCmd, ResizeSustainGroupCmd, SetBendIntentCmd, + AddNoteCmd, AddStringCmd, ChangeFretCmd, ChangeFretGroupCmd, DeleteNotesCmd, + MoveNoteCmd, RemoveStringCmd, ReplaceArrangementChartCmd, ResizeSustainGroupCmd, SetBendShapeCmd, SetPitchedSlideTargetsCmd, SetTeachingMarkCmd, ToggleTechniqueCmd, - _canMoveString, _execAcceptPositions, _execCyclePosition, _execMoveString, - _ROLL_REFUSE_REASONS, _commitAddResolved, _execMoveStringSameFret, _normalizeTuningToLanes, + _ROLL_REFUSE_REASONS, _canMoveString, _commitAddResolved, _execAcceptPositions, + _execCyclePosition, _execMoveString, _execMoveStringSameFret, _normalizeTuningToLanes, _rollAddByPitch, _rollDragPitchMove, _withStableSelection, } from './commands.js'; import { @@ -42,6 +41,15 @@ import { editorRecordMidiDeviceChanged, editorShowRecordMidiModal, editorStartRecordMidi, editorStopRecordMidi, } from './midi-record.js'; +import { + _renderInspector, _selectedChordContext, _selectedNotes, editorChordSetCaged, + editorChordSetDisplayName, editorChordSetFinger, editorChordSetFnDeg, + editorChordSetFnQuality, editorChordSetFnRn, editorChordSetGuideTones, editorChordSetName, + editorChordSetVoicing, editorChordToggleArp, editorGroupAsStrum, + editorInspectorSetBendIntent, editorInspectorSetField, editorInspectorSetFlag, + editorInspectorSetFretFinger, editorInspectorSetScaleDegree, editorInspectorSetTech, + editorOpenBendCurve, editorUngroupStrum, +} from './inspector.js'; import { setHostHooks } from './host.js'; import { MIN_MEASURE, TempoGridCmd, TempoMapCmd, _editorModulateTempoAtSelection, @@ -56,10 +64,9 @@ import { _tempoSetMeasureBpmPure, _tempoSyncAtX, } from './tempo.js'; import { - AddAnchorCmd, AddHandshapeCmd, AddToneChangeCmd, EditChordTemplateCmd, RemoveAnchorCmd, - RemoveHandshapeCmd, RemoveToneChangeCmd, - _anchorLaneTopY, _anchorsAreDirty, _currentAnchorArr, _currentToneArr, - _ensureTones, _handshapeLaneTopY, _readAnchorSnapshot, + AddAnchorCmd, AddHandshapeCmd, AddToneChangeCmd, RemoveAnchorCmd, RemoveHandshapeCmd, + RemoveToneChangeCmd, _anchorLaneTopY, _anchorsAreDirty, _currentAnchorArr, + _currentToneArr, _ensureTones, _handshapeLaneTopY, _readAnchorSnapshot, _stripToneInternals, _tonesAreDirty, _updateTonesButtonVisibility, drawAnchorLane, drawHandshapeLane, drawToneLane, editorApplyTonesModal, editorHideTonesModal, editorShowTonesModal, onAnchorLaneContextMenu, onAnchorLaneMouseDown, @@ -137,33 +144,12 @@ import { midiToY, noteToMidi, pianoLaneCount, updatePianoRange, viewFor, yToMidi, } from './keys.js'; import { - BEND_INTENTS, - FRET_FINGER_OPTIONS, - _isSuggested, - _resizeSustainsForDeltaPure, - _resizeTargetIndicesPure, - _restoreSuggestedMarks, - _saveSuggestedMarks, - _suggestedCount, - _suggestedStorageKeyPure, - bendPresetCurve, - chords, - nextUnusedStrumGroup, - notes, - rescaleBendCurveToPeak, - sanitizeBendCurve, + BEND_INTENTS, _isSuggested, _resizeSustainsForDeltaPure, _resizeTargetIndicesPure, + _restoreSuggestedMarks, _saveSuggestedMarks, _suggestedCount, _suggestedStorageKeyPure, + bendPresetCurve, chords, notes, rescaleBendCurveToPeak, sanitizeBendCurve, } from './notes.js'; import { - _fretKeyForL, - _groupFn, - _handshapesAreDirty, - _normFingers, - _normalizeHandshape, - _parseGuideTones, - _sanitizeCaged, - _sanitizeGuideTones, - flattenChords, - reconstructChords, + _handshapesAreDirty, _normalizeHandshape, flattenChords, reconstructChords, } from './chords.js'; (function () { @@ -1329,6 +1315,8 @@ setHostHooks({ refreshTempoMapButton: _refreshTempoMapButton, refreshPartsViewButton: _refreshPartsViewButton, finalizeActiveDrag: _finalizeActiveDrag, + promptBend, + scheduleCanvasResize: _scheduleCanvasResize, loadCDLC, loadAudio, kickLibraryRescan: _kickLibraryRescan, @@ -1339,6 +1327,25 @@ setHostHooks({ window.editorHideRecordMidiModal = editorHideRecordMidiModal; window.editorRecordMidiDeviceChanged = editorRecordMidiDeviceChanged; +window.editorChordSetCaged = editorChordSetCaged; +window.editorChordSetDisplayName = editorChordSetDisplayName; +window.editorChordSetFinger = editorChordSetFinger; +window.editorChordSetFnDeg = editorChordSetFnDeg; +window.editorChordSetFnQuality = editorChordSetFnQuality; +window.editorChordSetFnRn = editorChordSetFnRn; +window.editorChordSetGuideTones = editorChordSetGuideTones; +window.editorChordSetName = editorChordSetName; +window.editorChordSetVoicing = editorChordSetVoicing; +window.editorChordToggleArp = editorChordToggleArp; +window.editorGroupAsStrum = editorGroupAsStrum; +window.editorInspectorSetBendIntent = editorInspectorSetBendIntent; +window.editorInspectorSetField = editorInspectorSetField; +window.editorInspectorSetFlag = editorInspectorSetFlag; +window.editorInspectorSetFretFinger = editorInspectorSetFretFinger; +window.editorInspectorSetScaleDegree = editorInspectorSetScaleDegree; +window.editorInspectorSetTech = editorInspectorSetTech; +window.editorOpenBendCurve = editorOpenBendCurve; +window.editorUngroupStrum = editorUngroupStrum; window.editorShowRecordMidiModal = editorShowRecordMidiModal; window.editorStartRecordMidi = editorStartRecordMidi; window.editorStopRecordMidi = editorStopRecordMidi; @@ -5840,646 +5847,6 @@ function updateStatus() { setStatus('Ready'); } -// ════════════════════════════════════════════════════════════════════ -// Inspector panel — right-side note attribute editor (PR3b of the -// tones+notation UI follow-up). Reflects S.sel; mutations apply to -// every selected note so multi-select bulk edits work without a new -// command class. -// ════════════════════════════════════════════════════════════════════ - -// All boolean technique flags the inspector exposes. The label is what -// the UI shows; the key matches the `techniques` dict on a note. -const _INSPECTOR_FLAGS = [ - { key: 'hammer_on', label: 'Hammer-On' }, - { key: 'pull_off', label: 'Pull-Off' }, - { key: 'palm_mute', label: 'Palm Mute' }, - { key: 'fret_hand_mute', label: 'Fret-Hand Mute' }, - { key: 'mute', label: 'String Mute' }, - { key: 'harmonic', label: 'Harmonic' }, - { key: 'harmonic_pinch', label: 'Pinch Harmonic' }, - { key: 'accent', label: 'Accent' }, - { key: 'vibrato', label: 'Vibrato' }, - { key: 'tremolo', label: 'Tremolo' }, - { key: 'tap', label: 'Tap' }, - { key: 'slap', label: 'Slap' }, - { key: 'pluck', label: 'Pop (Pluck)' }, - { key: 'link_next', label: 'Link Next' }, - { key: 'ignore', label: 'Ignore' }, -]; - -function _selectedNotes() { - if (!S.sel || S.sel.size === 0) return []; - const nn = notes(); - return [...S.sel].map(i => nn[i]).filter(Boolean); -} - -// Reduce a getter across the selection: returns the shared value, or -// `null` when the selection is mixed. Used to render either a concrete -// value or the "(mixed)" placeholder. -function _selSharedValue(sel, getter, eq) { - eq = eq || ((a, b) => a === b); - if (sel.length === 0) return null; - const first = getter(sel[0]); - for (let i = 1; i < sel.length; i++) { - if (!eq(getter(sel[i]), first)) return null; - } - return first; -} - -function _renderInspector() { - const el = document.getElementById('editor-inspector'); - if (!el) return; - const sel = _selectedNotes(); - const wasVisible = !el.classList.contains('hidden'); - if (sel.length === 0) { - if (wasVisible) { - el.classList.add('hidden'); - el.innerHTML = ''; - // Hiding the panel grows the canvas wrap back to full - // width — without a resize the canvas backing buffer keeps - // the old narrower width and we render into a stale region. - _scheduleCanvasResize(); - } - return; - } - if (!wasVisible) { - el.classList.remove('hidden'); - // Showing the panel shrinks the canvas wrap; refresh the canvas - // backing dimensions so notes stay inside the visible region - // instead of being clipped past the panel's left edge. - _scheduleCanvasResize(); - } - - // Header: condensed summary of the selection. - const sharedString = _selSharedValue(sel, n => n.string); - const sharedFret = _selSharedValue(sel, n => n.fret); - const sharedTime = _selSharedValue(sel, n => n.time); - const sharedSustain = _selSharedValue(sel, n => n.sustain || 0); - const headerCount = sel.length === 1 - ? '1 note selected' - : `${sel.length} notes selected`; - const mixed = '(mixed)'; - const fmtStr = v => v === null ? mixed : v; - const fmtTime = v => v === null ? mixed : v.toFixed(3); - const fmtSus = v => v === null ? mixed : (v || 0).toFixed(3); - - // Numeric inputs — when the selection has a shared value, prefill - // it; when mixed, leave blank and let the user supply a new value - // that applies to all. - const sharedBend = _selSharedValue(sel, n => (n.techniques && n.techniques.bend) || 0); - const sharedBt = _selSharedValue(sel, n => (n.techniques && n.techniques.bend_intent) || 0); - const sharedSlide = _selSharedValue(sel, n => { - const v = n.techniques && n.techniques.slide_to; - return v === undefined ? -1 : v; - }); - const sharedSlideU = _selSharedValue(sel, n => { - const v = n.techniques && n.techniques.slide_unpitch_to; - return v === undefined ? -1 : v; - }); - // Teaching marks (§6.2.2): fret-hand finger, scale-degree override, strum - // group. Default to -1 (unset) so a note that never authored them reads as - // unset rather than "mixed" against an authored sibling. - const sharedFinger = _selSharedValue(sel, n => { - const v = n.techniques && n.techniques.fret_finger; - return Number.isInteger(v) ? v : -1; - }); - const sharedScaleDeg = _selSharedValue(sel, n => { - const v = n.techniques && n.techniques.scale_degree; - return Number.isInteger(v) ? v : -1; - }); - const sharedStrum = _selSharedValue(sel, n => { - const v = n.techniques && n.techniques.strum_group; - return Number.isInteger(v) ? v : -1; - }); - const inputVal = v => v === null ? '' : String(v); - - // Chord inspector (E1): when the selection is a chord (>=2 notes sharing a - // time), author the shared chord template — name / displayName / per-string - // fingering / arp. Edits land on the matching `arr.chord_templates` entry - // (created if this chord hasn't been saved yet), which reconstructChords() - // carries through save via relinkChordTemplate. - const chordHtml = _chordInspectorHtml(_selectedChordContext(sel)); - - let html = ` -
-
${headerCount}
-
string: ${fmtStr(sharedString)}
-
fret: ${fmtStr(sharedFret)}
-
time: ${fmtTime(sharedTime)}
-
sustain: ${fmtSus(sharedSustain)}
-
- ${chordHtml} -
- - - - -
- Bend curve - -
- - -
-
-
Teaching marks
- - -
- Strum grp ${sharedStrum === null ? '(mixed)' : (sharedStrum >= 0 ? '#' + sharedStrum : '—')} - - -
-
-
`; - - for (const f of _INSPECTOR_FLAGS) { - const sharedFlag = _selSharedValue(sel, n => !!(n.techniques && n.techniques[f.key])); - // Three states: true / false / null (mixed). HTML's `indeterminate` - // is only set via property, not attribute — handle it after - // injecting via the post-mount pass below. - const checked = sharedFlag === true; - const indeterminate = sharedFlag === null; - html += ` - `; - } - html += `
`; - el.innerHTML = html; - - // Apply indeterminate state to the inputs that need it — the - // attribute alone doesn't work; the JS property does. - for (const cb of el.querySelectorAll('input[type=checkbox][data-indeterminate="1"]')) { - cb.indeterminate = true; - } -} - -// Inspector mutators. All operate on the full S.sel so a multi-select -// edit applies bulk-style. Edits skip the undo history for now — PR3b -// keeps the scope tight; a TechBulkCmd lands when the inspector grows -// to need richer per-edit undo (PR3c handles tone/anchor lanes, where -// undo IS load-bearing). - -// Bounds for the inspector's numeric inputs. Mirrors the limits the -// prompt-based editors (`promptFret`, `promptSlide`, `promptBend`) -// enforce — `type="number" min/max` on the inputs is only a UI hint; -// users can paste / type out-of-range values, so we clamp here too. -const _INSPECTOR_BOUNDS = { - // Time (start position, seconds): non-negative, no upper clamp (a note - // can't sit before the song start; the duration bound is soft). Lets an - // author type a precise onset to align a note to the recording. - time: { min: 0, max: Infinity, integer: false }, - // Sustain has no hard upper bound elsewhere (drag-resize / add-note - // dialog leave it unconstrained), so the inspector matches — only - // the lower clamp matters for input sanity. - sustain: { min: 0, max: Infinity, integer: false }, - bend: { min: 0, max: 3, integer: false }, // half-steps, 3 = +3 semitones - // `emptyAs: -1` matches the prompt semantic ("-1 or empty = no - // slide") so the inspector and `promptSlide` / `promptSlideUnpitch` - // accept the same set of inputs. Without it, deleting the input - // value would be treated as a parse error and silently bounce back. - slide_to: { min: -1, max: 24, integer: true, emptyAs: -1 }, - slide_unpitch_to: { min: -1, max: 24, integer: true, emptyAs: -1 }, -}; - -function _coerceInspectorNumber(rawValue, bounds) { - if (rawValue === null || rawValue === undefined) return null; - const s = String(rawValue).trim(); - if (s === '') { - // Some fields (slide_to, slide_unpitch_to) interpret an empty - // input as a "clear" affordance — match the prompt-based path. - return bounds.emptyAs !== undefined ? bounds.emptyAs : null; - } - let v; - if (bounds.integer) { - // Strict plain-decimal integer regex — matches the - // prompt-based path's `_parseFretInput`. Rejects `1e1`, `1.9`, - // `12abc` so the inspector and the right-click prompt produce - // the same accept/reject decision on identical input. - if (!/^[-+]?\d+$/.test(s)) return null; - v = Number(s); - } else { - // `Number('1e1abc')` is NaN; `parseFloat('1e1abc')` would - // partial-parse to 10. Use `Number(...)` so junk-tail input - // rejects instead of coercing. - v = Number(s); - } - if (!Number.isFinite(v)) return null; - if (v < bounds.min) v = bounds.min; - if (v > bounds.max) v = bounds.max; - return v; -} - -window.editorInspectorSetField = (field, raw) => { - const idxs = _editorCurrentNoteIndices(); - if (!idxs.length) return; - const bounds = _INSPECTOR_BOUNDS[field]; - if (!bounds) return; - const v = _coerceInspectorNumber(raw, bounds); - if (v === null) { - // Reject silently — but re-render so the input snaps back to - // the current shared value instead of leaving the user looking - // at an unapplied edit. - _renderInspector(); - return; - } - // Route through the undo history: the sustain edit used to mutate - // notes in place with no undo, and Time is new. Both apply to every - // selected note (matching the field's "set all" semantics) as one - // command, so a numeric edit is a single Ctrl+Z. - const nn = notes(); - if (field === 'sustain') { - S.history.exec(new ResizeSustainGroupCmd(idxs, idxs.map(() => v))); - } else if (field === 'time') { - // MoveNoteCmd applies per-note deltas; convert the absolute target - // time to a delta per note (no re-sort — same as _editorResnapSelection, - // and hitNote is a linear scan, so order isn't load-bearing). - const dtimes = idxs.map(i => v - (nn[i] ? nn[i].time : 0)); - S.history.exec(new MoveNoteCmd(idxs, dtimes, idxs.map(() => 0), null)); - } else { - return; - } - draw(); - updateStatus(); -}; - -window.editorInspectorSetTech = (key, raw) => { - const sel = _selectedNotes(); - if (sel.length === 0) return; - // Read-only roll (V4): scalar technique edits mutate n.techniques in - // place (no EditHistory command), so the exec lock never sees them. - // Refuse and bounce the input back to the model value. - if (_rollReadOnly()) { _rollLockNotice(); _renderInspector(); return; } - const bounds = _INSPECTOR_BOUNDS[key]; - if (!bounds) return; - const v = _coerceInspectorNumber(raw, bounds); - if (v === null) { - // Same as `editorInspectorSetField` — bounce the input back - // to the current shared value on rejection so the panel can't - // drift visually from the underlying model. - _renderInspector(); - return; - } - for (const n of sel) { - if (!n.techniques) n.techniques = {}; - n.techniques[key] = v; - // Editing the scalar peak must keep any authored curve consistent - // (renderers/graders read bnv as authoritative): rescale the curve to - // the new peak, or drop it when the peak is 0 / the curve is unscalable. - if (key === 'bend' && sanitizeBendCurve(n.techniques.bend_values)) { - const scaled = v > 0 - ? rescaleBendCurveToPeak(n.techniques.bend_values, v) - : null; - n.techniques.bend_values = scaled; - // bnv rounds points to 0.1, so a non-0.1 `v` (e.g. 0.25) would leave - // bn disagreeing with the curve's real peak. Snap bn to the curve. - if (scaled) n.techniques.bend = scaled.reduce((m, p) => Math.max(m, p.v), 0); - } - } - draw(); - updateStatus(); -}; - -window.editorInspectorSetBendIntent = (raw) => { - const idxs = [...(S.sel || [])]; - if (!idxs.length) return; - const bt = Number(raw) || 0; - S.history.exec(new SetBendIntentCmd(idxs, bt)); - draw(); - updateStatus(); - _renderInspector(); -}; - -window.editorOpenBendCurve = () => { - const idxs = [...(S.sel || [])]; - if (!idxs.length) return; - // promptBend re-derives the target set from S.sel; pass any selected index. - promptBend(idxs[0]); -}; - -window.editorInspectorSetFlag = (key, on) => { - const sel = _selectedNotes(); - if (sel.length === 0) return; - // Read-only roll (V4): flag toggles mutate n.techniques directly — same - // bypass as editorInspectorSetTech. Refuse and re-render to reset the box. - if (_rollReadOnly()) { _rollLockNotice(); _renderInspector(); return; } - for (const n of sel) { - if (!n.techniques) n.techniques = {}; - n.techniques[key] = !!on; - } - draw(); - updateStatus(); -}; - -// ─── Teaching marks (§6.2.2) ──────────────────────────────────────── -// Author fg (fret-hand finger), sd (scale-degree override) and ch (strum -// group) on the current selection. Each is one undoable batch edit -// (SetTeachingMarkCmd). Display only — these never affect grading. -function _applyTeachingMark(key, value) { - const idxs = [...(S.sel || [])]; - if (!idxs.length) return; - S.history.exec(new SetTeachingMarkCmd(idxs, key, value)); - draw(); - updateStatus(); - _renderInspector(); -} - -window.editorInspectorSetFretFinger = (raw) => { - const v = Math.trunc(Number(raw)); - if (!Number.isFinite(v)) return; - _applyTeachingMark('fret_finger', Math.max(-1, Math.min(4, v))); -}; - -window.editorInspectorSetScaleDegree = (raw) => { - const s = String(raw).trim(); - // Empty input clears the override back to -1 (auto/unset). - const v = s === '' ? -1 : Math.trunc(Number(s)); - if (!Number.isFinite(v)) { _renderInspector(); return; } - _applyTeachingMark('scale_degree', Math.max(-1, Math.min(11, v))); -}; - -// "Group as strum": assign every selected note a shared, unused ch key so the -// highway renders them as one strum/rake gesture (pkd gives direction). -window.editorGroupAsStrum = () => { - if (!(S.sel && S.sel.size)) return; - _applyTeachingMark('strum_group', nextUnusedStrumGroup(notes())); -}; - -// "Ungroup": clear the strum-group key on the selection (-1 = not grouped). -window.editorUngroupStrum = () => { - if (!(S.sel && S.sel.size)) return; - _applyTeachingMark('strum_group', -1); -}; - -// ─── Chord inspector (E1) ─────────────────────────────────────────── -// Resolve the current selection to a chord and its width-L fret pattern + -// matching chord template, or null when the selection isn't a chord. -// -// The fret pattern is built from the FULL save-time group — every note sharing -// the selection's `time.toFixed(4)` key — not just the selected subset, and -// using the same key reconstructChords() groups by. That way a partial -// selection (e.g. rectangle-selecting 2 of a 3-note chord) still authors the -// triad's fret key, so the metadata survives the save-time rebuild instead of -// being dropped onto a dyad key reconstructChords() never produces. -function _selectedChordContext(sel) { - sel = sel || _selectedNotes(); - if (sel.length < 2) return null; - if (!S.arrangements.length) return null; - const arr = S.arrangements[S.currentArr]; - if (!arr) return null; - // The selection must fall within a single save-time group; reconstructChords - // keys groups on `time.toFixed(4)`, so match that exactly. - const key = sel[0].time.toFixed(4); - for (const n of sel) { if (n.time.toFixed(4) !== key) return null; } - const L = lanes(); - const frets = new Array(L).fill(-1); - const group = []; - for (const n of notes()) { - if (n.time.toFixed(4) !== key) continue; - group.push(n); - if (n.string >= 0 && n.string < L) frets[n.string] = n.fret; - } - if (group.length < 2) return null; // single note at this time isn't a chord - const fretKey = _fretKeyForL(frets, L); - let tmpl = null; - for (const ct of (arr.chord_templates || [])) { - if (ct && Array.isArray(ct.frets) && _fretKeyForL(ct.frets, L) === fretKey) { tmpl = ct; break; } - } - // Harmony function rides the instance — carried on the chord's notes (_fn). - const fn = _groupFn(group); - return { arr, L, frets, fretKey, tmpl, key, fn, group }; -} - -function _chordAttrEsc(s) { - return String(s == null ? '' : s) - .replace(/&/g, '&').replace(/"/g, '"') - .replace(//g, '>'); -} - -// Build the chord-section HTML, or '' when the selection isn't a chord. Finger -// pickers are shown only for sounding strings (fret >= 0); unused strings carry -// no finger. -function _chordInspectorHtml(ctx) { - if (!ctx) return ''; - const t = ctx.tmpl; - const name = t && typeof t.name === 'string' ? t.name : ''; - const displayName = t && typeof t.displayName === 'string' ? t.displayName : ''; - const arp = !!(t && t.arp); - const voicing = t && typeof t.voicing === 'string' ? t.voicing : ''; - // Harmony function (§6.3.1) rides the chord instance, not the template. - const fn = ctx.fn || {}; - const fnRn = typeof fn.rn === 'string' ? fn.rn : ''; - const fnQ = typeof fn.q === 'string' ? fn.q : ''; - const fnDeg = Number.isInteger(fn.deg) ? String(fn.deg) : ''; - const VOICINGS = ['', 'open', 'triad', 'shell', 'drop2', 'drop3', 'barre']; - const voicingOpts = VOICINGS.map(v => - ``).join(''); - // §6.6 CAGED shape + guide tones (template fields, display only). - const caged = _sanitizeCaged(t && t.caged); - const CAGED_SHAPES = ['', 'C', 'A', 'G', 'E', 'D']; - const cagedOpts = CAGED_SHAPES.map(v => - ``).join(''); - const guideTonesStr = _sanitizeGuideTones(t && t.guideTones).join(', '); - - let fingersHtml = ''; - for (let i = 0; i < ctx.L; i++) { - const fr = ctx.frets[i]; - if (fr < 0) continue; - const cur = (t && Array.isArray(t.fingers) && Number.isFinite(t.fingers[i])) ? t.fingers[i] : -1; - const opt = (v, label) => ``; - fingersHtml += ` - `; - } - - return ` -
-
Chord
- - - ${fingersHtml} - - - - -
-
Function
- - - -
-
`; -} - -// Apply a patch (subset of {name, displayName, fingers, arp}) to the selected -// chord's template via the undo history. -function _editorChordPatch(patch) { - const ctx = _selectedChordContext(); - if (!ctx) return; - S.history.exec(new EditChordTemplateCmd(S.currentArr, ctx.L, ctx.frets, patch)); - draw(); - _renderInspector(); -} - -window.editorChordSetName = (raw) => _editorChordPatch({ name: String(raw == null ? '' : raw).trim() }); -window.editorChordSetDisplayName = (raw) => _editorChordPatch({ displayName: String(raw == null ? '' : raw).trim() }); -window.editorChordToggleArp = (on) => _editorChordPatch({ arp: !!on }); -window.editorChordSetVoicing = (raw) => _editorChordPatch({ voicing: String(raw == null ? '' : raw).trim() }); -// §6.6 CAGED shape + guide tones — enum/range-guarded, routed as one undoable -// template patch like voicing (sanitizers live in the @pure:chord-relink block). -window.editorChordSetCaged = (raw) => _editorChordPatch({ caged: _sanitizeCaged(raw) }); -window.editorChordSetGuideTones = (raw) => _editorChordPatch({ guideTones: _parseGuideTones(raw) }); - -// Apply a partial harmony-function patch ({rn?|q?|deg?}) to the selected -// chord's instance (the notes at its time), merged onto the current fn, via the -// undo history. fn rides the instance, so it is NOT a template patch. -function _editorChordFnPatch(patch) { - const ctx = _selectedChordContext(); - if (!ctx) return; - S.history.exec(new EditChordFnCmd(S.currentArr, ctx.key, ctx.fn, patch)); - draw(); - _renderInspector(); -} - -window.editorChordSetFnRn = (raw) => _editorChordFnPatch({ rn: String(raw == null ? '' : raw).trim() }); -window.editorChordSetFnQuality = (raw) => _editorChordFnPatch({ q: String(raw == null ? '' : raw).trim() }); -window.editorChordSetFnDeg = (raw) => { - const s = String(raw == null ? '' : raw).trim(); - // Blank clears deg; otherwise parse and clamp-validate to 0..11 (else clear). - const d = s === '' ? null : parseInt(s, 10); - _editorChordFnPatch({ deg: (Number.isInteger(d) && d >= 0 && d <= 11) ? d : null }); -}; -window.editorChordSetFinger = (stringIdx, raw) => { - const ctx = _selectedChordContext(); - if (!ctx) return; - const i = Number(stringIdx); - if (!Number.isInteger(i) || i < 0 || i >= ctx.L) return; - const v = parseInt(raw, 10); - if (![-1, 0, 1, 2, 3, 4].includes(v)) { _renderInspector(); return; } - // Fingers persist as one width-L array — start from the current template - // (or a blank width-L array) and change just this string. - const base = _normFingers(ctx.tmpl && ctx.tmpl.fingers, ctx.L); - base[i] = v; - _editorChordPatch({ fingers: base }); -}; - function updateZoomDisplay() { const el = document.getElementById('editor-zoom-display'); if (el) el.textContent = Math.round(S.zoom); diff --git a/tests/inspector_time.test.mjs b/tests/inspector_time.test.mjs index da681c22..f8b3d2ea 100644 --- a/tests/inspector_time.test.mjs +++ b/tests/inspector_time.test.mjs @@ -22,21 +22,26 @@ import { S as realS } from '../src/state.js'; import { EditHistory } from '../src/history.js'; import { seedState, trackHooks } from './_history_env.mjs'; -const src = fs.readFileSync(new URL('../src/main.js', import.meta.url), 'utf8'); - -// Brace-match extraction of a named class/function/const (the waveform_render -// harness pattern) — drives the real source, no re-implementation. -function extractNamed(decl) { - const start = src.indexOf(decl); - assert.ok(start >= 0, `not found: ${decl}`); - const open = src.indexOf('{', start); +// The inspector's bounds table, coercion helper and field dispatcher are +// module-private (the dispatcher only reaches the page as a re-attached +// window.*), so they are still sliced — with the `export` keyword stripped, +// since that is a SyntaxError inside `new Function`. +const inspSrc = fs.readFileSync(new URL('../src/inspector.js', import.meta.url), 'utf8'); +const unexport = (code) => code.replace(/^export\s+/gm, ''); +function extractFromInspector(decl) { + const start = inspSrc.indexOf(decl); + assert.ok(start >= 0, `not found in inspector.js: ${decl}`); + const open = inspSrc.indexOf('{', start); let depth = 0; - for (let i = open; i < src.length; i++) { - if (src[i] === '{') depth++; - else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1); + for (let i = open; i < inspSrc.length; i++) { + if (inspSrc[i] === '{') depth++; + else if (inspSrc[i] === '}' && --depth === 0) return unexport(inspSrc.slice(start, i + 1)); } throw new Error(`unbalanced braces for ${decl}`); } + +// Brace-match extraction of a named class/function/const (the waveform_render +// harness pattern) — drives the real source, no re-implementation. // The commands are real imports and resolve their target through notes(), which // reads the REAL S. Seed one arrangement and point it at CURRENT so the cases // can keep asserting on the array object they built. @@ -49,8 +54,8 @@ const setCurrent = (arr) => { CURRENT = arr; realS.arrangements[0].notes = arr; // helper are still in main.js, so they are still brace-matched out of it. const api = new Function( '"use strict";' - + extractNamed('const _INSPECTOR_BOUNDS =') + '\n' - + extractNamed('function _coerceInspectorNumber') + '\n' + + extractFromInspector('export const _INSPECTOR_BOUNDS =') + '\n' + + extractFromInspector('export function _coerceInspectorNumber') + '\n' + 'return { _INSPECTOR_BOUNDS, _coerceInspectorNumber };' )(); const { _INSPECTOR_BOUNDS, _coerceInspectorNumber } = api; @@ -139,28 +144,27 @@ t('time: a note already at the target gets a zero delta (no-op move)', () => { let DISPATCH_NOTES = []; const dispatchS = { sel: new Set(), drumEditMode: false, tempoMapMode: false, history: null }; let renderCount = 0; // # of _renderInspector() calls (reject branch) -const win = {}; // captures `window.editorInspectorSetField = …` // `dispatchS` stays sandbox-local: editorInspectorSetField reads its `sel` and // mode flags. EditHistory itself closes over the real `S` (seeded above), which // only supplies the arrangement tag — irrelevant to these cases. -new Function( - 'notes', 'S', 'draw', 'updateStatus', '_renderInspector', 'window', +// The dispatcher reaches main.js through `host` now, and calls the module-local +// _renderInspector directly — both are injected here, so the reject branch's +// re-render stays observable. +const setField = new Function( + 'notes', 'S', 'host', '_renderInspector', 'MoveNoteCmd', 'ResizeSustainGroupCmd', '"use strict";' - + extractNamed('const _INSPECTOR_BOUNDS =') + '\n' - + extractNamed('function _coerceInspectorNumber') + '\n' - + extractNamed('function _editorCurrentNoteIndices') + '\n' - + extractNamed('window.editorInspectorSetField =') + '\n' - + 'return {};' + + extractFromInspector('export const _INSPECTOR_BOUNDS =') + '\n' + + extractFromInspector('export function _coerceInspectorNumber') + '\n' + + extractFromInspector('export function editorInspectorSetField') + '\n' + + 'return editorInspectorSetField;' )( () => DISPATCH_NOTES, dispatchS, - () => {}, () => {}, // editorInspectorSetField's own draw/updateStatus + { draw() {}, updateStatus() {}, editorCurrentNoteIndices: () => [...dispatchS.sel] }, () => { renderCount++; }, - win, MoveNoteCmd, ResizeSustainGroupCmd, ); -const setField = win.editorInspectorSetField; // Fresh notes + selection + history per case. // `dispatchS` stays sandbox-local for editorInspectorSetField's `sel` and mode diff --git a/tests/inspector_xss.test.mjs b/tests/inspector_xss.test.mjs new file mode 100644 index 00000000..2fd3add1 --- /dev/null +++ b/tests/inspector_xss.test.mjs @@ -0,0 +1,61 @@ +/* + * The inspector escapes note-derived values before assigning innerHTML. + * + * A feedpak is an untrusted file. The server's _note() coerces string/fret to + * ints (routes.py), so a hostile value cannot reach the client through the load + * path today — but the panel must not DEPEND on that: a note that ever arrived + * un-coerced would inject markup. This drives the real _renderInspector with a + * hostile fret and asserts the value is escaped in the innerHTML it writes + * (CodeRabbit, #176). + * + * Run: node tests/inspector_xss.test.mjs + */ +import assert from 'node:assert'; +import { _renderInspector } from '../src/inspector.js'; +import { S } from '../src/state.js'; + +const PAYLOAD = ''; + +// Capture the innerHTML the panel writes, with no jsdom. `_renderInspector` +// only needs #editor-inspector; give it a stub that records every assignment. +let lastHtml = ''; +const panel = { + _html: '', + get innerHTML() { return this._html; }, + set innerHTML(v) { this._html = v; lastHtml = v; }, + classList: { contains: () => false, add() {}, remove() {} }, + querySelectorAll: () => [], +}; +globalThis.document = { getElementById: (id) => (id === 'editor-inspector' ? panel : null) }; + +// A single selected note whose fret is the payload — exactly what would arrive +// from a persisted note that dodged coercion. +Object.assign(S, { + arrangements: [{ id: 'a1', name: 'Lead', notes: [ + { time: 0, string: PAYLOAD, fret: PAYLOAD, sustain: 0, techniques: {} }, + ] }], + currentArr: 0, + sel: new Set([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('the hostile fret does not survive as a raw tag', () => { + _renderInspector(); + assert.ok(lastHtml.length > 0, 'the panel rendered something'); + assert.ok(!/ tag reached innerHTML — the value was not escaped:\n' + lastHtml.slice(0, 400)); +}); + +t('the payload is present, but as escaped entities', () => { + // It should still be visible to the user — escaped, not stripped. + assert.ok(lastHtml.includes('<img') || lastHtml.includes('<'), + 'the payload was neither escaped nor present; expected <img…'); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +if (fail) process.exit(1); diff --git a/tests/view_switcher.test.mjs b/tests/view_switcher.test.mjs index 0a579216..cb3626ba 100644 --- a/tests/view_switcher.test.mjs +++ b/tests/view_switcher.test.mjs @@ -261,6 +261,27 @@ t('read-only roll: SONG-scope undo still works (drum edit reverts)', () => { // (regressions for #119: these bypass EditHistory, so the exec lock // alone can't stop them — each entry point is guarded at its source). +// The inspector moved to src/inspector.js, where its handlers are exported +// function declarations rather than `window.NAME = (…) => {…}` arrows, and reach +// main.js through `host`. Same body, different header. +const inspSrc = fs.readFileSync(new URL('../src/inspector.js', import.meta.url), 'utf8'); +function extractInspectorFn(name, globals) { + const marker = 'export function ' + name + '('; + const start = inspSrc.indexOf(marker); + assert.ok(start >= 0, `export function ${name} must exist in inspector.js`); + const open = inspSrc.indexOf('{', start); + let depth = 0, end = -1; + for (let i = open; i < inspSrc.length; i++) { + if (inspSrc[i] === '{') depth++; + else if (inspSrc[i] === '}' && --depth === 0) { end = i; break; } + } + assert.ok(end > 0, `unbalanced braces extracting ${name}`); + const decl = inspSrc.slice(start, end + 1).replace(/^export\s+/, ''); + const names = Object.keys(globals); + const fn = new Function(...names, '"use strict";' + decl + '\nreturn ' + name + ';'); + return fn(...names.map(k => globals[k])); +} + // Extract a `window.NAME = (...) => { ... };` arrow assignment and rebuild // it as a callable with the named globals stubbed in. function extractWinFn(name, globals) { @@ -304,13 +325,12 @@ t('read-only roll: inspector editorInspectorSetFlag does not mutate the fretted const note = { string: 0, fret: 3, techniques: {} }; const locked = { value: true }; let notices = 0, renders = 0; - const setFlag = extractWinFn('editorInspectorSetFlag', { + const setFlag = extractInspectorFn('editorInspectorSetFlag', { _selectedNotes: () => [note], _rollReadOnly: () => locked.value, _rollLockNotice: () => { notices++; }, _renderInspector: () => { renders++; }, - draw: () => {}, - updateStatus: () => {}, + host: { draw() {}, updateStatus() {} }, }); setFlag('accent', true); assert.strictEqual(note.techniques.accent, undefined, 'no write while read-only');