diff --git a/ts/src/browser.ts b/ts/src/browser.ts index cb532d3..37406af 100644 --- a/ts/src/browser.ts +++ b/ts/src/browser.ts @@ -146,7 +146,14 @@ function createNewElement(type: MonoDisplay.ElementType): MonoDisplay.MonoFormat const flags: Record = {}; (EL_FLAGS[MonoDisplay.ElementTypeToString[type]] as ElFlagMeta[] || []) .forEach(f => (flags[f.key] = f.default ?? false)); - return { type, ...fields, flags } as MonoDisplay.MonoFormatElement; + 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 { @@ -416,6 +423,8 @@ function FieldInput(si: number, ei: number, field: ElFieldMeta, el: MonoDisplay. ...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, @@ -424,7 +433,7 @@ function FieldInput(si: number, ei: number, field: ElFieldMeta, el: MonoDisplay. } } -// ─── Image2D pixel editor ───────────────────────────────────────────────────── +// --- Shared pixel canvas ------------------------------------------------------ const PIXEL_SCALE = 4; @@ -432,7 +441,7 @@ function getPixelIndex(img: MonoDisplay.MonoFormatPixelImage, x: number, y: numb return y * img.width + x; } -function drawImage2DCanvas(canvas: HTMLCanvasElement, img: MonoDisplay.MonoFormatPixelImage) { +function drawPixelCanvas(canvas: HTMLCanvasElement, img: MonoDisplay.MonoFormatPixelImage) { const ctx = canvas.getContext("2d")!; ctx.fillStyle = "#000"; ctx.fillRect(0, 0, canvas.width, canvas.height); @@ -446,23 +455,13 @@ function drawImage2DCanvas(canvas: HTMLCanvasElement, img: MonoDisplay.MonoForma } } -interface Image2DEditorState { - drawing: boolean; - drawValue: number; // 0 or 1 -} +interface PixelCanvasState { drawing: boolean; drawValue: number; } -const Image2DEditor: m.Component<{ si: number; ei: number; el: MonoDisplay.MonoFormatImage2D }, Image2DEditorState> = { +const PixelCanvas: m.Component<{ img: MonoDisplay.MonoFormatPixelImage; onpaint: () => void }, PixelCanvasState> = { drawing: false, drawValue: 1, - view({ attrs: { si, ei, el }, state }) { - 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 }; - } - const img = el.image; - + view({ attrs: { img, onpaint }, state }) { function pixelFromEvent(e: MouseEvent): { x: number; y: number } | null { const canvas = e.currentTarget as HTMLCanvasElement; const rect = canvas.getBoundingClientRect(); @@ -477,20 +476,16 @@ const Image2DEditor: m.Component<{ si: number; ei: number; el: MonoDisplay.MonoF 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); + 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) => { - (dom as any)._el = el; - drawImage2DCanvas(dom as HTMLCanvasElement, img); - }, - onupdate: ({ dom }: m.VnodeDOM) => drawImage2DCanvas(dom as HTMLCanvasElement, img), + 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); @@ -504,6 +499,80 @@ const Image2DEditor: m.Component<{ si: number; ei: number; el: MonoDisplay.MonoF }, }; +// --- 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", + 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; @@ -558,6 +627,11 @@ const ElementItem: m.Component<{ si: number; ei: number; el: MonoDisplay.MonoFor 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, ), ); diff --git a/ts/style.css b/ts/style.css index 184285b..d20e16f 100644 --- a/ts/style.css +++ b/ts/style.css @@ -263,6 +263,43 @@ canvas.pixel-editor { 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);