parent
4edc71a611
commit
5c6a665b45
@ -1,9 +1,500 @@ |
|||||||
// browser.ts — IIFE-style browser entry point
|
import { cycleTheme } from "./themes"; |
||||||
// Built by: bun build src/browser.ts --target browser --outfile public/mono-display.js
|
import type { ElementType } from "./types"; |
||||||
// Attaches everything to window.MonoDisplay so inline <script> tags can use it
|
|
||||||
// without ES module syntax.
|
|
||||||
|
|
||||||
import * as MonoDisplay from "./index"; |
const W = 120, H = 60; |
||||||
|
let file = { sections: [] as any[] }; |
||||||
|
let activeSec = null, activeEl = null; |
||||||
|
let secCounter = 0, elCounter = 0; |
||||||
|
let currentFilename = 'untitled'; |
||||||
|
let isDirty = false; |
||||||
|
|
||||||
// Expose on globalThis (= window in browser, globalThis elsewhere)
|
// --- Element type definitions ------------------------------------------------
|
||||||
(globalThis as typeof globalThis & { MonoDisplay: typeof MonoDisplay }).MonoDisplay = MonoDisplay; |
type ElFieldMeta = { k: string; l: string; t: string; d: any; full?: boolean }; |
||||||
|
type ElFlagMeta = { k: string; l: string }; |
||||||
|
const EL_FIELDS: Partial<Record<keyof typeof ElementType, ElFieldMeta[]>> = { |
||||||
|
Image2D: [ |
||||||
|
{ k: 'xOffset', l: 'X offset', t: 'number', d: 0 }, { k: 'yOffset', l: 'Y offset', t: 'number', d: 0 }, |
||||||
|
{ k: 'width', l: 'Width', t: 'number', d: W }, { k: 'height', l: 'Height', t: 'number', d: H }, |
||||||
|
], |
||||||
|
Animation: [ |
||||||
|
{ k: 'xOffset', l: 'X offset', t: 'number', d: 0 }, { k: 'yOffset', l: 'Y offset', t: 'number', d: 0 }, |
||||||
|
{ k: 'width', l: 'Width', t: 'number', d: W }, { k: 'height', l: 'Height', t: 'number', d: H }, |
||||||
|
{ k: 'updateInterval', l: 'Update interval', t: 'number', d: 12 }, |
||||||
|
], |
||||||
|
ClippedText: [ |
||||||
|
{ k: 'text', l: 'Text', t: 'text', d: 'Hello, World!', full: true }, |
||||||
|
{ k: 'xOffset', l: 'X offset', t: 'number', d: 0 }, { k: 'yOffset', l: 'Y offset', t: 'number', d: 32 }, |
||||||
|
{ k: 'width', l: 'Width', t: 'number', d: W }, { k: 'height', l: 'Height', t: 'number', d: 16 }, |
||||||
|
], |
||||||
|
HScrollText: [ |
||||||
|
{ k: 'text', l: 'Text', t: 'text', d: 'Scrolling text - ', full: true }, |
||||||
|
{ k: 'xOffset', l: 'X offset', t: 'number', d: 0 }, { k: 'yOffset', l: 'Y offset', t: 'number', d: 32 }, |
||||||
|
{ k: 'width', l: 'Width', t: 'number', d: W }, { k: 'height', l: 'Height', t: 'number', d: 16 }, |
||||||
|
{ k: 'scrollSpeed', l: 'Scroll speed', t: 'number', d: 50 }, |
||||||
|
], |
||||||
|
CurrentTime: [ |
||||||
|
{ k: 'xOffset', l: 'X offset', t: 'number', d: 0 }, { k: 'yOffset', l: 'Y offset', t: 'number', d: 8 }, |
||||||
|
{ k: 'width', l: 'Width', t: 'number', d: W }, { k: 'height', l: 'Height', t: 'number', d: 16 }, |
||||||
|
{ k: 'utcOffsetMinutes', l: 'UTC offset (min)', t: 'number', d: 0 }, |
||||||
|
], |
||||||
|
}; |
||||||
|
const EL_FLAGS: Partial<Record<keyof typeof ElementType, ElFlagMeta[]>> = { |
||||||
|
HScrollText: [{ k: 'endless', l: 'Endless' }, { k: 'invertDirection', l: 'Invert direction' }], |
||||||
|
CurrentTime: [{ k: 'clock12h', l: '12h mode' }, { k: 'showHours', l: 'Show hours' }, { k: 'showSeconds', l: 'Show seconds' }], |
||||||
|
}; |
||||||
|
const EL_TYPES = Object.keys(EL_FIELDS); |
||||||
|
|
||||||
|
// --- Confirm dialog -----------------------------------------------------------
|
||||||
|
function confirm(msg, buttons) { |
||||||
|
// buttons: [{label,primary,action}]
|
||||||
|
return new Promise(resolve => { |
||||||
|
const overlay = document.getElementById('confirm-overlay'); |
||||||
|
document.getElementById('confirm-msg').textContent = msg; |
||||||
|
const btns = document.getElementById('confirm-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); }; |
||||||
|
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 newSec() { |
||||||
|
secCounter++; |
||||||
|
return { |
||||||
|
id: 's' + secCounter, name: 'Section ' + secCounter, open: true, |
||||||
|
flags: { drawFront: true, clearBuffer: true }, elements: [] |
||||||
|
}; |
||||||
|
} |
||||||
|
function newEl(type) { |
||||||
|
elCounter++; |
||||||
|
const fields = {}; |
||||||
|
(EL_FIELDS[type] || []).forEach(f => fields[f.k] = f.d); |
||||||
|
const flags = {}; |
||||||
|
(EL_FLAGS[type] || []).forEach(f => flags[f.k] = false); |
||||||
|
return { id: 'e' + elCounter, type, fields, flags }; |
||||||
|
} |
||||||
|
function getSec(id) { return file.sections.find(s => s.id === id) } |
||||||
|
function getEl(secId, elId) { const s = getSec(secId); return s && s.elements.find(e => e.id === elId) } |
||||||
|
|
||||||
|
// --- Mutations ----------------------------------------------------------------
|
||||||
|
function addSection() { |
||||||
|
const s = newSec(); file.sections.push(s); |
||||||
|
activeSec = s.id; activeEl = null; |
||||||
|
markDirty(); render(); triggerPreview(); |
||||||
|
} |
||||||
|
function removeSection(id) { |
||||||
|
file.sections = file.sections.filter(s => s.id !== id); |
||||||
|
if (activeSec === id) { activeSec = file.sections.length ? file.sections[file.sections.length - 1].id : null; activeEl = null; } |
||||||
|
markDirty(); render(); triggerPreview(); |
||||||
|
} |
||||||
|
function toggleSection(id) { |
||||||
|
const s = getSec(id); if (!s) return; |
||||||
|
s.open = !s.open; activeSec = id; render(); updateMeta(); |
||||||
|
} |
||||||
|
function setSectionFlag(flag, val) { |
||||||
|
if (!activeSec) return; |
||||||
|
const s = getSec(activeSec); if (!s) return; |
||||||
|
s.flags[flag] = val; markDirty(); triggerPreview(); |
||||||
|
} |
||||||
|
function addElement(secId) { |
||||||
|
const s = getSec(secId); if (!s) return; |
||||||
|
const el = newEl(EL_TYPES[0]); s.elements.push(el); |
||||||
|
activeSec = secId; activeEl = { secId, elId: el.id }; |
||||||
|
markDirty(); render(); triggerPreview(); |
||||||
|
} |
||||||
|
function removeElement(secId, elId) { |
||||||
|
const s = getSec(secId); if (!s) return; |
||||||
|
s.elements = s.elements.filter(e => e.id !== elId); |
||||||
|
if (activeEl && activeEl.secId === secId && activeEl.elId === elId) activeEl = null; |
||||||
|
markDirty(); render(); triggerPreview(); |
||||||
|
} |
||||||
|
function selectElement(secId, elId) { |
||||||
|
activeSec = secId; |
||||||
|
activeEl = activeEl && activeEl.secId === secId && activeEl.elId === elId ? null : { secId, elId }; |
||||||
|
render(); |
||||||
|
} |
||||||
|
function changeElType(secId, elId, type) { |
||||||
|
const el = getEl(secId, elId); if (!el) return; |
||||||
|
const fresh = newEl(type); |
||||||
|
el.type = type; el.fields = fresh.fields; el.flags = fresh.flags; |
||||||
|
markDirty(); render(); triggerPreview(); |
||||||
|
} |
||||||
|
function setElField(secId, elId, key, val) { |
||||||
|
const el = getEl(secId, elId); if (!el) return; |
||||||
|
el.fields[key] = val; markDirty(); triggerPreview(); |
||||||
|
} |
||||||
|
function setElFlag(secId, elId, key, val) { |
||||||
|
const el = getEl(secId, elId); if (!el) return; |
||||||
|
el.flags[key] = val; markDirty(); triggerPreview(); |
||||||
|
} |
||||||
|
|
||||||
|
// --- Load demo into editor state ---------------------------------------------
|
||||||
|
const DEMO_DEFS = [ |
||||||
|
{ |
||||||
|
label: 'Checkerboard', build() { |
||||||
|
const s = newSec(); s.name = 'Checkerboard'; |
||||||
|
const el = newEl('Image2D'); |
||||||
|
s.elements.push(el); |
||||||
|
s.flags = { drawFront: true, clearBuffer: false }; |
||||||
|
return [s]; |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
label: 'Blink', build() { |
||||||
|
const s = newSec(); s.name = 'Blink'; s.flags = { drawFront: true, clearBuffer: true }; |
||||||
|
const el = newEl('Animation'); el.fields.updateInterval = 12; |
||||||
|
s.elements.push(el); return [s]; |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
label: 'Text', build() { |
||||||
|
const s = newSec(); s.name = 'Text'; s.flags = { drawFront: true, clearBuffer: true }; |
||||||
|
const el = newEl('ClippedText'); el.fields.text = 'Hello, World!'; el.fields.yOffset = 32; |
||||||
|
s.elements.push(el); return [s]; |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
label: 'Scrolltext', build() { |
||||||
|
const s = newSec(); s.name = 'Scrolltext'; s.flags = { drawFront: true, clearBuffer: true }; |
||||||
|
const el = newEl('HScrollText'); |
||||||
|
el.fields.text = 'MONO DISPLAY - scrolling ticker - 🚀 '; |
||||||
|
el.fields.yOffset = 32; el.fields.scrollSpeed = 50; |
||||||
|
el.flags.endless = true; el.flags.invertDirection = false; |
||||||
|
s.elements.push(el); return [s]; |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
label: 'Time', build() { |
||||||
|
const s = newSec(); s.name = 'Time'; s.flags = { drawFront: true, clearBuffer: true }; |
||||||
|
const defs = [ |
||||||
|
{ flags: {}, yOffset: 8 }, { flags: { clock12h: true }, xOffset: 40, yOffset: 16 }, |
||||||
|
{ flags: { clock12h: true, showHours: true }, yOffset: 24 }, |
||||||
|
{ flags: { clock12h: true, showHours: false }, xOffset: 40, yOffset: 32 }, |
||||||
|
{ flags: { clock12h: true, showSeconds: true }, xOffset: 120, yOffset: 32 }, |
||||||
|
]; |
||||||
|
defs.forEach(d => { |
||||||
|
const el = newEl('CurrentTime'); |
||||||
|
Object.assign(el.fields, { utcOffsetMinutes: 120, xOffset: d.xOffset || 0, yOffset: d.yOffset }); |
||||||
|
el.flags = d.flags; s.elements.push(el); |
||||||
|
}); |
||||||
|
return [s]; |
||||||
|
} |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
// --- Preview via MonoDisplayFile (same as original demos) --------------------
|
||||||
|
const DEMO_PREVIEWS = { |
||||||
|
Checkerboard() { |
||||||
|
const { MonoDisplayFile, ElementType } = window.MonoDisplay; |
||||||
|
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({ |
||||||
|
width: W, height: H, |
||||||
|
elements_always: [{ type: ElementType.Image2D, pixels, width: W, height: H }] |
||||||
|
}).toBuffer(); |
||||||
|
}, |
||||||
|
Blink() { |
||||||
|
const { MonoDisplayFile, ElementType } = window.MonoDisplay; |
||||||
|
return new MonoDisplayFile({ |
||||||
|
elements_always: { |
||||||
|
flags: { drawFront: true, clearBuffer: true }, |
||||||
|
elements: [{ |
||||||
|
type: ElementType.Animation, width: W, height: H, updateInterval: 12, |
||||||
|
frames: [{ pixels: new Uint8Array(W * H).fill(1) }, { pixels: new Uint8Array(W * H).fill(0) }] |
||||||
|
}] |
||||||
|
} |
||||||
|
}).toBuffer(); |
||||||
|
}, |
||||||
|
Text() { |
||||||
|
const { MonoDisplayFile, ElementType } = window.MonoDisplay; |
||||||
|
return new MonoDisplayFile({ |
||||||
|
elements_always: { |
||||||
|
flags: { drawFront: true, clearBuffer: true }, |
||||||
|
elements: [{ type: ElementType.ClippedText, text: 'Hello, World!', xOffset: 0, yOffset: 32, width: W, height: 16 }] |
||||||
|
} |
||||||
|
}).toBuffer(); |
||||||
|
}, |
||||||
|
Scrolltext() { |
||||||
|
const { MonoDisplayFile, ElementType } = window.MonoDisplay; |
||||||
|
return new MonoDisplayFile({ |
||||||
|
elements_always: { |
||||||
|
flags: { drawFront: true, clearBuffer: true }, |
||||||
|
elements: [{ |
||||||
|
type: ElementType.HScrollText, text: 'MONO DISPLAY - scrolling ticker - 🚀 ', |
||||||
|
xOffset: 0, yOffset: 32, width: W, height: 16, scrollSpeed: 50, flags: { endless: true, invertDirection: false } |
||||||
|
}] |
||||||
|
} |
||||||
|
}).toBuffer(); |
||||||
|
}, |
||||||
|
Time() { |
||||||
|
const { MonoDisplayFile, ElementType } = window.MonoDisplay; |
||||||
|
return new MonoDisplayFile({ |
||||||
|
elements_always: { |
||||||
|
flags: { drawFront: true, clearBuffer: true }, elements: [ |
||||||
|
{ flags: {}, type: ElementType.CurrentTime, xOffset: 0, yOffset: 8, width: W, height: 16, utcOffsetMinutes: 120 }, |
||||||
|
{ flags: { clock12h: true }, type: ElementType.CurrentTime, xOffset: 40, yOffset: 16, width: W, height: 16, utcOffsetMinutes: 120 }, |
||||||
|
{ flags: { clock12h: true, showHours: true }, type: ElementType.CurrentTime, xOffset: 0, yOffset: 24, width: W, height: 16, utcOffsetMinutes: 120 }, |
||||||
|
{ flags: { clock12h: true, showHours: false }, type: ElementType.CurrentTime, xOffset: 40, yOffset: 32, width: W, height: 16, utcOffsetMinutes: 120 }, |
||||||
|
{ flags: { clock12h: true, showSeconds: true }, type: ElementType.CurrentTime, xOffset: 80, yOffset: 32, width: W, height: 16, utcOffsetMinutes: 120 }, |
||||||
|
] |
||||||
|
} |
||||||
|
}).toBuffer(); |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
function initDemos() { |
||||||
|
const wrap = document.getElementById('demo-btns'); |
||||||
|
let activeBtn = null; |
||||||
|
DEMO_DEFS.forEach(d => { |
||||||
|
const btn = document.createElement('button'); |
||||||
|
btn.className = 'tb-btn'; btn.textContent = d.label; |
||||||
|
btn.onclick = async () => { |
||||||
|
const ok = await guardDirty(); |
||||||
|
if (!ok) return; |
||||||
|
// load into editor
|
||||||
|
file.sections = d.build(); |
||||||
|
activeSec = file.sections[0]?.id || null; activeEl = null; |
||||||
|
currentFilename = d.label.toLowerCase(); |
||||||
|
document.getElementById('filename').textContent = currentFilename; |
||||||
|
markClean(); render(); |
||||||
|
// drive preview with full fidelity build
|
||||||
|
if (!window._mdDriver) |
||||||
|
window._mdDriver = new window.MonoDisplay.MonoDisplayDriver('canvas_root', { onColor: '#EC0', offColor: '#000', fps: 25 }); |
||||||
|
window._mdDriver.load(() => Promise.resolve(DEMO_PREVIEWS[d.label]())); |
||||||
|
if (activeBtn) activeBtn.style.color = ''; |
||||||
|
btn.style.color = '#33ff66'; activeBtn = btn; |
||||||
|
}; |
||||||
|
wrap.appendChild(btn); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// --- Render -------------------------------------------------------------------
|
||||||
|
function render() { |
||||||
|
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(s => renderSection(s)).join(''); |
||||||
|
} |
||||||
|
|
||||||
|
function renderSection(s) { |
||||||
|
const isActive = activeSec === s.id, isOpen = s.open; |
||||||
|
return ` |
||||||
|
<div class="sec-card${isActive ? ' active' : ''}${isOpen ? ' open' : ''}" id="sc-${s.id}"> |
||||||
|
<div class="sec-hdr${isActive ? ' active' : ''}" onclick="toggleSection('${s.id}')"> |
||||||
|
<span class="sec-arrow">▶</span> |
||||||
|
<span class="sec-label">${esc(s.name)}</span> |
||||||
|
<span class="sec-badge">${s.elements.length} el</span> |
||||||
|
<button class="x-btn" title="Remove section" onclick="event.stopPropagation();removeSection('${s.id}')">×</button> |
||||||
|
</div> |
||||||
|
${isOpen ? ` |
||||||
|
<div class="el-list"> |
||||||
|
${s.elements.map(el => renderElement(s.id, el)).join('')} |
||||||
|
<button class="add-el" onclick="addElement('${s.id}')">+ add element</button> |
||||||
|
</div>`: ''}
|
||||||
|
</div>`;
|
||||||
|
} |
||||||
|
|
||||||
|
function renderElement(secId, el) { |
||||||
|
const isActive = activeEl && activeEl.secId === secId && activeEl.elId === el.id; |
||||||
|
const fields = EL_FIELDS[el.type] || []; |
||||||
|
const flags = EL_FLAGS[el.type] || []; |
||||||
|
return ` |
||||||
|
<div class="el-item${isActive ? ' active' : ''}" id="eli-${el.id}"> |
||||||
|
<div class="el-item-hdr" onclick="selectElement('${secId}','${el.id}')"> |
||||||
|
<span class="el-type">${el.type}</span> |
||||||
|
<span class="el-name">${elSummary(el)}</span> |
||||||
|
<button class="x-btn" title="Remove element" onclick="event.stopPropagation();removeElement('${secId}','${el.id}')">×</button> |
||||||
|
</div> |
||||||
|
${isActive ? ` |
||||||
|
<div class="el-fields"> |
||||||
|
<div class="field full"> |
||||||
|
<label>Type</label> |
||||||
|
<select onchange="changeElType('${secId}','${el.id}',this.value)"> |
||||||
|
${EL_TYPES.map(t => `<option value="${t}"${t === el.type ? ' selected' : ''}>${t}</option>`).join('')} |
||||||
|
</select> |
||||||
|
</div> |
||||||
|
<div class="fields-grid"> |
||||||
|
${fields.map(f => ` |
||||||
|
<div class="field${f.full ? ' full' : ''}"> |
||||||
|
<label>${f.l}</label> |
||||||
|
${f.t === 'text' |
||||||
|
? `<textarea onchange="setElField('${secId}','${el.id}','${f.k}',this.value)">${esc(el.fields[f.k] ?? f.d)}</textarea>` |
||||||
|
: `<input type="number" value="${el.fields[f.k] ?? f.d}" onchange="setElField('${secId}','${el.id}','${f.k}',+this.value)"/>` |
||||||
|
} |
||||||
|
</div>`).join('')}
|
||||||
|
</div> |
||||||
|
${flags.length ? ` |
||||||
|
<div class="field full"> |
||||||
|
<label>Flags</label> |
||||||
|
<div class="flags-row"> |
||||||
|
${flags.map(f => ` |
||||||
|
<label class="flag-check"> |
||||||
|
<input type="checkbox" ${el.flags[f.k] ? 'checked' : ''} onchange="setElFlag('${secId}','${el.id}','${f.k}',this.checked)"/> |
||||||
|
${f.l} |
||||||
|
</label>`).join('')}
|
||||||
|
</div> |
||||||
|
</div>`: ''}
|
||||||
|
</div>`: ''}
|
||||||
|
</div>`;
|
||||||
|
} |
||||||
|
|
||||||
|
function elSummary(el) { |
||||||
|
if (el.fields.text) return esc(el.fields.text.slice(0, 24) + (el.fields.text.length > 24 ? '...' : '')); |
||||||
|
return `${el.fields.xOffset ?? 0}, ${el.fields.yOffset ?? 0}`; |
||||||
|
} |
||||||
|
function esc(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') } |
||||||
|
|
||||||
|
function updateMeta() { |
||||||
|
const s = activeSec ? getSec(activeSec) : null; |
||||||
|
document.getElementById('meta-name').textContent = s ? s.name : '-'; |
||||||
|
document.getElementById('meta-count').textContent = s ? s.elements.length + ' element(s)' : '-'; |
||||||
|
document.getElementById('flag-drawFront').checked = s ? !!s.flags.drawFront : false; |
||||||
|
document.getElementById('flag-clearBuffer').checked = s ? !!s.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; |
||||||
|
const { MonoDisplayDriver, MonoDisplayFile, ElementType } = window.MonoDisplay; |
||||||
|
if (!window._mdDriver) |
||||||
|
window._mdDriver = new MonoDisplayDriver('canvas_root', { onColor: '#EC0', offColor: '#000', fps: 25 }); |
||||||
|
const s = activeSec ? getSec(activeSec) : file.sections[0]; |
||||||
|
if (!s || !s.elements.length) { |
||||||
|
window._mdDriver.load(() => Promise.resolve(new MonoDisplayFile({ elements_always: [] }).toBuffer())); |
||||||
|
return; |
||||||
|
} |
||||||
|
const elDefs = s.elements.map(el => ({ type: ElementType[el.type], ...el.fields, flags: el.flags })).filter(e => e.type != null); |
||||||
|
window._mdDriver.load(() => Promise.resolve( |
||||||
|
new MonoDisplayFile({ elements_always: { flags: s.flags, elements: elDefs } }).toBuffer() |
||||||
|
)); |
||||||
|
} |
||||||
|
|
||||||
|
// --- Load / Export ------------------------------------------------------------
|
||||||
|
function parsedToSections(parsedSecs) { |
||||||
|
const TYPE_MAP = { 1: 'Image2D', 2: 'Animation', 16: 'ClippedText', 17: 'HScrollText', 32: 'CurrentTime' }; |
||||||
|
return parsedSecs |
||||||
|
.filter(s => s.sectionType === 1 || s.sectionType === 2) |
||||||
|
.map(s => { |
||||||
|
secCounter++; |
||||||
|
const elements = (s.elements || []).map(el => { |
||||||
|
const typeName = TYPE_MAP[el.type]; |
||||||
|
if (!typeName) return null; |
||||||
|
elCounter++; |
||||||
|
let fields = {}, flags = {}; |
||||||
|
switch (typeName) { |
||||||
|
case 'Image2D': |
||||||
|
fields = { xOffset: el.xOffset, yOffset: el.yOffset, width: el.image?.width ?? W, height: el.image?.height ?? H, pixels: el.image?.pixels }; |
||||||
|
break; |
||||||
|
case 'Animation': |
||||||
|
fields = { xOffset: el.xOffset, yOffset: el.yOffset, width: el.width, height: el.height, updateInterval: el.updateInterval, frames: el.frames }; |
||||||
|
break; |
||||||
|
case 'ClippedText': |
||||||
|
fields = { text: el.text, xOffset: el.xOffset, yOffset: el.yOffset, width: el.width, height: el.height }; |
||||||
|
break; |
||||||
|
case 'HScrollText': |
||||||
|
fields = { text: el.text, xOffset: el.xOffset, yOffset: el.yOffset, width: el.width, height: el.height, scrollSpeed: el.scrollSpeed }; |
||||||
|
flags = { endless: !!el.flags?.endless, invertDirection: !!el.flags?.invertDirection }; |
||||||
|
break; |
||||||
|
case 'CurrentTime': |
||||||
|
fields = { xOffset: el.xOffset, yOffset: el.yOffset, width: el.width, height: el.height, utcOffsetMinutes: el.utcOffsetMinutes }; |
||||||
|
flags = { clock12h: !!el.flags?.clock12h, showHours: !!el.flags?.showHours, showSeconds: !!el.flags?.showSeconds }; |
||||||
|
break; |
||||||
|
} |
||||||
|
return { id: 'e' + elCounter, type: typeName, fields, flags }; |
||||||
|
}).filter(Boolean); |
||||||
|
return { |
||||||
|
id: 's' + secCounter, |
||||||
|
name: 'Section ' + secCounter, |
||||||
|
open: true, |
||||||
|
flags: { drawFront: !!s.flags?.drawFront, clearBuffer: !!s.flags?.clearBuffer }, |
||||||
|
elements |
||||||
|
}; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function loadBin(input) { |
||||||
|
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; |
||||||
|
const buf = new Uint8Array(arrayBuf); |
||||||
|
if (!window._mdDriver && window.MonoDisplay) |
||||||
|
window._mdDriver = new window.MonoDisplay.MonoDisplayDriver('canvas_root', { onColor: '#EC0', offColor: '#000', fps: 25 }); |
||||||
|
if (window._mdDriver) window._mdDriver.load(() => Promise.resolve(buf)); |
||||||
|
try { |
||||||
|
const parsed = new window.MonoDisplay.MonoDisplayParser().parse(arrayBuf); |
||||||
|
file.sections = parsedToSections(parsed.sections); |
||||||
|
activeSec = file.sections.length ? file.sections[0].id : null; |
||||||
|
activeEl = null; |
||||||
|
render(); |
||||||
|
} 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 { MonoDisplayFile, ElementType } = window.MonoDisplay; |
||||||
|
const s = activeSec ? getSec(activeSec) : file.sections[0]; |
||||||
|
if (!s) { alert('Nothing to export.'); return; } |
||||||
|
const elDefs = s.elements.map(el => ({ type: ElementType[el.type], ...el.fields, flags: el.flags })).filter(e => e.type != null); |
||||||
|
const buf = new MonoDisplayFile({ elements_always: { flags: s.flags, elements: elDefs } }).toBuffer(); |
||||||
|
const blob = new Blob([buf], { 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, |
||||||
|
cycleTheme, |
||||||
|
}); |
||||||
|
|
||||||
|
render(); |
||||||
|
if (window.MonoDisplay) { initDemos(); } |
||||||
|
else { const iv = setInterval(() => { if (window.MonoDisplay) { clearInterval(iv); initDemos(); } }, 100); } |
||||||
|
|||||||
@ -0,0 +1,26 @@ |
|||||||
|
// --- Theme --------------------------------------------------------------------
|
||||||
|
var _THEMES = ['dark', 'light', 'auto']; |
||||||
|
function _getTheme() { return document.documentElement.getAttribute('data-theme') || 'auto'; } |
||||||
|
function _applyTheme(t: string | undefined) { |
||||||
|
if (t === 'auto') { document.documentElement.removeAttribute('data-theme'); localStorage.removeItem('theme'); } |
||||||
|
else { document.documentElement.setAttribute('data-theme', t); localStorage.setItem('theme', t); } |
||||||
|
_updateThemeBtn(); |
||||||
|
} |
||||||
|
export function cycleTheme() { |
||||||
|
var cur = _getTheme(), idx = _THEMES.indexOf(cur); |
||||||
|
_applyTheme(_THEMES[(idx + 1) % _THEMES.length]); |
||||||
|
} |
||||||
|
function _detectDR() { |
||||||
|
return !!(document.querySelector('meta[name="darkreader"]') || |
||||||
|
document.querySelector('style[data-darkreader-style]') || |
||||||
|
document.documentElement.getAttribute('data-darkreader-mode')); |
||||||
|
} |
||||||
|
function _updateThemeBtn() { |
||||||
|
var btn = document.getElementById('theme-toggle'); |
||||||
|
if (!btn) return; |
||||||
|
var t = _getTheme(), dr = _detectDR(); |
||||||
|
var lbl = { dark: '☾ dark', light: '☀ light', auto: '⊙ auto' }; |
||||||
|
btn.textContent = (dr ? 'DR · ' : '') + (lbl[t] || lbl.auto); |
||||||
|
btn.title = dr ? 'DarkReader active - controlling theme' : ('Theme: ' + t + ' (click to cycle)'); |
||||||
|
} |
||||||
|
window.addEventListener('DOMContentLoaded', _updateThemeBtn); |
||||||
@ -1,522 +0,0 @@ |
|||||||
|
|
||||||
const W = 120, H = 60; |
|
||||||
let sections = []; |
|
||||||
let activeSec = null, activeEl = null; |
|
||||||
let secCounter = 0, elCounter = 0; |
|
||||||
let currentFilename = 'untitled'; |
|
||||||
let isDirty = false; |
|
||||||
|
|
||||||
// --- Element type definitions ------------------------------------------------
|
|
||||||
const EL_TYPES = ['Image2D', 'Animation', 'ClippedText', 'HScrollText', 'CurrentTime']; |
|
||||||
const EL_FIELDS = { |
|
||||||
Image2D: [ |
|
||||||
{ k: 'xOffset', l: 'X offset', t: 'number', d: 0 }, { k: 'yOffset', l: 'Y offset', t: 'number', d: 0 }, |
|
||||||
{ k: 'width', l: 'Width', t: 'number', d: W }, { k: 'height', l: 'Height', t: 'number', d: H }, |
|
||||||
], |
|
||||||
Animation: [ |
|
||||||
{ k: 'xOffset', l: 'X offset', t: 'number', d: 0 }, { k: 'yOffset', l: 'Y offset', t: 'number', d: 0 }, |
|
||||||
{ k: 'width', l: 'Width', t: 'number', d: W }, { k: 'height', l: 'Height', t: 'number', d: H }, |
|
||||||
{ k: 'updateInterval', l: 'Update interval', t: 'number', d: 12 }, |
|
||||||
], |
|
||||||
ClippedText: [ |
|
||||||
{ k: 'text', l: 'Text', t: 'text', d: 'Hello, World!', full: true }, |
|
||||||
{ k: 'xOffset', l: 'X offset', t: 'number', d: 0 }, { k: 'yOffset', l: 'Y offset', t: 'number', d: 32 }, |
|
||||||
{ k: 'width', l: 'Width', t: 'number', d: W }, { k: 'height', l: 'Height', t: 'number', d: 16 }, |
|
||||||
], |
|
||||||
HScrollText: [ |
|
||||||
{ k: 'text', l: 'Text', t: 'text', d: 'Scrolling text - ', full: true }, |
|
||||||
{ k: 'xOffset', l: 'X offset', t: 'number', d: 0 }, { k: 'yOffset', l: 'Y offset', t: 'number', d: 32 }, |
|
||||||
{ k: 'width', l: 'Width', t: 'number', d: W }, { k: 'height', l: 'Height', t: 'number', d: 16 }, |
|
||||||
{ k: 'scrollSpeed', l: 'Scroll speed', t: 'number', d: 50 }, |
|
||||||
], |
|
||||||
CurrentTime: [ |
|
||||||
{ k: 'xOffset', l: 'X offset', t: 'number', d: 0 }, { k: 'yOffset', l: 'Y offset', t: 'number', d: 8 }, |
|
||||||
{ k: 'width', l: 'Width', t: 'number', d: W }, { k: 'height', l: 'Height', t: 'number', d: 16 }, |
|
||||||
{ k: 'utcOffsetMinutes', l: 'UTC offset (min)', t: 'number', d: 0 }, |
|
||||||
], |
|
||||||
}; |
|
||||||
const EL_FLAGS = { |
|
||||||
Image2D: [], Animation: [], ClippedText: [], |
|
||||||
HScrollText: [{ k: 'endless', l: 'Endless' }, { k: 'invertDirection', l: 'Invert direction' }], |
|
||||||
CurrentTime: [{ k: 'clock12h', l: '12h mode' }, { k: 'showHours', l: 'Show hours' }, { k: 'showSeconds', l: 'Show seconds' }], |
|
||||||
}; |
|
||||||
|
|
||||||
// --- Confirm dialog -----------------------------------------------------------
|
|
||||||
function confirm(msg, buttons) { |
|
||||||
// buttons: [{label,primary,action}]
|
|
||||||
return new Promise(resolve => { |
|
||||||
const overlay = document.getElementById('confirm-overlay'); |
|
||||||
document.getElementById('confirm-msg').textContent = msg; |
|
||||||
const btns = document.getElementById('confirm-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); }; |
|
||||||
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 || !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 newSec() { |
|
||||||
secCounter++; |
|
||||||
return { |
|
||||||
id: 's' + secCounter, name: 'Section ' + secCounter, open: true, |
|
||||||
flags: { drawFront: true, clearBuffer: true }, elements: [] |
|
||||||
}; |
|
||||||
} |
|
||||||
function newEl(type) { |
|
||||||
elCounter++; |
|
||||||
const fields = {}; |
|
||||||
(EL_FIELDS[type] || []).forEach(f => fields[f.k] = f.d); |
|
||||||
const flags = {}; |
|
||||||
(EL_FLAGS[type] || []).forEach(f => flags[f.k] = false); |
|
||||||
return { id: 'e' + elCounter, type, fields, flags }; |
|
||||||
} |
|
||||||
function getSec(id) { return sections.find(s => s.id === id) } |
|
||||||
function getEl(secId, elId) { const s = getSec(secId); return s && s.elements.find(e => e.id === elId) } |
|
||||||
|
|
||||||
// --- Mutations ----------------------------------------------------------------
|
|
||||||
function addSection() { |
|
||||||
const s = newSec(); sections.push(s); |
|
||||||
activeSec = s.id; activeEl = null; |
|
||||||
markDirty(); render(); triggerPreview(); |
|
||||||
} |
|
||||||
function removeSection(id) { |
|
||||||
sections = sections.filter(s => s.id !== id); |
|
||||||
if (activeSec === id) { activeSec = sections.length ? sections[sections.length - 1].id : null; activeEl = null; } |
|
||||||
markDirty(); render(); triggerPreview(); |
|
||||||
} |
|
||||||
function toggleSection(id) { |
|
||||||
const s = getSec(id); if (!s) return; |
|
||||||
s.open = !s.open; activeSec = id; render(); updateMeta(); |
|
||||||
} |
|
||||||
function setSectionFlag(flag, val) { |
|
||||||
if (!activeSec) return; |
|
||||||
const s = getSec(activeSec); if (!s) return; |
|
||||||
s.flags[flag] = val; markDirty(); triggerPreview(); |
|
||||||
} |
|
||||||
function addElement(secId) { |
|
||||||
const s = getSec(secId); if (!s) return; |
|
||||||
const el = newEl(EL_TYPES[0]); s.elements.push(el); |
|
||||||
activeSec = secId; activeEl = { secId, elId: el.id }; |
|
||||||
markDirty(); render(); triggerPreview(); |
|
||||||
} |
|
||||||
function removeElement(secId, elId) { |
|
||||||
const s = getSec(secId); if (!s) return; |
|
||||||
s.elements = s.elements.filter(e => e.id !== elId); |
|
||||||
if (activeEl && activeEl.secId === secId && activeEl.elId === elId) activeEl = null; |
|
||||||
markDirty(); render(); triggerPreview(); |
|
||||||
} |
|
||||||
function selectElement(secId, elId) { |
|
||||||
activeSec = secId; |
|
||||||
activeEl = activeEl && activeEl.secId === secId && activeEl.elId === elId ? null : { secId, elId }; |
|
||||||
render(); |
|
||||||
} |
|
||||||
function changeElType(secId, elId, type) { |
|
||||||
const el = getEl(secId, elId); if (!el) return; |
|
||||||
const fresh = newEl(type); |
|
||||||
el.type = type; el.fields = fresh.fields; el.flags = fresh.flags; |
|
||||||
markDirty(); render(); triggerPreview(); |
|
||||||
} |
|
||||||
function setElField(secId, elId, key, val) { |
|
||||||
const el = getEl(secId, elId); if (!el) return; |
|
||||||
el.fields[key] = val; markDirty(); triggerPreview(); |
|
||||||
} |
|
||||||
function setElFlag(secId, elId, key, val) { |
|
||||||
const el = getEl(secId, elId); if (!el) return; |
|
||||||
el.flags[key] = val; markDirty(); triggerPreview(); |
|
||||||
} |
|
||||||
|
|
||||||
// --- Load demo into editor state ---------------------------------------------
|
|
||||||
const DEMO_DEFS = [ |
|
||||||
{ |
|
||||||
label: 'Checkerboard', build() { |
|
||||||
const s = newSec(); s.name = 'Checkerboard'; |
|
||||||
const el = newEl('Image2D'); |
|
||||||
s.elements.push(el); |
|
||||||
s.flags = { drawFront: true, clearBuffer: false }; |
|
||||||
return [s]; |
|
||||||
} |
|
||||||
}, |
|
||||||
{ |
|
||||||
label: 'Blink', build() { |
|
||||||
const s = newSec(); s.name = 'Blink'; s.flags = { drawFront: true, clearBuffer: true }; |
|
||||||
const el = newEl('Animation'); el.fields.updateInterval = 12; |
|
||||||
s.elements.push(el); return [s]; |
|
||||||
} |
|
||||||
}, |
|
||||||
{ |
|
||||||
label: 'Text', build() { |
|
||||||
const s = newSec(); s.name = 'Text'; s.flags = { drawFront: true, clearBuffer: true }; |
|
||||||
const el = newEl('ClippedText'); el.fields.text = 'Hello, World!'; el.fields.yOffset = 32; |
|
||||||
s.elements.push(el); return [s]; |
|
||||||
} |
|
||||||
}, |
|
||||||
{ |
|
||||||
label: 'Scrolltext', build() { |
|
||||||
const s = newSec(); s.name = 'Scrolltext'; s.flags = { drawFront: true, clearBuffer: true }; |
|
||||||
const el = newEl('HScrollText'); |
|
||||||
el.fields.text = 'MONO DISPLAY - scrolling ticker - 🚀 '; |
|
||||||
el.fields.yOffset = 32; el.fields.scrollSpeed = 50; |
|
||||||
el.flags.endless = true; el.flags.invertDirection = false; |
|
||||||
s.elements.push(el); return [s]; |
|
||||||
} |
|
||||||
}, |
|
||||||
{ |
|
||||||
label: 'Time', build() { |
|
||||||
const s = newSec(); s.name = 'Time'; s.flags = { drawFront: true, clearBuffer: true }; |
|
||||||
const defs = [ |
|
||||||
{ flags: {}, yOffset: 8 }, { flags: { clock12h: true }, xOffset: 40, yOffset: 16 }, |
|
||||||
{ flags: { clock12h: true, showHours: true }, yOffset: 24 }, |
|
||||||
{ flags: { clock12h: true, showHours: false }, xOffset: 40, yOffset: 32 }, |
|
||||||
{ flags: { clock12h: true, showSeconds: true }, xOffset: 120, yOffset: 32 }, |
|
||||||
]; |
|
||||||
defs.forEach(d => { |
|
||||||
const el = newEl('CurrentTime'); |
|
||||||
Object.assign(el.fields, { utcOffsetMinutes: 120, xOffset: d.xOffset || 0, yOffset: d.yOffset }); |
|
||||||
el.flags = d.flags; s.elements.push(el); |
|
||||||
}); |
|
||||||
return [s]; |
|
||||||
} |
|
||||||
}, |
|
||||||
]; |
|
||||||
|
|
||||||
// --- Preview via MonoDisplayFile (same as original demos) --------------------
|
|
||||||
const DEMO_PREVIEWS = { |
|
||||||
Checkerboard() { |
|
||||||
const { MonoDisplayFile, ElementType } = window.MonoDisplay; |
|
||||||
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({ |
|
||||||
width: W, height: H, |
|
||||||
elements_always: [{ type: ElementType.Image2D, pixels, width: W, height: H }] |
|
||||||
}).toBuffer(); |
|
||||||
}, |
|
||||||
Blink() { |
|
||||||
const { MonoDisplayFile, ElementType } = window.MonoDisplay; |
|
||||||
return new MonoDisplayFile({ |
|
||||||
elements_always: { |
|
||||||
flags: { drawFront: true, clearBuffer: true }, |
|
||||||
elements: [{ |
|
||||||
type: ElementType.Animation, width: W, height: H, updateInterval: 12, |
|
||||||
frames: [{ pixels: new Uint8Array(W * H).fill(1) }, { pixels: new Uint8Array(W * H).fill(0) }] |
|
||||||
}] |
|
||||||
} |
|
||||||
}).toBuffer(); |
|
||||||
}, |
|
||||||
Text() { |
|
||||||
const { MonoDisplayFile, ElementType } = window.MonoDisplay; |
|
||||||
return new MonoDisplayFile({ |
|
||||||
elements_always: { |
|
||||||
flags: { drawFront: true, clearBuffer: true }, |
|
||||||
elements: [{ type: ElementType.ClippedText, text: 'Hello, World!', xOffset: 0, yOffset: 32, width: W, height: 16 }] |
|
||||||
} |
|
||||||
}).toBuffer(); |
|
||||||
}, |
|
||||||
Scrolltext() { |
|
||||||
const { MonoDisplayFile, ElementType } = window.MonoDisplay; |
|
||||||
return new MonoDisplayFile({ |
|
||||||
elements_always: { |
|
||||||
flags: { drawFront: true, clearBuffer: true }, |
|
||||||
elements: [{ |
|
||||||
type: ElementType.HScrollText, text: 'MONO DISPLAY - scrolling ticker - 🚀 ', |
|
||||||
xOffset: 0, yOffset: 32, width: W, height: 16, scrollSpeed: 50, flags: { endless: true, invertDirection: false } |
|
||||||
}] |
|
||||||
} |
|
||||||
}).toBuffer(); |
|
||||||
}, |
|
||||||
Time() { |
|
||||||
const { MonoDisplayFile, ElementType } = window.MonoDisplay; |
|
||||||
return new MonoDisplayFile({ |
|
||||||
elements_always: { |
|
||||||
flags: { drawFront: true, clearBuffer: true }, elements: [ |
|
||||||
{ flags: {}, type: ElementType.CurrentTime, xOffset: 0, yOffset: 8, width: W, height: 16, utcOffsetMinutes: 120 }, |
|
||||||
{ flags: { clock12h: true }, type: ElementType.CurrentTime, xOffset: 40, yOffset: 16, width: W, height: 16, utcOffsetMinutes: 120 }, |
|
||||||
{ flags: { clock12h: true, showHours: true }, type: ElementType.CurrentTime, xOffset: 0, yOffset: 24, width: W, height: 16, utcOffsetMinutes: 120 }, |
|
||||||
{ flags: { clock12h: true, showHours: false }, type: ElementType.CurrentTime, xOffset: 40, yOffset: 32, width: W, height: 16, utcOffsetMinutes: 120 }, |
|
||||||
{ flags: { clock12h: true, showSeconds: true }, type: ElementType.CurrentTime, xOffset: 80, yOffset: 32, width: W, height: 16, utcOffsetMinutes: 120 }, |
|
||||||
] |
|
||||||
} |
|
||||||
}).toBuffer(); |
|
||||||
}, |
|
||||||
}; |
|
||||||
|
|
||||||
function initDemos() { |
|
||||||
const wrap = document.getElementById('demo-btns'); |
|
||||||
let activeBtn = null; |
|
||||||
DEMO_DEFS.forEach(d => { |
|
||||||
const btn = document.createElement('button'); |
|
||||||
btn.className = 'tb-btn'; btn.textContent = d.label; |
|
||||||
btn.onclick = async () => { |
|
||||||
const ok = await guardDirty(); |
|
||||||
if (!ok) return; |
|
||||||
// load into editor
|
|
||||||
sections = d.build(); |
|
||||||
activeSec = sections[0]?.id || null; activeEl = null; |
|
||||||
currentFilename = d.label.toLowerCase(); |
|
||||||
document.getElementById('filename').textContent = currentFilename; |
|
||||||
markClean(); render(); |
|
||||||
// drive preview with full fidelity build
|
|
||||||
if (!window._mdDriver) |
|
||||||
window._mdDriver = new window.MonoDisplay.MonoDisplayDriver('canvas_root', { onColor: '#EC0', offColor: '#000', fps: 25 }); |
|
||||||
window._mdDriver.load(() => Promise.resolve(DEMO_PREVIEWS[d.label]())); |
|
||||||
if (activeBtn) activeBtn.style.color = ''; |
|
||||||
btn.style.color = '#33ff66'; activeBtn = btn; |
|
||||||
}; |
|
||||||
wrap.appendChild(btn); |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
// --- Render -------------------------------------------------------------------
|
|
||||||
function render() { |
|
||||||
updateMeta(); |
|
||||||
const wrap = document.getElementById('sections-wrap'); |
|
||||||
if (!sections.length) { |
|
||||||
wrap.innerHTML = '<div class="empty-state">No sections yet.<br/>Use <b>Add section</b> to get started.</div>'; |
|
||||||
return; |
|
||||||
} |
|
||||||
wrap.innerHTML = sections.map(s => renderSection(s)).join(''); |
|
||||||
} |
|
||||||
|
|
||||||
function renderSection(s) { |
|
||||||
const isActive = activeSec === s.id, isOpen = s.open; |
|
||||||
return ` |
|
||||||
<div class="sec-card${isActive ? ' active' : ''}${isOpen ? ' open' : ''}" id="sc-${s.id}"> |
|
||||||
<div class="sec-hdr${isActive ? ' active' : ''}" onclick="toggleSection('${s.id}')"> |
|
||||||
<span class="sec-arrow">▶</span> |
|
||||||
<span class="sec-label">${esc(s.name)}</span> |
|
||||||
<span class="sec-badge">${s.elements.length} el</span> |
|
||||||
<button class="x-btn" title="Remove section" onclick="event.stopPropagation();removeSection('${s.id}')">×</button> |
|
||||||
</div> |
|
||||||
${isOpen ? ` |
|
||||||
<div class="el-list"> |
|
||||||
${s.elements.map(el => renderElement(s.id, el)).join('')} |
|
||||||
<button class="add-el" onclick="addElement('${s.id}')">+ add element</button> |
|
||||||
</div>`: ''}
|
|
||||||
</div>`;
|
|
||||||
} |
|
||||||
|
|
||||||
function renderElement(secId, el) { |
|
||||||
const isActive = activeEl && activeEl.secId === secId && activeEl.elId === el.id; |
|
||||||
const fields = EL_FIELDS[el.type] || []; |
|
||||||
const flags = EL_FLAGS[el.type] || []; |
|
||||||
return ` |
|
||||||
<div class="el-item${isActive ? ' active' : ''}" id="eli-${el.id}"> |
|
||||||
<div class="el-item-hdr" onclick="selectElement('${secId}','${el.id}')"> |
|
||||||
<span class="el-type">${el.type}</span> |
|
||||||
<span class="el-name">${elSummary(el)}</span> |
|
||||||
<button class="x-btn" title="Remove element" onclick="event.stopPropagation();removeElement('${secId}','${el.id}')">×</button> |
|
||||||
</div> |
|
||||||
${isActive ? ` |
|
||||||
<div class="el-fields"> |
|
||||||
<div class="field full"> |
|
||||||
<label>Type</label> |
|
||||||
<select onchange="changeElType('${secId}','${el.id}',this.value)"> |
|
||||||
${EL_TYPES.map(t => `<option value="${t}"${t === el.type ? ' selected' : ''}>${t}</option>`).join('')} |
|
||||||
</select> |
|
||||||
</div> |
|
||||||
<div class="fields-grid"> |
|
||||||
${fields.map(f => ` |
|
||||||
<div class="field${f.full ? ' full' : ''}"> |
|
||||||
<label>${f.l}</label> |
|
||||||
${f.t === 'text' |
|
||||||
? `<textarea onchange="setElField('${secId}','${el.id}','${f.k}',this.value)">${esc(el.fields[f.k] ?? f.d)}</textarea>` |
|
||||||
: `<input type="number" value="${el.fields[f.k] ?? f.d}" onchange="setElField('${secId}','${el.id}','${f.k}',+this.value)"/>` |
|
||||||
} |
|
||||||
</div>`).join('')}
|
|
||||||
</div> |
|
||||||
${flags.length ? ` |
|
||||||
<div class="field full"> |
|
||||||
<label>Flags</label> |
|
||||||
<div class="flags-row"> |
|
||||||
${flags.map(f => ` |
|
||||||
<label class="flag-check"> |
|
||||||
<input type="checkbox" ${el.flags[f.k] ? 'checked' : ''} onchange="setElFlag('${secId}','${el.id}','${f.k}',this.checked)"/> |
|
||||||
${f.l} |
|
||||||
</label>`).join('')}
|
|
||||||
</div> |
|
||||||
</div>`: ''}
|
|
||||||
</div>`: ''}
|
|
||||||
</div>`;
|
|
||||||
} |
|
||||||
|
|
||||||
function elSummary(el) { |
|
||||||
if (el.fields.text) return esc(el.fields.text.slice(0, 24) + (el.fields.text.length > 24 ? '...' : '')); |
|
||||||
return `${el.fields.xOffset ?? 0}, ${el.fields.yOffset ?? 0}`; |
|
||||||
} |
|
||||||
function esc(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') } |
|
||||||
|
|
||||||
function updateMeta() { |
|
||||||
const s = activeSec ? getSec(activeSec) : null; |
|
||||||
document.getElementById('meta-name').textContent = s ? s.name : '-'; |
|
||||||
document.getElementById('meta-count').textContent = s ? s.elements.length + ' element(s)' : '-'; |
|
||||||
document.getElementById('flag-drawFront').checked = s ? !!s.flags.drawFront : false; |
|
||||||
document.getElementById('flag-clearBuffer').checked = s ? !!s.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; |
|
||||||
const { MonoDisplayDriver, MonoDisplayFile, ElementType } = window.MonoDisplay; |
|
||||||
if (!window._mdDriver) |
|
||||||
window._mdDriver = new MonoDisplayDriver('canvas_root', { onColor: '#EC0', offColor: '#000', fps: 25 }); |
|
||||||
const s = activeSec ? getSec(activeSec) : sections[0]; |
|
||||||
if (!s || !s.elements.length) { |
|
||||||
window._mdDriver.load(() => Promise.resolve(new MonoDisplayFile({ elements_always: [] }).toBuffer())); |
|
||||||
return; |
|
||||||
} |
|
||||||
const elDefs = s.elements.map(el => ({ type: ElementType[el.type], ...el.fields, flags: el.flags })).filter(e => e.type != null); |
|
||||||
window._mdDriver.load(() => Promise.resolve( |
|
||||||
new MonoDisplayFile({ elements_always: { flags: s.flags, elements: elDefs } }).toBuffer() |
|
||||||
)); |
|
||||||
} |
|
||||||
|
|
||||||
// --- Load / Export ------------------------------------------------------------
|
|
||||||
function parsedToSections(parsedSecs) { |
|
||||||
const TYPE_MAP = { 1: 'Image2D', 2: 'Animation', 16: 'ClippedText', 17: 'HScrollText', 32: 'CurrentTime' }; |
|
||||||
return parsedSecs |
|
||||||
.filter(s => s.sectionType === 1 || s.sectionType === 2) |
|
||||||
.map(s => { |
|
||||||
secCounter++; |
|
||||||
const elements = (s.elements || []).map(el => { |
|
||||||
const typeName = TYPE_MAP[el.type]; |
|
||||||
if (!typeName) return null; |
|
||||||
elCounter++; |
|
||||||
let fields = {}, flags = {}; |
|
||||||
switch (typeName) { |
|
||||||
case 'Image2D': |
|
||||||
fields = { xOffset: el.xOffset, yOffset: el.yOffset, width: el.image?.width ?? W, height: el.image?.height ?? H, pixels: el.image?.pixels }; |
|
||||||
break; |
|
||||||
case 'Animation': |
|
||||||
fields = { xOffset: el.xOffset, yOffset: el.yOffset, width: el.width, height: el.height, updateInterval: el.updateInterval, frames: el.frames }; |
|
||||||
break; |
|
||||||
case 'ClippedText': |
|
||||||
fields = { text: el.text, xOffset: el.xOffset, yOffset: el.yOffset, width: el.width, height: el.height }; |
|
||||||
break; |
|
||||||
case 'HScrollText': |
|
||||||
fields = { text: el.text, xOffset: el.xOffset, yOffset: el.yOffset, width: el.width, height: el.height, scrollSpeed: el.scrollSpeed }; |
|
||||||
flags = { endless: !!el.flags?.endless, invertDirection: !!el.flags?.invertDirection }; |
|
||||||
break; |
|
||||||
case 'CurrentTime': |
|
||||||
fields = { xOffset: el.xOffset, yOffset: el.yOffset, width: el.width, height: el.height, utcOffsetMinutes: el.utcOffsetMinutes }; |
|
||||||
flags = { clock12h: !!el.flags?.clock12h, showHours: !!el.flags?.showHours, showSeconds: !!el.flags?.showSeconds }; |
|
||||||
break; |
|
||||||
} |
|
||||||
return { id: 'e' + elCounter, type: typeName, fields, flags }; |
|
||||||
}).filter(Boolean); |
|
||||||
return { |
|
||||||
id: 's' + secCounter, |
|
||||||
name: 'Section ' + secCounter, |
|
||||||
open: true, |
|
||||||
flags: { drawFront: !!s.flags?.drawFront, clearBuffer: !!s.flags?.clearBuffer }, |
|
||||||
elements |
|
||||||
}; |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
function loadBin(input) { |
|
||||||
const file = input.files[0]; if (!file) return; |
|
||||||
currentFilename = file.name; |
|
||||||
document.getElementById('filename').textContent = file.name; |
|
||||||
const reader = new FileReader(); |
|
||||||
reader.onload = e => { |
|
||||||
const arrayBuf = e.target.result; |
|
||||||
const buf = new Uint8Array(arrayBuf); |
|
||||||
if (!window._mdDriver && window.MonoDisplay) |
|
||||||
window._mdDriver = new window.MonoDisplay.MonoDisplayDriver('canvas_root', { onColor: '#EC0', offColor: '#000', fps: 25 }); |
|
||||||
if (window._mdDriver) window._mdDriver.load(() => Promise.resolve(buf)); |
|
||||||
try { |
|
||||||
const parsed = new window.MonoDisplay.MonoDisplayParser().parse(arrayBuf); |
|
||||||
sections = parsedToSections(parsed.sections); |
|
||||||
activeSec = sections.length ? sections[0].id : null; |
|
||||||
activeEl = null; |
|
||||||
render(); |
|
||||||
} catch (err) { |
|
||||||
console.warn('Could not parse sections from bin:', err); |
|
||||||
} |
|
||||||
}; |
|
||||||
reader.readAsArrayBuffer(file); |
|
||||||
input.value = ''; markClean(); |
|
||||||
} |
|
||||||
function exportBin() { |
|
||||||
if (!window.MonoDisplay) { alert('MonoDisplay library not loaded.'); return; } |
|
||||||
const { MonoDisplayFile, ElementType } = window.MonoDisplay; |
|
||||||
const s = activeSec ? getSec(activeSec) : sections[0]; |
|
||||||
if (!s) { alert('Nothing to export.'); return; } |
|
||||||
const elDefs = s.elements.map(el => ({ type: ElementType[el.type], ...el.fields, flags: el.flags })).filter(e => e.type != null); |
|
||||||
const buf = new MonoDisplayFile({ elements_always: { flags: s.flags, elements: elDefs } }).toBuffer(); |
|
||||||
const blob = new Blob([buf], { type: 'application/octet-stream' }); |
|
||||||
const a = document.createElement('a'); |
|
||||||
a.href = URL.createObjectURL(blob); |
|
||||||
a.download = currentFilename.replace(/\.bin$/, '') + '.bin'; |
|
||||||
a.click(); markClean(); |
|
||||||
} |
|
||||||
|
|
||||||
// --- Theme --------------------------------------------------------------------
|
|
||||||
var _THEMES = ['dark', 'light', 'auto']; |
|
||||||
function _getTheme() { return document.documentElement.getAttribute('data-theme') || 'auto'; } |
|
||||||
function _applyTheme(t) { |
|
||||||
if (t === 'auto') { document.documentElement.removeAttribute('data-theme'); localStorage.removeItem('theme'); } |
|
||||||
else { document.documentElement.setAttribute('data-theme', t); localStorage.setItem('theme', t); } |
|
||||||
_updateThemeBtn(); |
|
||||||
} |
|
||||||
function cycleTheme() { |
|
||||||
var cur = _getTheme(), idx = _THEMES.indexOf(cur); |
|
||||||
_applyTheme(_THEMES[(idx + 1) % _THEMES.length]); |
|
||||||
} |
|
||||||
function _detectDR() { |
|
||||||
return !!(document.querySelector('meta[name="darkreader"]') || |
|
||||||
document.querySelector('style[data-darkreader-style]') || |
|
||||||
document.documentElement.getAttribute('data-darkreader-mode')); |
|
||||||
} |
|
||||||
function _updateThemeBtn() { |
|
||||||
var btn = document.getElementById('theme-toggle'); |
|
||||||
if (!btn) return; |
|
||||||
var t = _getTheme(), dr = _detectDR(); |
|
||||||
var lbl = { dark: '☾ dark', light: '☀ light', auto: '⊙ auto' }; |
|
||||||
btn.textContent = (dr ? 'DR · ' : '') + (lbl[t] || lbl.auto); |
|
||||||
btn.title = dr ? 'DarkReader active - controlling theme' : ('Theme: ' + t + ' (click to cycle)'); |
|
||||||
} |
|
||||||
window.addEventListener('DOMContentLoaded', _updateThemeBtn); |
|
||||||
|
|
||||||
// --- 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, |
|
||||||
cycleTheme, |
|
||||||
}); |
|
||||||
|
|
||||||
render(); |
|
||||||
if (window.MonoDisplay) { initDemos(); } |
|
||||||
else { const iv = setInterval(() => { if (window.MonoDisplay) { clearInterval(iv); initDemos(); } }, 100); } |
|
||||||
Loading…
Reference in new issue