forked from flow/vue3js-app-proposal-for-sdk-claude
Navigation: - SubObjects: bar → voice (with chevron when offsets exist) → offsets - PaneCP shortView handles voice and offset types FO pane: - Bar: editable beats_per_minute, stress_pattern (serialized as groups string), upper/lower stress bounds and tempo shape via ShapeEditor; sets bar.isDirty on change - Voice: read-only name, articles, motifs, offset count - Offset: read-only tick, stem notes and clusters as label strings (note re-serialization deferred) Export: - exporter.patchScore() now accepts optional bars[] parameter - Splits rawScoreText by \n---\n; instruments patched in header, dirty bar _meta blocks regenerated, voice note lines kept verbatim - ast-parser buildBar() adds isDirty:false - PaneCP passes scoreModel.bars to patchScore
234 lines
10 KiB
JavaScript
234 lines
10 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';
|
|
|
|
function stressorToString(s) {
|
|
if (!s?.groups?.length) return '';
|
|
return s.groups.map(g => g.join(',')).join(';');
|
|
}
|
|
|
|
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 },
|
|
];
|
|
}
|
|
|
|
export const PaneFO = {
|
|
props: ['store'],
|
|
setup(props) {
|
|
// Pending edit held while the linked-instrument modal is shown.
|
|
const pendingEdit = ref(null); // { instr, apply: fn }
|
|
|
|
function focused() {
|
|
const fp = props.store.focusPath;
|
|
return fp.length ? fp[fp.length - 1] : null;
|
|
}
|
|
|
|
// Returns a change handler that intercepts the first edit to a linked
|
|
// instrument and shows the embed-or-discard modal before applying it.
|
|
// `info` may carry { undo } forwarded from ShapeEditor / EnvelopeEditor.
|
|
function makeChangeHandler(instr, apply) {
|
|
return (info) => {
|
|
if (instr.isLinked && !instr.isDirty) {
|
|
pendingEdit.value = { instr, apply, undo: info?.undo };
|
|
} else {
|
|
apply();
|
|
instr.isDirty = true;
|
|
props.store.markDirty();
|
|
}
|
|
};
|
|
}
|
|
|
|
function embedInstrument(instr) {
|
|
instr.name = instr.name.split('/').pop();
|
|
instr.isLinked = false;
|
|
instr.isDirty = true;
|
|
pendingEdit.value.apply();
|
|
pendingEdit.value = null;
|
|
props.store.markDirty();
|
|
}
|
|
|
|
function discardEdit() {
|
|
pendingEdit.value?.undo?.();
|
|
pendingEdit.value = null;
|
|
}
|
|
|
|
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', { style: 'margin:0 0 0.5rem' }, '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', { style: 'margin:0 0 0.5rem' }, `Instrument: ${node.name}`),
|
|
h(ObjectExtended, { fields: instrFields(node), onChange: null }),
|
|
);
|
|
} else if (node.type === 'variation') {
|
|
// Find the ancestor instrument for linked-instrument gating.
|
|
const instr = props.store.scoreModel?.instruments.find(
|
|
i => i.variations?.includes(node) ||
|
|
i.variations?.some(v => v.subvariations?.includes(node))
|
|
) ?? null;
|
|
|
|
const onChange = instr
|
|
? makeChangeHandler(instr, () => { props.store.markDirty(); })
|
|
: () => props.store.markDirty();
|
|
|
|
children.push(
|
|
h('h4', { style: 'margin:0 0 0.5rem' }, '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 = instr
|
|
? makeChangeHandler(instr, () => { props.store.markDirty(); })
|
|
: () => props.store.markDirty();
|
|
|
|
children.push(
|
|
h('h4', { style: 'margin:0 0 0.5rem' }, `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', { style: 'margin:0 0 0.5rem' }, `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();
|
|
},
|
|
}),
|
|
node.upperStressBound ? h('div', { style: 'margin-top:0.5rem' }, [
|
|
h('strong', null, 'Upper stress bound'),
|
|
h(ShapeEditor, { shape: node.upperStressBound, onChange: markBarDirty }),
|
|
]) : null,
|
|
node.lowerStressBound ? h('div', { style: 'margin-top:0.5rem' }, [
|
|
h('strong', null, 'Lower stress bound'),
|
|
h(ShapeEditor, { shape: node.lowerStressBound, onChange: markBarDirty }),
|
|
]) : null,
|
|
node.tempoShape ? h('div', { style: 'margin-top:0.5rem' }, [
|
|
h('strong', null, 'Tempo shape'),
|
|
h(ShapeEditor, { shape: node.tempoShape, onChange: markBarDirty }),
|
|
]) : null,
|
|
);
|
|
} else if (node.type === 'voice') {
|
|
children.push(
|
|
h('h4', { style: 'margin:0 0 0.5rem' }, `Voice: ${node.name}`),
|
|
h(ObjectExtended, {
|
|
fields: [
|
|
{ key: 'articles', value: node.articles.join(', ') || '—', editable: false },
|
|
{ key: 'motifs', value: node.motifs.join(', ') || '—', editable: false },
|
|
{ key: 'offsets', value: String(node.offsets.length), editable: false },
|
|
],
|
|
onChange: null,
|
|
}),
|
|
);
|
|
} else if (node.type === 'offset') {
|
|
const noteStr = n => `${n.pitch}${n.effLength != null ? ' ' + n.effLength : ''}`;
|
|
const clusterStr = c => c.notes.length
|
|
? c.notes.map(n => `${n.letter ?? ''}${n.shift != null ? n.shift : ''}${n.length != null ? ' ' + n.length : ''}`).join(', ')
|
|
: `cluster[${c.index}]`;
|
|
children.push(
|
|
h('h4', { style: 'margin:0 0 0.5rem' }, `Tick: ${node.tick}`),
|
|
h(ObjectExtended, {
|
|
fields: [{ key: 'tick', value: node.tick, editable: false }],
|
|
onChange: null,
|
|
}),
|
|
node.stemNotes.length ? h('div', { style: 'margin-top:0.5rem' }, [
|
|
h('strong', null, 'Stem notes'),
|
|
h('ul', { class: 'se-object-list' }, node.stemNotes.map((n, i) =>
|
|
h('li', { class: 'se-object-item', key: i },
|
|
h('span', { class: 'se-object-label' }, noteStr(n))))),
|
|
]) : null,
|
|
node.clusters.length ? h('div', { style: 'margin-top:0.5rem' }, [
|
|
h('strong', null, 'Clusters'),
|
|
h('ul', { class: 'se-object-list' }, node.clusters.map((c, i) =>
|
|
h('li', { class: 'se-object-item', key: i },
|
|
h('span', { class: 'se-object-label' }, clusterStr(c))))),
|
|
]) : null,
|
|
);
|
|
} 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,
|
|
]);
|
|
};
|
|
},
|
|
};
|