diff --git a/static/components/ObjectShort.js b/static/components/ObjectShort.js index 006e2d8..5dd5175 100644 --- a/static/components/ObjectShort.js +++ b/static/components/ObjectShort.js @@ -2,8 +2,8 @@ import { h } from 'vue'; // One-line summary row with a drill-down chevron. export const ObjectShort = { - props: ['label', 'typeTag', 'focused', 'hasChildren', 'readOnly'], - emits: ['focus', 'drillDown'], + props: ['label', 'typeTag', 'focused', 'hasChildren', 'readOnly', 'deletable'], + emits: ['focus', 'drillDown', 'delete'], setup(props, { emit }) { return () => h('li', { class: ['se-object-item', props.focused ? 'focused' : null, props.readOnly ? 'read-only' : null], @@ -11,6 +11,13 @@ export const ObjectShort = { }, [ props.typeTag ? h('span', { class: 'se-object-type' }, props.typeTag) : null, h('span', { class: 'se-object-label' }, props.label), + props.deletable + ? h('button', { + class: 'se-btn-delete', + title: 'Delete', + onClick: e => { e.stopPropagation(); emit('delete'); }, + }, '×') + : null, props.hasChildren ? h('button', { class: 'se-chevron', diff --git a/static/components/PaneCP.js b/static/components/PaneCP.js index dd50003..8a6432c 100644 --- a/static/components/PaneCP.js +++ b/static/components/PaneCP.js @@ -88,7 +88,9 @@ export const PaneCP = { const model = props.store.scoreModel; const { text: patched, log } = patchScore( raw, model.instruments, model.bars, model.info, model.articles ?? [], + { articlesModified: !!model.articlesModified }, ); + model.articlesModified = false; props.store.exportLog = log; await putScoreText(patched); props.store.synthesisStatus = { frozen: false, currently_rendered_notes: 0, notes_in_total: 0 }; diff --git a/static/components/PaneFO.js b/static/components/PaneFO.js index 5ea9a00..ea9fd61 100644 --- a/static/components/PaneFO.js +++ b/static/components/PaneFO.js @@ -217,11 +217,12 @@ export const PaneFO = { h('h4', H4, `Bar: ${node.id}`), h(ObjectExtended, { fields: [ - { key: 'id', value: node.id, editable: false }, + { key: 'id', value: node.id, editable: node.isNew ?? false }, { key: 'beats_per_minute', value: node.tempoLevels ?? '', editable: true, type: 'number' }, { key: 'stress_pattern', value: stressorToString(node.stressor), editable: true }, ], onChange: ({ key, value }) => { + if (key === 'id') { node.id = value; markBarDirty(); return; } if (key === 'beats_per_minute') node.tempoLevels = isNaN(value) ? null : value; if (key === 'stress_pattern') node.stressor = parseStressor(value); markBarDirty(); diff --git a/static/components/PaneSubObjects.js b/static/components/PaneSubObjects.js index 8c15b84..33b44a0 100644 --- a/static/components/PaneSubObjects.js +++ b/static/components/PaneSubObjects.js @@ -9,6 +9,15 @@ function barGroupKey(id) { return m ? id.slice(0, m.index) : id; } +// Increment the trailing numeric run of an ID, preserving zero-padding width. +function incrementId(id) { + const m = id.match(/(\d+)$/); + if (!m) return id + '1'; + const num = parseInt(m[1], 10) + 1; + const padded = String(num).padStart(m[1].length, '0'); + return id.slice(0, m.index) + padded; +} + export const PaneSubObjects = { props: ['store', 'kind', 'onFocusFO'], setup(props) { @@ -17,6 +26,67 @@ export const PaneSubObjects = { return fp.length ? fp[fp.length - 1] : props.store.scoreModel; } + function deleteItem(kind, node) { + const store = props.store; + const model = store.scoreModel; + if (!model) return; + + if (kind === 'instrument') { + const idx = model.instruments.indexOf(node); + if (idx !== -1) { model.instruments.splice(idx, 1); store.markDirty(); } + } else if (kind === 'articles') { + const idx = (model.articles ?? []).indexOf(node); + if (idx !== -1) { model.articles.splice(idx, 1); model.articlesModified = true; store.markDirty(); } + } else if (kind === 'bar') { + node.deleted = true; + node.isDirty = true; + store.markDirty(); + } else if (kind === 'variation') { + for (const instr of model.instruments) { + const idx = instr.variations.indexOf(node); + if (idx !== -1) { instr.variations.splice(idx, 1); instr.isDirty = true; store.markDirty(); return; } + for (const v of instr.variations) { + const sidx = v.subvariations.indexOf(node); + if (sidx !== -1) { v.subvariations.splice(sidx, 1); instr.isDirty = true; store.markDirty(); return; } + } + } + } else if (kind === 'label_spec') { + for (const instr of model.instruments) { + for (const v of instr.variations) { + const idx = v.labelSpecs.indexOf(node); + if (idx !== -1) { v.labelSpecs.splice(idx, 1); instr.isDirty = true; store.markDirty(); return; } + for (const sv of v.subvariations) { + const idx2 = sv.labelSpecs.indexOf(node); + if (idx2 !== -1) { sv.labelSpecs.splice(idx2, 1); instr.isDirty = true; store.markDirty(); return; } + } + } + } + } + } + + function addBar(afterId) { + const store = props.store; + const model = store.scoreModel; + if (!model) return; + let newId; + if (afterId) { + newId = incrementId(afterId); + } else { + const liveBars = (model.bars ?? []).filter(b => !b.deleted); + newId = liveBars.length ? incrementId(liveBars[liveBars.length - 1].id) : 'bar001'; + } + const newBar = { + type: 'bar', id: newId, isDirty: true, isNew: true, + stressor: null, tempoLevels: null, + upperStressBound: null, lowerStressBound: null, tempoShape: null, + voices: {}, + }; + model.bars.push(newBar); + store.markDirty(); + store.pushFocus(newBar); + props.onFocusFO?.(); + } + return () => { const node = focused(); const groups = getKindGroups(node); @@ -25,13 +95,14 @@ export const PaneSubObjects = { : groups[0]; const items = group?.items ?? []; - if (!items.length) return h('div', null, h('em', null, 'No sub-objects')); + if (!items.length && props.kind !== 'bar') return h('div', null, h('em', null, 'No sub-objects')); if (props.kind === 'bar') { - // Group bars by shared P?L? key; render each group as a row of inline chips. + const visibleItems = items.filter(item => !item.node.deleted); + const barGroups = []; const seen = new Map(); - for (const item of items) { + for (const item of visibleItems) { const key = barGroupKey(item.label); if (!seen.has(key)) { const g = { key, items: [] }; @@ -41,17 +112,39 @@ export const PaneSubObjects = { seen.get(key).items.push(item); } - return h('div', { class: 'se-bar-groups' }, barGroups.map(g => - h('div', { key: g.key, class: 'se-bar-group' }, - g.items.map((item, idx) => - h('button', { + const groupEls = barGroups.map(g => { + const lastId = g.items[g.items.length - 1].label; + return h('div', { key: g.key, class: 'se-bar-group' }, [ + ...g.items.map((item, idx) => + h('span', { key: idx, class: ['se-bar-chip', props.store.focusPath.includes(item.node) ? 'focused' : null], - onClick: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); }, - }, item.label) - ) - ) - )); + }, [ + h('span', { + class: 'se-bar-chip-label', + onClick: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); }, + }, item.label), + h('button', { + class: 'se-bar-chip-delete', + title: 'Delete bar', + onClick: e => { e.stopPropagation(); deleteItem('bar', item.node); }, + }, '×'), + ]) + ), + h('button', { + class: 'se-bar-add', + title: `Add bar after ${lastId}`, + onClick: () => addBar(lastId), + }, '+ add'), + ]); + }); + + return h('div', { class: 'se-bar-groups' }, [ + ...groupEls, + h('div', { class: 'se-bar-group' }, + h('button', { class: 'se-bar-add', onClick: () => addBar(null) }, '+ add bar') + ), + ]); } return h('div', null, @@ -63,8 +156,10 @@ export const PaneSubObjects = { focused: props.store.focusPath.includes(item.node), hasChildren: item.hasChildren, readOnly: item.readOnly ?? false, - onFocus: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); }, + deletable: item.deletable ?? false, + onFocus: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); }, onDrillDown: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); }, + onDelete: item.deletable ? () => deleteItem(item.kind, item.node) : undefined, }) )) ); diff --git a/static/exporter.js b/static/exporter.js index d8a8bca..38f73ca 100644 --- a/static/exporter.js +++ b/static/exporter.js @@ -220,7 +220,10 @@ function patchInstrumentHeader(text, instruments) { if (m) { const rawName = m[1].replace(/^'|'$/g, ''); const instr = instrMap[rawName]; - if (instr && instr.isDirty) { + if (instr && instr.deleted) { + i++; + while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) i++; + } else if (instr && instr.isDirty) { i++; while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) i++; result.push(exportInstrument(instr)); @@ -280,7 +283,15 @@ function patchBarMeta(doc, bar) { return out.join('\n'); } -export function patchScore(rawScoreText, instruments, bars = [], info = null, articles = []) { +function buildNewBarDoc(bar) { + const lines = [`_id: ${bar.id}`, '_meta:']; + const sp = stressorToString(bar.stressor); + if (sp) lines.push(` stress_pattern: ${sp}`); + if (bar.tempoLevels != null) lines.push(` beats_per_minute: ${bar.tempoLevels}`); + return lines.join('\n'); +} + +export function patchScore(rawScoreText, instruments, bars = [], info = null, articles = [], flags = {}) { const log = []; const SEP = '\n---\n'; const [header, ...barDocs] = rawScoreText.split(SEP); @@ -288,28 +299,43 @@ export function patchScore(rawScoreText, instruments, bars = [], info = null, ar let patchedHeader = patchMetadata(header, info); const dirtyArticles = articles.filter(a => a.isDirty); - if (dirtyArticles.length) { + if (dirtyArticles.length || flags.articlesModified) { patchedHeader = patchArticles(patchedHeader, articles); for (const a of dirtyArticles) log.push({ level: 'changed', path: `articles / ${a.name}` }); } patchedHeader = patchInstrumentHeader(patchedHeader, instruments); - for (const i of instruments.filter(i => i.isDirty)) log.push({ level: 'changed', path: `instrument / ${i.name}` }); - - if (!barDocs.length) return { text: patchedHeader, log }; + for (const i of instruments) { + if (i.deleted) log.push({ level: 'changed', path: `instrument / ${i.name} (deleted)` }); + else if (i.isDirty) log.push({ level: 'changed', path: `instrument / ${i.name}` }); + } const barMap = {}; for (const bar of bars) barMap[bar.id] = bar; + if (!barDocs.length) { + const newBarDocs = bars.filter(b => b.isNew && !b.deleted).map(b => buildNewBarDoc(b)); + for (const b of bars.filter(b => b.isNew && !b.deleted)) log.push({ level: 'changed', path: `bar / ${b.id}` }); + return { text: [patchedHeader, ...newBarDocs].join(SEP), log }; + } + let passedThrough = 0; - const patchedBarDocs = barDocs.map(doc => { + const patchedBarDocs = []; + for (const doc of barDocs) { const m = doc.match(/^_id:\s*(\S+)/m); - if (!m) { passedThrough++; return doc; } + if (!m) { passedThrough++; patchedBarDocs.push(doc); continue; } const bar = barMap[m[1]]; - if (!bar?.isDirty) { passedThrough++; return doc; } + if (!bar) { passedThrough++; patchedBarDocs.push(doc); continue; } + if (bar.deleted) continue; + if (!bar.isDirty) { passedThrough++; patchedBarDocs.push(doc); continue; } log.push({ level: 'changed', path: `bar / ${bar.id}` }); - return patchBarMeta(doc, bar); - }); + patchedBarDocs.push(patchBarMeta(doc, bar)); + } + + for (const bar of bars.filter(b => b.isNew && !b.deleted)) { + patchedBarDocs.push(buildNewBarDoc(bar)); + log.push({ level: 'changed', path: `bar / ${bar.id}` }); + } if (passedThrough > 0) { log.push({ level: 'info', message: `${passedThrough} bar document${passedThrough !== 1 ? 's' : ''} passed through unchanged` }); diff --git a/static/style.css b/static/style.css index f0778e5..acc8d5a 100644 --- a/static/style.css +++ b/static/style.css @@ -320,21 +320,16 @@ } .se-bar-chip { - display: inline-block; + display: inline-flex; + align-items: center; font-size: 0.7rem; font-family: monospace; - padding: 0.1rem 0.25rem; border: 1px solid #383838; background: #1e1e1e; color: #777; - cursor: pointer; margin: 1px; line-height: 1.4; -} - -.se-bar-chip:hover { - color: #ccc; - border-color: #555; + vertical-align: middle; } .se-bar-chip.focused { @@ -343,6 +338,49 @@ border-color: #4a7aaa; } +.se-bar-chip-label { + padding: 0.1rem 0.2rem; + cursor: pointer; +} + +.se-bar-chip:hover .se-bar-chip-label { + color: #ccc; +} + +.se-bar-chip-delete { + background: transparent; + border: none; + border-left: 1px solid #383838; + color: #604040; + cursor: pointer; + padding: 0.1rem 0.2rem; + font-size: 0.75rem; + line-height: 1; +} + +.se-bar-chip-delete:hover { + color: #e06060; +} + +.se-bar-add { + display: inline-block; + font-size: 0.65rem; + font-family: monospace; + padding: 0.1rem 0.3rem; + border: 1px dashed #383838; + background: transparent; + color: #505050; + cursor: pointer; + margin: 1px; + line-height: 1.4; + vertical-align: middle; +} + +.se-bar-add:hover { + color: #999; + border-color: #666; +} + /* Export log (shown in CP pane after export) */ .se-export-log { font-size: 0.75rem; @@ -387,3 +425,17 @@ color: #e06060; border-color: #602020; } + +.se-btn-delete { + background: transparent; + color: #e06060; + border: none; + cursor: pointer; + padding: 0 0.3rem; + font-size: 0.9rem; + line-height: 1; + margin-left: 0.15rem; +} +.se-btn-delete:hover { + color: #ff8080; +} diff --git a/static/subobject-kinds.js b/static/subobject-kinds.js index 60bb586..0e3a69c 100644 --- a/static/subobject-kinds.js +++ b/static/subobject-kinds.js @@ -39,6 +39,7 @@ export function getKindGroups(node) { if (node.instruments.length) { groups.push({ kind: 'instrument', items: node.instruments.map(i => ({ kind: 'instrument', node: i, label: i.name, hasChildren: !i.isLinked, readOnly: i.isLinked, + deletable: true, }))}); } @@ -48,13 +49,14 @@ export function getKindGroups(node) { acc[p.scope] = (acc[p.scope] ?? 0) + 1; return acc; }, {}); const suffix = Object.entries(counts).map(([s, n]) => `${n} ${s}`).join(', '); - return { kind: 'articles', node: a, label: `${a.name} (${suffix})`, hasChildren: false }; + return { kind: 'articles', node: a, label: `${a.name} (${suffix})`, hasChildren: false, deletable: true }; })}); } if (node.bars.length) { groups.push({ kind: 'bar', items: node.bars.map(b => ({ kind: 'bar', node: b, label: b.id, hasChildren: Object.keys(b.voices).length > 0, + deletable: true, }))}); } @@ -66,7 +68,7 @@ export function getKindGroups(node) { return [{ kind: 'variation', items: node.variations.map((v, idx) => ({ kind: 'variation', node: v, label: `variation ${idx + 1}${v.dependsOn ? ` (${v.dependsOn})` : ''}`, - hasChildren: true, + hasChildren: true, deletable: true, }))}]; } @@ -74,7 +76,7 @@ export function getKindGroups(node) { const groups = []; if (node.labelSpecs.length) { groups.push({ kind: 'label_spec', items: node.labelSpecs.map(ls => ({ - kind: 'label_spec', node: ls, label: ls.label ?? '(no label)', hasChildren: false, + kind: 'label_spec', node: ls, label: ls.label ?? '(no label)', hasChildren: false, deletable: true, }))}); } if (node.subvariations.length) { @@ -83,7 +85,7 @@ export function getKindGroups(node) { const label = dep == null ? `subvariation ${idx + 1}` : isNaN(Number(dep)) ? `ATTR: ${dep}` : String(dep); - return { kind: 'variation', node: sv, label, hasChildren: true }; + return { kind: 'variation', node: sv, label, hasChildren: true, deletable: true }; })}); } return groups; diff --git a/test-parser.mjs b/test-parser.mjs index 71fc1ba..1c2aab2 100644 --- a/test-parser.mjs +++ b/test-parser.mjs @@ -488,6 +488,43 @@ const artClean = [{ name: 'f', isDirty: false, properties: [{ name: 'add_stress' const { text: artUntouched } = patchScore(ART_SCORE, [], [], null, artClean); ok('clean articles section left verbatim', artUntouched.includes('{ add_stress: 3 }')); +// ── patchScore articlesModified flag ────────────────────────────────────── +section('patchScore articlesModified flag'); +const artCleanForFlag = [{ name: 'f', isDirty: false, properties: [{ name: 'add_stress', value: 99, scope: 'defaults' }] }]; +const { text: artFlagPatched, log: artFlagLog } = patchScore(ART_SCORE, [], [], null, artCleanForFlag, { articlesModified: true }); +ok('articlesModified forces re-serialization', artFlagPatched.includes('add_stress: 99')); +ok('articlesModified: old flow-style replaced', !artFlagPatched.includes('{ add_stress: 3 }')); + +// ── patchScore deleted bars ──────────────────────────────────────────────── +section('patchScore deleted bars'); +const delBar1 = { id: '001P1L1M1', isDirty: true, deleted: true, stressor: null, tempoLevels: null, upperStressBound: null, lowerStressBound: null, tempoShape: null }; +const delBar2 = { id: '001P1L1M2', isDirty: false, stressor: null, tempoLevels: null, upperStressBound: null, lowerStressBound: null, tempoShape: null }; +const { text: delBarPatched } = patchScore(RAW_SCORE_WITH_BARS, [], [delBar1, delBar2]); +ok('deleted bar removed from output', !delBarPatched.includes('_id: 001P1L1M1')); +ok('non-deleted bar preserved', delBarPatched.includes('_id: 001P1L1M2')); +ok('deleted bar reduces document count', (delBarPatched.match(/\n---\n/g) ?? []).length === 1); + +// ── patchScore new bars ──────────────────────────────────────────────────── +section('patchScore new bars'); +const newBarEntry = { id: '001P1L1M3', isDirty: true, isNew: true, stressor: null, tempoLevels: 120, upperStressBound: null, lowerStressBound: null, tempoShape: null }; +const { text: newBarPatched, log: newBarLog } = patchScore(RAW_SCORE_WITH_BARS, [], [newBarEntry]); +ok('new bar appended to output', newBarPatched.includes('_id: 001P1L1M3')); +ok('new bar has _meta with BPM', newBarPatched.includes('beats_per_minute: 120')); +ok('new bar appended as extra document', (newBarPatched.match(/\n---\n/g) ?? []).length === 3); +ok('new bar logged as changed', newBarLog.some(e => e.level === 'changed' && e.path === 'bar / 001P1L1M3')); + +// ── patchScore deleted instrument ───────────────────────────────────────── +section('patchScore deleted instrument'); +const delInstrScore = `instrument alpha:\n character:\n A: "1:0,10;1,0"\n\ninstrument ki:\n character:\n A: "1:0,5;1,0"\n`; +const delInstrList = [ + { name: 'alpha', deleted: true, isDirty: false, variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] }, + { name: 'ki', deleted: false, isDirty: false, variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] }, +]; +const { text: delInstrPatched, log: delInstrLog } = patchScore(delInstrScore, delInstrList); +ok('deleted instrument removed from output', !delInstrPatched.includes('instrument alpha:')); +ok('non-deleted instrument preserved', delInstrPatched.includes('instrument ki:')); +ok('deleted instrument logged', delInstrLog.some(e => e.level === 'changed' && e.path?.includes('alpha') && e.path?.includes('deleted'))); + // ── Motif parsing ───────────────────────────────────────────────────────── section('Motif parsing — dynamic motif'); const DYN_MOTIF = `00 bar '001P1L1M1'