parent
480b10f764
commit
d34b6f3035
@ -1,907 +0,0 @@ |
||||
// 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: 10 }, |
||||
{ key: "width", label: "Width", type: "number", default: 30 }, |
||||
{ key: "height", label: "Height", type: "number", default: 10 }, |
||||
{ 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: 10 }, |
||||
{ key: "width", label: "Width", type: "number", default: 30 }, |
||||
{ key: "height", label: "Height", type: "number", default: 10 }, |
||||
{ 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: 30 }, |
||||
{ key: "height", label: "Height", type: "number", default: 10 }, |
||||
{ key: "scrollSpeed", label: "Scroll speed", type: "number", default: 50 }, |
||||
{ key: "fontIndex", label: "Font", type: "font", default: 0 }, |
||||
], |
||||
Line: [ |
||||
{ key: "xOrigin", label: "X offset", type: "number", default: 0 }, |
||||
{ key: "yOrigin", label: "Y offset", type: "number", default: 32 }, |
||||
{ key: "xTarget", label: "X offset", type: "number", default: 0 }, |
||||
{ key: "yTarget", label: "Y offset", type: "number", default: 32 }, |
||||
{ key: "linestyle", label: "Lintylee S", type: "number", 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: 120 }, |
||||
{ 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" }, |
||||
{ key: "padBefore", label: "Pad Before" }, |
||||
{ key: "padAfter", label: "Pad After" }, |
||||
], |
||||
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 = Object.keys(MonoDisplay.MonoDisplayRenderer.builtinFonts); |
||||
|
||||
// --- 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"); |
||||
}); |
||||
} |
||||
|
||||
// --- Persistence -------------------------------------------------------------
|
||||
const STORAGE_KEY = "monodisplay_autosave"; |
||||
|
||||
function saveToStorage() { |
||||
try { |
||||
const buf = file.toBuffer(); |
||||
const b64 = btoa(String.fromCharCode(...buf)); |
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ filename: currentFilename, data: b64 })); |
||||
} catch { } |
||||
} |
||||
|
||||
function loadFromStorage() { |
||||
try { |
||||
const raw = localStorage.getItem(STORAGE_KEY); |
||||
if (!raw) return; |
||||
const { filename, data } = JSON.parse(raw); |
||||
const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0)); |
||||
const parsed = new MonoDisplay.MonoDisplayParser().parse(bytes.buffer); |
||||
file = new MonoDisplayFile(parsed.sections); |
||||
currentFilename = filename; |
||||
const filenameEl = document.getElementById("filename"); |
||||
if (filenameEl) filenameEl.textContent = filename; |
||||
isDirty = true; |
||||
document.getElementById("filename")?.classList.add("dirty"); |
||||
m.redraw(); |
||||
} catch { } |
||||
} |
||||
|
||||
// --- Dirty tracking ----------------------------------------------------------
|
||||
function markDirty() { |
||||
isDirty = true; |
||||
document.getElementById("filename")?.classList.add("dirty"); |
||||
saveToStorage(); |
||||
} |
||||
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)); |
||||
const el: any = { type, ...fields, flags }; |
||||
if (type === MonoDisplay.ElementType.Image2D) { |
||||
el.image = { pixels: new Uint8Array(el.width * el.height), width: el.width, height: el.height }; |
||||
} |
||||
if (type === MonoDisplay.ElementType.Animation) { |
||||
el.frames = [{ pixels: new Uint8Array(el.width * el.height), width: el.width, height: el.height }]; |
||||
} |
||||
return el 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)), |
||||
]); |
||||
case "list": |
||||
return m("span"); // rendered by dedicated editor
|
||||
default: |
||||
return m("input[type=number]", { |
||||
value: val, |
||||
onchange: (e: Event) => setElField(si, ei, field.key, +(e.target as HTMLInputElement).value), |
||||
}); |
||||
} |
||||
} |
||||
|
||||
// --- Shared pixel canvas ------------------------------------------------------
|
||||
|
||||
const PIXEL_SCALE = 4; |
||||
|
||||
function getPixelIndex(img: MonoDisplay.MonoFormatPixelImage, x: number, y: number): number { |
||||
return y * img.width + x; |
||||
} |
||||
|
||||
function drawPixelCanvas(canvas: HTMLCanvasElement, img: MonoDisplay.MonoFormatPixelImage) { |
||||
const ctx = canvas.getContext("2d")!; |
||||
ctx.fillStyle = "#000"; |
||||
ctx.fillRect(0, 0, canvas.width, canvas.height); |
||||
ctx.fillStyle = "#EC0"; |
||||
for (let y = 0; y < img.height; y++) { |
||||
for (let x = 0; x < img.width; x++) { |
||||
if (img.pixels[getPixelIndex(img, x, y)]) { |
||||
ctx.fillRect(x * PIXEL_SCALE, y * PIXEL_SCALE, PIXEL_SCALE, PIXEL_SCALE); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
interface PixelCanvasState { drawing: boolean; drawValue: number; } |
||||
|
||||
const PixelCanvas: m.Component<{ img: MonoDisplay.MonoFormatPixelImage; onpaint: () => void }, PixelCanvasState> = { |
||||
drawing: false, |
||||
drawValue: 1, |
||||
|
||||
view({ attrs: { img, onpaint }, state }) { |
||||
function pixelFromEvent(e: MouseEvent): { x: number; y: number } | null { |
||||
const canvas = e.currentTarget as HTMLCanvasElement; |
||||
const rect = canvas.getBoundingClientRect(); |
||||
const scaleX = canvas.width / rect.width; |
||||
const scaleY = canvas.height / rect.height; |
||||
const x = Math.floor((e.clientX - rect.left) * scaleX / PIXEL_SCALE); |
||||
const y = Math.floor((e.clientY - rect.top) * scaleY / PIXEL_SCALE); |
||||
if (x < 0 || x >= img.width || y < 0 || y >= img.height) return null; |
||||
return { x, y }; |
||||
} |
||||
|
||||
function paint(e: MouseEvent) { |
||||
const p = pixelFromEvent(e); |
||||
if (!p) return; |
||||
img.pixels[getPixelIndex(img, p.x, p.y)] = state.drawValue; |
||||
onpaint(); |
||||
drawPixelCanvas(e.currentTarget as HTMLCanvasElement, img); |
||||
} |
||||
|
||||
return m("canvas.pixel-editor", { |
||||
width: img.width * PIXEL_SCALE, |
||||
height: img.height * PIXEL_SCALE, |
||||
oncreate: ({ dom }: m.VnodeDOM) => drawPixelCanvas(dom as HTMLCanvasElement, img), |
||||
onupdate: ({ dom }: m.VnodeDOM) => drawPixelCanvas(dom as HTMLCanvasElement, img), |
||||
onmousedown: (e: MouseEvent) => { |
||||
state.drawing = true; |
||||
const p = pixelFromEvent(e); |
||||
if (p) state.drawValue = img.pixels[getPixelIndex(img, p.x, p.y)] ? 0 : 1; |
||||
paint(e); |
||||
}, |
||||
onmousemove: (e: MouseEvent) => { if (state.drawing) paint(e); }, |
||||
onmouseup: () => { state.drawing = false; }, |
||||
onmouseleave: () => { state.drawing = false; }, |
||||
ondragover: (e: DragEvent) => { |
||||
e.preventDefault(); |
||||
e.dataTransfer!.dropEffect = "copy"; |
||||
}, |
||||
|
||||
ondrop: (e: DragEvent) => { |
||||
e.preventDefault(); |
||||
const file = e.dataTransfer?.files[0]; |
||||
if (!file || !file.type.startsWith("image/")) return; |
||||
|
||||
const url = URL.createObjectURL(file); |
||||
const htmlImg = new Image(); |
||||
htmlImg.onload = () => { |
||||
// Draw the dropped image into an offscreen canvas to read pixel data
|
||||
const offscreen = document.createElement("canvas"); |
||||
offscreen.width = htmlImg.width; |
||||
offscreen.height = htmlImg.height; |
||||
const ctx = offscreen.getContext("2d")!; |
||||
ctx.drawImage(htmlImg, 0, 0); |
||||
|
||||
const imageData = ctx.getImageData(0, 0, htmlImg.width, htmlImg.height); |
||||
|
||||
// Convert to your pixel format — adjust this to match your img structure
|
||||
const scale = Math.min(1, 120 / htmlImg.width, 60 / htmlImg.height); |
||||
const w = Math.round(htmlImg.width * scale); |
||||
const h = Math.round(htmlImg.height * scale); |
||||
|
||||
offscreen.width = w; |
||||
offscreen.height = h; |
||||
ctx.drawImage(htmlImg, 0, 0, w, h); // scales the image down while drawing
|
||||
img.width = w; |
||||
img.height = h; |
||||
// then read imageData from the scaled offscreen canvas as before
|
||||
img.pixels = new Uint8Array(w * h).map((_, i) => { |
||||
const r = imageData.data[i * 4]; |
||||
const g = imageData.data[i * 4 + 1]; |
||||
const b = imageData.data[i * 4 + 2]; |
||||
const a = imageData.data[i * 4 + 3]; |
||||
// transparent = 0, dark pixels = 1, light pixels = 0
|
||||
return a > 128 && (r * 299 + g * 587 + b * 114) < 128_000 ? 0 : 1; |
||||
}); |
||||
|
||||
URL.revokeObjectURL(url); |
||||
m.redraw(); |
||||
}; |
||||
htmlImg.src = url; |
||||
}, |
||||
}); |
||||
}, |
||||
}; |
||||
|
||||
// --- Image2D editor -----------------------------------------------------------
|
||||
|
||||
const Image2DEditor: m.Component<{ si: number; ei: number; el: MonoDisplay.MonoFormatImage2D }> = { |
||||
view({ attrs: { si, ei, el } }) { |
||||
if (!el.image) { |
||||
const w = (el as any).width || W; |
||||
const h = (el as any).height || H; |
||||
el.image = { pixels: new Uint8Array(w * h), width: w, height: h }; |
||||
} |
||||
return m(PixelCanvas, { |
||||
img: el.image, |
||||
onpaint: () => { markDirty(); triggerPreview(); }, |
||||
}); |
||||
}, |
||||
}; |
||||
|
||||
// --- Animation editor ---------------------------------------------------------
|
||||
|
||||
interface AnimationEditorState { activeFrame: number; } |
||||
|
||||
const AnimationEditor: m.Component<{ si: number; ei: number; el: MonoDisplay.MonoFormatAnimation }, AnimationEditorState> = { |
||||
activeFrame: 0, |
||||
|
||||
view({ attrs: { si, ei, el }, state }) { |
||||
const w = el.width || W; |
||||
const h = el.height || H; |
||||
if (!el.frames) el.frames = []; |
||||
if (!el.frames.length) { |
||||
el.frames.push({ pixels: new Uint8Array(w * h), width: w, height: h }); |
||||
} |
||||
state.activeFrame = Math.min(state.activeFrame, el.frames.length - 1); |
||||
const frame = el.frames[state.activeFrame]; |
||||
|
||||
function addFrame() { |
||||
el.frames.push({ pixels: new Uint8Array(w * h), width: w, height: h }); |
||||
state.activeFrame = el.frames.length - 1; |
||||
markDirty(); triggerPreview(); m.redraw(); |
||||
} |
||||
|
||||
function removeFrame(fi: number) { |
||||
el.frames.splice(fi, 1); |
||||
if (!el.frames.length) |
||||
el.frames.push({ pixels: new Uint8Array(w * h), width: w, height: h }); |
||||
state.activeFrame = Math.min(state.activeFrame, el.frames.length - 1); |
||||
markDirty(); triggerPreview(); |
||||
} |
||||
|
||||
return m(".anim-editor", |
||||
m(".anim-frame-tabs", { key: "tabs" }, |
||||
el.frames.map((_, fi) => |
||||
m(".anim-frame-tab", { |
||||
class: fi === state.activeFrame ? "active" : "", |
||||
onclick: () => { state.activeFrame = fi; }, |
||||
}, |
||||
m("span", fi + 1), |
||||
el.frames.length > 1 |
||||
? m("button.x-btn-sm", { |
||||
onclick: (e: Event) => { e.stopPropagation(); removeFrame(fi); }, |
||||
}, "×") |
||||
: null, |
||||
) |
||||
), |
||||
m("button.add-el", { onclick: addFrame }, "+ frame"), |
||||
), |
||||
m("div", { key: state.activeFrame }, |
||||
m(PixelCanvas, { |
||||
img: frame, |
||||
onpaint: () => { markDirty(); triggerPreview(); }, |
||||
}), |
||||
), |
||||
); |
||||
}, |
||||
}; |
||||
|
||||
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, |
||||
el.type === MonoDisplay.ElementType.Image2D |
||||
? m(".field.full", |
||||
m("label", "Pixels"), |
||||
m(Image2DEditor, { si, ei, el: el as MonoDisplay.MonoFormatImage2D }), |
||||
) |
||||
: el.type === MonoDisplay.ElementType.Animation |
||||
? m(".field.full", |
||||
m("label", "Frames"), |
||||
m(AnimationEditor, { si, ei, el: el as MonoDisplay.MonoFormatAnimation }), |
||||
) |
||||
: 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, selected: name === MonoDisplay.SectionTypeToString[section.sectionType] }, 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); |
||||
|
||||
async function clearAll() { |
||||
if (isDirty && file.sections.length) { |
||||
const ok = await showConfirm("Clear all sections?", [ |
||||
{ label: "Cancel", action: false }, |
||||
{ label: "Clear", primary: true, action: true }, |
||||
]); |
||||
if (!ok) return; |
||||
} |
||||
file = new MonoDisplayFile([]); |
||||
activeSecIndex = null; |
||||
activeElIndex = null; |
||||
currentFilename = "untitled"; |
||||
const filenameEl = document.getElementById("filename"); |
||||
if (filenameEl) filenameEl.textContent = "untitled"; |
||||
localStorage.removeItem(STORAGE_KEY); |
||||
markClean(); |
||||
triggerPreview(); |
||||
m.redraw(); |
||||
} |
||||
|
||||
// Expose globals referenced by topbar inline handlers
|
||||
Object.assign(window, { loadBin, exportBin, addSection, clearAll, cycleTheme }); |
||||
|
||||
// window.addEventListener("beforeunload", (e) => {
|
||||
// if (isDirty) e.preventDefault();
|
||||
// });
|
||||
|
||||
loadFromStorage(); |
||||
triggerPreview(); |
||||
Loading…
Reference in new issue