From d34b6f3035780b3a25a67f2597d95d17525b2037 Mon Sep 17 00:00:00 2001 From: flop Date: Sat, 30 May 2026 14:59:36 +0200 Subject: [PATCH] feat: changes for library exports --- ts/package.json | 14 +- ts/src/browser.ts | 907 ---------------------------------------------- ts/src/index.ts | 1 + ts/tsconfig.json | 1 + 4 files changed, 14 insertions(+), 909 deletions(-) delete mode 100644 ts/src/browser.ts diff --git a/ts/package.json b/ts/package.json index 1efbac5..3f121b8 100644 --- a/ts/package.json +++ b/ts/package.json @@ -2,8 +2,18 @@ "name": "libmonoformat", "module": "src/index.ts", "type": "module", + "exports": { + ".": { + "require": "./public/index.cjs", + "import": "./public/index.mjs", + "types": "./public/index.d.ts" + } + }, "scripts": { - "build": "bun build src/browser.ts --target browser --outfile public/mono-display.js", + "build": "bun run buildcjs && bun run buildmjs && bun run builddts", + "buildcjs": "bun build src/index.ts --target browser --format cjs --outfile public/index.cjs", + "buildmjs": "bun build src/index.ts --target browser --format esm --outfile public/index.mjs", + "builddts": "bun build src/index.ts --dts --outfile public/index.d.ts", "test": "bun test" }, "devDependencies": { @@ -16,4 +26,4 @@ "@types/mithril": "^2.2.8", "mithril": "^2.3.8" } -} +} \ No newline at end of file diff --git a/ts/src/browser.ts b/ts/src/browser.ts deleted file mode 100644 index 1a3c678..0000000 --- a/ts/src/browser.ts +++ /dev/null @@ -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> = { - 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> = { - 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 { - 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 { - 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 = {}; - (EL_FIELDS[MonoDisplay.ElementTypeToString[type]] as ElFieldMeta[] || []) - .forEach(f => (fields[f.key] = f.default)); - const flags: Record = {}; - (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 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 | 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.
Use Add section 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(); diff --git a/ts/src/index.ts b/ts/src/index.ts index 48ef98f..744cb7b 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -56,3 +56,4 @@ export { MonoDisplayParser, MONOFORMAT_MAGIC_HEADER } from "./parser"; export { MonoDisplayRenderer } from "./renderer"; export { type MonoDisplayDriverOptions, MonoDisplayDriver } from "./driver"; export { MonoDisplayFile, loadBinFile, buildBinBuffer } from "./file"; +export { cycleTheme } from "./themes" \ No newline at end of file diff --git a/ts/tsconfig.json b/ts/tsconfig.json index be3d138..e1b65e3 100644 --- a/ts/tsconfig.json +++ b/ts/tsconfig.json @@ -13,6 +13,7 @@ "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "noEmit": true, + "isolatedDeclarations": true, // Best practices "strict": true,