Sompyler commit c3b51bc renamed slots across the board: - offset.stem_note → line.stem_note (parentSlot 'offset' → 'line') - motif.stem_note → line.stem_note (parentSlot 'motif' → 'line') - clause.note/pause/stack → seq.note/pause/stack (parentSlot 'clause' → 'seq') - stem_note.chain is now a real hierarchical node (depth 04) between line.stem_note and chain.clause; clauses are now its children, not direct children of stem_note Also adds: - offset.motifs[] for line.motif invocations at tick positions - stem_note.writeToName from stem_note.write_to positional - adjacent prop already stored; new tests verify True/False/absent PaneFO.js: show offset motif invocations inline (label + chord) test-parser.mjs: update synthetic fixtures to new depths/slot names; add tests for line.motif, stem_note.write_to, adjacent prop
266 lines
12 KiB
JavaScript
266 lines
12 KiB
JavaScript
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';
|
||
|
||
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 === '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,
|
||
]);
|
||
};
|
||
},
|
||
};
|