forked from flow/vue3js-app-proposal-for-sdk-claude
Added files generated by Claude, prompted by sdk based on the md files
This commit is contained in:
parent
7bc1ad4536
commit
822e2c9f42
16
__init__.py
Normal file
16
__init__.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from flask import Blueprint, render_template, request
|
||||||
|
|
||||||
|
blueprint = Blueprint(
|
||||||
|
'vue3_neusik',
|
||||||
|
__name__,
|
||||||
|
static_folder='static',
|
||||||
|
static_url_path='/static',
|
||||||
|
template_folder='templates',
|
||||||
|
)
|
||||||
|
|
||||||
|
@blueprint.route('/', methods=['GET'])
|
||||||
|
def index():
|
||||||
|
return render_template(
|
||||||
|
'vue3_neusik/index.html',
|
||||||
|
import_on_load='true' if request.args.get('import') == '1' else 'false',
|
||||||
|
)
|
||||||
42
static/api.js
Normal file
42
static/api.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
function authHeader(credentials) {
|
||||||
|
if (!credentials) return {};
|
||||||
|
const b64 = btoa(`${credentials.username}:${credentials.password}`);
|
||||||
|
return { Authorization: `Basic ${b64}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAstLog(credentials) {
|
||||||
|
const res = await fetch('/sompyle/astlog', {
|
||||||
|
headers: authHeader(credentials),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchScoreText(credentials) {
|
||||||
|
const res = await fetch('/sompyle/score.spls', {
|
||||||
|
headers: authHeader(credentials),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putScoreText(text, credentials) {
|
||||||
|
const res = await fetch('/sompyle/score.spls', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
...authHeader(credentials),
|
||||||
|
'Content-Type': 'text/yaml',
|
||||||
|
},
|
||||||
|
body: text,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStatus(credentials) {
|
||||||
|
const res = await fetch('/sompyle/status.json', {
|
||||||
|
headers: authHeader(credentials),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
12
static/app.js
Normal file
12
static/app.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { store } from './store.js';
|
||||||
|
import { AppShell } from './components/AppShell.js';
|
||||||
|
|
||||||
|
const root = document.getElementById('score-editor-app');
|
||||||
|
const importOnLoad = root?.dataset.importOnLoad === 'true';
|
||||||
|
|
||||||
|
createApp({
|
||||||
|
setup() {
|
||||||
|
return () => h(AppShell, { store, importOnLoad });
|
||||||
|
},
|
||||||
|
}).mount('#score-editor-app');
|
||||||
482
static/ast-parser.js
Normal file
482
static/ast-parser.js
Normal file
@ -0,0 +1,482 @@
|
|||||||
|
const LINE_RE = /^(\d{2}) (\S+(?:\.\S+)*) ?(.*)/;
|
||||||
|
const DEBUG_RE = /^\d{2} # DEBUG/;
|
||||||
|
|
||||||
|
|
||||||
|
function coerce(s) {
|
||||||
|
if (s === 'True' || s === 'Y' || s === 'on' || s === 'true') return true;
|
||||||
|
if (s === 'False' || s === 'N' || s === 'off' || s === 'false') return false;
|
||||||
|
if (s === '') return s;
|
||||||
|
const n = Number(s);
|
||||||
|
if (!isNaN(n) && s.trim() !== '') return n;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAstLog(text) {
|
||||||
|
const root = { slot: 'root', parentSlot: null, depth: -1, positionals: [], props: {}, children: [] };
|
||||||
|
const stack = [root];
|
||||||
|
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
if (DEBUG_RE.test(line)) continue;
|
||||||
|
|
||||||
|
const m = LINE_RE.exec(line);
|
||||||
|
if (!m) continue;
|
||||||
|
|
||||||
|
const depth = parseInt(m[1], 10);
|
||||||
|
const slotFull = m[2];
|
||||||
|
const rest = m[3] || '';
|
||||||
|
|
||||||
|
const dotIdx = slotFull.indexOf('.');
|
||||||
|
let parentSlot = null, slot = slotFull;
|
||||||
|
if (dotIdx !== -1) {
|
||||||
|
parentSlot = slotFull.slice(0, dotIdx);
|
||||||
|
slot = slotFull.slice(dotIdx + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { positionals, props } = parseRest(rest);
|
||||||
|
|
||||||
|
const node = { slot, parentSlot, depth, positionals, props, children: [] };
|
||||||
|
|
||||||
|
// Pop stack to find correct parent (parent must have depth < current)
|
||||||
|
while (stack.length > 1 && stack[stack.length - 1].depth >= depth) {
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
stack[stack.length - 1].children.push(node);
|
||||||
|
stack.push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRest(rest) {
|
||||||
|
const positionals = [];
|
||||||
|
const props = {};
|
||||||
|
let i = 0;
|
||||||
|
const n = rest.length;
|
||||||
|
let inProps = false;
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
// skip spaces
|
||||||
|
while (i < n && rest[i] === ' ') i++;
|
||||||
|
if (i >= n) break;
|
||||||
|
|
||||||
|
if (rest[i] === "'") {
|
||||||
|
// single-quoted value (positional string)
|
||||||
|
const j = rest.indexOf("'", i + 1);
|
||||||
|
const val = rest.slice(i + 1, j === -1 ? n : j);
|
||||||
|
i = j === -1 ? n : j + 1;
|
||||||
|
if (!inProps) positionals.push(val);
|
||||||
|
} else {
|
||||||
|
// scan to next space
|
||||||
|
let j = i;
|
||||||
|
while (j < n && rest[j] !== ' ') j++;
|
||||||
|
const tok = rest.slice(i, j);
|
||||||
|
i = j;
|
||||||
|
|
||||||
|
const eqIdx = tok.indexOf('=');
|
||||||
|
if (eqIdx !== -1) {
|
||||||
|
inProps = true;
|
||||||
|
const key = tok.slice(0, eqIdx);
|
||||||
|
let rawVal = tok.slice(eqIdx + 1);
|
||||||
|
|
||||||
|
if (rawVal.startsWith("'")) {
|
||||||
|
// value continues until next single quote
|
||||||
|
const valStart = i - (tok.length - eqIdx - 1);
|
||||||
|
// find closing quote: search from after the opening quote
|
||||||
|
const openPos = rest.indexOf("'", rest.lastIndexOf(key + '=', i) + key.length + 1);
|
||||||
|
const closePos = rest.indexOf("'", openPos + 1);
|
||||||
|
rawVal = closePos === -1 ? rest.slice(openPos + 1) : rest.slice(openPos + 1, closePos);
|
||||||
|
i = closePos === -1 ? n : closePos + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
props[key] = coerce(rawVal);
|
||||||
|
} else if (!inProps) {
|
||||||
|
positionals.push(coerce(tok));
|
||||||
|
}
|
||||||
|
// bare token after props start is ignored (shouldn't happen per spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { positionals, props };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Second pass: build typed model ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export function buildModel(rawTree) {
|
||||||
|
const score = {
|
||||||
|
type: 'score',
|
||||||
|
info: null,
|
||||||
|
tuning: null,
|
||||||
|
articles: [],
|
||||||
|
stageVoices: [],
|
||||||
|
instruments: [],
|
||||||
|
bars: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const node of rawTree.children) {
|
||||||
|
switch (node.slot) {
|
||||||
|
case 'info':
|
||||||
|
score.info = { ...node.props };
|
||||||
|
break;
|
||||||
|
case 'tuning':
|
||||||
|
score.tuning = buildTuning(node);
|
||||||
|
break;
|
||||||
|
case 'article':
|
||||||
|
score.articles.push(buildArticle(node));
|
||||||
|
break;
|
||||||
|
case 'stage_voice':
|
||||||
|
score.stageVoices.push({
|
||||||
|
name: node.positionals[0],
|
||||||
|
direction: node.props.direction,
|
||||||
|
distance: node.props.distance,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'instrument':
|
||||||
|
score.instruments.push(buildInstrument(node));
|
||||||
|
break;
|
||||||
|
case 'bar':
|
||||||
|
score.bars.push(buildBar(node));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
score[node.slot] = buildGeneric(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTuning(node) {
|
||||||
|
const t = { base: node.props.base, scales: {}, chords: {} };
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (child.slot === 'scales') {
|
||||||
|
t.scales[child.positionals[0]] = child.positionals.slice(1);
|
||||||
|
} else if (child.slot === 'chords') {
|
||||||
|
t.chords[child.positionals[0]] = child.positionals.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArticle(node) {
|
||||||
|
return {
|
||||||
|
type: 'article',
|
||||||
|
name: node.positionals[0],
|
||||||
|
props: { ...node.props },
|
||||||
|
properties: node.children
|
||||||
|
.filter(c => c.slot === 'property')
|
||||||
|
.map(c => ({ name: c.positionals[0], ...c.props })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInstrument(node) {
|
||||||
|
const instr = {
|
||||||
|
type: 'instrument',
|
||||||
|
name: node.positionals[0],
|
||||||
|
notChangedSince: node.props.NOT_CHANGED_SINCE ?? null,
|
||||||
|
isLinked: (node.positionals[0] ?? '').includes('/'),
|
||||||
|
isDirty: false,
|
||||||
|
variations: [],
|
||||||
|
basicProperties: null,
|
||||||
|
railsbackCurve: null,
|
||||||
|
volumes: null,
|
||||||
|
timbre: null,
|
||||||
|
fmModulations: [],
|
||||||
|
rawChildren: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const child of node.children) {
|
||||||
|
switch (child.parentSlot + '.' + child.slot) {
|
||||||
|
case 'character.variation':
|
||||||
|
instr.variations.push(buildVariation(child));
|
||||||
|
break;
|
||||||
|
case 'character.basic_properties':
|
||||||
|
instr.basicProperties = buildBasicProperties(child);
|
||||||
|
break;
|
||||||
|
case 'RAILSBACK_CURVE.shape':
|
||||||
|
instr.railsbackCurve = buildShape(child);
|
||||||
|
break;
|
||||||
|
case 'VOLUMES.shape':
|
||||||
|
instr.volumes = buildShape(child);
|
||||||
|
break;
|
||||||
|
case 'TIMBRE.shape':
|
||||||
|
instr.timbre = buildShape(child);
|
||||||
|
break;
|
||||||
|
case 'FM.modulation':
|
||||||
|
instr.fmModulations.push({ ...child.props });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
instr.rawChildren.push(buildGeneric(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return instr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVariation(node) {
|
||||||
|
const v = {
|
||||||
|
type: 'variation',
|
||||||
|
dependsOn: node.props.depends_on ?? null,
|
||||||
|
basicProperties: null,
|
||||||
|
labelSpecs: [],
|
||||||
|
subvariations: [],
|
||||||
|
spread: null,
|
||||||
|
rawChildren: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const child of node.children) {
|
||||||
|
const key = (child.parentSlot ?? child.slot) + '.' + child.slot;
|
||||||
|
switch (key) {
|
||||||
|
case 'variation.basic_properties':
|
||||||
|
v.basicProperties = buildBasicProperties(child);
|
||||||
|
break;
|
||||||
|
case 'variation.label_spec':
|
||||||
|
v.labelSpecs.push(buildLabelSpec(child));
|
||||||
|
break;
|
||||||
|
case 'variation.subvariation':
|
||||||
|
v.subvariations.push(buildVariation(child));
|
||||||
|
break;
|
||||||
|
case 'variation.SPREAD':
|
||||||
|
v.spread = child.positionals;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
v.rawChildren.push(buildGeneric(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBasicProperties(node) {
|
||||||
|
const bp = {
|
||||||
|
type: 'basic_properties',
|
||||||
|
A: null, S: null, R: null,
|
||||||
|
oscillator: null,
|
||||||
|
fmModulations: [],
|
||||||
|
rawChildren: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const child of node.children) {
|
||||||
|
const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot;
|
||||||
|
if (child.parentSlot === 'A' && child.slot === 'shape') {
|
||||||
|
bp.A = buildShape(child);
|
||||||
|
} else if (child.parentSlot === 'S' && child.slot === 'shape') {
|
||||||
|
bp.S = buildShape(child);
|
||||||
|
} else if (child.parentSlot === 'R' && child.slot === 'shape') {
|
||||||
|
bp.R = buildShape(child);
|
||||||
|
} else if (child.parentSlot === 'variation' && child.slot === 'O') {
|
||||||
|
bp.oscillator = child.props.ref ?? child.positionals[0];
|
||||||
|
} else if (child.parentSlot === 'FM' && child.slot === 'modulation') {
|
||||||
|
bp.fmModulations.push({ ...child.props });
|
||||||
|
} else {
|
||||||
|
bp.rawChildren.push(buildGeneric(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLabelSpec(node) {
|
||||||
|
const ls = {
|
||||||
|
type: 'label_spec',
|
||||||
|
label: node.positionals[0],
|
||||||
|
basicProperties: null,
|
||||||
|
rawChildren: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (child.parentSlot === 'variation' && child.slot === 'basic_properties') {
|
||||||
|
ls.basicProperties = buildBasicProperties(child);
|
||||||
|
} else {
|
||||||
|
ls.rawChildren.push(buildGeneric(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildShape(node) {
|
||||||
|
return {
|
||||||
|
type: 'shape',
|
||||||
|
length: node.props.length,
|
||||||
|
start: node.props.start,
|
||||||
|
z: node.props.z ?? 1,
|
||||||
|
coords: node.children
|
||||||
|
.filter(c => c.slot === 'coords')
|
||||||
|
.map(c => ({
|
||||||
|
x: c.props.x,
|
||||||
|
y: c.props.y,
|
||||||
|
z: c.props.z ?? 1,
|
||||||
|
isSharp: c.props.is_sharp ?? false,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBar(node) {
|
||||||
|
const id = node.positionals[0] ?? '';
|
||||||
|
const idMatch = id.match(/^(\w?)(\d+)P(\d+)L(\d+)M(\d+)$/);
|
||||||
|
const bar = {
|
||||||
|
type: 'bar',
|
||||||
|
id,
|
||||||
|
movement: idMatch ? idMatch[1] : '',
|
||||||
|
part: idMatch ? parseInt(idMatch[2]) : 0,
|
||||||
|
line: idMatch ? parseInt(idMatch[3]) : 0,
|
||||||
|
measure: idMatch ? parseInt(idMatch[4]) : 0,
|
||||||
|
stressor: null,
|
||||||
|
tempoShape: null,
|
||||||
|
tempoLevels: null,
|
||||||
|
lowerStressBound: null,
|
||||||
|
upperStressBound: null,
|
||||||
|
tunings: [],
|
||||||
|
voices: {},
|
||||||
|
rawChildren: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const child of node.children) {
|
||||||
|
const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot;
|
||||||
|
switch (fqSlot) {
|
||||||
|
case 'stress_pattern.stressor':
|
||||||
|
bar.stressor = buildStressor(child);
|
||||||
|
break;
|
||||||
|
case 'tempo.shape':
|
||||||
|
bar.tempoShape = buildShape(child);
|
||||||
|
break;
|
||||||
|
case 'tempo.levels':
|
||||||
|
bar.tempoLevels = child.positionals[0];
|
||||||
|
break;
|
||||||
|
case 'lower_stress_bound.shape':
|
||||||
|
bar.lowerStressBound = buildShape(child);
|
||||||
|
break;
|
||||||
|
case 'upper_stress_bound.shape':
|
||||||
|
bar.upperStressBound = buildShape(child);
|
||||||
|
break;
|
||||||
|
case 'bar.tuning':
|
||||||
|
bar.tunings.push({ ...child.props });
|
||||||
|
break;
|
||||||
|
case 'bar.voice':
|
||||||
|
bar.voices[child.positionals[0]] = buildVoice(child);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
bar.rawChildren.push(buildGeneric(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStressor(node) {
|
||||||
|
const levels = [];
|
||||||
|
let currentGroup = [];
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (child.slot === 'level') {
|
||||||
|
currentGroup.push(parseInt(child.positionals[0]));
|
||||||
|
} else if (child.slot === 'subdivision') {
|
||||||
|
levels.push(currentGroup);
|
||||||
|
currentGroup = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentGroup.length) levels.push(currentGroup);
|
||||||
|
return { type: 'stressor', groups: levels };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVoice(node) {
|
||||||
|
const voice = {
|
||||||
|
type: 'voice',
|
||||||
|
name: node.positionals[0],
|
||||||
|
offsets: [],
|
||||||
|
articles: [],
|
||||||
|
motifs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const child of node.children) {
|
||||||
|
const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot;
|
||||||
|
switch (fqSlot) {
|
||||||
|
case 'voice.offset':
|
||||||
|
voice.offsets.push(buildOffset(child));
|
||||||
|
break;
|
||||||
|
case 'voice.article':
|
||||||
|
voice.articles.push(child.positionals[0]);
|
||||||
|
break;
|
||||||
|
case 'voice.motif':
|
||||||
|
voice.motifs.push(child.props.label);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return voice;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOffset(node) {
|
||||||
|
const offset = {
|
||||||
|
type: 'offset',
|
||||||
|
tick: node.props.tick,
|
||||||
|
stemNotes: [],
|
||||||
|
clusters: [],
|
||||||
|
chains: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const child of node.children) {
|
||||||
|
const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot;
|
||||||
|
switch (fqSlot) {
|
||||||
|
case 'offset.stem_note':
|
||||||
|
offset.stemNotes.push({ pitch: child.props.pitch, effLength: child.props.eff_length });
|
||||||
|
break;
|
||||||
|
case 'offset.cluster':
|
||||||
|
offset.clusters.push(buildCluster(child));
|
||||||
|
break;
|
||||||
|
case 'offset.chain':
|
||||||
|
offset.chains.push({ index: child.positionals[0], children: child.children.map(buildGeneric) });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCluster(node) {
|
||||||
|
const cluster = {
|
||||||
|
type: 'cluster',
|
||||||
|
index: node.positionals[0],
|
||||||
|
repeat: node.props.repeat ?? null,
|
||||||
|
notes: [],
|
||||||
|
pauses: [],
|
||||||
|
groups: [],
|
||||||
|
subchains: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const child of node.children) {
|
||||||
|
switch (child.slot) {
|
||||||
|
case 'note':
|
||||||
|
cluster.notes.push({ ...child.props });
|
||||||
|
break;
|
||||||
|
case 'pause':
|
||||||
|
cluster.pauses.push({ length: child.props.length });
|
||||||
|
break;
|
||||||
|
case 'group':
|
||||||
|
cluster.groups.push({ length: child.props.length, netlength: child.props.netlength });
|
||||||
|
break;
|
||||||
|
case 'subchain':
|
||||||
|
cluster.subchains.push({ length: child.props.length, children: child.children.map(buildGeneric) });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGeneric(node) {
|
||||||
|
return {
|
||||||
|
type: node.slot,
|
||||||
|
parentSlot: node.parentSlot,
|
||||||
|
depth: node.depth,
|
||||||
|
positionals: node.positionals,
|
||||||
|
props: node.props,
|
||||||
|
children: node.children.map(buildGeneric),
|
||||||
|
};
|
||||||
|
}
|
||||||
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}%` } }),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
151
static/exporter.js
Normal file
151
static/exporter.js
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
// Template-based YAML serializer — instrument blocks only (v1).
|
||||||
|
// Each object selects a template by finding the first entry in its
|
||||||
|
// selectExportTemplate() list where all required slots have values.
|
||||||
|
// Placeholders #0, #1, ... are filled with slot values.
|
||||||
|
|
||||||
|
function fillTemplate(template, slots) {
|
||||||
|
return template.replace(/#(\d+)/g, (_, i) => slots[parseInt(i, 10)] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function indent(text, level) {
|
||||||
|
const pad = ' '.repeat(level);
|
||||||
|
return text.split('\n').map((line, i) => {
|
||||||
|
if (i === 0) return line;
|
||||||
|
if (line.startsWith('- ')) return pad.slice(2) + line;
|
||||||
|
return pad + line;
|
||||||
|
}).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shape ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function exportCoord(coord) {
|
||||||
|
let s = `x=${coord.x} y=${coord.y}`;
|
||||||
|
if (coord.z !== undefined && coord.z !== 1) s += ` z=${coord.z}`;
|
||||||
|
if (coord.isSharp) s += ` is_sharp=True`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportShape(shape, slotName, level) {
|
||||||
|
if (!shape) return '';
|
||||||
|
const coordLines = shape.coords.map(c => ` - coords: ${exportCoord(c)}`).join('\n');
|
||||||
|
let header = `${slotName}: length=${shape.length}`;
|
||||||
|
if (shape.start !== undefined) header += ` start=${shape.start}`;
|
||||||
|
if (shape.z !== undefined && shape.z !== 1) header += ` z=${shape.z}`;
|
||||||
|
const block = coordLines ? `${header}\n${coordLines}` : header;
|
||||||
|
return indent(block, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BasicProperties ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function exportBasicProperties(bp, level) {
|
||||||
|
if (!bp) return '';
|
||||||
|
const lines = [];
|
||||||
|
if (bp.oscillator) lines.push(` O: ref=${bp.oscillator}`);
|
||||||
|
if (bp.A) lines.push(` ${exportShape(bp.A, 'A', 1)}`);
|
||||||
|
if (bp.S) lines.push(` ${exportShape(bp.S, 'S', 1)}`);
|
||||||
|
if (bp.R) lines.push(` ${exportShape(bp.R, 'R', 1)}`);
|
||||||
|
for (const fm of (bp.fmModulations ?? [])) {
|
||||||
|
const parts = Object.entries(fm).map(([k, v]) => `${k}=${v}`).join(' ');
|
||||||
|
lines.push(` FM:\n modulation: ${parts}`);
|
||||||
|
}
|
||||||
|
if (!lines.length) return '';
|
||||||
|
return indent('basic_properties:\n' + lines.join('\n'), level);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LabelSpec ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function exportLabelSpec(ls, level) {
|
||||||
|
const label = ls.label ? ` '${ls.label}'` : '';
|
||||||
|
const bp = exportBasicProperties(ls.basicProperties, 1);
|
||||||
|
const body = bp ? `label_spec:${label}\n ${bp}` : `label_spec:${label}`;
|
||||||
|
return indent(body, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Variation ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function exportVariation(v, level) {
|
||||||
|
const dep = v.dependsOn ? ` depends_on=${v.dependsOn}` : '';
|
||||||
|
const lines = [`variation:${dep}`];
|
||||||
|
if (v.basicProperties) lines.push(` ${exportBasicProperties(v.basicProperties, 1)}`);
|
||||||
|
for (const ls of (v.labelSpecs ?? [])) lines.push(` ${exportLabelSpec(ls, 1)}`);
|
||||||
|
for (const sv of (v.subvariations ?? [])) lines.push(` ${exportVariation(sv, 1)}`);
|
||||||
|
if (v.spread?.length) lines.push(` SPREAD: ${v.spread.join(' ')}`);
|
||||||
|
return indent(lines.join('\n'), level);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Instrument ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function exportInstrument(instr) {
|
||||||
|
const lines = [];
|
||||||
|
const name = instr.name;
|
||||||
|
lines.push(`instrument: '${name}'`);
|
||||||
|
|
||||||
|
for (const v of (instr.variations ?? [])) {
|
||||||
|
lines.push(` character:\n ${exportVariation(v, 2)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instr.basicProperties) {
|
||||||
|
lines.push(` character:\n ${exportBasicProperties(instr.basicProperties, 2)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instr.railsbackCurve) {
|
||||||
|
lines.push(` ${exportShape(instr.railsbackCurve, 'RAILSBACK_CURVE', 1)}`);
|
||||||
|
}
|
||||||
|
if (instr.volumes) {
|
||||||
|
lines.push(` ${exportShape(instr.volumes, 'VOLUMES', 1)}`);
|
||||||
|
}
|
||||||
|
if (instr.timbre) {
|
||||||
|
lines.push(` ${exportShape(instr.timbre, 'TIMBRE', 1)}`);
|
||||||
|
}
|
||||||
|
for (const fm of (instr.fmModulations ?? [])) {
|
||||||
|
const parts = Object.entries(fm).map(([k, v]) => `${k}=${v}`).join(' ');
|
||||||
|
lines.push(` FM:\n modulation: ${parts}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Score patch ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Replace instrument blocks in rawScoreText with serialized model instruments.
|
||||||
|
// Non-dirty linked instruments (NOT_CHANGED_SINCE set, not edited) are left as-is
|
||||||
|
// from rawScoreText. Embedded and dirty instruments are emitted from the model.
|
||||||
|
export function patchScore(rawScoreText, instruments) {
|
||||||
|
// Split raw text into instrument blocks and other sections.
|
||||||
|
// Strategy: locate each `^instrument:` line and replace that block
|
||||||
|
// (up to next same-indent section or EOF) with the serialized model.
|
||||||
|
|
||||||
|
const lines = rawScoreText.split('\n');
|
||||||
|
const result = [];
|
||||||
|
const instrMap = {};
|
||||||
|
for (const instr of instruments) {
|
||||||
|
const basename = instr.name.includes('/') ? instr.name.split('/').pop() : instr.name;
|
||||||
|
instrMap[basename] = instr;
|
||||||
|
instrMap[instr.name] = instr;
|
||||||
|
}
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i];
|
||||||
|
const m = line.match(/^instrument:\s+'?([^']+)'?/);
|
||||||
|
if (m) {
|
||||||
|
const rawName = m[1];
|
||||||
|
const instr = instrMap[rawName];
|
||||||
|
if (instr && instr.isDirty) {
|
||||||
|
// consume the raw block
|
||||||
|
i++;
|
||||||
|
while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) i++;
|
||||||
|
result.push(exportInstrument(instr));
|
||||||
|
result.push('');
|
||||||
|
} else {
|
||||||
|
result.push(line);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push(line);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.join('\n');
|
||||||
|
}
|
||||||
28
static/store.js
Normal file
28
static/store.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { reactive } from 'vue';
|
||||||
|
|
||||||
|
export const store = reactive({
|
||||||
|
scoreModel: null,
|
||||||
|
rawScoreText: null,
|
||||||
|
focusPath: [],
|
||||||
|
credentials: null,
|
||||||
|
synthesisStatus: null,
|
||||||
|
isDirty: false,
|
||||||
|
errorMessage: '',
|
||||||
|
|
||||||
|
setFocus(path) {
|
||||||
|
this.focusPath = path;
|
||||||
|
},
|
||||||
|
|
||||||
|
pushFocus(node) {
|
||||||
|
const idx = this.focusPath.indexOf(node);
|
||||||
|
if (idx !== -1) {
|
||||||
|
this.focusPath = this.focusPath.slice(0, idx + 1);
|
||||||
|
} else {
|
||||||
|
this.focusPath = [...this.focusPath, node];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
markDirty() {
|
||||||
|
this.isDirty = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
357
static/style.css
Normal file
357
static/style.css
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
#score-editor-app {
|
||||||
|
height: calc(100vh - 8rem);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-pane-area {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-pane {
|
||||||
|
display: none;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-pane.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-handle-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-top: 2px solid #555;
|
||||||
|
background: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-handle {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #222;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid #444;
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-handle:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-handle.active {
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CP pane */
|
||||||
|
.se-cp-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
border-bottom: 1px solid #444;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-cp-title {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-cp-title:focus {
|
||||||
|
outline: 1px solid #888;
|
||||||
|
background: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-breadcrumb {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-breadcrumb span {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-breadcrumb span:hover {
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-object-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-object-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.25rem 0.3rem;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-object-item:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-object-item.focused {
|
||||||
|
background: #1a3a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-object-label {
|
||||||
|
flex: 1;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-object-type {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #666;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-chevron {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-chevron:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FO pane */
|
||||||
|
.se-fo-pane dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 0.3rem 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-fo-pane dt {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-fo-pane dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ShapeEditor */
|
||||||
|
.se-shape-editor {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-shape-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-shape-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.2rem 0.3rem;
|
||||||
|
color: #888;
|
||||||
|
border-bottom: 1px solid #444;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-shape-table td {
|
||||||
|
padding: 0.15rem 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-shape-table input[type=number] {
|
||||||
|
width: 4rem;
|
||||||
|
background: #111;
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: #ddd;
|
||||||
|
padding: 0.1rem 0.2rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-shape-table input[type=checkbox] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-shape-table button {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #555;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-shape-table button:hover {
|
||||||
|
color: #fff;
|
||||||
|
border-color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-shape-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 80px;
|
||||||
|
background: #111;
|
||||||
|
border: 1px solid #333;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* EnvelopeEditor */
|
||||||
|
.se-envelope-section {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-envelope-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-envelope-toggle label {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-envelope-disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Import dialog */
|
||||||
|
.se-import-dialog {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: #1e1e1e;
|
||||||
|
border: 1px solid #555;
|
||||||
|
padding: 1.5rem;
|
||||||
|
z-index: 1000;
|
||||||
|
min-width: 20rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-import-dialog h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-import-dialog label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-import-dialog input {
|
||||||
|
width: 100%;
|
||||||
|
background: #111;
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: #ddd;
|
||||||
|
padding: 0.3rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-import-dialog .se-dialog-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.se-btn {
|
||||||
|
background: #333;
|
||||||
|
border: 1px solid #555;
|
||||||
|
color: #ccc;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-btn:hover {
|
||||||
|
background: #444;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-btn-primary {
|
||||||
|
background: #1a4a8a;
|
||||||
|
border-color: #3a6aaa;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-btn-primary:hover {
|
||||||
|
background: #2a5a9a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status poller */
|
||||||
|
.se-status {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.4rem;
|
||||||
|
border: 1px solid #444;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-status-progress {
|
||||||
|
height: 4px;
|
||||||
|
background: #333;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-status-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: #2a6aaa;
|
||||||
|
transition: width 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-error {
|
||||||
|
color: #e06060;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.3rem;
|
||||||
|
border: 1px solid #602020;
|
||||||
|
margin: 0.3rem 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
20
templates/vue3_neusik/index.html
Normal file
20
templates/vue3_neusik/index.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{% extends "base.tmpl" %}
|
||||||
|
{% block title %}Score Editor{% endblock %}
|
||||||
|
|
||||||
|
{% block css %}
|
||||||
|
@import url("{{ url_for('vue3_neusik.static', filename='style.css') }}");
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div id="score-editor-app"
|
||||||
|
data-import-on-load="{{ import_on_load }}">
|
||||||
|
</div>
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.prod.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script type="module" src="{{ url_for('vue3_neusik.static', filename='app.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
Loading…
x
Reference in New Issue
Block a user