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 = `
+
`;
+
+ 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 = `
-
`;
-
- 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');