Add delete buttons and bar add/create to sub-objects pane

This commit is contained in:
c0dev0id
2026-06-30 13:18:29 +02:00
parent e011c9e7d8
commit 66c1f2c151
8 changed files with 261 additions and 39 deletions

View File

@@ -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',

View File

@@ -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 };

View File

@@ -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();

View File

@@ -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],
}, [
h('span', {
class: 'se-bar-chip-label',
onClick: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); },
}, item.label)
)
)
));
}, 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,
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,
})
))
);

View File

@@ -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` });

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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'