From 0d759e83157609ac884fcca3b6a808a84e176f90 Mon Sep 17 00:00:00 2001 From: flop Date: Sat, 30 May 2026 14:46:11 +0200 Subject: [PATCH] refactor(editor): split out web editor --- ts-editor/.gitignore | 37 ++ ts-editor/CLAUDE.md | 106 ++++ ts-editor/README.md | 15 + ts-editor/bun.lock | 37 ++ ts-editor/dist_old/index.html | 1042 +++++++++++++++++++++++++++++++++ ts-editor/index.html | 112 ++++ ts-editor/live.php | 215 +++++++ ts-editor/package.json | 20 + ts-editor/src/browser.ts | 906 ++++++++++++++++++++++++++++ ts-editor/style.css | 682 +++++++++++++++++++++ ts-editor/tsconfig.json | 29 + 11 files changed, 3201 insertions(+) create mode 100644 ts-editor/.gitignore create mode 100644 ts-editor/CLAUDE.md create mode 100644 ts-editor/README.md create mode 100644 ts-editor/bun.lock create mode 100644 ts-editor/dist_old/index.html create mode 100644 ts-editor/index.html create mode 100644 ts-editor/live.php create mode 100644 ts-editor/package.json create mode 100644 ts-editor/src/browser.ts create mode 100644 ts-editor/style.css create mode 100644 ts-editor/tsconfig.json diff --git a/ts-editor/.gitignore b/ts-editor/.gitignore new file mode 100644 index 0000000..bceeac2 --- /dev/null +++ b/ts-editor/.gitignore @@ -0,0 +1,37 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +public +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store +.claude/ +.claude/settings.local.json diff --git a/ts-editor/CLAUDE.md b/ts-editor/CLAUDE.md new file mode 100644 index 0000000..764c1dd --- /dev/null +++ b/ts-editor/CLAUDE.md @@ -0,0 +1,106 @@ + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/ts-editor/README.md b/ts-editor/README.md new file mode 100644 index 0000000..e5b9ae7 --- /dev/null +++ b/ts-editor/README.md @@ -0,0 +1,15 @@ +# libmonoformat-ts + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.3.11. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/ts-editor/bun.lock b/ts-editor/bun.lock new file mode 100644 index 0000000..029cd31 --- /dev/null +++ b/ts-editor/bun.lock @@ -0,0 +1,37 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "libmonoformat", + "dependencies": { + "@types/mithril": "^2.2.8", + "libmonoformat": "../ts", + "mithril": "^2.3.8", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/mithril": ["@types/mithril@2.2.8", "", {}, "sha512-FN9Tv1+Nlr0LNPGnIL/xOxLJfu5WW2n8HAFeo4yxF+/O0per/8g080xlXoo+xj8baowAcfsNI3k80DxyLY34gQ=="], + + "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "libmonoformat": ["libmonoformat@file:../ts", { "dependencies": { "@types/mithril": "^2.2.8", "mithril": "^2.3.8" }, "devDependencies": { "@types/bun": "latest" }, "peerDependencies": { "typescript": "^5" } }], + + "mithril": ["mithril@2.3.8", "", {}, "sha512-za/Yo7qXEckjm5syrSfaaI9Utf4tCUT3T1IOIYqH6Lrj7G0OZuYYLAY9SV4ygoaAf0+CNqU92MBt+7pmo53JVQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + } +} diff --git a/ts-editor/dist_old/index.html b/ts-editor/dist_old/index.html new file mode 100644 index 0000000..69c5157 --- /dev/null +++ b/ts-editor/dist_old/index.html @@ -0,0 +1,1042 @@ + + + + + + + MonoDisplay Editor + + + + + +
+ MonoDisplay +
+ + + +
+ + untitled +
+ +
+
+ preview · 120 × 60 +
+ +
+ demos +
+
+
+ + +
+
+ + +
+
+ + + +
+
+
+ + +
+ + +
+
+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/ts-editor/index.html b/ts-editor/index.html new file mode 100644 index 0000000..c91f6e3 --- /dev/null +++ b/ts-editor/index.html @@ -0,0 +1,112 @@ + + + + + + + MonoDisplay Editor + + + + + + +
+ MonoDisplay +
+ + + +
+ + + untitled +
+
+ +
+ +
+
+ preview · 120 × 60 +
+ +
+ demos +
+
+
+ + - +
+
+ + - +
+
+ + + + +
+
+
+ + +
+ + +
+
+
+
+
+
+ + + + + diff --git a/ts-editor/live.php b/ts-editor/live.php new file mode 100644 index 0000000..ee23cca --- /dev/null +++ b/ts-editor/live.php @@ -0,0 +1,215 @@ + + + +Login + + + +
+ + + + Wrong password. +
+ + + + +Select File + + + +

