You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
636 lines
25 KiB
636 lines
25 KiB
// browser.ts - Mithril entry point
|
|
// Built by: bun build src/browser.ts --target browser --outfile public/mono-display.js
|
|
|
|
import m from "mithril";
|
|
import * as MonoDisplay from "./index";
|
|
import { MonoDisplayFile } from "./file";
|
|
import { cycleTheme } from "./themes";
|
|
|
|
(globalThis as typeof globalThis & { MonoDisplay: typeof MonoDisplay }).MonoDisplay = MonoDisplay;
|
|
|
|
const W = 120, H = 60;
|
|
|
|
// --- State -------------------------------------------------------------------
|
|
let file: MonoDisplayFile = new MonoDisplayFile([]);
|
|
let activeSecIndex: number | null = null;
|
|
let activeElIndex: number | null = null;
|
|
let currentFilename = "untitled";
|
|
let isDirty = false;
|
|
|
|
// --- Element metadata --------------------------------------------------------
|
|
type ElFieldMeta = { key: string; label: string; type: string; default: any; full?: boolean };
|
|
type ElFlagMeta = { key: string; label: string; default?: boolean };
|
|
|
|
const EL_FIELDS: Partial<Record<MonoDisplay.ElementTypeName, ElFieldMeta[]>> = {
|
|
Image2D: [
|
|
{ key: "xOffset", label: "X offset", type: "number", default: 0 },
|
|
{ key: "yOffset", label: "Y offset", type: "number", default: 0 },
|
|
{ key: "width", label: "Width", type: "number", default: W },
|
|
{ key: "height", label: "Height", type: "number", default: H },
|
|
],
|
|
Animation: [
|
|
{ key: "xOffset", label: "X offset", type: "number", default: 0 },
|
|
{ key: "yOffset", label: "Y offset", type: "number", default: 0 },
|
|
{ key: "width", label: "Width", type: "number", default: W },
|
|
{ key: "height", label: "Height", type: "number", default: H },
|
|
{ key: "updateInterval", label: "Update interval", type: "number", default: 12 },
|
|
{ key: "frames", label: "Frames", type: "list", default: [] },
|
|
],
|
|
ClippedText: [
|
|
{ key: "text", label: "Text", type: "text", default: "Hello, World!", full: true },
|
|
{ key: "xOffset", label: "X offset", type: "number", default: 0 },
|
|
{ key: "yOffset", label: "Y offset", type: "number", default: 32 },
|
|
{ key: "width", label: "Width", type: "number", default: W },
|
|
{ key: "height", label: "Height", type: "number", default: H },
|
|
{ key: "fontIndex", label: "Font", type: "font", default: 0 },
|
|
],
|
|
HScrollText: [
|
|
{ key: "text", label: "Text", type: "text", default: "Scrolling text - ", full: true },
|
|
{ key: "xOffset", label: "X offset", type: "number", default: 0 },
|
|
{ key: "yOffset", label: "Y offset", type: "number", default: 32 },
|
|
{ key: "width", label: "Width", type: "number", default: W },
|
|
{ key: "height", label: "Height", type: "number", default: H },
|
|
{ key: "scrollSpeed", label: "Scroll speed", type: "number", default: 50 },
|
|
{ key: "fontIndex", label: "Font", type: "font", default: 0 },
|
|
],
|
|
VScrollText: [
|
|
{ key: "text", label: "Text", type: "text", default: "Scrolling text - ", full: true },
|
|
{ key: "xOffset", label: "X offset", type: "number", default: 0 },
|
|
{ key: "yOffset", label: "Y offset", type: "number", default: 32 },
|
|
{ key: "width", label: "Width", type: "number", default: W },
|
|
{ key: "height", label: "Height", type: "number", default: H },
|
|
{ key: "scrollSpeed", label: "Scroll speed", type: "number", default: 50 },
|
|
{ key: "fontIndex", label: "Font", type: "font", default: 0 },
|
|
],
|
|
CurrentTime: [
|
|
{ key: "xOffset", label: "X offset", type: "number", default: 0 },
|
|
{ key: "yOffset", label: "Y offset", type: "number", default: 8 },
|
|
{ key: "width", label: "Width", type: "number", default: W },
|
|
{ key: "height", label: "Height", type: "number", default: H },
|
|
{ key: "utcOffsetMinutes", label: "UTC offset (min)", type: "number", default: 0 },
|
|
{ key: "fontIndex", label: "Font", type: "font", default: 0 },
|
|
],
|
|
};
|
|
|
|
const EL_FLAGS: Partial<Record<MonoDisplay.ElementTypeName, ElFlagMeta[]>> = {
|
|
HScrollText: [
|
|
{ key: "endless", label: "Endless" },
|
|
{ key: "invertDirection", label: "Invert direction" },
|
|
],
|
|
CurrentTime: [
|
|
{ key: "clock12h", label: "12h mode", default: false },
|
|
{ key: "showHours", label: "Show hours", default: true },
|
|
{ key: "showSeconds", label: "Show seconds", default: true },
|
|
],
|
|
};
|
|
|
|
const EL_TYPES: MonoDisplay.ElementType[] = Object.keys(EL_FIELDS).map(
|
|
(x) => MonoDisplay.StringToElementType[x as MonoDisplay.ElementTypeName]
|
|
);
|
|
|
|
const DEFAULT_FONTS = ["Font 1", "Font 2", "Font 3", "Font 4", "Font 5"];
|
|
|
|
// --- Confirm dialog ----------------------------------------------------------
|
|
function showConfirm(
|
|
msg: string,
|
|
buttons: { label: string; primary?: boolean; action: boolean }[]
|
|
): Promise<boolean> {
|
|
return new Promise(resolve => {
|
|
const overlay = document.getElementById("confirm-overlay");
|
|
const msgEl = document.getElementById("confirm-msg");
|
|
const btnsEl = document.getElementById("confirm-btns");
|
|
if (msgEl) msgEl.textContent = msg;
|
|
if (btnsEl) btnsEl.innerHTML = "";
|
|
buttons.forEach(b => {
|
|
const btn = document.createElement("button");
|
|
btn.className = "cb" + (b.primary ? " primary" : "");
|
|
btn.textContent = b.label;
|
|
btn.onclick = () => { overlay?.classList.remove("show"); resolve(b.action); };
|
|
btnsEl?.appendChild(btn);
|
|
});
|
|
overlay?.classList.add("show");
|
|
});
|
|
}
|
|
|
|
// --- Dirty tracking ----------------------------------------------------------
|
|
function markDirty() {
|
|
isDirty = true;
|
|
document.getElementById("filename")?.classList.add("dirty");
|
|
}
|
|
function markClean() {
|
|
isDirty = false;
|
|
document.getElementById("filename")?.classList.remove("dirty");
|
|
}
|
|
async function guardDirty(): Promise<boolean> {
|
|
if (!isDirty || !file.sections.length) return true;
|
|
return showConfirm(
|
|
"You have unsaved changes. Loading a demo will replace the current editor state.",
|
|
[{ label: "Cancel", action: false }, { label: "Load anyway", primary: true, action: true }]
|
|
);
|
|
}
|
|
|
|
// --- Helpers -----------------------------------------------------------------
|
|
function newSec(): MonoDisplay.MonoFormatElementsAlways {
|
|
return {
|
|
sectionType: MonoDisplay.SectionType.ElementsAlways, elements: [], flags: {
|
|
clearBuffer: true,
|
|
drawBack: true,
|
|
drawFront: true,
|
|
} };
|
|
}
|
|
|
|
function createNewElement(type: MonoDisplay.ElementType): MonoDisplay.MonoFormatElement {
|
|
const fields: Record<string, any> = {};
|
|
(EL_FIELDS[MonoDisplay.ElementTypeToString[type]] as ElFieldMeta[] || [])
|
|
.forEach(f => (fields[f.key] = f.default));
|
|
const flags: Record<string, any> = {};
|
|
(EL_FLAGS[MonoDisplay.ElementTypeToString[type]] as ElFlagMeta[] || [])
|
|
.forEach(f => (flags[f.key] = f.default ?? false));
|
|
return { type, ...fields, flags } as MonoDisplay.MonoFormatElement;
|
|
}
|
|
|
|
function getSectionByIndex(i: number | null): MonoDisplay.MonoFormatSection | undefined {
|
|
return i !== null ? file.sections[i] : undefined;
|
|
}
|
|
|
|
function getElementByIndex(si: number, ei: number): MonoDisplay.MonoFormatElement | undefined {
|
|
const s = getSectionByIndex(si);
|
|
return s && "elements" in s ? s.elements[ei] : undefined;
|
|
}
|
|
|
|
function getCustomFonts(): { fontname: string; index: number }[] {
|
|
return file.sections
|
|
.filter(s => s.sectionType === MonoDisplay.SectionType.CustomFont)
|
|
.map((_, i) => ({ fontname: `CustomFont ${i}`, index: 0x8000 + i }));
|
|
}
|
|
|
|
function elSummary(el: MonoDisplay.MonoFormatElement): string {
|
|
switch (el.type) {
|
|
case MonoDisplay.ElementType.ClippedText:
|
|
case MonoDisplay.ElementType.HScrollText:
|
|
return el.text.slice(0, 24) + (el.text.length > 24 ? "..." : "");
|
|
case MonoDisplay.ElementType.CurrentTime:
|
|
case MonoDisplay.ElementType.Image2D:
|
|
case MonoDisplay.ElementType.HorizontalScroll:
|
|
case MonoDisplay.ElementType.VerticalScroll:
|
|
return `${(el as any).xOffset ?? 0}, ${(el as any).yOffset ?? 0}`;
|
|
default:
|
|
return el.type.toString();
|
|
}
|
|
}
|
|
|
|
// --- Mutations ----------------------------------------------------------------
|
|
function addSection() {
|
|
file.sections.push(newSec());
|
|
activeSecIndex = file.sections.length - 1;
|
|
activeElIndex = null;
|
|
markDirty(); triggerPreview(); m.redraw();
|
|
}
|
|
|
|
function removeSection(si: number) {
|
|
file.sections.splice(si, 1);
|
|
if (activeSecIndex === si) {
|
|
activeSecIndex = file.sections.length ? file.sections.length - 1 : null;
|
|
activeElIndex = null;
|
|
}
|
|
markDirty(); triggerPreview();
|
|
}
|
|
|
|
function toggleSection(si: number) {
|
|
activeSecIndex = si;
|
|
}
|
|
|
|
function setSectionFlag(flag: string, val: boolean) {
|
|
if (activeSecIndex === null) return;
|
|
const s = getSectionByIndex(activeSecIndex);
|
|
if (!s) return;
|
|
(s as any).flags[flag] = val;
|
|
markDirty(); triggerPreview();
|
|
}
|
|
|
|
function setSectionField(si: number, key: string, val: any) {
|
|
const s = getSectionByIndex(si);
|
|
if (!s) return;
|
|
(s as any)[key] = val;
|
|
markDirty(); triggerPreview();
|
|
}
|
|
|
|
function setSectionType(si: number, sectionType: string) {
|
|
const sec = getSectionByIndex(si);
|
|
if (!sec) return;
|
|
const wasElementsSection = sec.sectionType !== MonoDisplay.SectionType.CustomFont;
|
|
(sec as any).sectionType = MonoDisplay.StringToSectionType[sectionType as MonoDisplay.SectionTypeName];
|
|
if ((sec as any).sectionType === MonoDisplay.SectionType.CustomFont) {
|
|
(sec as any).fontData = new Uint8Array();
|
|
delete (sec as any).elements;
|
|
delete (sec as any).flags;
|
|
} else {
|
|
if (!wasElementsSection) {
|
|
(sec as any).elements = [];
|
|
(sec as any).flags = {};
|
|
}
|
|
delete (sec as any).fontData;
|
|
if ((sec as any).sectionType === MonoDisplay.SectionType.ElementsTimespan) {
|
|
const now = BigInt(Math.floor(Date.now() / 1000));
|
|
(sec as any).startTimestamp = now;
|
|
(sec as any).endTimestamp = now + 3600n;
|
|
} else {
|
|
delete (sec as any).startTimestamp;
|
|
delete (sec as any).endTimestamp;
|
|
}
|
|
}
|
|
markDirty(); triggerPreview();
|
|
}
|
|
|
|
function addElement(si: number) {
|
|
const s = getSectionByIndex(si);
|
|
if (!s || !("elements" in s)) return;
|
|
s.elements.push(createNewElement(MonoDisplay.ElementType.HScrollText));
|
|
activeElIndex = s.elements.length - 1;
|
|
activeSecIndex = si;
|
|
markDirty(); triggerPreview();
|
|
}
|
|
|
|
function removeElement(si: number, ei: number) {
|
|
const s = getSectionByIndex(si);
|
|
if (!s || !("elements" in s)) return;
|
|
s.elements.splice(ei, 1);
|
|
activeElIndex = null;
|
|
markDirty(); triggerPreview();
|
|
}
|
|
|
|
function selectElement(si: number, ei: number) {
|
|
if (activeSecIndex === si && activeElIndex === ei) {
|
|
activeElIndex = null;
|
|
} else {
|
|
activeSecIndex = si;
|
|
activeElIndex = ei;
|
|
}
|
|
}
|
|
|
|
function changeElType(si: number, ei: number, typeString: MonoDisplay.ElementTypeName) {
|
|
const el = getElementByIndex(si, ei);
|
|
if (!el) return;
|
|
const fresh = createNewElement(MonoDisplay.StringToElementType[typeString]);
|
|
Object.assign(el, fresh);
|
|
markDirty(); triggerPreview();
|
|
}
|
|
|
|
function setElField(si: number, ei: number, key: string, val: any) {
|
|
const el = getElementByIndex(si, ei);
|
|
if (!el) return;
|
|
(el as any)[key] = val;
|
|
markDirty(); triggerPreview();
|
|
}
|
|
|
|
function setElFlag(si: number, ei: number, key: string, val: any) {
|
|
const el = getElementByIndex(si, ei);
|
|
if (!el) return;
|
|
(el as any).flags[key] = val;
|
|
markDirty(); triggerPreview();
|
|
}
|
|
|
|
// --- DEMO --------------------------------------------------------------------
|
|
const DEMO: Record<string, () => MonoDisplayFile> = {
|
|
Checkerboard() {
|
|
const pixels = new Uint8Array(W * H);
|
|
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) pixels[y * W + x] = (x + y) % 2;
|
|
return new MonoDisplayFile([{
|
|
sectionType: MonoDisplay.SectionType.ElementsAlways,
|
|
elements: [{ type: MonoDisplay.ElementType.Image2D, image: { pixels, height: H, width: W }, xOffset: 0, yOffset: 0 }],
|
|
flags: { clearBuffer: true, drawFront: true, drawBack: true },
|
|
}]);
|
|
},
|
|
Blink() {
|
|
return new MonoDisplayFile([{
|
|
sectionType: MonoDisplay.SectionType.ElementsAlways,
|
|
elements: [{
|
|
type: MonoDisplay.ElementType.Animation, width: W, height: H, updateInterval: 12, xOffset: 0, yOffset: 0,
|
|
frames: [
|
|
{ pixels: new Uint8Array(W * H).fill(1), width: 0, height: 0 },
|
|
{ pixels: new Uint8Array(W * H).fill(0), width: 0, height: 0 },
|
|
],
|
|
}],
|
|
flags: { clearBuffer: true, drawFront: true, drawBack: true },
|
|
}]);
|
|
},
|
|
Text() {
|
|
return new MonoDisplayFile([{
|
|
sectionType: MonoDisplay.SectionType.ElementsAlways,
|
|
elements: [{ type: MonoDisplay.ElementType.ClippedText, fontIndex: 0, text: "Hello, World!", xOffset: 0, yOffset: 16, width: 60, height: 10 }],
|
|
flags: { clearBuffer: true, drawFront: true, drawBack: true },
|
|
}]);
|
|
},
|
|
Scrolltext() {
|
|
return new MonoDisplayFile([{
|
|
sectionType: MonoDisplay.SectionType.ElementsAlways,
|
|
elements: [{
|
|
type: MonoDisplay.ElementType.HScrollText, fontIndex: 0,
|
|
text: "MONO DISPLAY - scrolling ticker - 🚀 ",
|
|
xOffset: 0, yOffset: 32, width: W, height: 16, scrollSpeed: 50,
|
|
flags: { endless: true, invertDirection: false, padStart: false, padEnd: false },
|
|
}],
|
|
flags: { clearBuffer: true, drawFront: true, drawBack: true },
|
|
}]);
|
|
},
|
|
Time() {
|
|
return new MonoDisplayFile([{
|
|
sectionType: MonoDisplay.SectionType.ElementsAlways,
|
|
elements: [
|
|
{ type: MonoDisplay.ElementType.CurrentTime, fontIndex: 0, flags: {}, xOffset: 0, yOffset: 8, width: W, height: 16, utcOffsetMinutes: 120 },
|
|
{ type: MonoDisplay.ElementType.CurrentTime, fontIndex: 0, flags: { clock12h: true }, xOffset: 40, yOffset: 16, width: W, height: 16, utcOffsetMinutes: 120 },
|
|
{ type: MonoDisplay.ElementType.CurrentTime, fontIndex: 0, flags: { clock12h: true, showHours: true }, xOffset: 0, yOffset: 24, width: W, height: 16, utcOffsetMinutes: 120 },
|
|
{ type: MonoDisplay.ElementType.CurrentTime, fontIndex: 0, flags: { clock12h: true, showHours: false }, xOffset: 40, yOffset: 32, width: W, height: 16, utcOffsetMinutes: 120 },
|
|
{ type: MonoDisplay.ElementType.CurrentTime, fontIndex: 0, flags: { clock12h: true, showSeconds: true }, xOffset: 80, yOffset: 32, width: W, height: 16, utcOffsetMinutes: 120 },
|
|
],
|
|
flags: { clearBuffer: true, drawFront: true, drawBack: true },
|
|
}]);
|
|
},
|
|
};
|
|
|
|
// --- Preview -----------------------------------------------------------------
|
|
let previewTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function triggerPreview() {
|
|
if (previewTimer) clearTimeout(previewTimer);
|
|
previewTimer = setTimeout(() => {
|
|
try { buildPreview(); } catch (e) { console.warn("preview", e); }
|
|
}, 150);
|
|
}
|
|
|
|
function buildPreview() {
|
|
if (!window.MonoDisplay) return;
|
|
if (!window._mdDriver)
|
|
window._mdDriver = new MonoDisplay.MonoDisplayDriver("canvas_root", { onColor: "#EC0", offColor: "#000", fps: 25 });
|
|
window._mdDriver.load(() => Promise.resolve(file.toBuffer()));
|
|
}
|
|
|
|
// --- Load / Export -----------------------------------------------------------
|
|
function loadBin(input: HTMLInputElement) {
|
|
const binFile = input.files?.[0];
|
|
if (!binFile) return;
|
|
currentFilename = binFile.name;
|
|
const filenameEl = document.getElementById("filename");
|
|
if (filenameEl) filenameEl.textContent = binFile.name;
|
|
const reader = new FileReader();
|
|
reader.onload = e => {
|
|
try {
|
|
const parsed = new window.MonoDisplay.MonoDisplayParser().parse(e.target!.result as ArrayBuffer);
|
|
file = new MonoDisplayFile(parsed.sections);
|
|
activeSecIndex = null;
|
|
activeElIndex = null;
|
|
triggerPreview();
|
|
m.redraw();
|
|
} catch (err) {
|
|
console.warn("Could not parse sections from bin:", err);
|
|
}
|
|
};
|
|
reader.readAsArrayBuffer(binFile);
|
|
input.value = "";
|
|
markClean();
|
|
}
|
|
|
|
function exportBin() {
|
|
if (!window.MonoDisplay) { alert("MonoDisplay library not loaded."); return; }
|
|
const blob = new Blob([file.toBuffer() as any], { type: "application/octet-stream" });
|
|
const a = document.createElement("a");
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = currentFilename.replace(/\.bin$/, "") + ".bin";
|
|
a.click();
|
|
markClean();
|
|
}
|
|
|
|
// --- Mithril Components -------------------------------------------------------
|
|
|
|
function FieldInput(si: number, ei: number, field: ElFieldMeta, el: MonoDisplay.MonoFormatElement): m.Vnode {
|
|
const val = (el as any)[field.key] ?? field.default;
|
|
switch (field.type) {
|
|
case "text":
|
|
return m("textarea", {
|
|
onchange: (e: Event) => setElField(si, ei, field.key, (e.target as HTMLTextAreaElement).value),
|
|
}, val);
|
|
case "font":
|
|
return m("select", {
|
|
onchange: (e: Event) => setElField(si, ei, field.key, +(e.target as HTMLSelectElement).value),
|
|
}, [
|
|
...DEFAULT_FONTS.map((name, i) => m("option", { value: i, selected: val === i }, name)),
|
|
...getCustomFonts().map(cf => m("option", { value: cf.index, selected: val === cf.index }, cf.fontname)),
|
|
]);
|
|
default:
|
|
return m("input[type=number]", {
|
|
value: val,
|
|
onchange: (e: Event) => setElField(si, ei, field.key, +(e.target as HTMLInputElement).value),
|
|
});
|
|
}
|
|
}
|
|
|
|
const ElementItem: m.Component<{ si: number; ei: number; el: MonoDisplay.MonoFormatElement }> = {
|
|
view({ attrs: { si, ei, el } }) {
|
|
const isActive = activeSecIndex === si && activeElIndex === ei;
|
|
const typeStr = MonoDisplay.ElementTypeToString[el.type];
|
|
const fields = EL_FIELDS[typeStr] || [];
|
|
const flags = EL_FLAGS[typeStr] || [];
|
|
|
|
return m(".el-item", { class: isActive ? "active" : "" },
|
|
m(".el-item-hdr", { onclick: () => selectElement(si, ei) },
|
|
m("span.el-type", typeStr),
|
|
m("span.el-name", elSummary(el)),
|
|
m("button.x-btn", {
|
|
title: "Remove element",
|
|
onclick: (e: Event) => { e.stopPropagation(); removeElement(si, ei); },
|
|
}, "X"),
|
|
),
|
|
isActive && m(".el-fields",
|
|
m(".field.full",
|
|
m("label", "Type"),
|
|
m("select", {
|
|
onchange: (e: Event) => changeElType(si, ei, (e.target as HTMLSelectElement).value as MonoDisplay.ElementTypeName),
|
|
},
|
|
EL_TYPES.map(t =>
|
|
m("option", { value: MonoDisplay.ElementTypeToString[t], selected: t === el.type }, MonoDisplay.ElementTypeToString[t])
|
|
)
|
|
),
|
|
),
|
|
m(".fields-grid",
|
|
fields.map(field =>
|
|
m(`.field${field.full ? ".full" : ""}`,
|
|
m("label", field.label),
|
|
FieldInput(si, ei, field, el),
|
|
)
|
|
)
|
|
),
|
|
flags.length ? m(".field.full",
|
|
m("label", "Flags"),
|
|
m(".flags-row",
|
|
flags.map(flag =>
|
|
m("label.flag-check",
|
|
m("input[type=checkbox]", {
|
|
checked: !!(el as any).flags?.[flag.key],
|
|
onchange: (e: Event) => setElFlag(si, ei, flag.key, (e.target as HTMLInputElement).checked),
|
|
}),
|
|
" " + flag.label,
|
|
)
|
|
)
|
|
),
|
|
) : null,
|
|
),
|
|
);
|
|
},
|
|
};
|
|
|
|
const SectionCard: m.Component<{ si: number }> = {
|
|
view({ attrs: { si } }) {
|
|
const section = file.sections[si];
|
|
if (!section) return null;
|
|
const isActive = activeSecIndex === si;
|
|
const typeStr = MonoDisplay.SectionTypeToString[section.sectionType];
|
|
|
|
const hdr = m(`.sec-hdr${isActive ? ".active" : ""}`, { onclick: () => toggleSection(si) },
|
|
m("span.sec-arrow", "▶"),
|
|
m("span.sec-label", typeStr),
|
|
m("button.x-btn", {
|
|
title: "Remove section",
|
|
onclick: (e: Event) => { e.stopPropagation(); removeSection(si); },
|
|
}, "X"),
|
|
);
|
|
|
|
if (section.sectionType === MonoDisplay.SectionType.CustomFont) {
|
|
return m(`.sec-card.open${isActive ? ".active" : ""}`,
|
|
m(`.sec-hdr${isActive ? ".active" : ""}`, { onclick: () => toggleSection(si) },
|
|
m("span.sec-arrow", "▶"),
|
|
m("span.sec-label", "Custom Font"),
|
|
m("span.sec-badge", "1 el"),
|
|
m("button.x-btn", {
|
|
title: "Remove section",
|
|
onclick: (e: Event) => { e.stopPropagation(); removeSection(si); },
|
|
}, "X"),
|
|
),
|
|
m(".el-list", "?"),
|
|
);
|
|
}
|
|
|
|
const elSection = section as MonoDisplay.MonoFormatElementsAlways | MonoDisplay.MonoFormatElementsTimespan;
|
|
|
|
return m(`.sec-card.open${isActive ? ".active" : ""}`,
|
|
m(`.sec-hdr${isActive ? ".active" : ""}`, { onclick: () => toggleSection(si) },
|
|
m("span.sec-arrow", "▶"),
|
|
m("span.sec-label", typeStr),
|
|
m("span.sec-badge", `${elSection.elements.length} el`),
|
|
m("button.x-btn", {
|
|
title: "Remove section",
|
|
onclick: (e: Event) => { e.stopPropagation(); removeSection(si); },
|
|
}, "X"),
|
|
),
|
|
section.sectionType === MonoDisplay.SectionType.ElementsTimespan
|
|
? m(".el-list",
|
|
([ ["Start Time", "startTimestamp"], ["Stop Time", "endTimestamp"] ] as [string, string][])
|
|
.map(([label, key]) => {
|
|
let posix: bigint = (section as any)[key];
|
|
if (!posix) {
|
|
posix = BigInt(Math.floor(Date.now() / 1000));
|
|
setSectionField(si, key, posix);
|
|
}
|
|
const dtLocal = new Date(Number(posix) * 1000).toISOString().slice(0, 16);
|
|
return m(".field",
|
|
m("label", label),
|
|
m("input[type=datetime-local]", {
|
|
value: dtLocal,
|
|
onchange: (e: Event) => {
|
|
const ms = new Date((e.target as HTMLInputElement).value).getTime();
|
|
setSectionField(si, key, BigInt(Math.floor(ms / 1000)));
|
|
},
|
|
}),
|
|
);
|
|
})
|
|
)
|
|
: null,
|
|
m(".el-list",
|
|
elSection.elements.map((el, ei) => m(ElementItem, { key: String(ei), si, ei, el })),
|
|
m("button.add-el", { onclick: () => addElement(si) }, "+ add element"),
|
|
),
|
|
);
|
|
},
|
|
};
|
|
|
|
const SectionsList: m.Component = {
|
|
view() {
|
|
if (!file.sections.length)
|
|
return m(".empty-state", m.trust("No sections yet.<br/>Use <b>Add section</b> to get started."));
|
|
if (activeSecIndex === null && file.sections.length === 1) activeSecIndex = 0;
|
|
return m("", file.sections.map((_, si) => m(SectionCard, { key: String(si), si })));
|
|
},
|
|
};
|
|
|
|
const MetaPanel: m.Component = {
|
|
view() {
|
|
const section = getSectionByIndex(activeSecIndex);
|
|
return m("",
|
|
m(".meta-row",
|
|
m("label", "Selected section"),
|
|
section
|
|
? m("select.meta-val.green", {
|
|
value: MonoDisplay.SectionTypeToString[section.sectionType],
|
|
onchange: (e: Event) =>
|
|
activeSecIndex !== null && setSectionType(activeSecIndex, (e.target as HTMLSelectElement).value),
|
|
},
|
|
Object.values(MonoDisplay.SectionTypeToString).map(name =>
|
|
m("option", { value: name }, name)
|
|
)
|
|
)
|
|
: m("span.meta-val.green", "-"),
|
|
),
|
|
m(".meta-row",
|
|
m("label", "Elements"),
|
|
m("span.meta-val", section && "elements" in section ? `${section.elements.length} element(s)` : "-"),
|
|
),
|
|
m(".meta-row",
|
|
m("label", "Section flags"),
|
|
(["drawFront", "drawBack", "clearBuffer"] as const).map((flag, i) =>
|
|
m("label.flag-check", { style: i > 0 ? "margin-left:-8px" : "" },
|
|
m("input[type=checkbox]", {
|
|
checked: section ? !!(section as any).flags?.[flag] : false,
|
|
onchange: (e: Event) => setSectionFlag(flag, (e.target as HTMLInputElement).checked),
|
|
}),
|
|
" " + flag,
|
|
)
|
|
),
|
|
),
|
|
);
|
|
},
|
|
};
|
|
|
|
let activeDemoName: string | null = null;
|
|
|
|
const DemoButtons: m.Component = {
|
|
view() {
|
|
return Object.entries(DEMO).map(([name, fn]) =>
|
|
m("button.tb-btn", {
|
|
style: activeDemoName === name ? "color:#33ff66" : "",
|
|
onclick: async () => {
|
|
const ok = await guardDirty();
|
|
if (!ok) return;
|
|
file = fn();
|
|
activeSecIndex = null;
|
|
activeElIndex = null;
|
|
currentFilename = name.toLowerCase();
|
|
const filenameEl = document.getElementById("filename");
|
|
if (filenameEl) filenameEl.textContent = currentFilename;
|
|
activeDemoName = name;
|
|
markClean();
|
|
triggerPreview();
|
|
m.redraw();
|
|
},
|
|
}, name)
|
|
);
|
|
},
|
|
};
|
|
|
|
// --- Mount --------------------------------------------------------------------
|
|
m.mount(document.getElementById("sections-wrap")!, SectionsList);
|
|
m.mount(document.getElementById("demo-btns")!, DemoButtons);
|
|
m.mount(document.getElementById("sec-meta")!, MetaPanel);
|
|
|
|
// Expose globals referenced by topbar inline handlers
|
|
Object.assign(window, { loadBin, exportBin, addSection, cycleTheme });
|
|
|
|
triggerPreview();
|
|
|