Added files generated by Claude, prompted by sdk based on the md files
This commit is contained in:
62
static/components/AppShell.js
Normal file
62
static/components/AppShell.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { h, ref, onMounted } from 'vue';
|
||||
import { PaneCP } from './PaneCP.js';
|
||||
import { PaneFO } from './PaneFO.js';
|
||||
import { PaneSubObjects } from './PaneSubObjects.js';
|
||||
import { ImportDialog } from './ImportDialog.js';
|
||||
|
||||
const PANES = [
|
||||
{ id: 'cp', label: 'Position' },
|
||||
{ id: 'fo', label: 'Object' },
|
||||
{ id: 'sub', label: 'Sub-objects' },
|
||||
];
|
||||
|
||||
export const AppShell = {
|
||||
props: ['store', 'importOnLoad'],
|
||||
setup(props) {
|
||||
const activePane = ref('cp');
|
||||
const showImport = ref(false);
|
||||
|
||||
function openImport() {
|
||||
if (!props.store.isDirty) showImport.value = true;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.importOnLoad && !props.store.isDirty) showImport.value = true;
|
||||
});
|
||||
|
||||
return () => {
|
||||
const store = props.store;
|
||||
|
||||
return h('div', { class: 'se-shell' }, [
|
||||
// Pane area
|
||||
h('div', { class: 'se-pane-area' }, [
|
||||
h('div', { class: ['se-pane', activePane.value === 'cp' ? 'active' : null] },
|
||||
h(PaneCP, { store, onImportClick: openImport })),
|
||||
h('div', { class: ['se-pane', activePane.value === 'fo' ? 'active' : null] },
|
||||
h(PaneFO, { store })),
|
||||
h('div', { class: ['se-pane', activePane.value === 'sub' ? 'active' : null] },
|
||||
h(PaneSubObjects, { store })),
|
||||
]),
|
||||
|
||||
// Handle bar (tab switcher at bottom)
|
||||
h('div', { class: 'se-handle-bar' }, PANES.map(p =>
|
||||
h('button', {
|
||||
key: p.id,
|
||||
class: ['se-handle', activePane.value === p.id ? 'active' : null],
|
||||
onClick: () => { activePane.value = p.id; },
|
||||
}, p.label)
|
||||
)),
|
||||
|
||||
// Error banner
|
||||
store.errorMessage
|
||||
? h('div', { class: 'se-error', style: 'margin:0' }, store.errorMessage)
|
||||
: null,
|
||||
|
||||
// Import dialog
|
||||
showImport.value
|
||||
? h(ImportDialog, { store, onClose: () => { showImport.value = false; } })
|
||||
: null,
|
||||
]);
|
||||
};
|
||||
},
|
||||
};
|
||||
74
static/components/EnvelopeEditor.js
Normal file
74
static/components/EnvelopeEditor.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { h, computed } from 'vue';
|
||||
import { ShapeEditor } from './ShapeEditor.js';
|
||||
|
||||
// Default shapes used when toggling a section on for the first time.
|
||||
function defaultShape(section) {
|
||||
switch (section) {
|
||||
case 'A': return { length: 10, start: 0, z: 1, coords: [{ x: 1, y: 10, z: 1, isSharp: false }] };
|
||||
case 'S': return { length: 10, start: 0, z: 1, coords: [{ x: 1, y: 100, z: 1, isSharp: false }] };
|
||||
case 'R': return { length: 5, start: 0, z: 1, coords: [{ x: 1, y: 0, z: 1, isSharp: false }] };
|
||||
default: return { length: 1, start: 0, z: 1, coords: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// Co-dependence:
|
||||
// - Attack ending y=0 → disable S and R
|
||||
// - S absent → R disabled
|
||||
function attackEndsAtZero(shape) {
|
||||
if (!shape?.coords?.length) return false;
|
||||
return shape.coords[shape.coords.length - 1].y === 0;
|
||||
}
|
||||
|
||||
export const EnvelopeEditor = {
|
||||
props: ['basicProperties', 'onChange'],
|
||||
setup(props) {
|
||||
function toggle(section) {
|
||||
const bp = props.basicProperties;
|
||||
if (bp[section]) {
|
||||
bp[section] = null;
|
||||
} else {
|
||||
bp[section] = defaultShape(section);
|
||||
}
|
||||
props.onChange?.();
|
||||
}
|
||||
|
||||
return () => {
|
||||
const bp = props.basicProperties;
|
||||
if (!bp) return null;
|
||||
|
||||
const aZero = attackEndsAtZero(bp.A);
|
||||
const sDisabled = aZero;
|
||||
const rDisabled = aZero || !bp.S;
|
||||
|
||||
function section(key, label, disabled) {
|
||||
const active = !!bp[key];
|
||||
return h('div', { class: 'se-envelope-section' }, [
|
||||
h('div', { class: 'se-envelope-toggle' }, [
|
||||
h('input', {
|
||||
type: 'checkbox',
|
||||
id: `env-${key}`,
|
||||
checked: active,
|
||||
disabled,
|
||||
onChange: () => !disabled && toggle(key),
|
||||
}),
|
||||
h('label', { for: `env-${key}` }, label),
|
||||
]),
|
||||
active
|
||||
? h('div', { class: disabled ? 'se-envelope-disabled' : null }, [
|
||||
h(ShapeEditor, {
|
||||
shape: bp[key],
|
||||
onChange: props.onChange,
|
||||
}),
|
||||
])
|
||||
: null,
|
||||
]);
|
||||
}
|
||||
|
||||
return h('div', null, [
|
||||
section('A', 'Attack', false),
|
||||
section('S', 'Sustain', sDisabled),
|
||||
section('R', 'Release', rDisabled),
|
||||
]);
|
||||
};
|
||||
},
|
||||
};
|
||||
66
static/components/ImportDialog.js
Normal file
66
static/components/ImportDialog.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { h, ref } from 'vue';
|
||||
import { fetchAstLog } from '../api.js';
|
||||
import { parseAstLog, buildModel } from '../ast-parser.js';
|
||||
|
||||
export const ImportDialog = {
|
||||
props: ['store'],
|
||||
emits: ['close'],
|
||||
setup(props, { emit }) {
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
async function doImport() {
|
||||
error.value = '';
|
||||
loading.value = true;
|
||||
try {
|
||||
const creds = { username: username.value, password: password.value };
|
||||
const text = await fetchAstLog(creds);
|
||||
const rawTree = parseAstLog(text);
|
||||
props.store.scoreModel = buildModel(rawTree);
|
||||
props.store.credentials = creds;
|
||||
emit('close');
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') emit('close');
|
||||
if (e.key === 'Enter') doImport();
|
||||
}
|
||||
|
||||
return () => h('div', null, [
|
||||
h('div', { class: 'se-overlay', onClick: () => emit('close') }),
|
||||
h('div', { class: 'se-import-dialog', onKeydown: onKey, tabindex: -1 }, [
|
||||
h('h3', null, 'Import from server'),
|
||||
h('label', null, 'Username'),
|
||||
h('input', {
|
||||
type: 'text',
|
||||
value: username.value,
|
||||
onInput: e => { username.value = e.target.value; },
|
||||
autocomplete: 'username',
|
||||
}),
|
||||
h('label', null, 'Password'),
|
||||
h('input', {
|
||||
type: 'password',
|
||||
value: password.value,
|
||||
onInput: e => { password.value = e.target.value; },
|
||||
autocomplete: 'current-password',
|
||||
}),
|
||||
error.value ? h('div', { class: 'se-error' }, error.value) : null,
|
||||
h('div', { class: 'se-dialog-buttons' }, [
|
||||
h('button', { class: 'se-btn', onClick: () => emit('close') }, 'Cancel'),
|
||||
h('button', {
|
||||
class: 'se-btn se-btn-primary',
|
||||
onClick: doImport,
|
||||
disabled: loading.value,
|
||||
}, loading.value ? 'Loading…' : 'Import'),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
},
|
||||
};
|
||||
28
static/components/LinkedInstrumentModal.js
Normal file
28
static/components/LinkedInstrumentModal.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { h } from 'vue';
|
||||
|
||||
// Shown when the user edits a linked instrument (name contains '/').
|
||||
// onEmbed: drop path prefix, instrument becomes embedded.
|
||||
// onDiscard: revert the pending edit, keep instrument linked/unchanged.
|
||||
export const LinkedInstrumentModal = {
|
||||
props: ['instrumentName', 'onEmbed', 'onDiscard'],
|
||||
setup(props) {
|
||||
const basename = props.instrumentName.split('/').pop();
|
||||
return () => h('div', null, [
|
||||
h('div', { class: 'se-overlay' }),
|
||||
h('div', { class: 'se-import-dialog' }, [
|
||||
h('h3', null, 'Linked instrument edited'),
|
||||
h('p', { style: 'font-size:0.85rem;margin:0 0 1rem' }, [
|
||||
`Instrument `,
|
||||
h('code', null, props.instrumentName),
|
||||
` is linked. Embed it as `,
|
||||
h('code', null, basename),
|
||||
`? If not, this edit is discarded.`,
|
||||
]),
|
||||
h('div', { class: 'se-dialog-buttons' }, [
|
||||
h('button', { class: 'se-btn', onClick: props.onDiscard }, 'Discard edit'),
|
||||
h('button', { class: 'se-btn se-btn-primary', onClick: props.onEmbed }, 'Embed'),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
},
|
||||
};
|
||||
15
static/components/ObjectBasic.js
Normal file
15
static/components/ObjectBasic.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { h } from 'vue';
|
||||
|
||||
// Inline identifier + a few key props — used for list rows.
|
||||
export const ObjectBasic = {
|
||||
props: ['label', 'meta'], // meta: array of {key, value} pairs
|
||||
setup(props) {
|
||||
return () => h('div', { class: 'se-object-label' }, [
|
||||
h('strong', null, props.label),
|
||||
...(props.meta ?? []).map(({ key, value }) =>
|
||||
h('span', { style: 'color:#888;margin-left:0.5rem;font-size:0.8em' },
|
||||
`${key}=${value}`)
|
||||
),
|
||||
]);
|
||||
},
|
||||
};
|
||||
37
static/components/ObjectExtended.js
Normal file
37
static/components/ObjectExtended.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { h } from 'vue';
|
||||
|
||||
// Extended property view rendered as a definition list.
|
||||
// `fields`: array of { key, value, editable?, type? }
|
||||
// `onChange`: called with { key, value } when field changes.
|
||||
export const ObjectExtended = {
|
||||
props: ['fields', 'onChange'],
|
||||
setup(props) {
|
||||
function onInput(key, type, rawValue) {
|
||||
let value = rawValue;
|
||||
if (type === 'number') value = parseFloat(rawValue);
|
||||
if (type === 'boolean') value = rawValue === 'true';
|
||||
props.onChange?.({ key, value });
|
||||
}
|
||||
|
||||
return () => h('dl', null, (props.fields ?? []).flatMap(f => [
|
||||
h('dt', null, f.key),
|
||||
h('dd', null, f.editable
|
||||
? (f.type === 'boolean'
|
||||
? h('select', {
|
||||
value: String(f.value),
|
||||
onChange: e => onInput(f.key, 'boolean', e.target.value),
|
||||
}, [
|
||||
h('option', { value: 'true' }, 'True'),
|
||||
h('option', { value: 'false' }, 'False'),
|
||||
])
|
||||
: h('input', {
|
||||
type: f.type === 'number' ? 'number' : 'text',
|
||||
value: f.value ?? '',
|
||||
onInput: e => onInput(f.key, f.type, e.target.value),
|
||||
})
|
||||
)
|
||||
: h('span', null, String(f.value ?? '—'))
|
||||
),
|
||||
]));
|
||||
},
|
||||
};
|
||||
23
static/components/ObjectShort.js
Normal file
23
static/components/ObjectShort.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { h } from 'vue';
|
||||
|
||||
// One-line summary row with a drill-down chevron.
|
||||
export const ObjectShort = {
|
||||
props: ['node', 'label', 'typeTag', 'focused', 'hasChildren'],
|
||||
emits: ['focus', 'drillDown'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('li', {
|
||||
class: ['se-object-item', props.focused ? 'focused' : null],
|
||||
onClick: () => emit('focus'),
|
||||
}, [
|
||||
props.typeTag ? h('span', { class: 'se-object-type' }, props.typeTag) : null,
|
||||
h('span', { class: 'se-object-label' }, props.label),
|
||||
props.hasChildren
|
||||
? h('button', {
|
||||
class: 'se-chevron',
|
||||
title: 'Drill down',
|
||||
onClick: e => { e.stopPropagation(); emit('drillDown'); },
|
||||
}, '›')
|
||||
: null,
|
||||
]);
|
||||
},
|
||||
};
|
||||
98
static/components/PaneCP.js
Normal file
98
static/components/PaneCP.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import { h, ref } from 'vue';
|
||||
import { fetchScoreText, putScoreText } from '../api.js';
|
||||
import { patchScore } from '../exporter.js';
|
||||
import { StatusPoller } from './StatusPoller.js';
|
||||
|
||||
export const PaneCP = {
|
||||
props: ['store', 'onImportClick'],
|
||||
setup(props) {
|
||||
const exporting = ref(false);
|
||||
const exportError = ref('');
|
||||
|
||||
function breadcrumbLabel(node) {
|
||||
if (!node) return '?';
|
||||
if (node.type === 'score') return 'Score';
|
||||
if (node.type === 'instrument') return node.name;
|
||||
if (node.type === 'variation') return node.dependsOn ? `var(${node.dependsOn})` : 'variation';
|
||||
if (node.type === 'label_spec') return node.label ?? 'label';
|
||||
if (node.type === 'bar') return node.id;
|
||||
return node.type;
|
||||
}
|
||||
|
||||
async function doExport() {
|
||||
exportError.value = '';
|
||||
exporting.value = true;
|
||||
try {
|
||||
const raw = await fetchScoreText(props.store.credentials);
|
||||
props.store.rawScoreText = raw;
|
||||
const patched = patchScore(raw, props.store.scoreModel.instruments);
|
||||
await putScoreText(patched, props.store.credentials);
|
||||
// Start polling
|
||||
props.store.synthesisStatus = { frozen: false, progress: 0 };
|
||||
} catch (e) {
|
||||
exportError.value = e.message;
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
const store = props.store;
|
||||
const model = store.scoreModel;
|
||||
const fp = store.focusPath;
|
||||
|
||||
return h('div', { class: 'se-pane' }, [
|
||||
// Header
|
||||
h('div', { class: 'se-cp-header' }, [
|
||||
h('span', { class: 'se-cp-title' },
|
||||
model ? (model.info?.title ?? 'Untitled score') : 'No score loaded'),
|
||||
h('button', {
|
||||
class: 'se-btn',
|
||||
disabled: store.isDirty,
|
||||
title: store.isDirty ? 'Save or discard edits before re-importing' : 'Import from server',
|
||||
onClick: props.onImportClick,
|
||||
}, 'Import'),
|
||||
model ? h('button', {
|
||||
class: 'se-btn se-btn-primary',
|
||||
disabled: !store.isDirty || exporting.value,
|
||||
onClick: doExport,
|
||||
}, exporting.value ? 'Exporting…' : 'Export') : null,
|
||||
]),
|
||||
|
||||
// Breadcrumb
|
||||
fp.length ? h('div', { class: 'se-breadcrumb' }, [
|
||||
h('span', { onClick: () => store.setFocus([]) }, 'Score'),
|
||||
...fp.map((node, i) => [
|
||||
' › ',
|
||||
h('span', { onClick: () => store.setFocus(fp.slice(0, i + 1)) },
|
||||
breadcrumbLabel(node)),
|
||||
]).flat(),
|
||||
]) : null,
|
||||
|
||||
// Score info
|
||||
model ? h('dl', { style: 'font-size:0.8rem;margin:0.5rem 0' }, [
|
||||
h('dt', null, 'Instruments'),
|
||||
h('dd', null, String(model.instruments.length)),
|
||||
h('dt', null, 'Bars'),
|
||||
h('dd', null, String(model.bars.length)),
|
||||
]) : null,
|
||||
|
||||
// Export error
|
||||
exportError.value ? h('div', { class: 'se-error' }, exportError.value) : null,
|
||||
|
||||
// Status poller (shown after export started)
|
||||
store.synthesisStatus && !store.synthesisStatus.frozen
|
||||
? h(StatusPoller, { store })
|
||||
: null,
|
||||
|
||||
// Result link
|
||||
store.synthesisStatus?.frozen && !store.synthesisStatus?.error
|
||||
? h('a', {
|
||||
href: '/sompyle/result.mp3',
|
||||
style: 'display:block;margin-top:0.5rem',
|
||||
}, 'Download result')
|
||||
: null,
|
||||
]);
|
||||
};
|
||||
},
|
||||
};
|
||||
138
static/components/PaneFO.js
Normal file
138
static/components/PaneFO.js
Normal file
@@ -0,0 +1,138 @@
|
||||
import { h, ref } from 'vue';
|
||||
import { ObjectExtended } from './ObjectExtended.js';
|
||||
import { EnvelopeEditor } from './EnvelopeEditor.js';
|
||||
import { LinkedInstrumentModal } from './LinkedInstrumentModal.js';
|
||||
|
||||
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.
|
||||
function makeChangeHandler(instr, apply) {
|
||||
return () => {
|
||||
if (instr.isLinked && !instr.isDirty) {
|
||||
// Stash the apply callback and show the modal.
|
||||
pendingEdit.value = { instr, apply };
|
||||
} 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 = null;
|
||||
}
|
||||
|
||||
return () => {
|
||||
const node = focused();
|
||||
const children = [];
|
||||
|
||||
if (!node || node.type === 'score') {
|
||||
return h('div', { class: 'se-fo-pane' }, 'Nothing selected');
|
||||
}
|
||||
|
||||
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') node.dependsOn = value;
|
||||
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') {
|
||||
children.push(
|
||||
h('h4', { style: 'margin:0 0 0.5rem' }, `Bar: ${node.id}`),
|
||||
h(ObjectExtended, { fields: [
|
||||
{ key: 'movement', value: node.movement },
|
||||
{ key: 'part', value: node.part },
|
||||
{ key: 'line', value: node.line },
|
||||
{ key: 'measure', value: node.measure },
|
||||
], onChange: 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,
|
||||
]);
|
||||
};
|
||||
},
|
||||
};
|
||||
67
static/components/PaneSubObjects.js
Normal file
67
static/components/PaneSubObjects.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { h } from 'vue';
|
||||
import { ObjectShort } from './ObjectShort.js';
|
||||
|
||||
// Shows sub-objects of the currently focused node — variations, bars, voices, etc.
|
||||
export const PaneSubObjects = {
|
||||
props: ['store'],
|
||||
setup(props) {
|
||||
function focused() {
|
||||
const fp = props.store.focusPath;
|
||||
return fp.length ? fp[fp.length - 1] : props.store.scoreModel;
|
||||
}
|
||||
|
||||
function subItems(node) {
|
||||
if (!node) return [];
|
||||
if (node.type === 'score') {
|
||||
return [
|
||||
...node.instruments.map(i => ({ kind: 'instrument', node: i, label: i.name })),
|
||||
...node.bars.map(b => ({ kind: 'bar', node: b, label: b.id })),
|
||||
];
|
||||
}
|
||||
if (node.type === 'instrument') {
|
||||
return node.variations.map((v, idx) => ({
|
||||
kind: 'variation',
|
||||
node: v,
|
||||
label: `variation ${idx + 1}${v.dependsOn ? ` (${v.dependsOn})` : ''}`,
|
||||
}));
|
||||
}
|
||||
if (node.type === 'variation') {
|
||||
return [
|
||||
...node.labelSpecs.map(ls => ({
|
||||
kind: 'label_spec', node: ls, label: ls.label ?? '(no label)',
|
||||
})),
|
||||
...node.subvariations.map((sv, idx) => ({
|
||||
kind: 'variation', node: sv, label: `subvariation ${idx + 1}`,
|
||||
})),
|
||||
];
|
||||
}
|
||||
if (node.type === 'bar') {
|
||||
return Object.entries(node.voices).map(([name, v]) => ({
|
||||
kind: 'voice', node: v, label: name,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
return () => {
|
||||
const node = focused();
|
||||
const items = subItems(node);
|
||||
if (!items.length) return h('div', { class: 'se-pane' }, h('em', null, 'No sub-objects'));
|
||||
|
||||
return h('div', { class: 'se-pane' }, [
|
||||
h('ul', { class: 'se-object-list' }, items.map((item, idx) =>
|
||||
h(ObjectShort, {
|
||||
key: idx,
|
||||
node: item.node,
|
||||
label: item.label,
|
||||
typeTag: item.kind,
|
||||
focused: props.store.focusPath.includes(item.node),
|
||||
hasChildren: item.kind !== 'bar' && item.kind !== 'voice',
|
||||
onFocus: () => props.store.pushFocus(item.node),
|
||||
onDrillDown: () => props.store.pushFocus(item.node),
|
||||
})
|
||||
)),
|
||||
]);
|
||||
};
|
||||
},
|
||||
};
|
||||
115
static/components/ShapeEditor.js
Normal file
115
static/components/ShapeEditor.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import { h } from 'vue';
|
||||
|
||||
// Renders a shape's coord table with cascade-shift and an SVG preview.
|
||||
// `shape` is mutated in place; `onChange` called after each mutation.
|
||||
|
||||
export const ShapeEditor = {
|
||||
props: ['shape', 'onChange'],
|
||||
setup(props) {
|
||||
function updateCoord(i, field, value) {
|
||||
const coord = props.shape.coords[i];
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num)) return;
|
||||
const old = coord[field];
|
||||
const delta = num - old;
|
||||
coord[field] = num;
|
||||
|
||||
// cascade-shift: if x increased past next coord, shift all following
|
||||
if (field === 'x' && delta > 0) {
|
||||
for (let j = i + 1; j < props.shape.coords.length; j++) {
|
||||
if (props.shape.coords[j].x <= num) {
|
||||
props.shape.coords[j].x += delta;
|
||||
} else break;
|
||||
}
|
||||
}
|
||||
props.onChange?.();
|
||||
}
|
||||
|
||||
function addCoord() {
|
||||
const coords = props.shape.coords;
|
||||
const lastX = coords.length ? coords[coords.length - 1].x + 1 : 1;
|
||||
coords.push({ x: lastX, y: 0, z: 1, isSharp: false });
|
||||
props.onChange?.();
|
||||
}
|
||||
|
||||
function removeCoord(i) {
|
||||
props.shape.coords.splice(i, 1);
|
||||
props.onChange?.();
|
||||
}
|
||||
|
||||
function renderSvg() {
|
||||
const coords = props.shape.coords;
|
||||
if (!coords.length) return h('svg', { class: 'se-shape-svg' });
|
||||
|
||||
const xs = coords.map(c => c.x);
|
||||
const ys = coords.map(c => c.y);
|
||||
const minX = Math.min(...xs), maxX = Math.max(...xs);
|
||||
const minY = Math.min(...ys), maxY = Math.max(...ys);
|
||||
const W = 200, H = 60;
|
||||
const rangeX = maxX - minX || 1;
|
||||
const rangeY = maxY - minY || 1;
|
||||
|
||||
const toSvg = c => {
|
||||
const sx = ((c.x - minX) / rangeX) * W;
|
||||
const sy = H - ((c.y - minY) / rangeY) * H;
|
||||
return [sx, sy];
|
||||
};
|
||||
|
||||
const points = coords.map(c => toSvg(c).join(',') ).join(' ');
|
||||
|
||||
return h('svg', {
|
||||
class: 'se-shape-svg',
|
||||
viewBox: `0 0 ${W} ${H}`,
|
||||
preserveAspectRatio: 'none',
|
||||
}, [
|
||||
h('polyline', {
|
||||
points,
|
||||
fill: 'none',
|
||||
stroke: '#2a6aaa',
|
||||
'stroke-width': '1.5',
|
||||
}),
|
||||
...coords.map(c => {
|
||||
const [sx, sy] = toSvg(c);
|
||||
return h('circle', { cx: sx, cy: sy, r: 2.5, fill: '#6aacff' });
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const shape = props.shape;
|
||||
if (!shape) return null;
|
||||
|
||||
return h('div', { class: 'se-shape-editor' }, [
|
||||
h('table', { class: 'se-shape-table' }, [
|
||||
h('thead', null, h('tr', null, [
|
||||
h('th', null, 'x'), h('th', null, 'y'), h('th', null, 'z'),
|
||||
h('th', null, '♯'), h('th', null, ''),
|
||||
])),
|
||||
h('tbody', null, shape.coords.map((coord, i) =>
|
||||
h('tr', { key: i }, [
|
||||
h('td', null, h('input', {
|
||||
type: 'number', value: coord.x, step: 1,
|
||||
onInput: e => updateCoord(i, 'x', e.target.value),
|
||||
})),
|
||||
h('td', null, h('input', {
|
||||
type: 'number', value: coord.y, step: 1,
|
||||
onInput: e => updateCoord(i, 'y', e.target.value),
|
||||
})),
|
||||
h('td', null, h('input', {
|
||||
type: 'number', value: coord.z ?? 1, step: 0.1,
|
||||
onInput: e => updateCoord(i, 'z', e.target.value),
|
||||
})),
|
||||
h('td', null, h('input', {
|
||||
type: 'checkbox', checked: !!coord.isSharp,
|
||||
onChange: e => { coord.isSharp = e.target.checked; props.onChange?.(); },
|
||||
})),
|
||||
h('td', null, h('button', { onClick: () => removeCoord(i) }, '✕')),
|
||||
])
|
||||
)),
|
||||
]),
|
||||
h('button', { class: 'se-btn', style: 'margin-top:0.3rem', onClick: addCoord }, '+ coord'),
|
||||
renderSvg(),
|
||||
]);
|
||||
};
|
||||
},
|
||||
};
|
||||
55
static/components/StatusPoller.js
Normal file
55
static/components/StatusPoller.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { h, ref, onMounted, onUnmounted } from 'vue';
|
||||
import { fetchStatus } from '../api.js';
|
||||
|
||||
export const StatusPoller = {
|
||||
props: ['store'],
|
||||
setup(props) {
|
||||
const timer = ref(null);
|
||||
const eta = ref(null);
|
||||
|
||||
function nextInterval(status) {
|
||||
if (!status) return 2000;
|
||||
const pct = status.progress ?? 0;
|
||||
// At 0–20%: poll at centile-of-ETA intervals
|
||||
if (eta.value && pct > 0 && pct <= 20) {
|
||||
return Math.max(500, (eta.value * 10) | 0);
|
||||
}
|
||||
return 2000;
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const status = await fetchStatus(props.store.credentials);
|
||||
if (status.eta) eta.value = status.eta;
|
||||
props.store.synthesisStatus = status;
|
||||
if (status.frozen) {
|
||||
// done — stop polling
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// transient error — keep polling
|
||||
}
|
||||
timer.value = setTimeout(poll, nextInterval(props.store.synthesisStatus));
|
||||
}
|
||||
|
||||
onMounted(() => { poll(); });
|
||||
onUnmounted(() => { if (timer.value) clearTimeout(timer.value); });
|
||||
|
||||
return () => {
|
||||
const s = props.store.synthesisStatus;
|
||||
if (!s) return h('div', { class: 'se-status' }, 'Polling…');
|
||||
|
||||
const pct = s.progress ?? 0;
|
||||
const label = s.frozen
|
||||
? (s.error ? `Error: ${s.error}` : 'Done')
|
||||
: `${s.state ?? 'Running'} ${pct}%`;
|
||||
|
||||
return h('div', { class: 'se-status' }, [
|
||||
h('span', null, label),
|
||||
h('div', { class: 'se-status-progress' }, [
|
||||
h('div', { class: 'se-status-bar', style: { width: `${pct}%` } }),
|
||||
]),
|
||||
]);
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user