|
|
|
@ -4,6 +4,628 @@ |
|
|
|
// without ES module syntax.
|
|
|
|
// without ES module syntax.
|
|
|
|
|
|
|
|
|
|
|
|
import * as MonoDisplay from "./index"; |
|
|
|
import * as MonoDisplay from "./index"; |
|
|
|
|
|
|
|
import { MonoDisplayFile } from "./file"; |
|
|
|
|
|
|
|
import { cycleTheme } from "./themes"; |
|
|
|
|
|
|
|
import { ElementType } from "./types"; |
|
|
|
|
|
|
|
|
|
|
|
// Expose on globalThis (= window in browser, globalThis elsewhere)
|
|
|
|
// Expose on globalThis (= window in browser, globalThis elsewhere)
|
|
|
|
(globalThis as typeof globalThis & { MonoDisplay: typeof MonoDisplay }).MonoDisplay = MonoDisplay; |
|
|
|
(globalThis as typeof globalThis & { MonoDisplay: typeof MonoDisplay }).MonoDisplay = MonoDisplay; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const W = 120, H = 60; |
|
|
|
|
|
|
|
let file: MonoDisplayFile = new MonoDisplayFile([]); |
|
|
|
|
|
|
|
let activeSecIndex:number|null = null, activeElIndex:number|null = null; |
|
|
|
|
|
|
|
let currentFilename = 'untitled'; |
|
|
|
|
|
|
|
let isDirty = false; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// --- Element type definitions ------------------------------------------------
|
|
|
|
|
|
|
|
type ElFieldMeta = { key: string; label: string; type: string; default: any; full?: boolean }; |
|
|
|
|
|
|
|
type ElFlagMeta = { key: string; label: string }; |
|
|
|
|
|
|
|
const EL_FIELDS: Partial<Record<keyof typeof ElementType, 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: 16 }, |
|
|
|
|
|
|
|
{ key: 'fontIndex', label: 'Font', type: 'font', default: 'Font 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: 16 }, |
|
|
|
|
|
|
|
{ key: 'scrollSpeed', label: 'Scroll speed', type: 'number', default: 50 }, |
|
|
|
|
|
|
|
{ key: 'fontIndex', label: 'Font', type: 'font', default: 'Font 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: 16 }, |
|
|
|
|
|
|
|
{ key: 'scrollSpeed', label: 'Scroll speed', type: 'number', default: 50 }, |
|
|
|
|
|
|
|
{ key: 'fontIndex', label: 'Font', type: 'font', default: 'Font 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: 16 }, |
|
|
|
|
|
|
|
{ key: 'utcOffsetMinutes', label: 'UTC offset (min)', type: 'number', default: 0 }, |
|
|
|
|
|
|
|
{ key: 'fontIndex', label: 'Font', type: 'font', default: 'Font 0' }, |
|
|
|
|
|
|
|
], |
|
|
|
|
|
|
|
}; |
|
|
|
|
|
|
|
const EL_FLAGS: Partial<Record<keyof typeof ElementType, ElFlagMeta[]>> = { |
|
|
|
|
|
|
|
HScrollText: [{ key: 'endless', label: 'Endless' }, { key: 'invertDirection', label: 'Invert direction' }], |
|
|
|
|
|
|
|
CurrentTime: [{ key: 'clock12h', label: '12h mode' }, { key: 'showHours', label: 'Show hours' }, { key: 'showSeconds', label: 'Show seconds' }], |
|
|
|
|
|
|
|
}; |
|
|
|
|
|
|
|
const EL_TYPES: ElementType[] = Object.keys(EL_FIELDS).map((x: string) => MonoDisplay.StringToElementType[x as MonoDisplay.ElementTypeName]); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const DEFAULT_FONTS = [ |
|
|
|
|
|
|
|
"Font 1", |
|
|
|
|
|
|
|
"Font 2", |
|
|
|
|
|
|
|
"Font 3", |
|
|
|
|
|
|
|
"Font 4", |
|
|
|
|
|
|
|
"Font 5", |
|
|
|
|
|
|
|
] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// --- Confirm dialog -----------------------------------------------------------
|
|
|
|
|
|
|
|
function confirm(msg: string, buttons: { |
|
|
|
|
|
|
|
primary?: boolean, |
|
|
|
|
|
|
|
label: string, |
|
|
|
|
|
|
|
action: boolean, |
|
|
|
|
|
|
|
}[]) { |
|
|
|
|
|
|
|
// buttons: [{label,primary,action}]
|
|
|
|
|
|
|
|
return new Promise(resolve => { |
|
|
|
|
|
|
|
const overlay: HTMLElement | null = document.getElementById('confirm-overlay'); |
|
|
|
|
|
|
|
const confirm = document.getElementById('confirm-msg'); |
|
|
|
|
|
|
|
if(confirm) confirm.textContent = msg; |
|
|
|
|
|
|
|
const btns = document.getElementById('confirm-btns'); |
|
|
|
|
|
|
|
if(btns) btns.innerHTML = ''; |
|
|
|
|
|
|
|
buttons.forEach(b => { |
|
|
|
|
|
|
|
const el = document.createElement('button'); |
|
|
|
|
|
|
|
el.className = 'cb' + (b.primary ? ' primary' : ''); |
|
|
|
|
|
|
|
el.textContent = b.label; |
|
|
|
|
|
|
|
el.onclick = () => { overlay?.classList.remove('show'); resolve(b.action); }; |
|
|
|
|
|
|
|
if(btns) btns.appendChild(el); |
|
|
|
|
|
|
|
}); |
|
|
|
|
|
|
|
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() { |
|
|
|
|
|
|
|
if (!isDirty || !file.sections.length) return true; |
|
|
|
|
|
|
|
const action = await confirm( |
|
|
|
|
|
|
|
'You have unsaved changes. Loading a demo will replace the current editor state.', |
|
|
|
|
|
|
|
[{ label: 'Cancel', action: false }, { label: 'Load anyway', primary: true, action: true }] |
|
|
|
|
|
|
|
); |
|
|
|
|
|
|
|
return action; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// --- Helpers -----------------------------------------------------------------
|
|
|
|
|
|
|
|
function getElementId(elementIndex: number): string { |
|
|
|
|
|
|
|
return `Element-${elementIndex}`; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
function getSectionId(sectionIndex: number): string { |
|
|
|
|
|
|
|
return `Section-${sectionIndex}`; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
function newSec() { |
|
|
|
|
|
|
|
return { |
|
|
|
|
|
|
|
sectionType: MonoDisplay.SectionType.ElementsAlways, |
|
|
|
|
|
|
|
elements: [], |
|
|
|
|
|
|
|
flags: {}, |
|
|
|
|
|
|
|
}; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
function newEl(type: MonoDisplay.ElementType) { |
|
|
|
|
|
|
|
const fields = {}; |
|
|
|
|
|
|
|
(EL_FIELDS[type] || []).forEach(f => fields[f.k] = f.d); |
|
|
|
|
|
|
|
const flags = {}; |
|
|
|
|
|
|
|
(EL_FLAGS[type] || []).forEach(f => flags[f.k] = false); |
|
|
|
|
|
|
|
return { type, fields, flags }; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
function getSec(sectionIndex: number | null) { |
|
|
|
|
|
|
|
return file.sections.find((s,index) => index === sectionIndex) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function getEl(sectionIndex: number, elementIndex: number) { |
|
|
|
|
|
|
|
const section = getSec(sectionIndex); |
|
|
|
|
|
|
|
return section && "elements" in section && section.elements.find((e, index) => index == elementIndex) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// --- Mutations ----------------------------------------------------------------
|
|
|
|
|
|
|
|
function addSection() { |
|
|
|
|
|
|
|
const s = newSec(); file.sections.push(s); |
|
|
|
|
|
|
|
activeSecIndex = file.sections.length-1; activeElIndex = null; |
|
|
|
|
|
|
|
markDirty(); renderHTML(); triggerPreview(); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
function removeSection(sectionIndex:number) { |
|
|
|
|
|
|
|
file.sections = file.sections.filter((section, index) => index !== sectionIndex); |
|
|
|
|
|
|
|
if (activeSecIndex === sectionIndex) { |
|
|
|
|
|
|
|
activeSecIndex = file.sections.length ? file.sections.length - 1 : null; |
|
|
|
|
|
|
|
activeElIndex = null; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
markDirty(); renderHTML(); triggerPreview(); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
function toggleSection(id) { |
|
|
|
|
|
|
|
const s = getSec(id); if (!s) return; |
|
|
|
|
|
|
|
s.open = !s.open; activeSecIndex = id; renderHTML(); updateMeta(); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
function setSectionFlag(flag, val) { |
|
|
|
|
|
|
|
if (!activeSecIndex) return; |
|
|
|
|
|
|
|
const s = getSec(activeSecIndex); if (!s) return; |
|
|
|
|
|
|
|
s.flags[flag] = val; markDirty(); triggerPreview(); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
function addElement(sectionIndex:number) { |
|
|
|
|
|
|
|
const s = getSec(secId); if (!s) return; |
|
|
|
|
|
|
|
const el = newEl(EL_TYPES[0]); s.elements.push(el); |
|
|
|
|
|
|
|
activeSecIndex = secId; activeElIndex = { secId, elId: el.id }; |
|
|
|
|
|
|
|
markDirty(); renderHTML(); triggerPreview(); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function removeElement(sectionIndex:number, elementIndex:number) { |
|
|
|
|
|
|
|
const s = getSec(sectionIndex); if (!s) return; |
|
|
|
|
|
|
|
s.elements = s.elements.filter(e => e.id !== elId); |
|
|
|
|
|
|
|
if (activeElIndex && activeElIndex.secId === secId && activeElIndex.elId === elId) activeElIndex = null; |
|
|
|
|
|
|
|
markDirty(); renderHTML(); triggerPreview(); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
function selectElement(sectionIndex:number, elementIndex:number) { |
|
|
|
|
|
|
|
activeSecIndex = sectionIndex; |
|
|
|
|
|
|
|
activeElIndex = activeElIndex && |
|
|
|
|
|
|
|
activeElIndex === sectionIndex && |
|
|
|
|
|
|
|
activeElIndex === elementIndex ? |
|
|
|
|
|
|
|
null : elementIndex; |
|
|
|
|
|
|
|
renderHTML(); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
function changeElType(sectionIndex:number, elementIndex:number, typeString: string) { |
|
|
|
|
|
|
|
const el = getEl(sectionIndex, elementIndex); if (!el) return; |
|
|
|
|
|
|
|
const type = MonoDisplay.StringToElementType[typeString as MonoDisplay.ElementTypeName]; |
|
|
|
|
|
|
|
const fresh = newEl(type); |
|
|
|
|
|
|
|
el.type = type; el.fields = fresh.fields; el.flags = fresh.flags; |
|
|
|
|
|
|
|
markDirty(); renderHTML(); triggerPreview(); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
function setElField(sectionIndex:number, elementIndex:number, key:string, val:any) { |
|
|
|
|
|
|
|
const el = getEl(sectionIndex, elementIndex); if (!el) return; |
|
|
|
|
|
|
|
el[key] = val; |
|
|
|
|
|
|
|
markDirty(); triggerPreview(); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
function setElFlag(sectionIndex:number, elementIndex:number, key:string, val:any) { |
|
|
|
|
|
|
|
const el = getEl(sectionIndex, elementIndex); if (!el) return; |
|
|
|
|
|
|
|
el.flags[key] = val; |
|
|
|
|
|
|
|
markDirty(); triggerPreview(); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
function setSectionType(sectionIndex: number, sectionType: string) { |
|
|
|
|
|
|
|
const sec = getSec(sectionIndex); |
|
|
|
|
|
|
|
if (sec) { |
|
|
|
|
|
|
|
sec.sectionType = MonoDisplay.StringToSectionType[sectionType as MonoDisplay.SectionTypeName]; |
|
|
|
|
|
|
|
if (sec.sectionType == MonoDisplay.SectionType.CustomFont) { |
|
|
|
|
|
|
|
sec.fontData = new Uint8Array(); |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
sec.elements = []; |
|
|
|
|
|
|
|
sec.flags = {}; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
markDirty(); triggerPreview(); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// --- Load demo into editor state ---------------------------------------------
|
|
|
|
|
|
|
|
const DEMO = { |
|
|
|
|
|
|
|
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: 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: 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: ElementType.ClippedText, |
|
|
|
|
|
|
|
fontIndex: 0, |
|
|
|
|
|
|
|
text: 'Hello, World!', |
|
|
|
|
|
|
|
xOffset: 0, yOffset: 32, |
|
|
|
|
|
|
|
width: W, height: 16 |
|
|
|
|
|
|
|
}], |
|
|
|
|
|
|
|
flags: { |
|
|
|
|
|
|
|
clearBuffer: true, |
|
|
|
|
|
|
|
drawFront: true, |
|
|
|
|
|
|
|
drawBack: true |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
}]); |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
Scrolltext() { |
|
|
|
|
|
|
|
return new MonoDisplayFile( |
|
|
|
|
|
|
|
[{ |
|
|
|
|
|
|
|
sectionType: MonoDisplay.SectionType.ElementsAlways, |
|
|
|
|
|
|
|
elements: [{ |
|
|
|
|
|
|
|
type: 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: ElementType.CurrentTime, |
|
|
|
|
|
|
|
fontIndex: 0, |
|
|
|
|
|
|
|
flags: {}, |
|
|
|
|
|
|
|
xOffset: 0, yOffset: 8, width: W, height: 16, utcOffsetMinutes: 120 |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
{ |
|
|
|
|
|
|
|
type: ElementType.CurrentTime, |
|
|
|
|
|
|
|
fontIndex: 0, |
|
|
|
|
|
|
|
flags: { clock12h: true }, |
|
|
|
|
|
|
|
xOffset: 40, yOffset: 16, width: W, height: 16, utcOffsetMinutes: 120 |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
{ |
|
|
|
|
|
|
|
type: ElementType.CurrentTime, |
|
|
|
|
|
|
|
fontIndex: 0, |
|
|
|
|
|
|
|
flags: { clock12h: true, showHours: true }, |
|
|
|
|
|
|
|
xOffset: 0, yOffset: 24, width: W, height: 16, utcOffsetMinutes: 120 |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
{ |
|
|
|
|
|
|
|
type: ElementType.CurrentTime, |
|
|
|
|
|
|
|
flags: { clock12h: true, showHours: false }, |
|
|
|
|
|
|
|
fontIndex: 0, |
|
|
|
|
|
|
|
xOffset: 40, yOffset: 32, width: W, height: 16, utcOffsetMinutes: 120 |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
{ |
|
|
|
|
|
|
|
type: 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 |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
}]); |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function initDemos() { |
|
|
|
|
|
|
|
const wrap = document.getElementById('demo-btns'); |
|
|
|
|
|
|
|
let activeBtn = null; |
|
|
|
|
|
|
|
Object.entries(DEMO).forEach((entry: [demoName:string, demoFileFunc: () => MonoDisplayFile]) => { |
|
|
|
|
|
|
|
const [demoName, demoFileFunc] = entry; |
|
|
|
|
|
|
|
const btn = document.createElement('button'); |
|
|
|
|
|
|
|
btn.className = 'tb-btn'; btn.textContent = demoName; |
|
|
|
|
|
|
|
btn.onclick = async () => { |
|
|
|
|
|
|
|
const ok = await guardDirty(); |
|
|
|
|
|
|
|
if (!ok) return; |
|
|
|
|
|
|
|
// load into editor
|
|
|
|
|
|
|
|
file = demoFileFunc(); |
|
|
|
|
|
|
|
activeSecIndex = null; |
|
|
|
|
|
|
|
activeElIndex = null; |
|
|
|
|
|
|
|
currentFilename = demoName.toLowerCase(); |
|
|
|
|
|
|
|
document.getElementById('filename').textContent = currentFilename; |
|
|
|
|
|
|
|
markClean(); renderHTML(); |
|
|
|
|
|
|
|
triggerPreview(); |
|
|
|
|
|
|
|
if (activeBtn) activeBtn.style.color = ''; |
|
|
|
|
|
|
|
btn.style.color = '#33ff66'; activeBtn = btn; |
|
|
|
|
|
|
|
}; |
|
|
|
|
|
|
|
wrap.appendChild(btn); |
|
|
|
|
|
|
|
}); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// --- Render -------------------------------------------------------------------
|
|
|
|
|
|
|
|
function renderHTML() { |
|
|
|
|
|
|
|
updateMeta(); |
|
|
|
|
|
|
|
const wrap = document.getElementById('sections-wrap'); |
|
|
|
|
|
|
|
if (!file.sections.length) { |
|
|
|
|
|
|
|
wrap.innerHTML = '<div class="empty-state">No sections yet.<br/>Use <b>Add section</b> to get started.</div>'; |
|
|
|
|
|
|
|
return; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
wrap.innerHTML = file.sections.map((section, index, sections) => renderHTMLSection(index)).join(''); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function renderHTMLSection(sectionIndex: number) { |
|
|
|
|
|
|
|
const section = file.sections.at(sectionIndex); |
|
|
|
|
|
|
|
if (!section) return ``; |
|
|
|
|
|
|
|
if (activeSecIndex == null && file.sections.length == 1) activeSecIndex = 0; |
|
|
|
|
|
|
|
const isActive = activeSecIndex === sectionIndex, isOpen = true; |
|
|
|
|
|
|
|
const sectionId = getSectionId(sectionIndex); |
|
|
|
|
|
|
|
switch (section.sectionType) { |
|
|
|
|
|
|
|
case MonoDisplay.SectionType.ElementsAlways: |
|
|
|
|
|
|
|
case MonoDisplay.SectionType.ElementsTimespan: |
|
|
|
|
|
|
|
return ` |
|
|
|
|
|
|
|
<div class="sec-card${isActive ? ' active' : ''}${isOpen ? ' open' : ''}" id="${sectionId}"> |
|
|
|
|
|
|
|
<div class="sec-hdr${isActive ? ' active' : ''}" onclick="toggleSection('${sectionId}')"> |
|
|
|
|
|
|
|
<span class="sec-arrow">▶</span> |
|
|
|
|
|
|
|
<span class="sec-label">${MonoDisplay.SectionTypeToString[section.sectionType]}</span> |
|
|
|
|
|
|
|
<span class="sec-badge">${section.elements.length} el</span> |
|
|
|
|
|
|
|
<button class="x-btn" title="Remove section" onclick="event.stopPropagation();removeSection(${sectionIndex})">X</button> |
|
|
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
${isOpen ? ` |
|
|
|
|
|
|
|
<div class="el-list"> |
|
|
|
|
|
|
|
${section.elements.map((el: MonoDisplay.MonoFormatElement, index:number) => renderHTMLElement(sectionIndex, index, el)).join('')} |
|
|
|
|
|
|
|
<button class="add-el" onclick="addElement('${sectionId}')">+ add element</button> |
|
|
|
|
|
|
|
</div>`: ''}
|
|
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
case MonoDisplay.SectionType.CustomFont: { |
|
|
|
|
|
|
|
return ` |
|
|
|
|
|
|
|
<div class="sec-card${isActive ? ' active' : ''}${isOpen ? ' open' : ''}" id="${sectionId}"> |
|
|
|
|
|
|
|
<div class="sec-hdr${isActive ? ' active' : ''}" onclick="toggleSection('${sectionId}')"> |
|
|
|
|
|
|
|
<span class="sec-arrow">▶</span> |
|
|
|
|
|
|
|
<span class="sec-label">Custom Font</span> |
|
|
|
|
|
|
|
<span class="sec-badge">1 el</span> |
|
|
|
|
|
|
|
<button class="x-btn" title="Remove section" onclick="event.stopPropagation();removeSection(${sectionIndex})">X</button> |
|
|
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
${isOpen ? ` |
|
|
|
|
|
|
|
<div class="el-list"> |
|
|
|
|
|
|
|
? |
|
|
|
|
|
|
|
</div>`: ''}
|
|
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function renderHTMLElement(sectionIndex: number, elementIndex: number, el: MonoDisplay.MonoFormatElement) { |
|
|
|
|
|
|
|
const section = file.sections.at(sectionIndex); |
|
|
|
|
|
|
|
const currentSectionElements: any[] = section && "elements" in section ? section.elements: []; |
|
|
|
|
|
|
|
if (activeElIndex == null && currentSectionElements.length == 1) activeElIndex = 0; |
|
|
|
|
|
|
|
const isActive = activeElIndex == elementIndex && activeSecIndex == sectionIndex; |
|
|
|
|
|
|
|
const elementId = getElementId(elementIndex); |
|
|
|
|
|
|
|
const sectionId = getSectionId(sectionIndex); |
|
|
|
|
|
|
|
const typeString = MonoDisplay.ElementTypeToString[el.type]; |
|
|
|
|
|
|
|
const fields = EL_FIELDS[typeString] || []; |
|
|
|
|
|
|
|
const flags = EL_FLAGS[typeString] || []; |
|
|
|
|
|
|
|
const getCustomFonts = ():{fontname:string, index:number}[] => { |
|
|
|
|
|
|
|
return file.sections |
|
|
|
|
|
|
|
.filter(section => section.sectionType == MonoDisplay.SectionType.CustomFont) |
|
|
|
|
|
|
|
.map((fontSection, index) => { |
|
|
|
|
|
|
|
return { |
|
|
|
|
|
|
|
fontname: `CustomFont ${index}`, |
|
|
|
|
|
|
|
index: 0x8000 + index, |
|
|
|
|
|
|
|
}; |
|
|
|
|
|
|
|
}); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
function renderHTMLField(field: ElFieldMeta) { |
|
|
|
|
|
|
|
switch (field.type) { |
|
|
|
|
|
|
|
case 'text': { |
|
|
|
|
|
|
|
return `<textarea
|
|
|
|
|
|
|
|
onchange="setElField(${sectionIndex},${elementIndex},'${field.key}',this.value)" |
|
|
|
|
|
|
|
>${esc(el[field.key] ?? field.default)}</textarea>` |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
case 'font': { |
|
|
|
|
|
|
|
return `<select onchange="setElField(${sectionIndex},${elementIndex},'${field.key}',+this.value)">
|
|
|
|
|
|
|
|
${DEFAULT_FONTS.map((fontname, index) => `<option value="${index}" >${fontname}</option>`).join("")} |
|
|
|
|
|
|
|
${getCustomFonts().map((k:{fontname:string, index:number}) => `<option value="${k.index}" >${k.fontname}</option>`).join("")} |
|
|
|
|
|
|
|
</select> |
|
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
default: |
|
|
|
|
|
|
|
return `<input type="number"
|
|
|
|
|
|
|
|
value="${el[field.key as string] ?? field.default}" |
|
|
|
|
|
|
|
onchange="setElField(${sectionIndex},${elementIndex},'${field.key}',+this.value)" |
|
|
|
|
|
|
|
/>`; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
function renderHTMLFlag(el: MonoDisplay.MonoFormatElement & {flags: Record<string, any>}, flag: ElFlagMeta | null) { |
|
|
|
|
|
|
|
if (el.flags && flag) |
|
|
|
|
|
|
|
return ` |
|
|
|
|
|
|
|
<label class="flag-check"> |
|
|
|
|
|
|
|
<input type="checkbox" ${el.flags[flag.key] ? 'checked' : ''} |
|
|
|
|
|
|
|
onchange="setElFlag(${sectionIndex},${elementIndex},'${flag.key}',this.checked)" |
|
|
|
|
|
|
|
/> |
|
|
|
|
|
|
|
${flag.label} |
|
|
|
|
|
|
|
</label>`;
|
|
|
|
|
|
|
|
return ``; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const fieldsGrid = fields.map(field => ` |
|
|
|
|
|
|
|
<div class="field${field.full ? ' full' : ''}"> |
|
|
|
|
|
|
|
<label>${field.label}</label> |
|
|
|
|
|
|
|
${renderHTMLField(field)} |
|
|
|
|
|
|
|
</div>`).join('');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const flagsGrid = flags.length ? ` |
|
|
|
|
|
|
|
<div class="field full"> |
|
|
|
|
|
|
|
<label>Flags</label> |
|
|
|
|
|
|
|
<div class="flags-row"> |
|
|
|
|
|
|
|
${flags.map(flag => renderHTMLFlag(el, flag)).join('')} |
|
|
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
</div>`: ``;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const activeRender = ` |
|
|
|
|
|
|
|
<div class="el-fields"> |
|
|
|
|
|
|
|
<div class="field full"> |
|
|
|
|
|
|
|
<label>Type</label> |
|
|
|
|
|
|
|
<select onchange="changeElType(${sectionIndex},${elementIndex},this.value)"> |
|
|
|
|
|
|
|
${EL_TYPES.map(t => `<option value="${MonoDisplay.ElementTypeToString[t]}"${t === el.type ? ' selected' : ''}>${MonoDisplay.ElementTypeToString[t]}</option>`).join('')} |
|
|
|
|
|
|
|
</select> |
|
|
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
<div class="fields-grid"> |
|
|
|
|
|
|
|
${fieldsGrid} |
|
|
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
${flagsGrid} |
|
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return ` |
|
|
|
|
|
|
|
<div class="el-item${isActive ? ' active' : ''}" id="${elementId}"> |
|
|
|
|
|
|
|
<div class="el-item-hdr" onclick="selectElement(${sectionIndex},${elementIndex})"> |
|
|
|
|
|
|
|
<span class="el-type">${MonoDisplay.ElementTypeToString[el.type]}</span> |
|
|
|
|
|
|
|
<span class="el-name">${elSummary(el)}</span> |
|
|
|
|
|
|
|
<button class="x-btn" title="Remove element" onclick="event.stopPropagation();removeElement(${sectionIndex},${elementIndex})">X</button> |
|
|
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
${isActive ? activeRender : ``} |
|
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function elSummary(el: MonoDisplay.MonoFormatElement) { |
|
|
|
|
|
|
|
switch (el.type) { |
|
|
|
|
|
|
|
case ElementType.ClippedText: |
|
|
|
|
|
|
|
case ElementType.HScrollText: |
|
|
|
|
|
|
|
// case ElementType.VScrollText:
|
|
|
|
|
|
|
|
return esc(el.text.slice(0, 24) + (el.text.length > 24 ? '...' : '')); |
|
|
|
|
|
|
|
case ElementType.CurrentTime: |
|
|
|
|
|
|
|
case ElementType.Image2D: |
|
|
|
|
|
|
|
case ElementType.HorizontalScroll: |
|
|
|
|
|
|
|
case ElementType.VerticalScroll: |
|
|
|
|
|
|
|
return `${el.xOffset ?? 0}, ${el.yOffset ?? 0}`; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
case ElementType.Animation: |
|
|
|
|
|
|
|
case ElementType.Line: |
|
|
|
|
|
|
|
return `${el.type.toString()}`; |
|
|
|
|
|
|
|
default: |
|
|
|
|
|
|
|
return "?"; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
function esc(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') } |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function updateMeta() { |
|
|
|
|
|
|
|
const section = getSec(activeSecIndex); |
|
|
|
|
|
|
|
const sectionSelection = document.getElementById('meta-name'); |
|
|
|
|
|
|
|
if (sectionSelection) { |
|
|
|
|
|
|
|
if (section) { |
|
|
|
|
|
|
|
const options = Object.entries(MonoDisplay.SectionTypeToString) |
|
|
|
|
|
|
|
.map((element, index) => `<option value=${element[1]}>${element[1]}</option>`) |
|
|
|
|
|
|
|
.join(""); |
|
|
|
|
|
|
|
sectionSelection.innerHTML = `<select id="section-type-selection" onchange="setSectionType(activeSecIndex, this.value)">
|
|
|
|
|
|
|
|
${options} |
|
|
|
|
|
|
|
</select>`;
|
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
sectionSelection.innerHTML = "-"; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
document.getElementById('meta-count').textContent = section && "elements" in section ? section.elements.length + ' element(s)' : '-'; |
|
|
|
|
|
|
|
document.getElementById('flag-drawFront').checked = section ? !!section.flags.drawFront : false; |
|
|
|
|
|
|
|
document.getElementById('flag-drawBack').checked = section ? !!section.flags.drawBack : false; |
|
|
|
|
|
|
|
document.getElementById('flag-clearBuffer').checked = section ? !!section.flags.clearBuffer : false; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// --- Preview ------------------------------------------------------------------
|
|
|
|
|
|
|
|
let previewTimer = null; |
|
|
|
|
|
|
|
function triggerPreview() { |
|
|
|
|
|
|
|
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 }); |
|
|
|
|
|
|
|
const s = file.sections[activeSecIndex || 0]; |
|
|
|
|
|
|
|
window._mdDriver.load(() => Promise.resolve(file.toBuffer())); |
|
|
|
|
|
|
|
// now update the sections
|
|
|
|
|
|
|
|
renderHTML(); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// --- Load / Export ------------------------------------------------------------
|
|
|
|
|
|
|
|
function loadBin(input: { files: any[]; value: string; }) { |
|
|
|
|
|
|
|
const binFile = input.files[0]; if (!binFile) return; |
|
|
|
|
|
|
|
currentFilename = binFile.name; |
|
|
|
|
|
|
|
document.getElementById('filename').textContent = binFile.name; |
|
|
|
|
|
|
|
const reader = new FileReader(); |
|
|
|
|
|
|
|
reader.onload = e => { |
|
|
|
|
|
|
|
const arrayBuf = e.target.result; |
|
|
|
|
|
|
|
try { |
|
|
|
|
|
|
|
file = new window.MonoDisplay.MonoDisplayParser().parse(arrayBuf); |
|
|
|
|
|
|
|
activeSecIndex = null; |
|
|
|
|
|
|
|
activeElIndex = null; |
|
|
|
|
|
|
|
triggerPreview(); |
|
|
|
|
|
|
|
} 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(); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// --- Init ---------------------------------------------------------------------
|
|
|
|
|
|
|
|
// Pin all functions referenced from inline HTML onclick/onchange strings so
|
|
|
|
|
|
|
|
// Bun's bundler does not tree-shake them as unreachable dead code.
|
|
|
|
|
|
|
|
Object.assign(window, { |
|
|
|
|
|
|
|
loadBin, exportBin, |
|
|
|
|
|
|
|
addSection, removeSection, toggleSection, setSectionFlag, |
|
|
|
|
|
|
|
addElement, removeElement, selectElement, |
|
|
|
|
|
|
|
changeElType, setElField, setElFlag, setSectionType, |
|
|
|
|
|
|
|
cycleTheme, |
|
|
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
triggerPreview(); |
|
|
|
|
|
|
|
if (window.MonoDisplay) { initDemos(); } |
|
|
|
|
|
|
|
else { const iv = setInterval(() => { if (window.MonoDisplay) { clearInterval(iv); initDemos(); } }, 100); } |
|
|
|
|