From a61927ef1dfc6a42e46181085fb304d46450473f Mon Sep 17 00:00:00 2001 From: flop Date: Fri, 22 May 2026 17:53:20 +0200 Subject: [PATCH] feat: image editor support --- ts/src/browser.ts | 91 +++++++++++++++++++++++++++++++++++++++++++++++ ts/style.css | 8 +++++ 2 files changed, 99 insertions(+) diff --git a/ts/src/browser.ts b/ts/src/browser.ts index 0e6fc78..1b008ff 100644 --- a/ts/src/browser.ts +++ b/ts/src/browser.ts @@ -424,6 +424,91 @@ function FieldInput(si: number, ei: number, field: ElFieldMeta, el: MonoDisplay. } } +// ─── Image2D pixel editor ───────────────────────────────────────────────────── + +const PIXEL_SCALE = 4; + +function getPixelIndex(img: MonoDisplay.MonoFormatPixelImage, x: number, y: number): number { + return y * img.width + x; +} + +function drawImage2DCanvas(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 Image2DEditorState { + drawing: boolean; + drawValue: number; // 0 or 1 +} + +const Image2DEditor: m.Component<{ si: number; ei: number; el: MonoDisplay.MonoFormatImage2D }, Image2DEditorState> = { + drawing: false, + drawValue: 1, + + oncreate({ dom }) { + const canvas = dom as HTMLCanvasElement; + const el = (canvas as any)._el as MonoDisplay.MonoFormatImage2D; + drawImage2DCanvas(canvas, el.image); + }, + + onupdate({ dom, attrs }) { + drawImage2DCanvas(dom as HTMLCanvasElement, attrs.el.image); + }, + + view({ attrs: { si, ei, el }, state }) { + const img = el.image; + + 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; + const idx = getPixelIndex(img, p.x, p.y); + img.pixels[idx] = state.drawValue; + markDirty(); triggerPreview(); + drawImage2DCanvas(e.currentTarget as HTMLCanvasElement, img); + } + + return m("canvas.pixel-editor", { + width: img.width * PIXEL_SCALE, + height: img.height * PIXEL_SCALE, + oncreate: ({ dom }: m.VnodeDOM) => { + (dom as any)._el = el; + drawImage2DCanvas(dom as HTMLCanvasElement, img); + }, + onupdate: ({ dom }: m.VnodeDOM) => drawImage2DCanvas(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; }, + }); + }, +}; + const ElementItem: m.Component<{ si: number; ei: number; el: MonoDisplay.MonoFormatElement }> = { view({ attrs: { si, ei, el } }) { const isActive = activeSecIndex === si && activeElIndex === ei; @@ -473,6 +558,12 @@ const ElementItem: m.Component<{ si: number; ei: number; el: MonoDisplay.MonoFor ) ), ) : null, + el.type === MonoDisplay.ElementType.Image2D + ? m(".field.full", + m("label", "Pixels"), + m(Image2DEditor, { si, ei, el: el as MonoDisplay.MonoFormatImage2D }), + ) + : null, ), ); }, diff --git a/ts/style.css b/ts/style.css index 103ddd2..184285b 100644 --- a/ts/style.css +++ b/ts/style.css @@ -256,6 +256,14 @@ body { display: contents; } +canvas.pixel-editor { + display: block; + cursor: crosshair; + image-rendering: pixelated; + max-width: 100%; + border: 1px solid var(--bd-inner); +} + #sec-meta { background: var(--bg-sunken); border: 1px solid var(--bd-inner);