Multi-pane Sub-objects driven by SLOT of SLOT.SUBTYPE
The bottom handle bar is now dynamic. Fixed CP + FO handles, then one
handle per kind of sub-object the focused node has. "Kind" is the SLOT
part of the AST's SLOT.SUBTYPE namespace: sub-objects sharing a SLOT
(e.g. `line.stem_note` + `line.motif`, or `articles.<subtypeA>` +
`articles.<subtypeB>`) collapse into one pane; different SLOTs split.
Score-level kinds emit, in this order: TU (tuning), ST (stage cone +
voices), IN (instruments — linked ones listed read-only), AR (articles),
BA (bars). info metadata is folded into the FO pane (was previously
listed as a sub-object).
Handles are 2-letter labels for now, intended as alt-text for icons in
a later release.
- `subobject-kinds.js` new: `getKindGroups(node)` returns ordered
`[{ kind, items }]`; `KIND_LABEL` maps kind -> 2-letter label.
- `PaneSubObjects.js` now parameterized by `kind` prop; renders one group.
- `AppShell.js` builds the handle bar from `getKindGroups(focusedNode)`;
drops back to FO when the active sub-pane disappears after a focus change.
- `ObjectShort.js` accepts a `readOnly` flag (used for linked instruments).
This commit is contained in:
parent
b23e243225
commit
913114bb02
@ -1,13 +1,13 @@
|
|||||||
import { h, ref, onMounted } from 'vue';
|
import { h, ref, computed, onMounted, watch } from 'vue';
|
||||||
import { PaneCP } from './PaneCP.js';
|
import { PaneCP } from './PaneCP.js';
|
||||||
import { PaneFO } from './PaneFO.js';
|
import { PaneFO } from './PaneFO.js';
|
||||||
import { PaneSubObjects } from './PaneSubObjects.js';
|
import { PaneSubObjects } from './PaneSubObjects.js';
|
||||||
import { ImportDialog } from './ImportDialog.js';
|
import { ImportDialog } from './ImportDialog.js';
|
||||||
|
import { getKindGroups, KIND_LABEL } from '../subobject-kinds.js';
|
||||||
|
|
||||||
const PANES = [
|
const FIXED_PANES = [
|
||||||
{ id: 'cp', label: 'Position' },
|
{ id: 'cp', label: 'CP', title: 'Current position' },
|
||||||
{ id: 'fo', label: 'Object' },
|
{ id: 'fo', label: 'FO', title: 'Focused object' },
|
||||||
{ id: 'sub', label: 'Sub-objects' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const AppShell = {
|
export const AppShell = {
|
||||||
@ -16,6 +16,25 @@ export const AppShell = {
|
|||||||
const activePane = ref('cp');
|
const activePane = ref('cp');
|
||||||
const showImport = ref(false);
|
const showImport = ref(false);
|
||||||
|
|
||||||
|
function focusedNode() {
|
||||||
|
const fp = props.store.focusPath;
|
||||||
|
return fp.length ? fp[fp.length - 1] : props.store.scoreModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subPanes = computed(() => getKindGroups(focusedNode()).map(g => ({
|
||||||
|
id: `sub:${g.kind}`,
|
||||||
|
kind: g.kind,
|
||||||
|
label: KIND_LABEL[g.kind] ?? g.kind.slice(0, 2).toUpperCase(),
|
||||||
|
title: g.kind,
|
||||||
|
})));
|
||||||
|
|
||||||
|
const panes = computed(() => [...FIXED_PANES, ...subPanes.value]);
|
||||||
|
|
||||||
|
// If active pane disappears after focus change, drop back to FO.
|
||||||
|
watch(panes, ps => {
|
||||||
|
if (!ps.some(p => p.id === activePane.value)) activePane.value = 'fo';
|
||||||
|
});
|
||||||
|
|
||||||
function openImport() {
|
function openImport() {
|
||||||
if (!props.store.isDirty) showImport.value = true;
|
if (!props.store.isDirty) showImport.value = true;
|
||||||
}
|
}
|
||||||
@ -26,33 +45,34 @@ export const AppShell = {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
const store = props.store;
|
const store = props.store;
|
||||||
|
const ap = activePane.value;
|
||||||
|
|
||||||
|
const subPaneNodes = subPanes.value.map(p =>
|
||||||
|
h('div', { key: p.id, class: ['se-pane', ap === p.id ? 'active' : null] },
|
||||||
|
h(PaneSubObjects, { store, kind: p.kind, onFocusFO: () => { activePane.value = 'fo'; } })));
|
||||||
|
|
||||||
return h('div', { class: 'se-shell' }, [
|
return h('div', { class: 'se-shell' }, [
|
||||||
// Pane area
|
|
||||||
h('div', { class: 'se-pane-area' }, [
|
h('div', { class: 'se-pane-area' }, [
|
||||||
h('div', { class: ['se-pane', activePane.value === 'cp' ? 'active' : null] },
|
h('div', { class: ['se-pane', ap === 'cp' ? 'active' : null] },
|
||||||
h(PaneCP, { store, onImportClick: openImport, onFocusFO: () => { activePane.value = 'fo'; } })),
|
h(PaneCP, { store, onImportClick: openImport, onFocusFO: () => { activePane.value = 'fo'; } })),
|
||||||
h('div', { class: ['se-pane', activePane.value === 'fo' ? 'active' : null] },
|
h('div', { class: ['se-pane', ap === 'fo' ? 'active' : null] },
|
||||||
h(PaneFO, { store })),
|
h(PaneFO, { store })),
|
||||||
h('div', { class: ['se-pane', activePane.value === 'sub' ? 'active' : null] },
|
...subPaneNodes,
|
||||||
h(PaneSubObjects, { store, onFocusFO: () => { activePane.value = 'fo'; } })),
|
|
||||||
]),
|
]),
|
||||||
|
|
||||||
// Handle bar (tab switcher at bottom)
|
h('div', { class: 'se-handle-bar' }, panes.value.map(p =>
|
||||||
h('div', { class: 'se-handle-bar' }, PANES.map(p =>
|
|
||||||
h('button', {
|
h('button', {
|
||||||
key: p.id,
|
key: p.id,
|
||||||
class: ['se-handle', activePane.value === p.id ? 'active' : null],
|
class: ['se-handle', ap === p.id ? 'active' : null],
|
||||||
|
title: p.title,
|
||||||
onClick: () => { activePane.value = p.id; },
|
onClick: () => { activePane.value = p.id; },
|
||||||
}, p.label)
|
}, p.label)
|
||||||
)),
|
)),
|
||||||
|
|
||||||
// Error banner
|
|
||||||
store.errorMessage
|
store.errorMessage
|
||||||
? h('div', { class: 'se-error', style: 'margin:0' }, store.errorMessage)
|
? h('div', { class: 'se-error', style: 'margin:0' }, store.errorMessage)
|
||||||
: null,
|
: null,
|
||||||
|
|
||||||
// Import dialog
|
|
||||||
showImport.value
|
showImport.value
|
||||||
? h(ImportDialog, { store, onClose: () => { showImport.value = false; } })
|
? h(ImportDialog, { store, onClose: () => { showImport.value = false; } })
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@ -2,11 +2,11 @@ import { h } from 'vue';
|
|||||||
|
|
||||||
// One-line summary row with a drill-down chevron.
|
// One-line summary row with a drill-down chevron.
|
||||||
export const ObjectShort = {
|
export const ObjectShort = {
|
||||||
props: ['label', 'typeTag', 'focused', 'hasChildren'],
|
props: ['label', 'typeTag', 'focused', 'hasChildren', 'readOnly'],
|
||||||
emits: ['focus', 'drillDown'],
|
emits: ['focus', 'drillDown'],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
return () => h('li', {
|
return () => h('li', {
|
||||||
class: ['se-object-item', props.focused ? 'focused' : null],
|
class: ['se-object-item', props.focused ? 'focused' : null, props.readOnly ? 'read-only' : null],
|
||||||
onClick: () => emit('focus'),
|
onClick: () => emit('focus'),
|
||||||
}, [
|
}, [
|
||||||
props.typeTag ? h('span', { class: 'se-object-type' }, props.typeTag) : null,
|
props.typeTag ? h('span', { class: 'se-object-type' }, props.typeTag) : null,
|
||||||
|
|||||||
@ -1,90 +1,23 @@
|
|||||||
import { h } from 'vue';
|
import { h } from 'vue';
|
||||||
import { ObjectShort } from './ObjectShort.js';
|
import { ObjectShort } from './ObjectShort.js';
|
||||||
|
import { getKindGroups } from '../subobject-kinds.js';
|
||||||
|
|
||||||
export const PaneSubObjects = {
|
export const PaneSubObjects = {
|
||||||
props: ['store', 'onFocusFO'],
|
props: ['store', 'kind', 'onFocusFO'],
|
||||||
setup(props) {
|
setup(props) {
|
||||||
function focused() {
|
function focused() {
|
||||||
const fp = props.store.focusPath;
|
const fp = props.store.focusPath;
|
||||||
return fp.length ? fp[fp.length - 1] : props.store.scoreModel;
|
return fp.length ? fp[fp.length - 1] : props.store.scoreModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
function subItems(node) {
|
|
||||||
if (!node) return [];
|
|
||||||
if (node.type === 'score') {
|
|
||||||
const items = [];
|
|
||||||
if (node.info)
|
|
||||||
items.push({ kind: 'info', node: node.info, label: node.info.title ?? '(no title)', hasChildren: false });
|
|
||||||
if (node.tuning)
|
|
||||||
items.push({ kind: 'tuning', node: node.tuning, label: `base ${node.tuning.base ?? '?'}`, hasChildren: false });
|
|
||||||
for (const a of (node.articles ?? []))
|
|
||||||
items.push({ kind: 'article', node: a, label: a.name, hasChildren: false });
|
|
||||||
for (const sv of (node.stageVoices ?? []))
|
|
||||||
items.push({ kind: 'stage', node: sv, label: sv.name, hasChildren: false });
|
|
||||||
for (const i of node.instruments)
|
|
||||||
items.push({ kind: 'instrument', node: i, label: i.name, hasChildren: true });
|
|
||||||
for (const b of node.bars)
|
|
||||||
items.push({ kind: 'bar', node: b, label: b.id, hasChildren: Object.keys(b.voices).length > 0 });
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
if (node.type === 'instrument') {
|
|
||||||
return node.variations.map((v, idx) => ({
|
|
||||||
kind: 'variation',
|
|
||||||
node: v,
|
|
||||||
label: `variation ${idx + 1}${v.dependsOn ? ` (${v.dependsOn})` : ''}`,
|
|
||||||
hasChildren: true,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (node.type === 'variation') {
|
|
||||||
return [
|
|
||||||
...node.labelSpecs.map(ls => ({
|
|
||||||
kind: 'label_spec', node: ls, label: ls.label ?? '(no label)', hasChildren: false,
|
|
||||||
})),
|
|
||||||
...node.subvariations.map((sv, idx) => {
|
|
||||||
const dep = sv.dependsOn;
|
|
||||||
const label = dep == null ? `subvariation ${idx + 1}`
|
|
||||||
: isNaN(Number(dep)) ? `ATTR: ${dep}`
|
|
||||||
: String(dep);
|
|
||||||
return { kind: 'variation', node: sv, label, hasChildren: true };
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (node.type === 'bar') {
|
|
||||||
return Object.entries(node.voices).map(([name, v]) => ({
|
|
||||||
kind: 'voice', node: v, label: name,
|
|
||||||
hasChildren: v.offsets.length > 0 || v.motifs.some(m => m.isStatic),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (node.type === 'voice') {
|
|
||||||
return [
|
|
||||||
...node.motifs
|
|
||||||
.filter(m => m.isStatic)
|
|
||||||
.map(m => ({ kind: 'motif', node: m, label: m.label, hasChildren: m.stemNotes.length > 0 })),
|
|
||||||
...node.offsets.map((o, idx) => ({
|
|
||||||
kind: 'offset', node: o, label: `tick ${o.tick ?? idx}`, hasChildren: o.stemNotes.length > 0,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (node.type === 'motif') {
|
|
||||||
return node.stemNotes.map(sn => ({
|
|
||||||
kind: 'stem_note', node: sn,
|
|
||||||
label: `pitch ${sn.pitch}${sn.clauses.length ? ` (${sn.clauses.length} clause${sn.clauses.length > 1 ? 's' : ''})` : ''}`,
|
|
||||||
hasChildren: false,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (node.type === 'offset') {
|
|
||||||
return node.stemNotes.map(sn => ({
|
|
||||||
kind: 'stem_note', node: sn,
|
|
||||||
label: `pitch ${sn.pitch}${sn.clauses.length ? ` (${sn.clauses.length} clause${sn.clauses.length > 1 ? 's' : ''})` : ''}`,
|
|
||||||
hasChildren: false,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
const node = focused();
|
const node = focused();
|
||||||
const items = subItems(node);
|
const groups = getKindGroups(node);
|
||||||
|
const group = props.kind
|
||||||
|
? groups.find(g => g.kind === props.kind)
|
||||||
|
: groups[0];
|
||||||
|
const items = group?.items ?? [];
|
||||||
|
|
||||||
if (!items.length) return h('div', null, h('em', null, 'No sub-objects'));
|
if (!items.length) return h('div', null, h('em', null, 'No sub-objects'));
|
||||||
|
|
||||||
return h('div', null,
|
return h('div', null,
|
||||||
@ -95,6 +28,7 @@ export const PaneSubObjects = {
|
|||||||
typeTag: item.kind,
|
typeTag: item.kind,
|
||||||
focused: props.store.focusPath.includes(item.node),
|
focused: props.store.focusPath.includes(item.node),
|
||||||
hasChildren: item.hasChildren,
|
hasChildren: item.hasChildren,
|
||||||
|
readOnly: item.readOnly ?? false,
|
||||||
onFocus: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); },
|
onFocus: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); },
|
||||||
onDrillDown: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); },
|
onDrillDown: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); },
|
||||||
})
|
})
|
||||||
|
|||||||
123
static/subobject-kinds.js
Normal file
123
static/subobject-kinds.js
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
// Group a node's sub-objects by KIND (the SLOT side of SLOT.SUBTYPE in the AST).
|
||||||
|
// Per the editor design: items sharing a kind share one pane; different kinds
|
||||||
|
// produce separate panes whose handles render in the AppShell bottom bar.
|
||||||
|
// Each group is { kind, items: [{ kind, node, label, hasChildren, readOnly? }] }.
|
||||||
|
// The returned order is the display order for the handle bar.
|
||||||
|
|
||||||
|
export const KIND_LABEL = {
|
||||||
|
tuning: 'TU',
|
||||||
|
stage: 'ST',
|
||||||
|
instrument: 'IN',
|
||||||
|
articles: 'AR',
|
||||||
|
bar: 'BA',
|
||||||
|
variation: 'VR',
|
||||||
|
label_spec: 'LA',
|
||||||
|
voice: 'VO',
|
||||||
|
motif: 'MO',
|
||||||
|
offset: 'OF',
|
||||||
|
stem_note: 'SN',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getKindGroups(node) {
|
||||||
|
if (!node) return [];
|
||||||
|
|
||||||
|
if (node.type === 'score') {
|
||||||
|
const groups = [];
|
||||||
|
|
||||||
|
if (node.tuning) {
|
||||||
|
groups.push({ kind: 'tuning', items: [
|
||||||
|
{ kind: 'tuning', node: node.tuning, label: `base ${node.tuning.base ?? '?'}`, hasChildren: false },
|
||||||
|
]});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stage = [];
|
||||||
|
if (node.stageCone) stage.push({ kind: 'stage', node: node.stageCone, label: 'cone (orchestra)', hasChildren: false });
|
||||||
|
for (const sv of (node.stageVoices ?? []))
|
||||||
|
stage.push({ kind: 'stage', node: sv, label: sv.name, hasChildren: false });
|
||||||
|
if (stage.length) groups.push({ kind: 'stage', items: stage });
|
||||||
|
|
||||||
|
if (node.instruments.length) {
|
||||||
|
groups.push({ kind: 'instrument', items: node.instruments.map(i => ({
|
||||||
|
kind: 'instrument', node: i, label: i.name, hasChildren: !i.isLinked, readOnly: i.isLinked,
|
||||||
|
}))});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((node.articles ?? []).length) {
|
||||||
|
groups.push({ kind: 'articles', items: node.articles.map(a => ({
|
||||||
|
kind: 'articles', node: a, label: `${a.subtype}: ${a.name}`, hasChildren: false,
|
||||||
|
}))});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.bars.length) {
|
||||||
|
groups.push({ kind: 'bar', items: node.bars.map(b => ({
|
||||||
|
kind: 'bar', node: b, label: b.id, hasChildren: Object.keys(b.voices).length > 0,
|
||||||
|
}))});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'instrument') {
|
||||||
|
if (!node.variations.length) return [];
|
||||||
|
return [{ kind: 'variation', items: node.variations.map((v, idx) => ({
|
||||||
|
kind: 'variation', node: v,
|
||||||
|
label: `variation ${idx + 1}${v.dependsOn ? ` (${v.dependsOn})` : ''}`,
|
||||||
|
hasChildren: true,
|
||||||
|
}))}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'variation') {
|
||||||
|
const groups = [];
|
||||||
|
if (node.labelSpecs.length) {
|
||||||
|
groups.push({ kind: 'label_spec', items: node.labelSpecs.map(ls => ({
|
||||||
|
kind: 'label_spec', node: ls, label: ls.label ?? '(no label)', hasChildren: false,
|
||||||
|
}))});
|
||||||
|
}
|
||||||
|
if (node.subvariations.length) {
|
||||||
|
groups.push({ kind: 'variation', items: node.subvariations.map((sv, idx) => {
|
||||||
|
const dep = sv.dependsOn;
|
||||||
|
const label = dep == null ? `subvariation ${idx + 1}`
|
||||||
|
: isNaN(Number(dep)) ? `ATTR: ${dep}`
|
||||||
|
: String(dep);
|
||||||
|
return { kind: 'variation', node: sv, label, hasChildren: true };
|
||||||
|
})});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'bar') {
|
||||||
|
const entries = Object.entries(node.voices);
|
||||||
|
if (!entries.length) return [];
|
||||||
|
return [{ kind: 'voice', items: entries.map(([name, v]) => ({
|
||||||
|
kind: 'voice', node: v, label: name,
|
||||||
|
hasChildren: v.offsets.length > 0 || v.motifs.some(m => m.isStatic),
|
||||||
|
}))}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'voice') {
|
||||||
|
const groups = [];
|
||||||
|
const staticMotifs = node.motifs.filter(m => m.isStatic);
|
||||||
|
if (staticMotifs.length) {
|
||||||
|
groups.push({ kind: 'motif', items: staticMotifs.map(m => ({
|
||||||
|
kind: 'motif', node: m, label: m.label, hasChildren: m.stemNotes.length > 0,
|
||||||
|
}))});
|
||||||
|
}
|
||||||
|
if (node.offsets.length) {
|
||||||
|
groups.push({ kind: 'offset', items: node.offsets.map((o, idx) => ({
|
||||||
|
kind: 'offset', node: o, label: `tick ${o.tick ?? idx}`, hasChildren: o.stemNotes.length > 0,
|
||||||
|
}))});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'motif' || node.type === 'offset') {
|
||||||
|
if (!node.stemNotes.length) return [];
|
||||||
|
return [{ kind: 'stem_note', items: node.stemNotes.map(sn => ({
|
||||||
|
kind: 'stem_note', node: sn,
|
||||||
|
label: `pitch ${sn.pitch}${sn.clauses.length ? ` (${sn.clauses.length} clause${sn.clauses.length > 1 ? 's' : ''})` : ''}`,
|
||||||
|
hasChildren: false,
|
||||||
|
}))}];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user