Live file selector

+

Active: none' ?>

+ +
    + +
  • +
    +
    + + + +
    + + + +
    +
    +
  • + +
+ +

No .bin files found in avj2305/

+ + + serve active .bin file +// GET /live.php?login -> login page +// POST /live.php?login -> process login +// GET /live.php?edit -> file picker [auth required] +// POST /live.php?edit -> set active file [auth required] +// GET /live.php?img=x.bin -> serve x.png preview [auth required] +// +// ============================================================================= + +$method = $_SERVER['REQUEST_METHOD']; +$route = array_key_first($_GET) ?? ''; + +match (true) { + $method === 'POST' && $route === 'login' => action_login(), + $method === 'POST' && $route === 'edit' => action_set_file(), + $method === 'GET' && $route === 'login' => render_login(), + $method === 'GET' && $route === 'edit' => render_edit(), + $method === 'GET' && $route === 'img' => serve_png(), + default => serve_active_file(), +}; diff --git a/ts-editor/package.json b/ts-editor/package.json new file mode 100644 index 0000000..0205c92 --- /dev/null +++ b/ts-editor/package.json @@ -0,0 +1,20 @@ +{ + "name": "libmonoformat", + "module": "src/index.ts", + "type": "module", + "scripts": { + "build": "bun build src/browser.ts --target browser --outfile public/mono-display.js", + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "libmonoformat": "../ts", + "@types/mithril": "^2.2.8", + "mithril": "^2.3.8" + } +} \ No newline at end of file diff --git a/ts-editor/src/browser.ts b/ts-editor/src/browser.ts new file mode 100644 index 0000000..0b164f2 --- /dev/null +++ b/ts-editor/src/browser.ts @@ -0,0 +1,906 @@ +// 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 "libmonoformat"; +import { MonoDisplayFile, cycleTheme } from "libmonoformat"; + +(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-editor/style.css b/ts-editor/style.css new file mode 100644 index 0000000..49a023e --- /dev/null +++ b/ts-editor/style.css @@ -0,0 +1,682 @@ +:root { + --bg: #111; + --bg-bar: #1a1a1a; + --bg-raised: #161616; + --bg-hover: #1b1b1b; + --bg-hover2: #222; + --bg-sunken: #141414; + --bg-accent: #141e14; + --bg-active: #1e2e1e; + --bg-deep: #0f0f0f; + --bg-input: #0a0a0a; + --bg-canvas: #000; + --bg-xhover: #1e1010; + --bg-overlay: rgba(0,0,0,.6); + --bd: #2a2a2a; + --bd-inner: #1e1e1e; + --bd-deep: #1c1c1c; + --bd-card: #222; + --bd-btn: #2d2d2d; + --bd-type: #252525; + --bd-active: #2b3d2b; + --bd-hover: #444; + --bd-strong: #333; + --tx: #ccc; + --tx-hover: #bbb; + --tx-sub: #888; + --tx-label: #777; + --tx-meta: #666; + --tx-dim: #555; + --tx-faint: #444; + --tx-ghost: #333; + --tx-file: #3a3a3a; + --tx-empty: #2e2e2e; + --ac: #33ff66; + --dirty: #886622 +} + +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) { + --bg: #f5f5f5; + --bg-bar: #e8e8e8; + --bg-raised: #efefef; + --bg-hover: #e5e5e5; + --bg-hover2: #e0e0e0; + --bg-sunken: #ebebeb; + --bg-accent: #e8f5e8; + --bg-active: #dff0df; + --bg-deep: #f0f0f0; + --bg-input: #fff; + --bg-canvas: #fff; + --bg-xhover: #ffe8e8; + --bg-overlay: rgba(0,0,0,.4); + --bd: #d0d0d0; + --bd-inner: #ddd; + --bd-deep: #e0e0e0; + --bd-card: #ddd; + --bd-btn: #ccc; + --bd-type: #d8d8d8; + --bd-active: #7dc87d; + --bd-hover: #bbb; + --bd-strong: #ccc; + --tx: #222; + --tx-hover: #333; + --tx-sub: #555; + --tx-label: #666; + --tx-meta: #777; + --tx-dim: #888; + --tx-faint: #999; + --tx-ghost: #aaa; + --tx-file: #aaa; + --tx-empty: #bbb; + --ac: #1a9940; + --dirty: #b37a00 + } +} + +[data-theme="light"] { + --bg: #f5f5f5; + --bg-bar: #e8e8e8; + --bg-raised: #efefef; + --bg-hover: #e5e5e5; + --bg-hover2: #e0e0e0; + --bg-sunken: #ebebeb; + --bg-accent: #e8f5e8; + --bg-active: #dff0df; + --bg-deep: #f0f0f0; + --bg-input: #fff; + --bg-canvas: #fff; + --bg-xhover: #ffe8e8; + --bg-overlay: rgba(0,0,0,.4); + --bd: #d0d0d0; + --bd-inner: #ddd; + --bd-deep: #e0e0e0; + --bd-card: #ddd; + --bd-btn: #ccc; + --bd-type: #d8d8d8; + --bd-active: #7dc87d; + --bd-hover: #bbb; + --bd-strong: #ccc; + --tx: #222; + --tx-hover: #333; + --tx-sub: #555; + --tx-label: #666; + --tx-meta: #777; + --tx-dim: #888; + --tx-faint: #999; + --tx-ghost: #aaa; + --tx-file: #aaa; + --tx-empty: #bbb; + --ac: #1a9940; + --dirty: #b37a00 +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0 +} + +body { + background: var(--bg); + color: var(--tx); + font-family: monospace; + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; + font-size: 12px +} + +#topbar { + height: 36px; + background: var(--bg-bar); + border-bottom: 1px solid var(--bd); + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + flex-shrink: 0 +} + +#topbar .app-name { + color: var(--tx-dim); + font-size: 11px; + letter-spacing: .12em; + text-transform: uppercase; + margin-right: 4px +} + +.tb-btn { + background: var(--bg-raised); + color: var(--tx-sub); + border: 1px solid var(--bd-btn); + border-radius: 3px; + padding: 3px 10px; + font-family: monospace; + font-size: 11px; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; + white-space: nowrap +} + +.tb-btn:hover { + background: var(--bg-hover2); + color: var(--tx-hover); + border-color: var(--bd-hover) +} + +.tb-btn svg { + width: 13px; + height: 13px; + stroke: currentColor; + fill: none; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + flex-shrink: 0 +} + +#file-input { + display: none +} + +#filename { + color: var(--tx-file); + font-size: 11px; + margin-left: 2px; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap +} + +.tb-sep { + width: 1px; + height: 18px; + background: var(--bd) +} + +.dirty { + color: var(--dirty) !important +} + +#main { + flex: 1; + display: flex; + min-height: 0 +} + +#left { + width: 48%; + border-right: 1px solid var(--bd-inner); + display: flex; + flex-direction: column; + padding: 14px; + gap: 10px; + overflow-y: auto; + flex-shrink: 0 +} + +#display-box { + width: 100%; + aspect-ratio: 2/1; + background: var(--bg-canvas); + border: 1px solid var(--bd); + border-radius: 3px; + overflow: hidden +} + +#canvas_root { + width: 100%; + height: 100%; + display: block; + image-rendering: pixelated; + image-rendering: crisp-edges +} + +.pv-label { + font-size: 10px; + color: var(--tx-ghost); + letter-spacing: .1em; + text-transform: uppercase +} + +#demo-btns { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 6px +} +#demo-btns > div { + display: contents; +} + +canvas.pixel-editor { + display: block; + cursor: crosshair; + image-rendering: pixelated; + max-width: 100%; + border: 1px solid var(--bd-inner); +} +.anim-editor { + display: flex; + flex-direction: column; + gap: 4px; +} +.anim-frame-tabs { + display: flex; + flex-wrap: wrap; + gap: 3px; + align-items: center; +} +.anim-frame-tab { + display: flex; + align-items: center; + gap: 2px; + padding: 2px 6px; + border: 1px solid var(--bd-inner); + border-radius: 3px; + cursor: pointer; + font-size: 11px; + user-select: none; +} +.anim-frame-tab.active { + border-color: var(--accent, #EC0); + color: var(--accent, #EC0); +} +.x-btn-sm { + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 0 2px; + font-size: 13px; + line-height: 1; + opacity: 0.6; +} +.x-btn-sm:hover { opacity: 1; } + +#sec-meta { + background: var(--bg-sunken); + border: 1px solid var(--bd-inner); + border-radius: 3px; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 6px +} + +.meta-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px +} + +.meta-row label { + color: var(--tx-faint); + min-width: 90px +} + +.meta-val { + color: var(--tx-meta) +} + +.green { + color: var(--ac) +} + +select.meta-val { + background: var(--bg-input); + color: var(--ac); + border: 1px solid var(--bd-inner); + border-radius: 3px; + padding: 1px 4px; +} + +.flag-check { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--tx-dim); + cursor: pointer +} + +.flag-check input { + accent-color: var(--ac); + cursor: pointer +} + +#right { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden +} + +#right-hdr { + height: 36px; + background: var(--bg-sunken); + border-bottom: 1px solid var(--bd-inner); + display: flex; + align-items: center; + padding: 0 10px; + gap: 6px; + flex-shrink: 0 +} + +#right-hdr .rh-title { + flex: 1; + color: var(--tx-faint); + font-size: 11px; + letter-spacing: .08em; + text-transform: uppercase +} + +#sections-wrap { + flex: 1; + overflow-y: auto; + padding: 8px +} + +.empty-state { + color: var(--tx-empty); + font-size: 11px; + padding: 28px 16px; + text-align: center; + line-height: 2 +} + +/* section card */ +.sec-card { + border: 1px solid var(--bd-card); + border-radius: 3px; + margin-bottom: 5px; + overflow: hidden +} + +.sec-card.active { + border-color: var(--bd-active) +} + +.sec-hdr { + display: flex; + align-items: center; + gap: 7px; + padding: 6px 8px; + cursor: pointer; + user-select: none; + background: var(--bg-raised); + position: relative +} + +.sec-hdr:hover { + background: var(--bg-hover) +} + +.sec-card.active .sec-hdr, +.sec-hdr.active { + background: var(--bg-active) +} + +.sec-arrow { + color: var(--tx-ghost); + font-size: 13px; + line-height: 1; + transition: transform .12s; + flex-shrink: 0 +} + +.sec-card.open .sec-arrow { + transform: rotate(90deg) +} + +.sec-label { + flex: 1; + color: var(--tx-label); + font-size: 11px +} + +.sec-badge { + font-size: 10px; + color: var(--ac); + border: 1px solid var(--bd-active); + background: var(--bg-accent); + border-radius: 2px; + padding: 1px 6px; + flex-shrink: 0 +} + +/* red × - section & element */ +.x-btn { + background: none; + border: none; + color: var(--bd); + cursor: pointer; + font-size: 15px; + line-height: 1; + padding: 2px 5px; + border-radius: 2px; + flex-shrink: 0; + transition: color .1s, background .1s +} + +.x-btn:hover { + color: #ff4444; + background: var(--bg-xhover) +} + +/* element */ +.el-list { + background: var(--bg-deep); + border-top: 1px solid var(--bd-deep); + padding: 6px +} + +.el-item { + border: 1px solid var(--bd-inner); + border-radius: 3px; + background: var(--bg-sunken); + margin-bottom: 4px; + overflow: hidden +} + +.el-item.active { + border-color: var(--ac) +} + +.el-item-hdr { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 8px; + cursor: pointer +} + +.el-item-hdr:hover { + background: var(--bg-hover) +} + +.el-type { + font-size: 10px; + color: var(--tx-meta); + border: 1px solid var(--bd-type); + border-radius: 2px; + padding: 1px 6px; + flex-shrink: 0 +} + +.el-item.active .el-type { + color: var(--ac); + border-color: var(--bd-active) +} + +.el-name { + flex: 1; + color: var(--tx-dim); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 120px +} + +.el-fields { + padding: 6px 8px 8px; + border-top: 1px solid var(--bg-bar); + display: flex; + flex-direction: column; + gap: 5px +} + +.fields-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px 10px +} + +.field { + display: flex; + flex-direction: column; + gap: 2px +} + +.field.full { + grid-column: 1/-1 +} + +.field>label { + font-size: 10px; + color: var(--tx-faint) +} + +.field input[type=text], +.field input[type=number], +.field input[type=datetime-local], +.field select, +.field textarea { + background: var(--bg-input); + color: var(--tx-sub); + border: 1px solid var(--bd-card); + border-radius: 2px; + padding: 3px 6px; + font-family: monospace; + font-size: 11px; + width: 100%; + outline: none +} + +.field input:focus, +.field select:focus, +.field textarea:focus { + border-color: var(--ac); + color: var(--tx) +} + +.field textarea { + resize: vertical; + min-height: 44px; + line-height: 1.4 +} + +.field select option { + background: var(--bg-bar); + color: var(--tx-ghost) +} + +.flags-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + padding: 2px 0 +} + +.add-el { + background: transparent; + color: var(--tx-ghost); + border: 1px dashed var(--bd-card); + border-radius: 3px; + padding: 5px 8px; + font-family: monospace; + font-size: 11px; + cursor: pointer; + width: 100%; + text-align: center; + margin-top: 2px +} + +.add-el:hover { + color: var(--tx-sub); + border-color: var(--bd-hover) +} + +/* confirm dialog */ +#confirm-overlay { + display: none; + position: fixed; + inset: 0; + background: var(--bg-overlay); + z-index: 100; + align-items: center; + justify-content: center +} + +#confirm-overlay.show { + display: flex +} + +#confirm-box { + background: var(--bg-bar); + border: 1px solid var(--bd-strong); + border-radius: 4px; + padding: 20px 24px; + max-width: 340px; + width: 90%; + display: flex; + flex-direction: column; + gap: 14px +} + +#confirm-msg { + color: var(--tx-ghost); + font-size: 12px; + line-height: 1.6 +} + +#confirm-btns { + display: flex; + gap: 8px; + justify-content: flex-end +} + +.cb { + background: var(--bg-raised); + color: var(--tx-sub); + border: 1px solid var(--bd-btn); + border-radius: 3px; + padding: 4px 14px; + font-family: monospace; + font-size: 11px; + cursor: pointer +} + +.cb:hover { + background: var(--bg-hover2); + color: var(--tx-hover) +} + +.cb.primary { + border-color: var(--bd-active); + color: var(--ac) +} + +.cb.primary:hover { + background: var(--bg-active) +} diff --git a/ts-editor/tsconfig.json b/ts-editor/tsconfig.json new file mode 100644 index 0000000..be3d138 --- /dev/null +++ b/ts-editor/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}