188 lines
8.0 KiB
JavaScript
188 lines
8.0 KiB
JavaScript
import { h, ref, watch, onMounted } from 'vue';
|
|
import { fetchAstLog, fetchScoreText, putScoreText, fetchAudioWidget, URLS } from '../api.js';
|
|
import { parseAstLog, buildModel } from '../ast-parser.js';
|
|
import { patchScore } from '../exporter.js';
|
|
import { StatusPoller } from './StatusPoller.js';
|
|
|
|
// Short label + identifying meta for each node type.
|
|
function shortView(node) {
|
|
if (!node) return { typeTag: '?', label: '?', meta: [] };
|
|
switch (node.type) {
|
|
case 'score':
|
|
return {
|
|
typeTag: 'score',
|
|
label: node.info?.title ?? '(untitled)',
|
|
meta: node.info?.composer ? [{ key: 'composer', value: node.info.composer }] : [],
|
|
};
|
|
case 'instrument':
|
|
return { typeTag: 'instrument', label: node.name, meta: [] };
|
|
case 'variation': {
|
|
const dep = node.dependsOn;
|
|
const label = dep == null ? '(root variation)'
|
|
: isNaN(Number(dep)) ? `ATTR: ${dep}`
|
|
: String(dep);
|
|
return { typeTag: 'variation', label, meta: [] };
|
|
}
|
|
case 'label_spec':
|
|
return { typeTag: 'label', label: node.label ?? '(no label)', meta: [] };
|
|
case 'bar':
|
|
return { typeTag: 'bar', label: node.id, meta: [] };
|
|
case 'voice':
|
|
return { typeTag: 'voice', label: node.name, meta: [] };
|
|
case 'offset':
|
|
return { typeTag: 'tick', label: String(node.tick ?? '?'), meta: [] };
|
|
case 'motif':
|
|
return { typeTag: 'motif', label: node.label, meta: node.isStatic ? [{ key: 'static', value: '✓' }] : [] };
|
|
case 'stem_note':
|
|
return { typeTag: 'stem_note', label: String(node.pitch), meta: [] };
|
|
case 'article': {
|
|
const n = node.properties?.length ?? 0;
|
|
return { typeTag: 'article', label: node.name, meta: n ? [{ key: 'props', value: n }] : [] };
|
|
}
|
|
default:
|
|
return { typeTag: node.type, label: node.type, meta: [] };
|
|
}
|
|
}
|
|
|
|
export const PaneCP = {
|
|
props: ['store', 'importOnLoad', 'onFocusFO'],
|
|
setup(props) {
|
|
const importing = ref(false);
|
|
const importError = ref('');
|
|
const exporting = ref(false);
|
|
const exportError = ref('');
|
|
const audioWidgetHtml = ref('');
|
|
|
|
watch(
|
|
() => props.store.synthesisStatus,
|
|
async (s) => {
|
|
if (!s?.frozen) { audioWidgetHtml.value = ''; return; }
|
|
audioWidgetHtml.value = await fetchAudioWidget(
|
|
s.file_accomplished ? URLS.result : null,
|
|
s.errors ?? null,
|
|
);
|
|
},
|
|
);
|
|
|
|
async function doImport() {
|
|
if (props.store.isDirty) return;
|
|
importError.value = '';
|
|
importing.value = true;
|
|
try {
|
|
const text = await fetchAstLog();
|
|
props.store.scoreModel = buildModel(parseAstLog(text));
|
|
props.store.exportLog = [];
|
|
} catch (e) {
|
|
importError.value = e.message;
|
|
} finally {
|
|
importing.value = false;
|
|
}
|
|
}
|
|
|
|
async function doExport() {
|
|
exportError.value = '';
|
|
exporting.value = true;
|
|
try {
|
|
const raw = await fetchScoreText();
|
|
props.store.rawScoreText = raw;
|
|
const model = props.store.scoreModel;
|
|
const { text: patched, log } = patchScore(
|
|
raw, model.instruments, model.bars, model.info, model.articles ?? [],
|
|
{ articlesModified: !!model.articlesModified },
|
|
);
|
|
model.articlesModified = false;
|
|
props.store.exportLog = log;
|
|
await putScoreText(patched);
|
|
props.store.synthesisStatus = { frozen: false, currently_rendered_notes: 0, notes_in_total: 0 };
|
|
} catch (e) {
|
|
exportError.value = e.message;
|
|
} finally {
|
|
exporting.value = false;
|
|
}
|
|
}
|
|
|
|
onMounted(() => { if (props.importOnLoad) doImport(); });
|
|
|
|
return () => {
|
|
const store = props.store;
|
|
const model = store.scoreModel;
|
|
const fp = store.focusPath;
|
|
|
|
// Build vertical path list: score root + each focus node.
|
|
const pathItems = model ? [
|
|
{ node: model, idx: -1 },
|
|
...fp.map((node, idx) => ({ node, idx })),
|
|
] : [];
|
|
|
|
return h('div', null, [
|
|
// Header
|
|
h('div', { class: 'se-cp-header' }, [
|
|
h('button', {
|
|
class: 'se-btn',
|
|
disabled: store.isDirty || importing.value,
|
|
title: store.isDirty ? 'Save or discard edits before re-importing' : 'Import from server',
|
|
onClick: doImport,
|
|
}, importing.value ? 'Importing…' : '→ Import'),
|
|
h('span', { class: 'se-cp-title' },
|
|
model ? (model.info?.title ?? 'Untitled score') : 'No score loaded'),
|
|
model ? h('button', {
|
|
class: 'se-btn se-btn-primary',
|
|
disabled: !store.isDirty || exporting.value,
|
|
onClick: doExport,
|
|
}, exporting.value ? 'Exporting…' : 'Export ↑') : null,
|
|
]),
|
|
|
|
// Vertical focus path — short views, clickable to navigate up
|
|
pathItems.length ? h('ul', { class: 'se-object-list se-cp-path' },
|
|
pathItems.map(({ node, idx }) => {
|
|
const { typeTag, label, meta } = shortView(node);
|
|
const isCurrent = idx === fp.length - 1 || (idx === -1 && fp.length === 0);
|
|
return h('li', {
|
|
class: ['se-object-item', isCurrent ? 'focused' : null],
|
|
onClick: () => {
|
|
if (idx === -1) store.setFocus([]);
|
|
else store.setFocus(fp.slice(0, idx + 1));
|
|
props.onFocusFO?.();
|
|
},
|
|
}, [
|
|
h('span', { class: 'se-object-type' }, typeTag),
|
|
h('span', { class: 'se-object-label' }, [
|
|
h('strong', null, label),
|
|
...meta.map(({ key, value }) =>
|
|
h('span', { style: 'color:#888;margin-left:0.5rem;font-size:0.8em' },
|
|
`${key}=${value}`)
|
|
),
|
|
]),
|
|
]);
|
|
})
|
|
) : null,
|
|
|
|
// Import / export errors
|
|
importError.value ? h('div', { class: 'se-error' }, importError.value) : null,
|
|
exportError.value ? h('div', { class: 'se-error' }, exportError.value) : null,
|
|
|
|
// Export log (shown after export, cleared on next import)
|
|
store.exportLog?.length
|
|
? h('div', { class: 'se-export-log' },
|
|
store.exportLog.map((entry, i) =>
|
|
h('div', {
|
|
key: i,
|
|
class: entry.level === 'changed' ? 'se-export-changed' : 'se-export-info',
|
|
}, entry.path ? `→ ${entry.path}` : entry.message)
|
|
)
|
|
)
|
|
: null,
|
|
|
|
// Status poller (while running)
|
|
store.synthesisStatus && !store.synthesisStatus.frozen
|
|
? h(StatusPoller, { store }) : null,
|
|
|
|
// Audio widget (rendered server-side from audiowidget.tmpl)
|
|
audioWidgetHtml.value
|
|
? h('div', { innerHTML: audioWidgetHtml.value, style: 'margin-top:0.5rem' })
|
|
: null,
|
|
]);
|
|
};
|
|
},
|
|
};
|