From c9ab2d8380d518aee451a606fa9675bec3589a67 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Thu, 9 Jul 2026 22:10:58 +0200 Subject: [PATCH 1/2] refactor(editor): move the annotation lanes to src/annotation-lanes.js (R2, step 16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/main.js 17,756 -> 16,078. Nineteenth module; the graph stays acyclic. 1,678 lines — the tone lane, the anchor lane and the handshape lane, which turned out to be a contiguous tail of the IIFE. They move together because they lean on each other: the handshape lane positions itself off _anchorLaneTopY, and both it and the anchor lane share _currentAnchorArr. Split apart, those would be cross-module imports for no gain. main.js keeps the canvas event routing (deciding which strip is under the cursor) and forwards to the on*LaneMouse* handlers. Four main.js symbols travel back — draw, hideContextMenu, snapTime, _editorPromptText — and would close a cycle, so they arrive through setLaneHooks(). snapTime stays behind because its onset-snap path reaches _ensureOnsets and the onset cache; _editorPromptText stays because it owns a modal and the shared _editorPromptCancel handle. Neither is a lane concern. TONE_LANE_H moved to geometry.js, where ANCHOR_LANE_H and HS_LANE_H already live. The tones modal's three window.* handlers became exported functions that main.js re-attaches: a top-level `window.x =` throws when the module is imported under node. Two internal call sites that reached back through window.editorHideTonesModal() now call the module-local function (Codex). Also fixes a regression this refactor's own predecessors introduced. `draw` is reassigned near the bottom of main.js to a wrapper that refreshes seven toolbar buttons before repainting. setHistoryHooks() and setDrumHooks() were handed the bare identifier, so they captured the ORIGINAL function at wiring time; every undo, redo and drum-density toggle has been skipping those refreshes since #165/#166. The canvas repaints either way, which is exactly why it went unnoticed — the only visible symptom is the drum-density button keeping its "Rows: Full" label after the grid has collapsed to Compact. All three hook sites now take `_drawLive = (...args) => draw(...args)`, resolving the live binding at call time as the in-IIFE call sites always did. Found by Codex; verify_drum.py now asserts the label, and fails on the pre-fix code. Tests: anchor_authoring (CJS -> .mjs) and handshape_authoring stop brace-matching declarations out of main.js and import them. Verified beyond the unit tests, which cannot see hook wiring: verify_lanes.py clicks the anchor lane and asserts an anchor is added, the canvas repaints (_hooks.draw), the anchor lands on the grid rather than under the cursor (_hooks.snapTime), and the tones modal opens through the re-attached window.* handler. Comment out setLaneHooks() and all 88 unit tests still pass while three of its eight checks fail. node --test 88/88, pytest 248/248, npm run lint 0 errors, Codex clean, all 12 headless harnesses pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 30 + src/annotation-lanes.js | 1737 +++++++++++++++++ src/geometry.js | 3 + src/main.js | 1732 +--------------- ...ring.test.js => anchor_authoring.test.mjs} | 68 +- tests/handshape_authoring.test.mjs | 20 +- 6 files changed, 1825 insertions(+), 1765 deletions(-) create mode 100644 src/annotation-lanes.js rename tests/{anchor_authoring.test.js => anchor_authoring.test.mjs} (72%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01430b7c..6bdfadfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Hook callbacks captured the pre-wrapper `draw`.** `draw` is reassigned near + the bottom of `main.js` to a wrapper that refreshes seven toolbar buttons + before repainting. `setHistoryHooks()` and `setDrumHooks()` were handed the + bare identifier, so they froze the ORIGINAL function at wiring time and every + undo, redo and drum-density toggle skipped those refreshes. The canvas still + repainted, which is why nothing looked obviously wrong — the visible symptom + was the drum-density button keeping its old "Rows: Full" label after the grid + had already collapsed to Compact. All three hook sites now take a thunk that + resolves the live binding at call time. Introduced by the `history.js` and + `drum.js` extractions; found by Codex on review of the next one. + ### Changed +- **The annotation lanes now live in `src/annotation-lanes.js` (R2, step 16).** + 1,678 lines — the tone lane, the anchor lane and the handshape lane, which + were a contiguous tail of the `main.js` IIFE. `main.js` is down to 16,078. + They travel together because they lean on each other: the handshape lane + positions itself off `_anchorLaneTopY`, and both it and the anchor lane share + `_currentAnchorArr`. `main.js` keeps the canvas event routing and forwards to + the `on*LaneMouse*` handlers. + Four of its symbols travel back — `draw`, `hideContextMenu`, `snapTime`, + `_editorPromptText` — and arrive through `setLaneHooks()`. `snapTime` stays + behind because its onset-snap path reaches the onset cache; `_editorPromptText` + stays because it owns a modal and the shared `_editorPromptCancel` handle. + `TONE_LANE_H` moved to `geometry.js`, joining `ANCHOR_LANE_H` and `HS_LANE_H`. + The tones modal's three `window.*` handlers became exported functions that + `main.js` re-attaches — a top-level `window.x =` throws when the module is + imported under node. + + - **The drum editor now lives in `src/drum.js` (R2, step 15).** 714 lines out of `src/main.js`, which is down to 17,757 — the largest single lift of the split so far. It carries the lane/density model, the limb-lint memo, the drum diff --git a/src/annotation-lanes.js b/src/annotation-lanes.js new file mode 100644 index 00000000..8e03d032 --- /dev/null +++ b/src/annotation-lanes.js @@ -0,0 +1,1737 @@ +// ════════════════════════════════════════════════════════════════════ +// The annotation lanes — tone changes, anchors, and handshapes. +// +// Three thin strips drawn around the chart, each with its own draw pass and +// its own mouse handlers. They travel together because they lean on each +// other: the handshape lane positions itself off _anchorLaneTopY, and both it +// and the anchor lane share _currentAnchorArr. Split apart, those would be +// cross-module imports for no gain. +// +// main.js keeps the canvas event routing (deciding which strip is under the +// cursor) and forwards to the on*LaneMouse* handlers here. Four of its symbols +// travel the other way — draw, hideContextMenu, snapTime, _editorPromptText — +// and would close a cycle, so they arrive through setLaneHooks(), the same +// shape as history.js's setHistoryHooks() and drum.js's setDrumHooks(). +// +// snapTime stays in main.js because its onset-snap path reaches _ensureOnsets +// and the onset cache; _editorPromptText stays because it owns a modal and the +// shared _editorPromptCancel handle. Neither is a lane concern. +// +// TONE_LANE_H moved to geometry.js, where ANCHOR_LANE_H and HS_LANE_H already +// live. The tones modal's window.* handlers are exported as plain functions — +// a top-level `window.x =` throws when this module is imported under node. +// +// Browser surface: `ctx` (the shared 2D context) and the tones-modal DOM. +// ════════════════════════════════════════════════════════════════════ +import { ctx } from './canvas.js'; +import { + _buildPreservedTemplates, _bumpHandshapesDirty, _ensureHandshapes, _fretKeyForL, + _normFingers, relinkChordTemplate, +} from './chords.js'; +import { _beatBarTopY } from './draw.js'; +import { + ANCHOR_LANE_H, BEAT_H, HS_LANE_H, LABEL_W, TONE_LANE_H, timeToX, xToTime, +} from './geometry.js'; +import { lanes } from './lanes.js'; +import { S } from './state.js'; +import { setStatus } from './ui.js'; + +const _hooks = { + draw: () => {}, + hideContextMenu: () => {}, + snapTime: (t) => t, + _editorPromptText: async () => null, +}; + +export function setLaneHooks(hooks) { Object.assign(_hooks, hooks); } + +// ─── Tone-lane slot data (PR3c) ──────────────────────────────────── +export const _TONE_SLOT_DEFAULTS = ['Clean', 'Drive', 'Lead', 'Crunch', 'Effect']; +export const _TONE_SLOT_COLORS = ['#7dd3fc', '#f87171', '#fbbf24', '#a78bfa', '#34d399']; + +// ════════════════════════════════════════════════════════════════════ +// Tone lane — PR3c of the tones+notation UI follow-up. +// +// Renders tone-change markers on a thin strip at the top of the +// canvas, lets the user click-to-add / drag-to-move / Del-to-remove +// markers, and surfaces a Tones… modal for slot renaming + base +// selection. All edits go through `S.history` so undo/redo works. +// +// `TONE_LANE_H`, `_TONE_SLOT_DEFAULTS`, `_TONE_SLOT_COLORS` are +// declared at the top of this IIFE (alongside `S`) so callsites +// further down the file resolve them safely. +// ════════════════════════════════════════════════════════════════════ + +// Derive a 5-slot list from a raw tones object's `base` + `changes`. +// Shared between `_readToneSnapshot` (no mutation) and `_ensureTones` +// (writes back) so they always produce the same ordering. Without +// this the UI would show `_TONE_SLOT_DEFAULTS` for archive loads (where +// the backend writes `{base, changes, definitions}` without `slots`) +// and `RenameToneSlotsCmd`'s index-based remap would target the +// wrong names. +function _deriveSlots(t) { + if (t && Array.isArray(t.slots) && t.slots.length === 5 + && t.slots.every(s => typeof s === 'string' && s)) { + return t.slots.slice(); + } + const seen = new Set(); + const seeded = []; + const consider = name => { + if (typeof name === 'string' && name && !seen.has(name) + && seeded.length < 5) { + seen.add(name); + seeded.push(name); + } + }; + if (t) consider(t.base); + if (t && Array.isArray(t.changes)) { + for (const c of t.changes) { + if (c && typeof c.name === 'string') consider(c.name); + } + } + for (const name of _TONE_SLOT_DEFAULTS) consider(name); + // Pad with synthetic names, looping suffix until we find one that + // doesn't collide with already-seeded user names. + let synthetic = 1; + while (seeded.length < 5) { + const candidate = 'Slot ' + synthetic++; + if (!seen.has(candidate)) { + seen.add(candidate); + seeded.push(candidate); + } + } + return seeded.slice(0, 5); +} + +// Read-only projection of an arrangement's tones — returns the +// authored data when present and a safe default otherwise, WITHOUT +// mutating `arr`. Use this from display / no-op-compare paths so +// merely opening the Tones modal doesn't synthesize a `tones` object +// the sloppak full-snapshot save would then persist to disk. +function _readToneSnapshot(arr) { + const t = (arr && typeof arr.tones === 'object' && arr.tones) || null; + const slots = _deriveSlots(t); + const baseFromArr = t && typeof t.base === 'string' && t.base; + const base = baseFromArr && slots.includes(baseFromArr) + ? baseFromArr + : slots[0]; + return { + slots, + base, + changes: Array.isArray(t && t.changes) ? t.changes : [], + definitions: Array.isArray(t && t.definitions) ? t.definitions : [], + }; +} + +export function _ensureTones(arr) { + if (!arr) return null; + if (!arr.tones || typeof arr.tones !== 'object') arr.tones = {}; + const t = arr.tones; + if (!Array.isArray(t.changes)) t.changes = []; + if (!Array.isArray(t.definitions)) t.definitions = []; + // Reuse `_deriveSlots` so the seeded slot ordering matches what + // `_readToneSnapshot` returned to the read-only paths (modal, + // context menu, click-to-add). Without that alignment, + // `RenameToneSlotsCmd`'s index-based name remap would target a + // different slot than the user saw in the UI. + t.slots = _deriveSlots(t); + if (typeof t.base !== 'string' || !t.slots.includes(t.base)) { + t.base = t.slots[0]; + } + return t; +} + +// Track authored tone edits via a per-arrangement counter rather +// than a sticky boolean. Every mutating command bumps the counter +// on `exec` and decrements it on `rollback`, so the count returns +// to 0 after a complete undo to the load state — and `_buildSaveBody` +// + the Build Song warning can skip arrangements where the net +// authored count is zero. +// +// A sticky `_dirty` would cause the editor to ship `` even +// after the user undid every edit, silently downgrading what the +// backend writes for a no-net-change arrangement. +function _bumpTonesDirty(arr, delta) { + if (!arr) return; + _ensureTones(arr); + const next = (arr.tones._editCount || 0) + delta; + arr.tones._editCount = next > 0 ? next : 0; +} +export function _tonesAreDirty(arr) { + return !!(arr && arr.tones && (arr.tones._editCount || 0) > 0); +} + +// Strip client-only fields (`_editCount`, formerly `_dirty`) before +// shipping `arr.tones` to the backend. Returns a fresh object so we +// don't mutate the in-memory state. +export function _stripToneInternals(tones) { + if (!tones || typeof tones !== 'object') return tones; + const { _editCount, _dirty, ...wire } = tones; + return wire; +} + +export function _currentToneArr() { + if (!S.arrangements || !S.arrangements[S.currentArr]) return null; + return S.arrangements[S.currentArr]; +} + +// ─── Lane drawing ─────────────────────────────────────────────────── + +export function drawToneLane(w) { + const arr = _currentToneArr(); + if (!arr) return; + // Don't mutate `arr` from the render path — calling `_ensureTones` + // here would silently attach an empty `tones` object to every + // archive/sloppak just by drawing the canvas, which the Build Song + // warning would then mistake for authored content. Use + // `_readToneSnapshot` so the slot list is derived from + // `base + changes[].name` for archive loads where `arr.tones.slots` + // is absent — markers render with their authored color/label + // instead of grey "unknown" until the first mutation. + const snap = _readToneSnapshot(arr); + const slots = snap.slots; + const base = (arr.tones && typeof arr.tones.base === 'string') + ? arr.tones.base + : ''; + const changes = snap.changes; + + // Lane background — a darker strip overlaid on the waveform's top + // edge so markers stand out against the waveform noise below. + ctx.fillStyle = 'rgba(8,8,20,0.85)'; + ctx.fillRect(0, 0, w, TONE_LANE_H); + ctx.strokeStyle = '#1f2937'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, TONE_LANE_H - 0.5); + ctx.lineTo(w, TONE_LANE_H - 0.5); + ctx.stroke(); + + // Base-tone label. Hide entirely when there's no authored tone + // data (no base AND no changes) so the lane stays visually empty + // for unauthored projects. When changes exist but `base` is empty + // (older XML loaded without ``) fall back to the first + // slot so the lane still shows *some* base context. + // Draw past `LABEL_W` because `drawLabels()` later paints the + // 0..LABEL_W strip and would otherwise cover this text with the + // waveform's "Audio" label. + const effectiveBase = base || (changes.length > 0 ? slots[0] : ''); + if (effectiveBase) { + const baseIdx = slots.indexOf(effectiveBase); + ctx.fillStyle = baseIdx >= 0 + ? _TONE_SLOT_COLORS[baseIdx] + : '#94a3b8'; + ctx.font = 'bold 9px monospace'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText('base: ' + effectiveBase, LABEL_W + 4, TONE_LANE_H / 2); + } + + // Markers — small filled triangles at each change time, colored + // by slot, with the slot name to the right. Selection is tracked + // by object ref (`S.toneSel`) rather than index so that + // Add/Move/Remove-induced sorts/splices don't shift it onto a + // different marker. Clip the marker region to `LABEL_W..w` so a + // marker at t==0 (centered at x=LABEL_W) doesn't draw its left + // half under the label strip that `drawLabels()` later paints + // over. + ctx.save(); + ctx.beginPath(); + ctx.rect(LABEL_W, 0, Math.max(0, w - LABEL_W), TONE_LANE_H); + ctx.clip(); + for (let i = 0; i < changes.length; i++) { + const c = changes[i]; + if (typeof c.t !== 'number' || !isFinite(c.t)) continue; + const x = timeToX(c.t); + if (x < -40 || x > w + 40) continue; + const sel = S.toneSel === c; + const slotIdx = slots.indexOf(c.name); + const color = slotIdx >= 0 + ? _TONE_SLOT_COLORS[slotIdx] + : '#94a3b8'; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(x, 2); + ctx.lineTo(x + 5, TONE_LANE_H - 2); + ctx.lineTo(x - 5, TONE_LANE_H - 2); + ctx.closePath(); + ctx.fill(); + if (sel) { + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + // Label to the right of the marker. + ctx.fillStyle = color; + ctx.font = '9px monospace'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(c.name, x + 7, TONE_LANE_H / 2); + } + ctx.restore(); +} + +// Returns the nearest tone-change *ref* under the cursor, or `null` +// when no marker is in range. Ref-based so callers don't have to +// re-derive when the changes list re-sorts after a move/add/remove. +function _hitToneMarker(x) { + const arr = _currentToneArr(); + if (!arr || !arr.tones || !Array.isArray(arr.tones.changes)) return null; + const HIT = 7; // px tolerance around the triangle + let best = null, bestDx = Infinity; + for (const c of arr.tones.changes) { + if (typeof c.t !== 'number' || !isFinite(c.t)) continue; + const dx = Math.abs(timeToX(c.t) - x); + // Enforce the documented HIT tolerance — without the `<=HIT` + // gate, an `Infinity`-seeded `bestDx` would accept any marker + // regardless of distance. + if (dx <= HIT && dx < bestDx) { best = c; bestDx = dx; } + } + return best; +} + +// ─── Cmd classes ──────────────────────────────────────────────────── + +export class AddToneChangeCmd { + constructor(arrIdx, change) { + this.arrIdx = arrIdx; this.change = change; + } + exec() { + const arr = S.arrangements[this.arrIdx]; + if (!arr) return; + _bumpTonesDirty(arr, +1); + const changes = arr.tones.changes; + changes.push(this.change); + changes.sort((a, b) => a.t - b.t); + } + rollback() { + const arr = S.arrangements[this.arrIdx]; + if (!arr || !arr.tones) return; + _bumpTonesDirty(arr, -1); + const i = arr.tones.changes.indexOf(this.change); + if (i >= 0) arr.tones.changes.splice(i, 1); + } +} + +export class RemoveToneChangeCmd { + constructor(arrIdx, change) { + this.arrIdx = arrIdx; this.change = change; + } + exec() { + const arr = S.arrangements[this.arrIdx]; + if (!arr || !arr.tones) return; + _bumpTonesDirty(arr, +1); + const i = arr.tones.changes.indexOf(this.change); + if (i >= 0) arr.tones.changes.splice(i, 1); + } + rollback() { + const arr = S.arrangements[this.arrIdx]; + if (!arr) return; + _bumpTonesDirty(arr, -1); + const changes = arr.tones.changes; + changes.push(this.change); + changes.sort((a, b) => a.t - b.t); + } +} + +class MoveToneChangeCmd { + constructor(arrIdx, change, oldT, newT) { + this.arrIdx = arrIdx; this.change = change; + this.oldT = oldT; this.newT = newT; + } + exec() { + const arr = S.arrangements[this.arrIdx]; + if (!arr) return; + _bumpTonesDirty(arr, +1); + this.change.t = this.newT; + arr.tones.changes.sort((a, b) => a.t - b.t); + } + rollback() { + const arr = S.arrangements[this.arrIdx]; + if (!arr) return; + _bumpTonesDirty(arr, -1); + this.change.t = this.oldT; + arr.tones.changes.sort((a, b) => a.t - b.t); + } +} + +class RenameToneSlotsCmd { + constructor(arrIdx, newSlots, newBase) { + this.arrIdx = arrIdx; + this.newSlots = newSlots.slice(); + this.newBase = newBase; + this.oldSlots = null; + this.oldBase = null; + } + exec() { + const arr = S.arrangements[this.arrIdx]; + if (!arr) return; + const t = _ensureTones(arr); + this.oldSlots = t.slots.slice(); + this.oldBase = t.base; + // Rename placed-change slot references that point at the old + // slot names — keeps the lane's existing markers attached to + // the renamed slots so they don't orphan to an unknown name. + for (const c of t.changes) { + const idx = this.oldSlots.indexOf(c.name); + if (idx >= 0) c.name = this.newSlots[idx]; + } + // Same for the base name — pick the renamed slot at the old + // base's index. + const baseIdx = this.oldSlots.indexOf(this.oldBase); + t.slots = this.newSlots.slice(); + // Honor an explicit `newBase` choice; fall back to the + // index-preserved rename so the active base survives renames. + if (this.newBase && t.slots.includes(this.newBase)) { + t.base = this.newBase; + } else if (baseIdx >= 0) { + t.base = t.slots[baseIdx]; + } else { + t.base = t.slots[0]; + } + _bumpTonesDirty(arr, +1); + } + rollback() { + const arr = S.arrangements[this.arrIdx]; + if (!arr || !this.oldSlots) return; + const t = _ensureTones(arr); + for (const c of t.changes) { + const idx = this.newSlots.indexOf(c.name); + if (idx >= 0) c.name = this.oldSlots[idx]; + } + t.slots = this.oldSlots.slice(); + t.base = this.oldBase; + _bumpTonesDirty(arr, -1); + } +} + +// ─── Mouse interactions ───────────────────────────────────────────── + +export function onToneLaneMouseDown(e, x) { + const arr = _currentToneArr(); + if (!arr) return false; + // Mutually exclusive selection across timeline lanes — without + // this clear, `S.anchorSel` would survive a tone-lane click and + // the Del handler (which checks anchor first) would delete the + // stale anchor instead of the just-clicked tone marker. + S.anchorSel = null; + S.handshapeSel = null; + const hit = _hitToneMarker(x); + if (hit) { + S.toneSel = hit; + S.drag = { + type: 'tone', + startX: x, + origT: hit.t, + change: hit, + }; + _hooks.draw(); + return true; + } + // Empty area click — place a new change snapped to the grid. The + // first add against an unauthored arrangement is what should + // synthesise `arr.tones`, and that happens inside + // `AddToneChangeCmd.exec` via `_bumpTonesDirty(+1)`. Read via + // `_readToneSnapshot` here so the slot lookup doesn't mutate + // state for a click outside any marker. + const t = _hooks.snapTime(Math.max(0, xToTime(x))); + if (t < 0) return false; + const snap = _readToneSnapshot(arr); + const nonBase = snap.slots.filter(s => s !== snap.base); + // Use a Map so user-controlled slot names like "__proto__" or + // "constructor" can't pollute the count lookup via an Object + // prototype chain hit. + const counts = new Map(); + for (const s of nonBase) counts.set(s, 0); + for (const c of snap.changes) { + if (counts.has(c.name)) counts.set(c.name, counts.get(c.name) + 1); + } + let pick = nonBase[0] || snap.base; + let pickCount = Infinity; + for (const s of nonBase) { + const n = counts.get(s) || 0; + if (n < pickCount) { pick = s; pickCount = n; } + } + const change = { t, name: pick }; + S.history.exec(new AddToneChangeCmd(S.currentArr, change)); + S.toneSel = change; + _hooks.draw(); + return true; +} + +export function onToneLaneMouseMove(e, x) { + if (!S.drag || S.drag.type !== 'tone') return false; + const arr = _currentToneArr(); + if (!arr) return false; + // Snap the drag target so dropped markers land on the same grid + // subdivision the rest of the editor uses. Skip the sort during + // live drag — the commit on mouseup goes through + // `MoveToneChangeCmd` which sorts once, and sorting on every + // mousemove was O(n log n) per frame on big arrangements. + const newT = _hooks.snapTime(Math.max(0, xToTime(x))); + S.drag.change.t = newT; + // Selection is by ref, so the deferred sort doesn't invalidate it. + S.toneSel = S.drag.change; + _hooks.draw(); + return true; +} + +export function onToneLaneMouseUp() { + if (!S.drag || S.drag.type !== 'tone') return false; + const change = S.drag.change; + const origT = S.drag.origT; + const newT = change.t; + S.drag = null; + if (origT !== newT) { + // The drag mutated `change.t` in-place for live feedback; + // replay through the command history so undo/redo can restore + // the pre-drag time. Roll back to `origT` first, then `exec()` + // applies `newT` and re-sorts. + const arr = _currentToneArr(); + if (arr) { + change.t = origT; + S.history.exec(new MoveToneChangeCmd(S.currentArr, change, origT, newT)); + } + } + _hooks.draw(); + return true; +} + +export function onToneLaneContextMenu(e, x) { + const arr = _currentToneArr(); + if (!arr) return false; + const change = _hitToneMarker(x); + if (!change) return false; + // Clear the anchor selection while interacting with a tone marker + // so a subsequent Del hits the right path. Mirrors the mousedown + // mutual-exclusion above. + S.anchorSel = null; + S.handshapeSel = null; + // Capture the arrangement index NOW. If the user switches + // arrangements while the context menu is open, a later + // `S.currentArr` read inside the click handlers would dispatch + // the command at the wrong arrangement. + const menuArrIdx = S.currentArr; + // A hit means `arr.tones.changes` already contains this change, so + // `arr.tones` is non-null. `arr.tones.slots` / `arr.tones.base` + // may still be absent on freshly-loaded data (the load path leaves + // slot seeding to first-author), so go through `_readToneSnapshot` + // for the slot list to avoid iterating `undefined`. + const snap = _readToneSnapshot(arr); + // Build the slot-picker via DOM APIs (not `innerHTML`) so a + // user-named slot like `` can't inject markup + // into the menu. The previous `innerHTML` version interpolated + // slot names into both an attribute and the button body without + // escaping. + const menu = document.getElementById('editor-context-menu'); + menu.replaceChildren(); + + const header = document.createElement('div'); + header.className = 'px-3 py-1 text-[10px] text-gray-500'; + header.textContent = 'Change slot'; + menu.appendChild(header); + + for (const slot of snap.slots) { + const active = slot === change.name; + const btn = document.createElement('button'); + btn.className = 'w-full text-left px-3 py-1 text-xs hover:bg-dark-500 flex items-center gap-2'; + const tick = document.createElement('span'); + tick.className = 'w-3'; + tick.textContent = active ? '✓' : ''; + btn.appendChild(tick); + btn.appendChild(document.createTextNode(slot)); + if (slot === snap.base) { + const baseTag = document.createElement('span'); + baseTag.className = 'text-[10px] text-gray-500'; + baseTag.textContent = ' (base)'; + btn.appendChild(baseTag); + } + btn.onclick = () => { + _hooks.hideContextMenu(); + const oldName = change.name; + if (oldName === slot) return; + // Slot rename via single-name rebind. Wrap in a command so + // undo restores the prior name. + S.history.exec({ + _change: change, + _old: oldName, + _new: slot, + _arr: arr, + exec() { this._change.name = this._new; _bumpTonesDirty(this._arr, +1); }, + rollback() { this._change.name = this._old; _bumpTonesDirty(this._arr, -1); }, + }); + _hooks.draw(); + }; + menu.appendChild(btn); + } + + const sep = document.createElement('div'); + sep.className = 'border-t border-gray-700 my-1'; + menu.appendChild(sep); + + const delBtn = document.createElement('button'); + delBtn.className = 'w-full text-left px-3 py-1 text-xs hover:bg-dark-500 text-rose-300'; + delBtn.textContent = 'Delete tone change'; + delBtn.onclick = () => { + _hooks.hideContextMenu(); + // Use the captured `menuArrIdx` rather than the live + // `S.currentArr` so a mid-menu arrangement switch can't + // route the delete (and its dirty-counter bump) to the wrong + // arrangement. + S.history.exec(new RemoveToneChangeCmd(menuArrIdx, change)); + S.toneSel = null; + _hooks.draw(); + }; + menu.appendChild(delBtn); + + menu.style.left = e.clientX + 'px'; + menu.style.top = e.clientY + 'px'; + menu.classList.remove('hidden'); + return true; +} + +// ─── Modal handlers ───────────────────────────────────────────────── + +export function editorShowTonesModal() { + const arr = _currentToneArr(); + if (!arr) return; + // Read-only snapshot — don't synthesize an `arr.tones` just by + // opening the modal. Apply path mutates via `RenameToneSlotsCmd` + // when the user actually changes something. + const t = _readToneSnapshot(arr); + const container = document.getElementById('editor-tones-slots'); + // Build the per-slot rows via DOM APIs (not `innerHTML`) so a + // pathological loaded slot name like `">