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, ]); }; }, };