c0dev0id a91b5747a3 Article scope: render an actual sliding toggle, not an ASCII-labelled button
Previous commit took the user's (O-) / (-O) shorthand literally and put
those glyphs as the button label. They were describing the visual states
of a real toggle switch, not text to display.

Now: pill-shaped track with a sliding circular thumb, "default" and
"overwrite" labels flanking it (the active side brightens). The button
carries role="switch" + aria-checked for accessibility. Click anywhere
on the track to flip and mark the score dirty.
2026-06-28 20:37:53 +02:00

289 lines
14 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';
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 = () => props.store.markDirty();
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(); };
return h('li', { key: idx, style: 'display:flex;gap:0.5rem;align-items:center;padding:0.25rem 0' }, [
h('span', { style: 'flex:1' }, `${p.name} = ${p.value}`),
h('span', { class: ['se-toggle-label', !isOverwrite ? 'active' : null], style: 'text-align:right' }, 'default'),
h('button', {
class: 'se-toggle',
role: 'switch',
'aria-checked': String(isOverwrite),
title: isOverwrite ? 'overwrites — click to switch to defaults' : 'defaults — click to switch to overwrites',
onClick: flip,
}),
h('span', { class: ['se-toggle-label', isOverwrite ? 'active' : null] }, 'overwrite'),
]);
})
),
);
} 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,
]);
};
},
};