Files
vue3js-app-proposal-for-sdk…/static/components/PaneFO.js
c0dev0id 86b14dfaa3 Article export + Bootstrap toggle + export change log
- patchScore() now accepts articles[] and returns { text, log } instead
  of a plain string. Dirty articles are re-serialized into the articles:
  block (block style, replacing any existing flow-style entries). Clean
  articles bypass the patcher entirely.
- article.isDirty is set on direct property edits so the exporter can
  distinguish directly changed entities from propagated dirty state.
- patchScore() log collects { level:'changed', path } for each patched
  article/instrument/bar, and a { level:'info', message } entry for the
  count of bar documents passed through unchanged.
- CP pane shows the export log above the status poller after each export.
- Article scope toggle replaced with Bootstrap form-check form-switch;
  hand-rolled .se-toggle CSS removed.
- Test suite updated for { text, log } return; 11 new assertions (188 total).
2026-06-30 06:27:12 +02:00

324 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { h, ref } from 'vue';
import { ObjectExtended } from './ObjectExtended.js';
import { EnvelopeEditor } from './EnvelopeEditor.js';
import { ShapeEditor } from './ShapeEditor.js';
import { LinkedInstrumentModal } from './LinkedInstrumentModal.js';
import { stressorToString } from '../exporter.js';
import { coerce } from '../ast-parser.js';
const H4 = { style: 'margin:0 0 0.5rem' };
function unknownPropFields(node) {
return Object.entries(node.unknownProps ?? {})
.map(([key, value]) => ({ key, value: String(value), editable: false }));
}
function parseStressor(str) {
const groups = str.split(';').map(seg =>
seg.split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n))
).filter(g => g.length > 0);
return groups.length ? { type: 'stressor', groups } : null;
}
function scoreInfoFields(info) {
return [
{ key: 'title', value: info?.title ?? '', editable: true },
{ key: 'composer', value: info?.composer ?? '', editable: true },
{ key: 'source', value: info?.source ?? '', editable: true },
{ key: 'encrypter', value: info?.encrypter ?? '', editable: true },
];
}
function instrFields(instr) {
return [
{ key: 'name', value: instr.name, editable: false },
{ key: 'linked', value: instr.isLinked, editable: false, type: 'boolean' },
{ key: 'NOT_CHANGED_SINCE', value: instr.notChangedSince ?? '—', editable: false },
];
}
function variationFields(v) {
return [{ key: 'depends_on', value: v.dependsOn ?? '—', editable: true }];
}
function shapeSection(label, shape, onChange) {
if (!shape) return null;
return h('div', { style: 'margin-top:0.5rem' }, [
h('strong', null, label),
h(ShapeEditor, { shape, onChange }),
]);
}
export const PaneFO = {
props: ['store'],
setup(props) {
const pendingEdit = ref(null); // { instr, undo? }
function focused() {
const fp = props.store.focusPath;
return fp.length ? fp[fp.length - 1] : null;
}
// Intercepts the first edit to a linked instrument:
// shows embed-or-discard modal before committing. `info.undo`
// (forwarded from ShapeEditor/EnvelopeEditor) reverts the mutation on discard.
function makeChangeHandler(instr) {
return (info) => {
if (instr.isLinked && !instr.isDirty) {
pendingEdit.value = { instr, undo: info?.undo };
} else {
instr.isDirty = true;
props.store.markDirty();
}
};
}
function embedInstrument(instr) {
instr.name = instr.name.split('/').pop();
instr.isLinked = false;
instr.isDirty = true;
pendingEdit.value = null;
props.store.markDirty();
}
function discardEdit() {
pendingEdit.value?.undo?.();
pendingEdit.value = null;
}
function instrOnChange(instr) {
return instr
? makeChangeHandler(instr)
: () => props.store.markDirty();
}
return () => {
const node = focused();
const children = [];
if (!node || node.type === 'score') {
const model = props.store.scoreModel;
if (!model) return h('div', { class: 'se-fo-pane' }, 'No score loaded');
return h('div', { class: 'se-fo-pane' }, [
h('h4', H4, 'Score'),
h(ObjectExtended, {
fields: scoreInfoFields(model.info),
onChange: ({ key, value }) => {
if (!model.info) model.info = {};
model.info[key] = value;
props.store.markDirty();
},
}),
]);
}
if (node.type === 'instrument') {
children.push(
h('h4', H4, `Instrument: ${node.name}`),
h(ObjectExtended, { fields: instrFields(node), onChange: null }),
);
} else if (node.type === 'variation') {
const instr = props.store.scoreModel.instruments.find(
i => i.variations.includes(node) ||
i.variations.some(v => v.subvariations.includes(node))
) ?? null;
const onChange = instrOnChange(instr);
children.push(
h('h4', H4, 'Variation'),
h(ObjectExtended, { fields: variationFields(node), onChange: ({ key, value }) => {
if (key === 'depends_on') {
const old = node.dependsOn;
node.dependsOn = value;
onChange({ undo: () => { node.dependsOn = old; } });
} else {
onChange({});
}
}}),
node.basicProperties
? h(EnvelopeEditor, { basicProperties: node.basicProperties, onChange })
: null,
);
} else if (node.type === 'label_spec') {
const instr = props.store.scoreModel.instruments.find(
i => i.variations.some(v =>
v.labelSpecs.includes(node) ||
v.subvariations.some(sv => sv.labelSpecs.includes(node))
)
) ?? null;
const onChange = instrOnChange(instr);
children.push(
h('h4', H4, `Label: ${node.label}`),
node.basicProperties
? h(EnvelopeEditor, { basicProperties: node.basicProperties, onChange })
: null,
);
} else if (node.type === 'article') {
const markDirty = () => { node.isDirty = true; props.store.markDirty(); };
const rowStyle = 'display:flex;gap:0.4rem;align-items:center;padding:0.2rem 0';
children.push(
h('h4', H4, `Article: ${node.name}`),
h('ul', { class: 'se-article-props', style: 'list-style:none;padding:0;margin:0' }, [
...node.properties.map((p, idx) => {
const isOverwrite = p.scope === 'overwrites';
const flip = () => { p.scope = isOverwrite ? 'defaults' : 'overwrites'; markDirty(); };
const onKeyInput = (e) => { p.name = e.target.value; markDirty(); };
const onValInput = (e) => { p.value = coerce(e.target.value); markDirty(); };
const onRemove = () => { node.properties.splice(idx, 1); markDirty(); };
return h('li', { key: idx, style: rowStyle }, [
h('input', {
class: 'se-prop-key',
value: p.name,
style: 'flex:1;min-width:6em',
onInput: onKeyInput,
}),
h('span', null, '='),
h('input', {
class: 'se-prop-val',
value: String(p.value),
style: 'flex:1;min-width:6em',
onInput: onValInput,
}),
h('div', { class: 'form-check form-switch mb-0' }, [
h('input', {
class: 'form-check-input mt-0',
type: 'checkbox',
role: 'switch',
id: `prop-scope-${idx}`,
checked: isOverwrite,
onChange: flip,
}),
h('label', {
class: 'form-check-label',
for: `prop-scope-${idx}`,
}, isOverwrite ? 'overwrite' : 'default'),
]),
h('button', {
class: 'se-btn-remove',
title: 'Remove property',
onClick: onRemove,
}, '×'),
]);
}),
h('li', { key: '__add', style: rowStyle },
h('button', {
class: 'se-btn',
onClick: () => {
node.properties.push({ name: '', value: '', scope: 'defaults' });
markDirty();
},
}, '+ add property')),
]),
);
} else if (node.type === 'bar') {
const markBarDirty = () => { node.isDirty = true; props.store.markDirty(); };
children.push(
h('h4', H4, `Bar: ${node.id}`),
h(ObjectExtended, {
fields: [
{ key: 'id', value: node.id, editable: 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 === 'beats_per_minute') node.tempoLevels = isNaN(value) ? null : value;
if (key === 'stress_pattern') node.stressor = parseStressor(value);
markBarDirty();
},
}),
shapeSection('Upper stress bound', node.upperStressBound, markBarDirty),
shapeSection('Lower stress bound', node.lowerStressBound, markBarDirty),
shapeSection('Tempo shape', node.tempoShape, markBarDirty),
);
} else if (node.type === 'voice') {
children.push(
h('h4', H4, `Voice: ${node.name}`),
h(ObjectExtended, {
fields: [
{ key: 'articles', value: node.articles.join(', ') || '—', editable: false },
{ key: 'motifs', value: node.motifs.map(m => m.label).join(', ') || '—', editable: false },
{ key: 'offsets', value: String(node.offsets.length), editable: false },
],
onChange: null,
}),
);
} else if (node.type === 'offset') {
const snLabel = sn => `${sn.pitch}${sn.effLength != null ? ' /' + sn.effLength : ''}${sn.clauses.length ? ' ×' + sn.clauses.length : ''}`;
children.push(
h('h4', H4, `Tick: ${node.tick}`),
h(ObjectExtended, {
fields: [
{ key: 'tick', value: node.tick, editable: false },
{ key: 'stem notes', value: node.stemNotes.map(snLabel).join(', ') || '—', editable: false },
node.motifs?.length
? { key: 'motifs', value: node.motifs.map(m => m.chord ? `${m.label}(${m.chord})` : m.label).join(', '), editable: false }
: null,
...unknownPropFields(node),
].filter(Boolean),
onChange: null,
}),
);
} else if (node.type === 'motif') {
const pitchLabel = sn => (Number.isInteger(sn.pitch) ? `(ref${sn.pitch !== 0 ? sn.pitch : ''})` : String(sn.pitch))
+ (sn.clauses.length ? ' ×' + sn.clauses.length : '');
children.push(
h('h4', H4, `Motif: ${node.label}`),
h(ObjectExtended, {
fields: [
{ key: 'label', value: node.label, editable: false },
{ key: 'static', value: node.isStatic, editable: false, type: 'boolean' },
{ key: 'stem notes', value: node.stemNotes.map(pitchLabel).join(', ') || '—', editable: false },
...unknownPropFields(node),
],
onChange: null,
}),
);
} else if (node.type === 'stem_note') {
const bar = props.store.focusPath.find(n => n.type === 'bar') ?? null;
const markDirty = () => {
node.isDirty = true;
if (bar) bar.isDirty = true;
props.store.markDirty();
};
children.push(
h('h4', H4, `Stem note: ${node.pitch}`),
h(ObjectExtended, {
fields: [
{ key: 'pitch', value: node.pitch, editable: true },
{ key: 'length', value: node.length ?? '', editable: true },
{ key: 'weight', value: node.weight != null ? String(node.weight) : '', editable: true, type: 'number' },
{ key: 'adj_stress', value: node.adjStress != null ? String(node.adjStress) : '', editable: true, type: 'number' },
{ key: 'chain', value: node.chainText, editable: true },
{ key: 'clauses', value: String(node.clauses.length), editable: false },
...unknownPropFields(node),
],
onChange: ({ key, value }) => {
if (key === 'pitch') node.pitch = value;
if (key === 'length') node.length = value || null;
if (key === 'weight') node.weight = value === '' ? null : Number(value);
if (key === 'adj_stress') node.adjStress = value === '' ? null : Number(value);
if (key === 'chain') node.chainText = value;
markDirty();
},
}),
);
} else {
children.push(h('pre', { style: 'font-size:0.75rem;white-space:pre-wrap' },
JSON.stringify(node, null, 2)));
}
return h('div', { class: 'se-fo-pane' }, [
...children,
pendingEdit.value
? h(LinkedInstrumentModal, {
instrumentName: pendingEdit.value.instr.name,
onEmbed: () => embedInstrument(pendingEdit.value.instr),
onDiscard: discardEdit,
})
: null,
]);
};
},
};