From 8dffd62153d48a9884a024ae9582ce31c4169e99 Mon Sep 17 00:00:00 2001 From: Cyanoxide Date: Sun, 14 Jun 2026 09:11:43 +0100 Subject: [PATCH 01/34] Add a themeable XP Paint application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lightweight, theme-aware MS Paint recreation as an in-repo application, wired into the Start menu (pinned + All Programs ▸ Accessories). - Raster engine with pencil, brush, line, rectangle, ellipse, flood-fill, eraser and eyedropper; right-button draws with the background colour, the classic 28-colour palette, fg/bg swatch and a size selector. - Keyboard actions: Ctrl+Z undo, Ctrl+S save PNG, Ctrl+N new (menu bar kept decorative for consistency with the other apps). - Toolbox/palette/menu chrome restyles for the blue/olive/silver themes via the global theme-color mixin; the canvas stays white. - SVG app icon (crisp at every size, no binary asset to vendor). Co-Authored-By: Claude Fable 5 --- README.md | 2 +- frontend/public/icon__paint.svg | 18 + .../Applications/Paint/Paint.module.scss | 213 +++++++++++ .../components/Applications/Paint/Paint.tsx | 352 ++++++++++++++++++ .../src/components/StartMenu/StartMenu.tsx | 1 + frontend/src/data/applications.json | 8 + frontend/src/data/subMenus.json | 7 + 7 files changed, 600 insertions(+), 1 deletion(-) create mode 100644 frontend/public/icon__paint.svg create mode 100644 frontend/src/components/Applications/Paint/Paint.module.scss create mode 100644 frontend/src/components/Applications/Paint/Paint.tsx diff --git a/README.md b/README.md index 65d8465..12b81ff 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ An authentic recreation of Windows XP, created using **React** and **Typescript* - **Internet Explorer** with Wayback Machine implementation for period accurate web browsing. - **Run** can open applications and Folders, either by internal appId, or title. URLs will also open in Internet Explorer. - **[Solitaire](https://github.com/Cyanoxide/react-solitaire)** +- **Paint** — a lightweight, theme-aware recreation with pencil, brush, shapes, fill, eraser and the classic colour palette (Ctrl+Z undo, Ctrl+S save, Ctrl+N new). - **Clippy**, the desktop assistant — drag him around, ask him questions, I can't guarentee he will be much help though. - Login, Shutdown and boot up sequences @@ -21,7 +22,6 @@ An authentic recreation of Windows XP, created using **React** and **Typescript* This is an ongoing project, with many more features I’d like to include in the future, here are some potential features I make look into: - MSN Messenger -- MS Paint - Minesweeper ## Demo diff --git a/frontend/public/icon__paint.svg b/frontend/public/icon__paint.svg new file mode 100644 index 0000000..4fb0b6e --- /dev/null +++ b/frontend/public/icon__paint.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/Applications/Paint/Paint.module.scss b/frontend/src/components/Applications/Paint/Paint.module.scss new file mode 100644 index 0000000..7414541 --- /dev/null +++ b/frontend/src/components/Applications/Paint/Paint.module.scss @@ -0,0 +1,213 @@ +.paint { + background: #ece9d8; + color: #000; + outline: none; + font-family: Tahoma, "Trebuchet MS", sans-serif; + font-size: 1.1rem; + + @include theme-color("silver") { + background: #ece9e1; + } + + &, + * { + box-sizing: border-box; + } +} + +.main { + min-height: 0; +} + +// ---- Toolbox --------------------------------------------------------------- +.toolbox { + width: 5.4rem; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 0.4rem; + background: #ece9d8; + border-right: 0.1rem solid #919b9c; + + @include theme-color("green") { + background: #e9ead2; + border-right-color: #8a936b; + } + + @include theme-color("silver") { + background: #ece9e1; + border-right-color: #aeb0bd; + } +} + +.tools { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.3rem; +} + +.toolButton { + width: 2.2rem; + height: 2.2rem; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + cursor: pointer; + color: #000; + background: #ece9d8; + // Raised XP bevel + border: 0.1rem solid; + border-color: #fff #808080 #808080 #fff; + + svg { + display: block; + fill: currentColor; + } + + &:hover { + background: #f4f2e6; + } + + &[data-active="true"] { + // Sunken + themed tint so the active tool reads clearly per theme + border-color: #808080 #fff #fff #808080; + background: #c9d9f0; + box-shadow: inset 0.1rem 0.1rem 0.2rem #00000022; + + @include theme-color("green") { + background: #d3dabb; + } + + @include theme-color("silver") { + background: #d3d7e6; + } + } +} + +.sizes { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.3rem; + background: #fff; + border: 0.1rem solid #808080; +} + +.sizeOption { + display: flex; + align-items: center; + height: 1.4rem; + padding: 0 0.2rem; + background: transparent; + border: none; + cursor: pointer; + + span { + display: block; + width: 100%; + min-height: 0.1rem; + background: #000; + border-radius: 1rem; + } + + &[data-active="true"] { + background: #316ac5; + + span { + background: #fff; + } + + @include theme-color("green") { + background: #94a06f; + } + + @include theme-color("silver") { + background: #8084a0; + } + } +} + +// ---- Canvas ---------------------------------------------------------------- +.canvasArea { + flex: 1; + min-width: 0; + padding: 0.8rem; + overflow: auto; + background: #808080; + + @include theme-color("silver") { + background: #7e8088; + } +} + +.canvas { + display: block; + background: #fff; + box-shadow: 0 0 0 0.1rem #000; + cursor: crosshair; + touch-action: none; +} + +// ---- Palette --------------------------------------------------------------- +.palette { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.6rem; + background: #ece9d8; + border-top: 0.1rem solid #919b9c; + + @include theme-color("green") { + background: #e9ead2; + border-top-color: #8a936b; + } + + @include theme-color("silver") { + background: #ece9e1; + border-top-color: #aeb0bd; + } +} + +.swatches { + position: relative; + width: 2.8rem; + height: 2.6rem; + flex-shrink: 0; +} + +.swatchFg, +.swatchBg { + position: absolute; + width: 1.6rem; + height: 1.6rem; + border: 0.1rem solid #000; + box-shadow: inset 0 0 0 0.1rem #fff; +} + +.swatchFg { + top: 0; + left: 0.2rem; + z-index: 1; +} + +.swatchBg { + bottom: 0; + right: 0.2rem; +} + +.paletteGrid { + display: grid; + grid-template-columns: repeat(14, 1.3rem); + grid-template-rows: repeat(2, 1.3rem); + gap: 0.1rem; +} + +.colorCell { + width: 1.3rem; + height: 1.3rem; + padding: 0; + cursor: pointer; + border: 0.1rem solid #808080; +} diff --git a/frontend/src/components/Applications/Paint/Paint.tsx b/frontend/src/components/Applications/Paint/Paint.tsx new file mode 100644 index 0000000..e59b1a4 --- /dev/null +++ b/frontend/src/components/Applications/Paint/Paint.tsx @@ -0,0 +1,352 @@ +import { useEffect, useRef, useState } from "react"; +import WindowMenu from "../../WindowMenu/WindowMenu"; +import styles from "./Paint.module.scss"; +import type { PointerEvent as ReactPointerEvent, KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react"; + +type Tool = "pencil" | "brush" | "line" | "rectangle" | "fill" | "ellipse" | "eraser" | "eyedropper"; + +interface ToolDef { + id: Tool; + title: string; + icon: ReactNode; +} + +// Inline SVG glyphs keep the toolbox crisp at any size and tintable per theme, +// with no binary assets to vendor. viewBox 0 0 16 16, drawn in currentColor. +const TOOLS: ToolDef[] = [ + { id: "pencil", title: "Pencil", icon: }, + { id: "brush", title: "Brush", icon: }, + { id: "line", title: "Line", icon: }, + { id: "rectangle", title: "Rectangle", icon: }, + { id: "fill", title: "Fill With Color", icon: }, + { id: "ellipse", title: "Ellipse", icon: }, + { id: "eraser", title: "Eraser", icon: }, + { id: "eyedropper", title: "Pick Color", icon: }, +]; + +// The classic Windows Paint 28-colour palette (two rows of fourteen). +const PALETTE = [ + "#000000", "#808080", "#800000", "#808000", "#008000", "#008080", "#000080", "#800080", "#808040", "#004040", "#0080ff", "#004080", "#8000ff", "#804000", + "#ffffff", "#c0c0c0", "#ff0000", "#ffff00", "#00ff00", "#00ffff", "#0000ff", "#ff00ff", "#ffff80", "#00ff80", "#80ffff", "#8080ff", "#ff0080", "#ff8040", +]; + +const SIZES = [1, 2, 3, 5, 8]; + +const hexToRgba = (hex: string): [number, number, number, number] => { + const v = hex.replace("#", ""); + return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16), 255]; +}; + +const rgbToHex = (r: number, g: number, b: number) => + "#" + [r, g, b].map((c) => c.toString(16).padStart(2, "0")).join(""); + +// Scanline flood fill with a small tolerance (for anti-aliased edges) and a +// visited mask so a near-match fill colour can't cause it to loop forever. +const floodFill = (ctx: CanvasRenderingContext2D, x: number, y: number, fill: [number, number, number, number]) => { + const { width, height } = ctx.canvas; + if (x < 0 || y < 0 || x >= width || y >= height) return; + + const img = ctx.getImageData(0, 0, width, height); + const data = img.data; + const visited = new Uint8Array(width * height); + const at = (px: number, py: number) => (py * width + px) * 4; + const start = at(x, y); + const target = [data[start], data[start + 1], data[start + 2], data[start + 3]]; + if (target[0] === fill[0] && target[1] === fill[1] && target[2] === fill[2] && target[3] === fill[3]) return; + + const tol = 32; + const matches = (px: number, py: number) => { + if (visited[py * width + px]) return false; + const i = at(px, py); + return ( + Math.abs(data[i] - target[0]) <= tol && + Math.abs(data[i + 1] - target[1]) <= tol && + Math.abs(data[i + 2] - target[2]) <= tol && + Math.abs(data[i + 3] - target[3]) <= tol + ); + }; + + const stack: Array<[number, number]> = [[x, y]]; + while (stack.length) { + const [sx, sy] = stack.pop()!; + let nx = sx; + while (nx >= 0 && matches(nx, sy)) nx--; + nx++; + let spanUp = false; + let spanDown = false; + while (nx < width && matches(nx, sy)) { + const i = at(nx, sy); + data[i] = fill[0]; + data[i + 1] = fill[1]; + data[i + 2] = fill[2]; + data[i + 3] = fill[3]; + visited[sy * width + nx] = 1; + + if (sy > 0) { + if (matches(nx, sy - 1)) { if (!spanUp) { stack.push([nx, sy - 1]); spanUp = true; } } + else spanUp = false; + } + if (sy < height - 1) { + if (matches(nx, sy + 1)) { if (!spanDown) { stack.push([nx, sy + 1]); spanDown = true; } } + else spanDown = false; + } + nx++; + } + } + ctx.putImageData(img, 0, 0); +}; + +const Paint = () => { + const rootRef = useRef(null); + const canvasRef = useRef(null); + const canvasAreaRef = useRef(null); + const initedRef = useRef(false); + + const [tool, setTool] = useState("pencil"); + const [fgColor, setFgColor] = useState("#000000"); + const [bgColor, setBgColor] = useState("#ffffff"); + const [size, setSize] = useState(2); + + // Mutable per-stroke state (avoids re-render churn while dragging) + const drawingRef = useRef(false); + const startRef = useRef<{ x: number; y: number } | null>(null); + const lastRef = useRef<{ x: number; y: number } | null>(null); + const snapshotRef = useRef(null); + const buttonRef = useRef(0); + const undoStackRef = useRef([]); + + // Size the canvas to fill the drawing area once it has a real layout, then + // leave the bitmap fixed (resizing a canvas clears it; XP Paint's bitmap is + // a fixed size in a scrollable grey area too). + useEffect(() => { + const canvas = canvasRef.current; + const area = canvasAreaRef.current; + if (!canvas || !area) return; + + const observer = new ResizeObserver(() => { + if (initedRef.current) return; + const w = Math.floor(area.clientWidth - 16); + const h = Math.floor(area.clientHeight - 16); + if (w <= 1 || h <= 1) return; + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, w, h); + } + initedRef.current = true; + observer.disconnect(); + }); + observer.observe(area); + return () => observer.disconnect(); + }, []); + + const getCtx = () => canvasRef.current?.getContext("2d") ?? null; + + const getPos = (event: ReactPointerEvent) => { + const canvas = canvasRef.current!; + const rect = canvas.getBoundingClientRect(); + return { + x: Math.round((event.clientX - rect.left) * (canvas.width / rect.width)), + y: Math.round((event.clientY - rect.top) * (canvas.height / rect.height)), + }; + }; + + const pushUndo = (ctx: CanvasRenderingContext2D) => { + undoStackRef.current.push(ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height)); + if (undoStackRef.current.length > 25) undoStackRef.current.shift(); + }; + + const undo = () => { + const ctx = getCtx(); + const img = undoStackRef.current.pop(); + if (ctx && img) ctx.putImageData(img, 0, 0); + }; + + const clearCanvas = () => { + const ctx = getCtx(); + const canvas = canvasRef.current; + if (!ctx || !canvas) return; + pushUndo(ctx); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + }; + + const saveImage = () => { + const canvas = canvasRef.current; + if (!canvas) return; + const link = document.createElement("a"); + link.download = "untitled.png"; + link.href = canvas.toDataURL("image/png"); + link.click(); + }; + + const strokeColor = (button: number) => (button === 2 ? bgColor : fgColor); + + const drawSegment = (ctx: CanvasRenderingContext2D, from: { x: number; y: number }, to: { x: number; y: number }, button: number) => { + ctx.strokeStyle = tool === "eraser" ? bgColor : strokeColor(button); + ctx.lineWidth = tool === "eraser" ? Math.max(size * 3, 8) : tool === "pencil" ? 1 : size; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.beginPath(); + ctx.moveTo(from.x, from.y); + ctx.lineTo(to.x, to.y); + ctx.stroke(); + }; + + const drawShape = (ctx: CanvasRenderingContext2D, from: { x: number; y: number }, to: { x: number; y: number }, button: number) => { + ctx.strokeStyle = strokeColor(button); + ctx.lineWidth = size; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.beginPath(); + if (tool === "line") { + ctx.moveTo(from.x, from.y); + ctx.lineTo(to.x, to.y); + } else if (tool === "rectangle") { + ctx.rect(from.x, from.y, to.x - from.x, to.y - from.y); + } else if (tool === "ellipse") { + ctx.ellipse((from.x + to.x) / 2, (from.y + to.y) / 2, Math.abs(to.x - from.x) / 2, Math.abs(to.y - from.y) / 2, 0, 0, Math.PI * 2); + } + ctx.stroke(); + }; + + const handlePointerDown = (event: ReactPointerEvent) => { + const canvas = canvasRef.current; + const ctx = getCtx(); + if (!canvas || !ctx) return; + rootRef.current?.focus(); + canvas.setPointerCapture(event.pointerId); + buttonRef.current = event.button; + const pos = getPos(event); + + if (tool === "eyedropper") { + const p = ctx.getImageData(pos.x, pos.y, 1, 1).data; + const hex = rgbToHex(p[0], p[1], p[2]); + if (event.button === 2) setBgColor(hex); else setFgColor(hex); + return; + } + + pushUndo(ctx); + + if (tool === "fill") { + floodFill(ctx, pos.x, pos.y, hexToRgba(strokeColor(event.button))); + return; + } + + drawingRef.current = true; + startRef.current = pos; + lastRef.current = pos; + if (tool === "line" || tool === "rectangle" || tool === "ellipse") { + snapshotRef.current = ctx.getImageData(0, 0, canvas.width, canvas.height); + } else { + drawSegment(ctx, pos, pos, event.button); + } + }; + + const handlePointerMove = (event: ReactPointerEvent) => { + if (!drawingRef.current) return; + const ctx = getCtx(); + if (!ctx) return; + const pos = getPos(event); + + if (tool === "pencil" || tool === "brush" || tool === "eraser") { + if (lastRef.current) drawSegment(ctx, lastRef.current, pos, buttonRef.current); + lastRef.current = pos; + } else if (snapshotRef.current && startRef.current) { + ctx.putImageData(snapshotRef.current, 0, 0); + drawShape(ctx, startRef.current, pos, buttonRef.current); + } + }; + + const endStroke = () => { + drawingRef.current = false; + startRef.current = null; + lastRef.current = null; + snapshotRef.current = null; + }; + + const handleKeyDown = (event: ReactKeyboardEvent) => { + if (!(event.ctrlKey || event.metaKey)) return; + const key = event.key.toLowerCase(); + if (key === "z") { event.preventDefault(); undo(); } + else if (key === "s") { event.preventDefault(); saveImage(); } + else if (key === "n") { event.preventDefault(); clearCanvas(); } + }; + + return ( +
+ + +
+
+
+ {TOOLS.map((t) => ( + + ))} +
+
+ {SIZES.map((s) => ( + + ))} +
+
+ +
+ e.preventDefault()} + /> +
+
+ +
+
+ + +
+
+ {PALETTE.map((color) => ( +
+
+
+ ); +}; + +export default Paint; diff --git a/frontend/src/components/StartMenu/StartMenu.tsx b/frontend/src/components/StartMenu/StartMenu.tsx index f3edae4..4a3edae 100644 --- a/frontend/src/components/StartMenu/StartMenu.tsx +++ b/frontend/src/components/StartMenu/StartMenu.tsx @@ -74,6 +74,7 @@ const StartMenu = ({ startButton }: StartMenuProps) => {
  • +
  • diff --git a/frontend/src/data/applications.json b/frontend/src/data/applications.json index d0a3ae4..18b792c 100644 --- a/frontend/src/data/applications.json +++ b/frontend/src/data/applications.json @@ -14,6 +14,14 @@ "height": 500, "width": 500 }, + "paint": { + "title": "untitled - Paint", + "icon": "/icon__paint.svg", + "iconLarge": "/icon__paint.svg", + "component": "Paint", + "height": 460, + "width": 600 + }, "outlook": { "title": "Microsoft Outlook", "iconLarge": "/icon__outlook--large.png", diff --git a/frontend/src/data/subMenus.json b/frontend/src/data/subMenus.json index b0cff7d..255e294 100644 --- a/frontend/src/data/subMenus.json +++ b/frontend/src/data/subMenus.json @@ -64,6 +64,13 @@ } ] }, + "accessories": { + "contents": [ + { + "appId": "paint" + } + ] + }, "placeholder1": { "contents": [ { From e2845956827317db03de6570d6d9f702587fc8d1 Mon Sep 17 00:00:00 2001 From: Cyanoxide Date: Sun, 14 Jun 2026 09:27:02 +0100 Subject: [PATCH 02/34] Match the authentic XP Paint layout Rework the Paint app to mirror the real Windows XP layout: - Full 16-tool toolbox in the correct two-column order (Free-Form Select, Select, Eraser, Fill, Pick Color, Magnifier, Pencil, Brush, Airbrush, Text, Line, Curve, Rectangle, Polygon, Ellipse, Rounded Rectangle), flat buttons that raise on hover and sink when selected. - A sunken tool-options box below the tools (line widths for the relevant tools; empty for pencil, matching the real app). - Canvas as a fixed bitmap in a grey surround with right/bottom/corner resize handles. - Bottom colour palette plus a status bar (help text, two sunken panels, corner grip) with live cursor coordinates. - Airbrush and rounded-rectangle now draw; curve/polygon approximate a line and the selection/text/magnifier tools are present but not yet interactive. Tool glyphs are placeholders to be swapped for the XP spritemap. Chrome restyles across the blue/olive/silver themes. Co-Authored-By: Claude Fable 5 --- .../Applications/Paint/Paint.module.scss | 161 +++++++++++++++--- .../components/Applications/Paint/Paint.tsx | 161 ++++++++++++------ 2 files changed, 245 insertions(+), 77 deletions(-) diff --git a/frontend/src/components/Applications/Paint/Paint.module.scss b/frontend/src/components/Applications/Paint/Paint.module.scss index 7414541..9c1681c 100644 --- a/frontend/src/components/Applications/Paint/Paint.module.scss +++ b/frontend/src/components/Applications/Paint/Paint.module.scss @@ -5,6 +5,10 @@ font-family: Tahoma, "Trebuchet MS", sans-serif; font-size: 1.1rem; + @include theme-color("green") { + background: #e9ead2; + } + @include theme-color("silver") { background: #ece9e1; } @@ -21,12 +25,11 @@ // ---- Toolbox --------------------------------------------------------------- .toolbox { - width: 5.4rem; + width: 5.6rem; flex-shrink: 0; display: flex; flex-direction: column; - gap: 0.6rem; - padding: 0.4rem; + padding: 0.3rem; background: #ece9d8; border-right: 0.1rem solid #919b9c; @@ -44,11 +47,11 @@ .tools { display: grid; grid-template-columns: repeat(2, 1fr); - gap: 0.3rem; + gap: 0.1rem; } .toolButton { - width: 2.2rem; + width: 2.4rem; height: 2.2rem; display: flex; align-items: center; @@ -56,50 +59,65 @@ padding: 0; cursor: pointer; color: #000; - background: #ece9d8; - // Raised XP bevel - border: 0.1rem solid; - border-color: #fff #808080 #808080 #fff; + background: transparent; + // Flat until hovered/selected, like the XP toolbar + border: 0.1rem solid transparent; svg { display: block; - fill: currentColor; } &:hover { - background: #f4f2e6; + border-color: #fff #808080 #808080 #fff; } &[data-active="true"] { - // Sunken + themed tint so the active tool reads clearly per theme border-color: #808080 #fff #fff #808080; - background: #c9d9f0; - box-shadow: inset 0.1rem 0.1rem 0.2rem #00000022; + background: #faf9f4; @include theme-color("green") { - background: #d3dabb; + background: #f1f2e3; } @include theme-color("silver") { - background: #d3d7e6; + background: #f3f3f7; } } } +// Tool-options box below the tools (shows width choices for the relevant tools) +.options { + height: 5.6rem; + margin-top: 0.5rem; + padding: 0.3rem; + background: #ece9d8; + // Sunken bevel + border: 0.1rem solid; + border-color: #808080 #fff #fff #808080; + + @include theme-color("green") { + background: #e9ead2; + } + + @include theme-color("silver") { + background: #ece9e1; + } +} + .sizes { display: flex; flex-direction: column; - gap: 0.2rem; - padding: 0.3rem; - background: #fff; - border: 0.1rem solid #808080; + gap: 0.3rem; + height: 100%; + justify-content: center; + padding: 0 0.3rem; } .sizeOption { display: flex; align-items: center; - height: 1.4rem; - padding: 0 0.2rem; + height: 0.9rem; + padding: 0 0.1rem; background: transparent; border: none; cursor: pointer; @@ -113,18 +131,18 @@ } &[data-active="true"] { - background: #316ac5; + background: #0a246a; span { background: #fff; } @include theme-color("green") { - background: #94a06f; + background: #5e6948; } @include theme-color("silver") { - background: #8084a0; + background: #4a4d63; } } } @@ -133,7 +151,7 @@ .canvasArea { flex: 1; min-width: 0; - padding: 0.8rem; + padding: 0.3rem; overflow: auto; background: #808080; @@ -142,12 +160,47 @@ } } +.canvasWrap { + position: relative; + display: inline-block; + line-height: 0; +} + .canvas { display: block; background: #fff; - box-shadow: 0 0 0 0.1rem #000; cursor: crosshair; touch-action: none; + box-shadow: 0 0 0 0.1rem #000; +} + +// Bitmap resize handles (visual for now): right-middle, bottom-middle, corner +.handle { + position: absolute; + width: 0.6rem; + height: 0.6rem; + background: #316ac5; + box-shadow: 0 0 0 0.1rem #fff; +} + +.handleRight { + top: 50%; + right: -0.4rem; + transform: translateY(-50%); + cursor: ew-resize; +} + +.handleBottom { + bottom: -0.4rem; + left: 50%; + transform: translateX(-50%); + cursor: ns-resize; +} + +.handleCorner { + bottom: -0.4rem; + right: -0.4rem; + cursor: nwse-resize; } // ---- Palette --------------------------------------------------------------- @@ -155,7 +208,7 @@ display: flex; align-items: center; gap: 0.6rem; - padding: 0.4rem 0.6rem; + padding: 0.3rem 0.4rem; background: #ece9d8; border-top: 0.1rem solid #919b9c; @@ -211,3 +264,55 @@ cursor: pointer; border: 0.1rem solid #808080; } + +// ---- Status bar ------------------------------------------------------------ +.statusBar { + display: flex; + align-items: center; + gap: 0.2rem; + height: 2.2rem; + padding: 0.2rem; + background: #ece9d8; + border-top: 0.1rem solid #fff; + font-size: 1.05rem; + + @include theme-color("green") { + background: #e9ead2; + } + + @include theme-color("silver") { + background: #ece9e1; + } +} + +.statusHelp, +.statusPanel { + height: 100%; + display: flex; + align-items: center; + padding: 0 0.6rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + // Sunken inset bevel + border: 0.1rem solid; + border-color: #808080 #fff #fff #808080; +} + +.statusHelp { + flex: 1; + min-width: 0; +} + +.statusPanel { + width: 8rem; + flex-shrink: 0; +} + +.statusGrip { + width: 1.6rem; + height: 100%; + flex-shrink: 0; + // Diagonal "grip" hatch in the bottom-right corner + background-image: repeating-linear-gradient(135deg, transparent 0 0.2rem, #fff 0.2rem 0.3rem, transparent 0.3rem 0.5rem, #919b9c 0.5rem 0.6rem); +} diff --git a/frontend/src/components/Applications/Paint/Paint.tsx b/frontend/src/components/Applications/Paint/Paint.tsx index e59b1a4..95c6ed2 100644 --- a/frontend/src/components/Applications/Paint/Paint.tsx +++ b/frontend/src/components/Applications/Paint/Paint.tsx @@ -3,7 +3,10 @@ import WindowMenu from "../../WindowMenu/WindowMenu"; import styles from "./Paint.module.scss"; import type { PointerEvent as ReactPointerEvent, KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react"; -type Tool = "pencil" | "brush" | "line" | "rectangle" | "fill" | "ellipse" | "eraser" | "eyedropper"; +type Tool = + | "freeSelect" | "select" | "eraser" | "fill" | "eyedropper" | "magnifier" + | "pencil" | "brush" | "airbrush" | "text" | "line" | "curve" + | "rectangle" | "polygon" | "ellipse" | "roundRectangle"; interface ToolDef { id: Tool; @@ -11,19 +14,32 @@ interface ToolDef { icon: ReactNode; } -// Inline SVG glyphs keep the toolbox crisp at any size and tintable per theme, -// with no binary assets to vendor. viewBox 0 0 16 16, drawn in currentColor. +// Placeholder inline-SVG glyphs (viewBox 0 0 16 16). These will be swapped for +// the XP tool spritemap once it lands; the 2-column order below matches the +// real Paint toolbox row-for-row. const TOOLS: ToolDef[] = [ - { id: "pencil", title: "Pencil", icon: }, - { id: "brush", title: "Brush", icon: }, - { id: "line", title: "Line", icon: }, - { id: "rectangle", title: "Rectangle", icon: }, - { id: "fill", title: "Fill With Color", icon: }, - { id: "ellipse", title: "Ellipse", icon: }, - { id: "eraser", title: "Eraser", icon: }, - { id: "eyedropper", title: "Pick Color", icon: }, + { id: "freeSelect", title: "Free-Form Select", icon: }, + { id: "select", title: "Select", icon: }, + { id: "eraser", title: "Eraser/Color Eraser", icon: }, + { id: "fill", title: "Fill With Color", icon: <> }, + { id: "eyedropper", title: "Pick Color", icon: }, + { id: "magnifier", title: "Magnifier", icon: <> }, + { id: "pencil", title: "Pencil", icon: }, + { id: "brush", title: "Brush", icon: }, + { id: "airbrush", title: "Airbrush", icon: <> }, + { id: "text", title: "Text", icon: }, + { id: "line", title: "Line", icon: }, + { id: "curve", title: "Curve", icon: }, + { id: "rectangle", title: "Rectangle", icon: }, + { id: "polygon", title: "Polygon", icon: }, + { id: "ellipse", title: "Ellipse", icon: }, + { id: "roundRectangle", title: "Rounded Rectangle", icon: }, ]; +// Tools whose options box shows a line-width picker (matches Paint, where the +// pencil/select/fill/text/pick/magnifier have no width option). +const WIDTH_TOOLS = new Set(["brush", "eraser", "airbrush", "line", "curve", "rectangle", "polygon", "ellipse", "roundRectangle"]); + // The classic Windows Paint 28-colour palette (two rows of fourteen). const PALETTE = [ "#000000", "#808080", "#800000", "#808000", "#008000", "#008080", "#000080", "#800080", "#808040", "#004040", "#0080ff", "#004080", "#8000ff", "#804000", @@ -106,6 +122,7 @@ const Paint = () => { const [fgColor, setFgColor] = useState("#000000"); const [bgColor, setBgColor] = useState("#ffffff"); const [size, setSize] = useState(2); + const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null); // Mutable per-stroke state (avoids re-render churn while dragging) const drawingRef = useRef(false); @@ -115,9 +132,9 @@ const Paint = () => { const buttonRef = useRef(0); const undoStackRef = useRef([]); - // Size the canvas to fill the drawing area once it has a real layout, then - // leave the bitmap fixed (resizing a canvas clears it; XP Paint's bitmap is - // a fixed size in a scrollable grey area too). + // Size the canvas to the drawing area once it has a real layout, leaving a + // grey margin to the right/bottom (XP Paint's bitmap is a fixed size inside a + // scrollable grey area). The bitmap then stays fixed. useEffect(() => { const canvas = canvasRef.current; const area = canvasAreaRef.current; @@ -125,8 +142,8 @@ const Paint = () => { const observer = new ResizeObserver(() => { if (initedRef.current) return; - const w = Math.floor(area.clientWidth - 16); - const h = Math.floor(area.clientHeight - 16); + const w = Math.floor(area.clientWidth - 28); + const h = Math.floor(area.clientHeight - 28); if (w <= 1 || h <= 1) return; canvas.width = w; canvas.height = h; @@ -195,30 +212,47 @@ const Paint = () => { ctx.stroke(); }; + const spray = (ctx: CanvasRenderingContext2D, at: { x: number; y: number }, button: number) => { + ctx.fillStyle = strokeColor(button); + const radius = Math.max(size * 2, 6); + for (let i = 0; i < 14; i++) { + const angle = Math.random() * Math.PI * 2; + const dist = Math.random() * radius; + ctx.fillRect(Math.round(at.x + Math.cos(angle) * dist), Math.round(at.y + Math.sin(angle) * dist), 1, 1); + } + }; + const drawShape = (ctx: CanvasRenderingContext2D, from: { x: number; y: number }, to: { x: number; y: number }, button: number) => { ctx.strokeStyle = strokeColor(button); ctx.lineWidth = size; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.beginPath(); - if (tool === "line") { - ctx.moveTo(from.x, from.y); - ctx.lineTo(to.x, to.y); - } else if (tool === "rectangle") { + if (tool === "rectangle" || tool === "polygon") { ctx.rect(from.x, from.y, to.x - from.x, to.y - from.y); + } else if (tool === "roundRectangle") { + const r = 10; + const x = Math.min(from.x, to.x); + const y = Math.min(from.y, to.y); + ctx.roundRect(x, y, Math.abs(to.x - from.x), Math.abs(to.y - from.y), r); } else if (tool === "ellipse") { ctx.ellipse((from.x + to.x) / 2, (from.y + to.y) / 2, Math.abs(to.x - from.x) / 2, Math.abs(to.y - from.y) / 2, 0, 0, Math.PI * 2); + } else { + // line and curve (curve approximated as a straight line for now) + ctx.moveTo(from.x, from.y); + ctx.lineTo(to.x, to.y); } ctx.stroke(); }; + const isFreehand = tool === "pencil" || tool === "brush" || tool === "eraser"; + const isShape = tool === "line" || tool === "curve" || tool === "rectangle" || tool === "polygon" || tool === "ellipse" || tool === "roundRectangle"; + const handlePointerDown = (event: ReactPointerEvent) => { const canvas = canvasRef.current; const ctx = getCtx(); if (!canvas || !ctx) return; rootRef.current?.focus(); - canvas.setPointerCapture(event.pointerId); - buttonRef.current = event.button; const pos = getPos(event); if (tool === "eyedropper") { @@ -227,7 +261,12 @@ const Paint = () => { if (event.button === 2) setBgColor(hex); else setFgColor(hex); return; } + // Selection / text / magnifier are present in the toolbox but not yet + // interactive — ignore canvas input for them. + if (tool === "freeSelect" || tool === "select" || tool === "text" || tool === "magnifier") return; + canvas.setPointerCapture(event.pointerId); + buttonRef.current = event.button; pushUndo(ctx); if (tool === "fill") { @@ -238,23 +277,28 @@ const Paint = () => { drawingRef.current = true; startRef.current = pos; lastRef.current = pos; - if (tool === "line" || tool === "rectangle" || tool === "ellipse") { + if (isShape) { snapshotRef.current = ctx.getImageData(0, 0, canvas.width, canvas.height); + } else if (tool === "airbrush") { + spray(ctx, pos, event.button); } else { drawSegment(ctx, pos, pos, event.button); } }; const handlePointerMove = (event: ReactPointerEvent) => { - if (!drawingRef.current) return; const ctx = getCtx(); if (!ctx) return; const pos = getPos(event); + setCursor(pos); + if (!drawingRef.current) return; - if (tool === "pencil" || tool === "brush" || tool === "eraser") { + if (isFreehand) { if (lastRef.current) drawSegment(ctx, lastRef.current, pos, buttonRef.current); lastRef.current = pos; - } else if (snapshotRef.current && startRef.current) { + } else if (tool === "airbrush") { + spray(ctx, pos, buttonRef.current); + } else if (isShape && snapshotRef.current && startRef.current) { ctx.putImageData(snapshotRef.current, 0, 0); drawShape(ctx, startRef.current, pos, buttonRef.current); } @@ -275,6 +319,8 @@ const Paint = () => { else if (key === "n") { event.preventDefault(); clearCanvas(); } }; + const showWidths = WIDTH_TOOLS.has(tool); + return (
    @@ -296,33 +342,43 @@ const Paint = () => { ))}
    -
    - {SIZES.map((s) => ( - - ))} +
    + {showWidths && ( +
    + {SIZES.map((s) => ( + + ))} +
    + )}
    - e.preventDefault()} - /> +
    + setCursor(null)} + onContextMenu={(e) => e.preventDefault()} + /> + + + +
    @@ -345,6 +401,13 @@ const Paint = () => { ))} + +
    + For Help, click Help Topics on the Help Menu. + {cursor ? `${cursor.x},${cursor.y}` : ""} + + +
    ); }; From e43ec4ae588c3d0402c572332b2b0f5d5217990e Mon Sep 17 00:00:00 2001 From: Cyanoxide Date: Sun, 14 Jun 2026 09:43:36 +0100 Subject: [PATCH 03/34] Make canvas handles functional, fix the status grip, use XP scrollbars - Shrink the canvas resize handles and wire them up: dragging the right/bottom/ corner handle resizes the white bitmap (anchored top-left, drawing preserved). - Replace the status-bar grip with the authentic six-square 3-2-1 staircase that points into the bottom-right corner. - Scroll the canvas area with the shared XPScrollbars component instead of the browser's default scrollbars, so the bitmap pans with XP-styled bars. Co-Authored-By: Claude Fable 5 --- .../Applications/Paint/Paint.module.scss | 38 +++++--- .../components/Applications/Paint/Paint.tsx | 96 +++++++++++++++---- 2 files changed, 106 insertions(+), 28 deletions(-) diff --git a/frontend/src/components/Applications/Paint/Paint.module.scss b/frontend/src/components/Applications/Paint/Paint.module.scss index 9c1681c..38834af 100644 --- a/frontend/src/components/Applications/Paint/Paint.module.scss +++ b/frontend/src/components/Applications/Paint/Paint.module.scss @@ -151,8 +151,19 @@ .canvasArea { flex: 1; min-width: 0; + min-height: 0; + display: flex; +} + +.scroll { + flex: 1; + min-width: 0; + min-height: 0; +} + +// The grey scrollable region the bitmap floats in +.scrollViewport { padding: 0.3rem; - overflow: auto; background: #808080; @include theme-color("silver") { @@ -164,6 +175,8 @@ position: relative; display: inline-block; line-height: 0; + // Room for the handles to sit just outside the bitmap without being clipped + margin: 0 0.4rem 0.4rem 0; } .canvas { @@ -174,32 +187,34 @@ box-shadow: 0 0 0 0.1rem #000; } -// Bitmap resize handles (visual for now): right-middle, bottom-middle, corner +// Bitmap resize handles: small blue squares at right-middle, bottom-middle and +// the corner; dragging them resizes the white canvas. .handle { position: absolute; - width: 0.6rem; - height: 0.6rem; - background: #316ac5; + width: 0.5rem; + height: 0.5rem; + background: #2f6bf6; box-shadow: 0 0 0 0.1rem #fff; + touch-action: none; } .handleRight { top: 50%; - right: -0.4rem; + right: -0.3rem; transform: translateY(-50%); cursor: ew-resize; } .handleBottom { - bottom: -0.4rem; + bottom: -0.3rem; left: 50%; transform: translateX(-50%); cursor: ns-resize; } .handleCorner { - bottom: -0.4rem; - right: -0.4rem; + bottom: -0.3rem; + right: -0.3rem; cursor: nwse-resize; } @@ -311,8 +326,7 @@ .statusGrip { width: 1.6rem; - height: 100%; + height: 1.6rem; flex-shrink: 0; - // Diagonal "grip" hatch in the bottom-right corner - background-image: repeating-linear-gradient(135deg, transparent 0 0.2rem, #fff 0.2rem 0.3rem, transparent 0.3rem 0.5rem, #919b9c 0.5rem 0.6rem); + align-self: flex-end; } diff --git a/frontend/src/components/Applications/Paint/Paint.tsx b/frontend/src/components/Applications/Paint/Paint.tsx index 95c6ed2..e487c5d 100644 --- a/frontend/src/components/Applications/Paint/Paint.tsx +++ b/frontend/src/components/Applications/Paint/Paint.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; import WindowMenu from "../../WindowMenu/WindowMenu"; +import XPScrollbars from "../../XPScrollbars/XPScrollbars"; import styles from "./Paint.module.scss"; import type { PointerEvent as ReactPointerEvent, KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react"; @@ -48,6 +49,11 @@ const PALETTE = [ const SIZES = [1, 2, 3, 5, 8]; +// Bottom-right resize grip: six bevelled squares in a 3-2-1 staircase whose +// right angle points into the corner. Each entry is a dark square's top-left +// (a white highlight is drawn one pixel down-right of it). +const GRIP: Array<[number, number]> = [[5, 13], [9, 13], [13, 13], [9, 9], [13, 9], [13, 5]]; + const hexToRgba = (hex: string): [number, number, number, number] => { const v = hex.replace("#", ""); return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16), 255]; @@ -311,6 +317,48 @@ const Paint = () => { snapshotRef.current = null; }; + // Dragging a canvas handle resizes the bitmap, anchored top-left, preserving + // the existing drawing (new area is filled white). + const resizeRef = useRef<{ dir: string; startX: number; startY: number; startW: number; startH: number; snapshot: ImageData } | null>(null); + + const handleResizeDown = (dir: string) => (event: ReactPointerEvent) => { + const canvas = canvasRef.current; + const ctx = getCtx(); + if (!canvas || !ctx) return; + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + resizeRef.current = { + dir, + startX: event.clientX, + startY: event.clientY, + startW: canvas.width, + startH: canvas.height, + snapshot: ctx.getImageData(0, 0, canvas.width, canvas.height), + }; + // Past snapshots no longer match the new dimensions + undoStackRef.current = []; + }; + + const handleResizeMove = (event: ReactPointerEvent) => { + const r = resizeRef.current; + const canvas = canvasRef.current; + const ctx = getCtx(); + if (!r || !canvas || !ctx) return; + const w = r.dir.includes("e") ? Math.max(1, r.startW + Math.round(event.clientX - r.startX)) : r.startW; + const h = r.dir.includes("s") ? Math.max(1, r.startH + Math.round(event.clientY - r.startY)) : r.startH; + canvas.width = w; + canvas.height = h; + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, w, h); + ctx.putImageData(r.snapshot, 0, 0); + }; + + const handleResizeUp = (event: ReactPointerEvent) => { + if (!resizeRef.current) return; + event.currentTarget.releasePointerCapture(event.pointerId); + resizeRef.current = null; + }; + const handleKeyDown = (event: ReactKeyboardEvent) => { if (!(event.ctrlKey || event.metaKey)) return; const key = event.key.toLowerCase(); @@ -364,21 +412,30 @@ const Paint = () => {
    -
    - setCursor(null)} - onContextMenu={(e) => e.preventDefault()} - /> - - - -
    + +
    + setCursor(null)} + onContextMenu={(e) => e.preventDefault()} + /> + {([["e", styles.handleRight], ["s", styles.handleBottom], ["se", styles.handleCorner]] as const).map(([dir, cls]) => ( + + ))} +
    +
    @@ -406,7 +463,14 @@ const Paint = () => { For Help, click Help Topics on the Help Menu. {cursor ? `${cursor.x},${cursor.y}` : ""} - + ); From e475dac91304ed4caa9324674b486dcb28409d5d Mon Sep 17 00:00:00 2001 From: Cyanoxide Date: Sun, 14 Jun 2026 09:51:10 +0100 Subject: [PATCH 04/34] Use the XP tool spritemap for the Paint toolbox icons Replace the placeholder glyphs with the real Windows XP tool icons from spritemap__paint-tools.png. The 16 icons sit in a single row with uniform gaps and varying widths, so each tool maps to a measured [startX, width] cell (edges taken at the gap midpoints to avoid neighbour bleed) and the sheet is scaled by height so every icon shares one scale. Co-Authored-By: Claude Fable 5 --- frontend/public/spritemap__paint-tools.png | Bin 0 -> 20278 bytes .../Applications/Paint/Paint.module.scss | 12 ++-- .../components/Applications/Paint/Paint.tsx | 65 +++++++++++------- 3 files changed, 50 insertions(+), 27 deletions(-) create mode 100644 frontend/public/spritemap__paint-tools.png diff --git a/frontend/public/spritemap__paint-tools.png b/frontend/public/spritemap__paint-tools.png new file mode 100644 index 0000000000000000000000000000000000000000..6efe3a94688db6e33f4046993614cffb1c458a34 GIT binary patch literal 20278 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 literal 0 HcmV?d00001 diff --git a/frontend/src/components/Applications/Paint/Paint.module.scss b/frontend/src/components/Applications/Paint/Paint.module.scss index 38834af..61e87cb 100644 --- a/frontend/src/components/Applications/Paint/Paint.module.scss +++ b/frontend/src/components/Applications/Paint/Paint.module.scss @@ -63,10 +63,6 @@ // Flat until hovered/selected, like the XP toolbar border: 0.1rem solid transparent; - svg { - display: block; - } - &:hover { border-color: #fff #808080 #808080 #fff; } @@ -85,6 +81,14 @@ } } +// One sprite cell per tool (size/position set inline from the CELLS table) +.toolIcon { + display: block; + background-image: url("/spritemap__paint-tools.png"); + background-repeat: no-repeat; + image-rendering: pixelated; +} + // Tool-options box below the tools (shows width choices for the relevant tools) .options { height: 5.6rem; diff --git a/frontend/src/components/Applications/Paint/Paint.tsx b/frontend/src/components/Applications/Paint/Paint.tsx index e487c5d..6fa5501 100644 --- a/frontend/src/components/Applications/Paint/Paint.tsx +++ b/frontend/src/components/Applications/Paint/Paint.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import WindowMenu from "../../WindowMenu/WindowMenu"; import XPScrollbars from "../../XPScrollbars/XPScrollbars"; import styles from "./Paint.module.scss"; -import type { PointerEvent as ReactPointerEvent, KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react"; +import type { PointerEvent as ReactPointerEvent, KeyboardEvent as ReactKeyboardEvent } from "react"; type Tool = | "freeSelect" | "select" | "eraser" | "fill" | "eyedropper" | "magnifier" @@ -12,29 +12,40 @@ type Tool = interface ToolDef { id: Tool; title: string; - icon: ReactNode; } -// Placeholder inline-SVG glyphs (viewBox 0 0 16 16). These will be swapped for -// the XP tool spritemap once it lands; the 2-column order below matches the -// real Paint toolbox row-for-row. +// Tools in the same left-to-right order as spritemap__paint-tools.png. const TOOLS: ToolDef[] = [ - { id: "freeSelect", title: "Free-Form Select", icon: }, - { id: "select", title: "Select", icon: }, - { id: "eraser", title: "Eraser/Color Eraser", icon: }, - { id: "fill", title: "Fill With Color", icon: <> }, - { id: "eyedropper", title: "Pick Color", icon: }, - { id: "magnifier", title: "Magnifier", icon: <> }, - { id: "pencil", title: "Pencil", icon: }, - { id: "brush", title: "Brush", icon: }, - { id: "airbrush", title: "Airbrush", icon: <> }, - { id: "text", title: "Text", icon: }, - { id: "line", title: "Line", icon: }, - { id: "curve", title: "Curve", icon: }, - { id: "rectangle", title: "Rectangle", icon: }, - { id: "polygon", title: "Polygon", icon: }, - { id: "ellipse", title: "Ellipse", icon: }, - { id: "roundRectangle", title: "Rounded Rectangle", icon: }, + { id: "freeSelect", title: "Free-Form Select" }, + { id: "select", title: "Select" }, + { id: "eraser", title: "Eraser/Color Eraser" }, + { id: "fill", title: "Fill With Color" }, + { id: "eyedropper", title: "Pick Color" }, + { id: "magnifier", title: "Magnifier" }, + { id: "pencil", title: "Pencil" }, + { id: "brush", title: "Brush" }, + { id: "airbrush", title: "Airbrush" }, + { id: "text", title: "Text" }, + { id: "line", title: "Line" }, + { id: "curve", title: "Curve" }, + { id: "rectangle", title: "Rectangle" }, + { id: "polygon", title: "Polygon" }, + { id: "ellipse", title: "Ellipse" }, + { id: "roundRectangle", title: "Rounded Rectangle" }, +]; + +// Icons come from a single-row spritemap (16 icons, evenly ~50px apart, varying +// widths). Each cell is [startX, width] in source pixels, measured from the gap +// midpoints so a cell's edges fall in the blank gaps and no neighbour bleeds in. +// The sheet is scaled by height, so every icon shares one scale and the wider +// shapes (rectangle, fill) stay proportionally wider — as in the real toolbox. +const SPRITE_W = 2517; +const SPRITE_H = 129; +const ICON_H = 16; +const SCALE = ICON_H / SPRITE_H; +const CELLS: Array<[number, number]> = [ + [0, 152], [152, 170], [322, 165], [487, 180], [667, 179], [846, 170], [1016, 122], [1138, 122], + [1260, 179], [1439, 163], [1602, 164], [1766, 100], [1866, 171], [2037, 163], [2200, 171], [2371, 146], ]; // Tools whose options box shows a line-width picker (matches Paint, where the @@ -376,7 +387,7 @@ const Paint = () => {
    - {TOOLS.map((t) => ( + {TOOLS.map((t, i) => ( ))}
    From 644428b8bda661a988933144c88403bcefeff93d Mon Sep 17 00:00:00 2001 From: Cyanoxide Date: Sun, 14 Jun 2026 10:07:40 +0100 Subject: [PATCH 05/34] Centre all tool icons, shrink canvas handles, fix horizontal scroll - Position each tool icon by its tight bounding box so the sprite-edge icons (free-form select, select, rounded rectangle) centre correctly in both axes. - Shrink the canvas resize handles and drop the white halo. - Make the horizontal scrollbar engage: the wide bitmap was inflating the window's intrinsic width (Window has min-width: fit-content), so the canvas area now hosts an absolutely-positioned scroller, keeping the viewport constrained so both XP scrollbars appear. Co-Authored-By: Claude Fable 5 --- .../Applications/Paint/Paint.module.scss | 24 ++++++++++-------- .../components/Applications/Paint/Paint.tsx | 25 ++++++++++--------- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/Applications/Paint/Paint.module.scss b/frontend/src/components/Applications/Paint/Paint.module.scss index 61e87cb..4e8fda0 100644 --- a/frontend/src/components/Applications/Paint/Paint.module.scss +++ b/frontend/src/components/Applications/Paint/Paint.module.scss @@ -153,16 +153,19 @@ // ---- Canvas ---------------------------------------------------------------- .canvasArea { + position: relative; flex: 1; min-width: 0; min-height: 0; - display: flex; + // Absolutely-positioned scroller below keeps the wide bitmap from inflating + // the window's intrinsic width (Window has min-width: fit-content), so the + // viewport stays constrained and the XP scrollbars actually engage. + overflow: hidden; } .scroll { - flex: 1; - min-width: 0; - min-height: 0; + position: absolute; + inset: 0; } // The grey scrollable region the bitmap floats in @@ -195,30 +198,29 @@ // the corner; dragging them resizes the white canvas. .handle { position: absolute; - width: 0.5rem; - height: 0.5rem; + width: 0.4rem; + height: 0.4rem; background: #2f6bf6; - box-shadow: 0 0 0 0.1rem #fff; touch-action: none; } .handleRight { top: 50%; - right: -0.3rem; + right: -0.2rem; transform: translateY(-50%); cursor: ew-resize; } .handleBottom { - bottom: -0.3rem; + bottom: -0.2rem; left: 50%; transform: translateX(-50%); cursor: ns-resize; } .handleCorner { - bottom: -0.3rem; - right: -0.3rem; + bottom: -0.2rem; + right: -0.2rem; cursor: nwse-resize; } diff --git a/frontend/src/components/Applications/Paint/Paint.tsx b/frontend/src/components/Applications/Paint/Paint.tsx index 6fa5501..772e55f 100644 --- a/frontend/src/components/Applications/Paint/Paint.tsx +++ b/frontend/src/components/Applications/Paint/Paint.tsx @@ -34,18 +34,19 @@ const TOOLS: ToolDef[] = [ { id: "roundRectangle", title: "Rounded Rectangle" }, ]; -// Icons come from a single-row spritemap (16 icons, evenly ~50px apart, varying -// widths). Each cell is [startX, width] in source pixels, measured from the gap -// midpoints so a cell's edges fall in the blank gaps and no neighbour bleeds in. -// The sheet is scaled by height, so every icon shares one scale and the wider -// shapes (rectangle, fill) stay proportionally wider — as in the real toolbox. +// Icons come from a single-row spritemap (16 icons). Each cell is the icon's +// tight bounding box [x, y, w, h] in source pixels, so the sheet is scaled by a +// single factor (by height) and each box is flex-centred in its button — every +// icon lands centred on both axes, including the ones flush to the sheet edges. const SPRITE_W = 2517; const SPRITE_H = 129; const ICON_H = 16; const SCALE = ICON_H / SPRITE_H; -const CELLS: Array<[number, number]> = [ - [0, 152], [152, 170], [322, 165], [487, 180], [667, 179], [846, 170], [1016, 122], [1138, 122], - [1260, 179], [1439, 163], [1602, 164], [1766, 100], [1866, 171], [2037, 163], [2200, 171], [2371, 146], +const CELLS: Array<[number, number, number, number]> = [ + [0, 0, 130, 129], [175, 16, 122, 90], [348, 19, 114, 90], [512, 7, 130, 114], + [692, 3, 130, 122], [871, 0, 122, 129], [1040, 3, 74, 122], [1162, 0, 74, 129], + [1284, 11, 130, 106], [1464, 13, 114, 98], [1627, 9, 114, 106], [1791, 1, 50, 122], + [1891, 17, 122, 90], [2061, 9, 114, 106], [2225, 20, 122, 82], [2395, 12, 122, 90], ]; // Tools whose options box shows a line-width picker (matches Paint, where the @@ -400,10 +401,10 @@ const Paint = () => { From 64610bcb7e7b32d9690730bd59e9ec6ff8207bea Mon Sep 17 00:00:00 2001 From: Cyanoxide Date: Sun, 14 Jun 2026 15:44:58 +0100 Subject: [PATCH 06/34] Functional selection tools, footer/handle/maximize fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Select draws a rectangular marquee; Free-Form Select traces a freehand lasso and lifts only the pixels inside it (marquee is the bounding box once drawn). Drag inside a selection to move it (leaving white behind); Delete clears it. - Move the canvas resize handles fully outside the bitmap (in the grey margin). - Keep the menu bar's bottom border painting above the canvas scrollbar. - Fix maximized windows hiding their footer under the taskbar: the taskbar height was read at module load (before the taskbar existed, so 0); read it lazily so a maximised window stops at the taskbar (Window.tsx — benefits all apps, surfaced by Paint's colour palette + status bar). - Use the supplied PNG Paint icon; drop the placeholder SVG. Co-Authored-By: Claude Fable 5 --- frontend/public/icon__paint--large.png | Bin 0 -> 4445 bytes frontend/public/icon__paint.svg | 18 -- .../Applications/Paint/Paint.module.scss | 27 ++- .../components/Applications/Paint/Paint.tsx | 190 +++++++++++++++++- frontend/src/components/Window/Window.tsx | 11 +- frontend/src/data/applications.json | 3 +- 6 files changed, 215 insertions(+), 34 deletions(-) create mode 100644 frontend/public/icon__paint--large.png delete mode 100644 frontend/public/icon__paint.svg diff --git a/frontend/public/icon__paint--large.png b/frontend/public/icon__paint--large.png new file mode 100644 index 0000000000000000000000000000000000000000..8774e8620523b86b4dc9b604519a276ff0dc38e1 GIT binary patch literal 4445 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 - - - - - - - - - - - - - - - - diff --git a/frontend/src/components/Applications/Paint/Paint.module.scss b/frontend/src/components/Applications/Paint/Paint.module.scss index 4e8fda0..7d081a2 100644 --- a/frontend/src/components/Applications/Paint/Paint.module.scss +++ b/frontend/src/components/Applications/Paint/Paint.module.scss @@ -19,6 +19,13 @@ } } +// Keep the menu bar (and its bottom border) painting above the canvas area so +// the vertical scrollbar can't cover it +.menuBar { + position: relative; + z-index: 1; +} + .main { min-height: 0; } @@ -182,8 +189,8 @@ position: relative; display: inline-block; line-height: 0; - // Room for the handles to sit just outside the bitmap without being clipped - margin: 0 0.4rem 0.4rem 0; + // Room for the handles to sit fully outside the bitmap without being clipped + margin: 0 0.8rem 0.8rem 0; } .canvas { @@ -206,24 +213,32 @@ .handleRight { top: 50%; - right: -0.2rem; + right: -0.5rem; transform: translateY(-50%); cursor: ew-resize; } .handleBottom { - bottom: -0.2rem; + bottom: -0.5rem; left: 50%; transform: translateX(-50%); cursor: ns-resize; } .handleCorner { - bottom: -0.2rem; - right: -0.2rem; + bottom: -0.5rem; + right: -0.5rem; cursor: nwse-resize; } +// Selection marquee (dashed outline over the bitmap) +.marquee { + position: absolute; + border: 0.1rem dashed #000; + pointer-events: none; + z-index: 1; +} + // ---- Palette --------------------------------------------------------------- .palette { display: flex; diff --git a/frontend/src/components/Applications/Paint/Paint.tsx b/frontend/src/components/Applications/Paint/Paint.tsx index 772e55f..8490d4e 100644 --- a/frontend/src/components/Applications/Paint/Paint.tsx +++ b/frontend/src/components/Applications/Paint/Paint.tsx @@ -141,6 +141,8 @@ const Paint = () => { const [bgColor, setBgColor] = useState("#ffffff"); const [size, setSize] = useState(2); const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null); + // Rectangular selection (Select / Free-Form Select): the marquee rectangle + const [selection, setSelection] = useState<{ x: number; y: number; w: number; h: number } | null>(null); // Mutable per-stroke state (avoids re-render churn while dragging) const drawingRef = useRef(false); @@ -150,6 +152,18 @@ const Paint = () => { const buttonRef = useRef(0); const undoStackRef = useRef([]); + // Selection drag state: define a region (rectangle, or a freehand lasso for + // Free-Form Select), then drag inside it to move the pixels (lifted, leaving + // white behind). selCanvas = lifted pixels (masked to the lasso when present), + // selBase = the canvas with the original area cleared, selPath = lasso points. + const selDefiningRef = useRef(false); + const selMovingRef = useRef(false); + const selStartRef = useRef({ x: 0, y: 0 }); + const selGrabRef = useRef({ dx: 0, dy: 0 }); + const selBaseRef = useRef(null); + const selCanvasRef = useRef(null); + const selPathRef = useRef | null>(null); + // Size the canvas to the drawing area once it has a real layout, leaving a // grey margin to the right/bottom (XP Paint's bitmap is a fixed size inside a // scrollable grey area). The bitmap then stays fixed. @@ -177,6 +191,17 @@ const Paint = () => { return () => observer.disconnect(); }, []); + // Switching away from a selection tool commits the floating selection (its + // pixels are already drawn) and drops the marquee. + useEffect(() => { + if (tool !== "select" && tool !== "freeSelect") { + setSelection(null); + selCanvasRef.current = null; + selBaseRef.current = null; + selPathRef.current = null; + } + }, [tool]); + const getCtx = () => canvasRef.current?.getContext("2d") ?? null; const getPos = (event: ReactPointerEvent) => { @@ -265,6 +290,128 @@ const Paint = () => { const isFreehand = tool === "pencil" || tool === "brush" || tool === "eraser"; const isShape = tool === "line" || tool === "curve" || tool === "rectangle" || tool === "polygon" || tool === "ellipse" || tool === "roundRectangle"; + const isSelect = tool === "select" || tool === "freeSelect"; + + // Selection. Select drags a rectangle; Free-Form Select traces a freehand + // lasso (its marquee becomes the bounding box once complete, but only the + // pixels inside the lasso are lifted). Drag inside to move; Delete clears. + const tracePath = (c: CanvasRenderingContext2D, path: Array<{ x: number; y: number }>, ox: number, oy: number) => { + c.beginPath(); + path.forEach((p, i) => (i === 0 ? c.moveTo(p.x - ox, p.y - oy) : c.lineTo(p.x - ox, p.y - oy))); + c.closePath(); + }; + + // Lift the selected pixels into an offscreen canvas (clipped to the lasso when + // present) and clear the original area to white. + const liftSelection = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, sel: { x: number; y: number; w: number; h: number }) => { + const off = document.createElement("canvas"); + off.width = Math.max(1, sel.w); + off.height = Math.max(1, sel.h); + const octx = off.getContext("2d"); + if (!octx) return; + octx.save(); + if (selPathRef.current) { tracePath(octx, selPathRef.current, sel.x, sel.y); octx.clip(); } + octx.drawImage(canvas, -sel.x, -sel.y); + octx.restore(); + selCanvasRef.current = off; + + ctx.save(); + if (selPathRef.current) tracePath(ctx, selPathRef.current, 0, 0); + else { ctx.beginPath(); ctx.rect(sel.x, sel.y, sel.w, sel.h); } + ctx.clip(); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(sel.x, sel.y, sel.w, sel.h); + ctx.restore(); + + selBaseRef.current = ctx.getImageData(0, 0, canvas.width, canvas.height); + ctx.drawImage(off, sel.x, sel.y); + }; + + const handleSelectDown = (event: ReactPointerEvent) => { + const canvas = canvasRef.current; + const ctx = getCtx(); + if (!canvas || !ctx) return; + canvas.setPointerCapture(event.pointerId); + const pos = getPos(event); + const inside = selection && pos.x >= selection.x && pos.x < selection.x + selection.w && pos.y >= selection.y && pos.y < selection.y + selection.h; + + if (inside && selection) { + pushUndo(ctx); + if (!selCanvasRef.current) liftSelection(ctx, canvas, selection); + selGrabRef.current = { dx: pos.x - selection.x, dy: pos.y - selection.y }; + selMovingRef.current = true; + } else { + // Commit any floating selection (already drawn) and start a new one + selCanvasRef.current = null; + selBaseRef.current = null; + selDefiningRef.current = true; + if (tool === "freeSelect") { + selPathRef.current = [pos]; + snapshotRef.current = ctx.getImageData(0, 0, canvas.width, canvas.height); + setSelection(null); + } else { + selPathRef.current = null; + selStartRef.current = pos; + setSelection({ x: pos.x, y: pos.y, w: 0, h: 0 }); + } + } + }; + + const handleSelectMove = (event: ReactPointerEvent) => { + const ctx = getCtx(); + if (!ctx) return; + const pos = getPos(event); + if (selDefiningRef.current) { + if (tool === "freeSelect" && selPathRef.current && snapshotRef.current) { + selPathRef.current.push(pos); + ctx.putImageData(snapshotRef.current, 0, 0); + ctx.save(); + ctx.strokeStyle = "#000"; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + selPathRef.current.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y))); + ctx.stroke(); + ctx.restore(); + } else { + setSelection({ + x: Math.min(pos.x, selStartRef.current.x), + y: Math.min(pos.y, selStartRef.current.y), + w: Math.abs(pos.x - selStartRef.current.x), + h: Math.abs(pos.y - selStartRef.current.y), + }); + } + } else if (selMovingRef.current && selBaseRef.current && selCanvasRef.current) { + const nx = pos.x - selGrabRef.current.dx; + const ny = pos.y - selGrabRef.current.dy; + ctx.putImageData(selBaseRef.current, 0, 0); + ctx.drawImage(selCanvasRef.current, nx, ny); + setSelection((s) => (s ? { ...s, x: nx, y: ny } : s)); + } + }; + + const handleSelectUp = () => { + const ctx = getCtx(); + if (selDefiningRef.current) { + selDefiningRef.current = false; + if (tool === "freeSelect" && selPathRef.current && snapshotRef.current && ctx) { + ctx.putImageData(snapshotRef.current, 0, 0); + snapshotRef.current = null; + const path = selPathRef.current; + const xs = path.map((p) => p.x); + const ys = path.map((p) => p.y); + const x = Math.min(...xs); + const y = Math.min(...ys); + const w = Math.max(...xs) - x; + const h = Math.max(...ys) - y; + if (path.length > 2 && w > 2 && h > 2) setSelection({ x, y, w, h }); + else { selPathRef.current = null; setSelection(null); } + } else { + setSelection((s) => (s && s.w > 2 && s.h > 2 ? s : null)); + } + } + selMovingRef.current = false; + }; const handlePointerDown = (event: ReactPointerEvent) => { const canvas = canvasRef.current; @@ -279,9 +426,9 @@ const Paint = () => { if (event.button === 2) setBgColor(hex); else setFgColor(hex); return; } - // Selection / text / magnifier are present in the toolbox but not yet - // interactive — ignore canvas input for them. - if (tool === "freeSelect" || tool === "select" || tool === "text" || tool === "magnifier") return; + if (isSelect) { handleSelectDown(event); return; } + // Text / magnifier are present in the toolbox but not yet interactive. + if (tool === "text" || tool === "magnifier") return; canvas.setPointerCapture(event.pointerId); buttonRef.current = event.button; @@ -309,6 +456,7 @@ const Paint = () => { if (!ctx) return; const pos = getPos(event); setCursor(pos); + if (isSelect) { handleSelectMove(event); return; } if (!drawingRef.current) return; if (isFreehand) { @@ -323,6 +471,7 @@ const Paint = () => { }; const endStroke = () => { + if (isSelect) { handleSelectUp(); return; } drawingRef.current = false; startRef.current = null; lastRef.current = null; @@ -372,6 +521,31 @@ const Paint = () => { }; const handleKeyDown = (event: ReactKeyboardEvent) => { + if (event.key === "Delete" || event.key === "Backspace") { + if (!selection) return; + event.preventDefault(); + const ctx = getCtx(); + if (ctx) { + pushUndo(ctx); + if (selCanvasRef.current && selBaseRef.current) { + // Already lifted: drop the floating pixels (base is already cleared) + ctx.putImageData(selBaseRef.current, 0, 0); + } else { + ctx.save(); + if (selPathRef.current) tracePath(ctx, selPathRef.current, 0, 0); + else { ctx.beginPath(); ctx.rect(selection.x, selection.y, selection.w, selection.h); } + ctx.clip(); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(selection.x, selection.y, selection.w, selection.h); + ctx.restore(); + } + } + selCanvasRef.current = null; + selBaseRef.current = null; + selPathRef.current = null; + setSelection(null); + return; + } if (!(event.ctrlKey || event.metaKey)) return; const key = event.key.toLowerCase(); if (key === "z") { event.preventDefault(); undo(); } @@ -383,7 +557,9 @@ const Paint = () => { return (
    - +
    + +
    @@ -454,6 +630,12 @@ const Paint = () => { onPointerCancel={handleResizeUp} /> ))} + {selection && selection.w > 0 && selection.h > 0 && ( +
    + )}
    diff --git a/frontend/src/components/Window/Window.tsx b/frontend/src/components/Window/Window.tsx index e591e8a..6366535 100644 --- a/frontend/src/components/Window/Window.tsx +++ b/frontend/src/components/Window/Window.tsx @@ -12,7 +12,10 @@ interface WindowProps extends currentWindow { } const THROTTLE_DELAY = 50; -const taskBarHeight = document.querySelector("[data-label=taskbar]")?.getBoundingClientRect().height || 0; +// Read the taskbar height lazily: this module loads before the desktop (and its +// taskbar) is in the DOM, so a module-level snapshot would be 0 and a maximised +// window would extend under the taskbar. +const getTaskBarHeight = () => document.querySelector("[data-label=taskbar]")?.getBoundingClientRect().height || 0; const applications = applicationsJSON as unknown as Record; const Window = ({ ...props }: WindowProps) => { @@ -45,7 +48,7 @@ const Window = ({ ...props }: WindowProps) => { activeWindow?.style?.left === "0px" && activeWindow?.style?.top === "0px" && activeWindow?.style?.width === "100%" - && activeWindow?.style?.height === (window.innerHeight - taskBarHeight) + "px" + && activeWindow?.style?.height === (window.innerHeight - getTaskBarHeight()) + "px" ); useEffect(() => { @@ -78,7 +81,7 @@ const Window = ({ ...props }: WindowProps) => { activeWindow.style.left = (isMaximized) ? unmaximizedValues.left : "0px"; activeWindow.style.top = (isMaximized) ? unmaximizedValues.top : "0px"; activeWindow.style.width = (isMaximized) ? unmaximizedValues.width : "100%"; - activeWindow.style.height = (isMaximized) ? unmaximizedValues.height : window.innerHeight - taskBarHeight + "px"; + activeWindow.style.height = (isMaximized) ? unmaximizedValues.height : window.innerHeight - getTaskBarHeight() + "px"; }; const onTitleBarPointerDown = (event: React.PointerEvent) => { @@ -96,7 +99,7 @@ const Window = ({ ...props }: WindowProps) => { } const onPointerMove = (event: PointerEvent) => { - if (isMaximized || event.clientY <= 0 || event.clientY > window.innerHeight - taskBarHeight) return; + if (isMaximized || event.clientY <= 0 || event.clientY > window.innerHeight - getTaskBarHeight()) return; setWindowPosition({ top: event.clientY - windowOffsetY, left: event.clientX - windowOffsetX, right: undefined, bottom: undefined }); document.body.style.userSelect = "none"; diff --git a/frontend/src/data/applications.json b/frontend/src/data/applications.json index 18b792c..a4bc345 100644 --- a/frontend/src/data/applications.json +++ b/frontend/src/data/applications.json @@ -16,8 +16,7 @@ }, "paint": { "title": "untitled - Paint", - "icon": "/icon__paint.svg", - "iconLarge": "/icon__paint.svg", + "iconLarge": "/icon__paint--large.png", "component": "Paint", "height": 460, "width": 600 From 19817bd1087d9ec6fba656791eb87d9d9cb05175 Mon Sep 17 00:00:00 2001 From: Cyanoxide Date: Sun, 14 Jun 2026 17:02:25 +0100 Subject: [PATCH 07/34] Implement the polygon and magnifier (zoom) tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Polygon: click to drop vertices with a rubber-band preview, double-click to close — produces a real multi-sided polygon instead of a rectangle. Switching tools mid-draw abandons the in-progress polygon. - Magnifier: the options box offers 1x/2x/4x/8x zoom (clicking the canvas cycles in, right-click zooms out). Zoom scales the bitmap via CSS zoom so the XP scrollbars pan the enlarged view; pointer mapping and the resize handles account for the zoom, so drawing still lands on the right pixel. Co-Authored-By: Claude Fable 5 --- .../Applications/Paint/Paint.module.scss | 36 +++++++ .../components/Applications/Paint/Paint.tsx | 97 +++++++++++++++++-- 2 files changed, 124 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/Applications/Paint/Paint.module.scss b/frontend/src/components/Applications/Paint/Paint.module.scss index 7d081a2..2ecc435 100644 --- a/frontend/src/components/Applications/Paint/Paint.module.scss +++ b/frontend/src/components/Applications/Paint/Paint.module.scss @@ -158,6 +158,42 @@ } } +// Magnifier zoom-level options +.zooms { + display: flex; + flex-direction: column; + gap: 0.2rem; + height: 100%; + justify-content: center; + padding: 0 0.3rem; +} + +.zoomOption { + padding: 0.2rem 0; + font-size: 1.1rem; + color: #000; + cursor: pointer; + background: transparent; + border: 0.1rem solid transparent; + + &:hover { + border-color: #fff #808080 #808080 #fff; + } + + &[data-active="true"] { + border-color: #808080 #fff #fff #808080; + background: #c9d9f0; + + @include theme-color("green") { + background: #d3dabb; + } + + @include theme-color("silver") { + background: #d3d7e6; + } + } +} + // ---- Canvas ---------------------------------------------------------------- .canvasArea { position: relative; diff --git a/frontend/src/components/Applications/Paint/Paint.tsx b/frontend/src/components/Applications/Paint/Paint.tsx index 8490d4e..925fbbf 100644 --- a/frontend/src/components/Applications/Paint/Paint.tsx +++ b/frontend/src/components/Applications/Paint/Paint.tsx @@ -140,6 +140,7 @@ const Paint = () => { const [fgColor, setFgColor] = useState("#000000"); const [bgColor, setBgColor] = useState("#ffffff"); const [size, setSize] = useState(2); + const [zoom, setZoom] = useState(1); const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null); // Rectangular selection (Select / Free-Form Select): the marquee rectangle const [selection, setSelection] = useState<{ x: number; y: number; w: number; h: number } | null>(null); @@ -164,6 +165,11 @@ const Paint = () => { const selCanvasRef = useRef(null); const selPathRef = useRef | null>(null); + // Polygon: click to drop vertices (rubber-band preview between clicks), + // double-click to close. polyBase is the canvas before the polygon started. + const polyPointsRef = useRef | null>(null); + const polyBaseRef = useRef(null); + // Size the canvas to the drawing area once it has a real layout, leaving a // grey margin to the right/bottom (XP Paint's bitmap is a fixed size inside a // scrollable grey area). The bitmap then stays fixed. @@ -200,6 +206,12 @@ const Paint = () => { selBaseRef.current = null; selPathRef.current = null; } + // Abandon an unfinished polygon (restore the canvas to before it started) + if (tool !== "polygon" && polyBaseRef.current) { + canvasRef.current?.getContext("2d")?.putImageData(polyBaseRef.current, 0, 0); + polyPointsRef.current = null; + polyBaseRef.current = null; + } }, [tool]); const getCtx = () => canvasRef.current?.getContext("2d") ?? null; @@ -271,7 +283,7 @@ const Paint = () => { ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.beginPath(); - if (tool === "rectangle" || tool === "polygon") { + if (tool === "rectangle") { ctx.rect(from.x, from.y, to.x - from.x, to.y - from.y); } else if (tool === "roundRectangle") { const r = 10; @@ -289,9 +301,52 @@ const Paint = () => { }; const isFreehand = tool === "pencil" || tool === "brush" || tool === "eraser"; - const isShape = tool === "line" || tool === "curve" || tool === "rectangle" || tool === "polygon" || tool === "ellipse" || tool === "roundRectangle"; + const isShape = tool === "line" || tool === "curve" || tool === "rectangle" || tool === "ellipse" || tool === "roundRectangle"; const isSelect = tool === "select" || tool === "freeSelect"; + // Polygon: click to drop vertices, double-click to close. Each frame redraws + // from the snapshot taken when the first vertex was placed. + const drawPolygon = (ctx: CanvasRenderingContext2D, cursor: { x: number; y: number } | null, button: number, close: boolean) => { + if (!polyBaseRef.current || !polyPointsRef.current) return; + ctx.putImageData(polyBaseRef.current, 0, 0); + const pts = polyPointsRef.current; + ctx.strokeStyle = strokeColor(button); + ctx.lineWidth = size; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.beginPath(); + ctx.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y); + if (cursor) ctx.lineTo(cursor.x, cursor.y); + if (close) ctx.closePath(); + ctx.stroke(); + }; + + const handlePolygonDown = (event: ReactPointerEvent) => { + const canvas = canvasRef.current; + const ctx = getCtx(); + if (!canvas || !ctx) return; + const pos = getPos(event); + buttonRef.current = event.button; + if (!polyPointsRef.current) { + pushUndo(ctx); + polyBaseRef.current = ctx.getImageData(0, 0, canvas.width, canvas.height); + polyPointsRef.current = [pos]; + } else { + polyPointsRef.current.push(pos); + } + drawPolygon(ctx, null, event.button, false); + }; + + const handlePolygonClose = () => { + const ctx = getCtx(); + if (ctx && polyPointsRef.current && polyPointsRef.current.length >= 2) { + drawPolygon(ctx, null, buttonRef.current, true); + } + polyPointsRef.current = null; + polyBaseRef.current = null; + }; + // Selection. Select drags a rectangle; Free-Form Select traces a freehand // lasso (its marquee becomes the bounding box once complete, but only the // pixels inside the lasso are lifted). Drag inside to move; Delete clears. @@ -427,8 +482,14 @@ const Paint = () => { return; } if (isSelect) { handleSelectDown(event); return; } - // Text / magnifier are present in the toolbox but not yet interactive. - if (tool === "text" || tool === "magnifier") return; + if (tool === "polygon") { handlePolygonDown(event); return; } + if (tool === "magnifier") { + // Left-click zooms in (1→2→4→1), right-click zooms back out + setZoom((z) => (event.button === 2 ? (z <= 1 ? 1 : z / 2) : (z >= 4 ? 1 : z * 2))); + return; + } + // Text is present in the toolbox but not yet interactive. + if (tool === "text") return; canvas.setPointerCapture(event.pointerId); buttonRef.current = event.button; @@ -457,6 +518,7 @@ const Paint = () => { const pos = getPos(event); setCursor(pos); if (isSelect) { handleSelectMove(event); return; } + if (tool === "polygon") { if (polyPointsRef.current) drawPolygon(ctx, pos, buttonRef.current, false); return; } if (!drawingRef.current) return; if (isFreehand) { @@ -472,6 +534,7 @@ const Paint = () => { const endStroke = () => { if (isSelect) { handleSelectUp(); return; } + if (tool === "polygon") return; drawingRef.current = false; startRef.current = null; lastRef.current = null; @@ -505,8 +568,8 @@ const Paint = () => { const canvas = canvasRef.current; const ctx = getCtx(); if (!r || !canvas || !ctx) return; - const w = r.dir.includes("e") ? Math.max(1, r.startW + Math.round(event.clientX - r.startX)) : r.startW; - const h = r.dir.includes("s") ? Math.max(1, r.startH + Math.round(event.clientY - r.startY)) : r.startH; + const w = r.dir.includes("e") ? Math.max(1, r.startW + Math.round((event.clientX - r.startX) / zoom)) : r.startW; + const h = r.dir.includes("s") ? Math.max(1, r.startH + Math.round((event.clientY - r.startY) / zoom)) : r.startH; canvas.width = w; canvas.height = h; ctx.fillStyle = "#ffffff"; @@ -587,7 +650,22 @@ const Paint = () => { ))}
    - {showWidths && ( + {tool === "magnifier" ? ( +
    + {[1, 2, 4, 8].map((z) => ( + + ))} +
    + ) : showWidths ? (
    {SIZES.map((s) => ( ))}
    - )} + ) : null}
    -
    +
    { onPointerUp={endStroke} onPointerCancel={endStroke} onPointerLeave={() => setCursor(null)} + onDoubleClick={() => { if (tool === "polygon") handlePolygonClose(); }} onContextMenu={(e) => e.preventDefault()} /> {([["e", styles.handleRight], ["s", styles.handleBottom], ["se", styles.handleCorner]] as const).map(([dir, cls]) => ( From 30ce3d7f41eada141f8550dc937e5e8290cfe2c1 Mon Sep 17 00:00:00 2001 From: Cyanoxide Date: Sun, 14 Jun 2026 17:17:23 +0100 Subject: [PATCH 08/34] Polish polygon and zoom interactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Polygon: segments now only commit on click (no rubber-band line trailing the cursor), and a manual double-click detector closes the shape reliably so it actually ends. - Magnifier: clicking the canvas cycles through every level 1→2→4→8→1 (the cap was stopping at 4x). Co-Authored-By: Claude Fable 5 --- .../components/Applications/Paint/Paint.tsx | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Applications/Paint/Paint.tsx b/frontend/src/components/Applications/Paint/Paint.tsx index 925fbbf..d356719 100644 --- a/frontend/src/components/Applications/Paint/Paint.tsx +++ b/frontend/src/components/Applications/Paint/Paint.tsx @@ -169,6 +169,7 @@ const Paint = () => { // double-click to close. polyBase is the canvas before the polygon started. const polyPointsRef = useRef | null>(null); const polyBaseRef = useRef(null); + const polyLastClickRef = useRef<{ t: number; x: number; y: number } | null>(null); // Size the canvas to the drawing area once it has a real layout, leaving a // grey margin to the right/bottom (XP Paint's bitmap is a fixed size inside a @@ -328,6 +329,18 @@ const Paint = () => { if (!canvas || !ctx) return; const pos = getPos(event); buttonRef.current = event.button; + + // Detect a double-click manually (a second click in the same spot, soon + // after): close the polygon rather than add another vertex. + const now = Date.now(); + const last = polyLastClickRef.current; + if (polyPointsRef.current && polyPointsRef.current.length >= 2 && last && now - last.t < 400 && Math.abs(pos.x - last.x) < 6 && Math.abs(pos.y - last.y) < 6) { + handlePolygonClose(); + polyLastClickRef.current = null; + return; + } + polyLastClickRef.current = { t: now, x: pos.x, y: pos.y }; + if (!polyPointsRef.current) { pushUndo(ctx); polyBaseRef.current = ctx.getImageData(0, 0, canvas.width, canvas.height); @@ -484,8 +497,8 @@ const Paint = () => { if (isSelect) { handleSelectDown(event); return; } if (tool === "polygon") { handlePolygonDown(event); return; } if (tool === "magnifier") { - // Left-click zooms in (1→2→4→1), right-click zooms back out - setZoom((z) => (event.button === 2 ? (z <= 1 ? 1 : z / 2) : (z >= 4 ? 1 : z * 2))); + // Left-click zooms in (1→2→4→8→1), right-click zooms back out + setZoom((z) => (event.button === 2 ? (z <= 1 ? 1 : z / 2) : (z >= 8 ? 1 : z * 2))); return; } // Text is present in the toolbox but not yet interactive. @@ -518,7 +531,8 @@ const Paint = () => { const pos = getPos(event); setCursor(pos); if (isSelect) { handleSelectMove(event); return; } - if (tool === "polygon") { if (polyPointsRef.current) drawPolygon(ctx, pos, buttonRef.current, false); return; } + // Polygon segments only commit on click — no line follows the cursor + if (tool === "polygon") return; if (!drawingRef.current) return; if (isFreehand) { From 2e9c0d61b611a2d5800765e7cc05b9f889bed7fd Mon Sep 17 00:00:00 2001 From: Cyanoxide Date: Sun, 14 Jun 2026 17:49:25 +0100 Subject: [PATCH 09/34] Polygon supports click-drag edges as well as click-to-click Commit a vertex on pointer-up, so a segment can be drawn either by dragging (the edge previews like the Line tool while the button is held, releasing locks it in) or by clicking point to point. Plain moves still show no trailing line; double-click still closes the shape. Co-Authored-By: Claude Fable 5 --- .../components/Applications/Paint/Paint.tsx | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/Applications/Paint/Paint.tsx b/frontend/src/components/Applications/Paint/Paint.tsx index d356719..8fea70c 100644 --- a/frontend/src/components/Applications/Paint/Paint.tsx +++ b/frontend/src/components/Applications/Paint/Paint.tsx @@ -170,6 +170,7 @@ const Paint = () => { const polyPointsRef = useRef | null>(null); const polyBaseRef = useRef(null); const polyLastClickRef = useRef<{ t: number; x: number; y: number } | null>(null); + const polyDraggingRef = useRef(false); // Size the canvas to the drawing area once it has a real layout, leaving a // grey margin to the right/bottom (XP Paint's bitmap is a fixed size inside a @@ -323,6 +324,9 @@ const Paint = () => { ctx.stroke(); }; + // A vertex is committed on pointer-up, so a plain click and a click-drag both + // work: while the button is held the segment from the last vertex previews + // (like the Line tool); releasing locks it in. const handlePolygonDown = (event: ReactPointerEvent) => { const canvas = canvasRef.current; const ctx = getCtx(); @@ -330,8 +334,8 @@ const Paint = () => { const pos = getPos(event); buttonRef.current = event.button; - // Detect a double-click manually (a second click in the same spot, soon - // after): close the polygon rather than add another vertex. + // Manual double-click: a second click in the same spot soon after closes + // the polygon instead of adding another vertex. const now = Date.now(); const last = polyLastClickRef.current; if (polyPointsRef.current && polyPointsRef.current.length >= 2 && last && now - last.t < 400 && Math.abs(pos.x - last.x) < 6 && Math.abs(pos.y - last.y) < 6) { @@ -345,10 +349,20 @@ const Paint = () => { pushUndo(ctx); polyBaseRef.current = ctx.getImageData(0, 0, canvas.width, canvas.height); polyPointsRef.current = [pos]; - } else { - polyPointsRef.current.push(pos); } - drawPolygon(ctx, null, event.button, false); + polyDraggingRef.current = true; + }; + + const handlePolygonUp = (event: ReactPointerEvent) => { + if (!polyDraggingRef.current) return; + polyDraggingRef.current = false; + const ctx = getCtx(); + if (!ctx || !polyPointsRef.current) return; + const pos = getPos(event); + const pts = polyPointsRef.current; + const lastV = pts[pts.length - 1]; + if (lastV.x !== pos.x || lastV.y !== pos.y) pts.push(pos); + drawPolygon(ctx, null, buttonRef.current, false); }; const handlePolygonClose = () => { @@ -531,8 +545,12 @@ const Paint = () => { const pos = getPos(event); setCursor(pos); if (isSelect) { handleSelectMove(event); return; } - // Polygon segments only commit on click — no line follows the cursor - if (tool === "polygon") return; + // Polygon: preview the next segment only while dragging (button held); a + // plain move shows no trailing line. + if (tool === "polygon") { + if (polyDraggingRef.current && polyPointsRef.current) drawPolygon(ctx, pos, buttonRef.current, false); + return; + } if (!drawingRef.current) return; if (isFreehand) { @@ -546,9 +564,9 @@ const Paint = () => { } }; - const endStroke = () => { + const endStroke = (event?: ReactPointerEvent) => { if (isSelect) { handleSelectUp(); return; } - if (tool === "polygon") return; + if (tool === "polygon") { if (event) handlePolygonUp(event); return; } drawingRef.current = false; startRef.current = null; lastRef.current = null; From fe5d22e3806defe6fd8f400d3156f20a2b641196 Mon Sep 17 00:00:00 2001 From: Cyanoxide Date: Sun, 14 Jun 2026 20:49:33 +0100 Subject: [PATCH 10/34] Implement the Text and Curve tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Text: drag out a box (like the selection tool) and type plain text into an overlaid textarea; the text word-wraps to the box and is stamped onto the canvas (in the foreground colour) when it loses focus or the tool changes. - Curve: drag a straight line (phase 1), then drag to bend it into a quadratic curve with the endpoints fixed (phase 2) — matching the Line-then-bend flow. Every toolbox tool except none is now interactive. Co-Authored-By: Claude Fable 5 --- .../Applications/Paint/Paint.module.scss | 15 ++ .../components/Applications/Paint/Paint.tsx | 167 +++++++++++++++++- 2 files changed, 178 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Applications/Paint/Paint.module.scss b/frontend/src/components/Applications/Paint/Paint.module.scss index 2ecc435..6d8cc75 100644 --- a/frontend/src/components/Applications/Paint/Paint.module.scss +++ b/frontend/src/components/Applications/Paint/Paint.module.scss @@ -275,6 +275,21 @@ z-index: 1; } +// Text tool: a plain textarea overlaid on the bitmap while editing +.textInput { + position: absolute; + z-index: 2; + margin: 0; + padding: 0.1rem 0.2rem; + border: 0.1rem dashed #000; + background: transparent; + font: 14px/1.3 sans-serif; + resize: none; + outline: none; + overflow: hidden; + box-sizing: border-box; +} + // ---- Palette --------------------------------------------------------------- .palette { display: flex; diff --git a/frontend/src/components/Applications/Paint/Paint.tsx b/frontend/src/components/Applications/Paint/Paint.tsx index 8fea70c..e6107d0 100644 --- a/frontend/src/components/Applications/Paint/Paint.tsx +++ b/frontend/src/components/Applications/Paint/Paint.tsx @@ -60,6 +60,7 @@ const PALETTE = [ ]; const SIZES = [1, 2, 3, 5, 8]; +const TEXT_SIZE = 14; // Bottom-right resize grip: six bevelled squares in a 3-2-1 staircase whose // right angle points into the corner. Each entry is a dark square's top-left @@ -142,6 +143,10 @@ const Paint = () => { const [size, setSize] = useState(2); const [zoom, setZoom] = useState(1); const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null); + // Text tool: a box (dragged out like a selection) you type plain text into + const [textBox, setTextBox] = useState<{ x: number; y: number; w: number; h: number } | null>(null); + const [textValue, setTextValue] = useState(""); + const [textEditing, setTextEditing] = useState(false); // Rectangular selection (Select / Free-Form Select): the marquee rectangle const [selection, setSelection] = useState<{ x: number; y: number; w: number; h: number } | null>(null); @@ -172,6 +177,16 @@ const Paint = () => { const polyLastClickRef = useRef<{ t: number; x: number; y: number } | null>(null); const polyDraggingRef = useRef(false); + // Curve: drag a straight line (phase 1), then drag to bend it (phase 2), + // keeping the endpoints fixed. base = the canvas before the curve started. + const curveRef = useRef<{ phase: number; a: { x: number; y: number }; b: { x: number; y: number }; base: ImageData } | null>(null); + const curveDraggingRef = useRef(false); + + // Text define-drag state + the textarea overlay + const textDefiningRef = useRef(false); + const textStartRef = useRef({ x: 0, y: 0 }); + const textareaRef = useRef(null); + // Size the canvas to the drawing area once it has a real layout, leaving a // grey margin to the right/bottom (XP Paint's bitmap is a fixed size inside a // scrollable grey area). The bitmap then stays fixed. @@ -214,8 +229,18 @@ const Paint = () => { polyPointsRef.current = null; polyBaseRef.current = null; } + // Leaving the curve tool finalises whatever's already drawn + if (tool !== "curve") { + curveRef.current = null; + curveDraggingRef.current = false; + } }, [tool]); + // Focus the text box when it opens for editing + useEffect(() => { + if (textEditing) textareaRef.current?.focus(); + }, [textEditing]); + const getCtx = () => canvasRef.current?.getContext("2d") ?? null; const getPos = (event: ReactPointerEvent) => { @@ -295,7 +320,7 @@ const Paint = () => { } else if (tool === "ellipse") { ctx.ellipse((from.x + to.x) / 2, (from.y + to.y) / 2, Math.abs(to.x - from.x) / 2, Math.abs(to.y - from.y) / 2, 0, 0, Math.PI * 2); } else { - // line and curve (curve approximated as a straight line for now) + // line ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); } @@ -303,7 +328,7 @@ const Paint = () => { }; const isFreehand = tool === "pencil" || tool === "brush" || tool === "eraser"; - const isShape = tool === "line" || tool === "curve" || tool === "rectangle" || tool === "ellipse" || tool === "roundRectangle"; + const isShape = tool === "line" || tool === "rectangle" || tool === "ellipse" || tool === "roundRectangle"; const isSelect = tool === "select" || tool === "freeSelect"; // Polygon: click to drop vertices, double-click to close. Each frame redraws @@ -374,6 +399,121 @@ const Paint = () => { polyBaseRef.current = null; }; + // Curve: first drag lays a straight line (phase 1); the second drag bends it + // into a quadratic curve with the endpoints fixed (phase 2). + const strokeCurve = (ctx: CanvasRenderingContext2D, c: { phase: number; a: { x: number; y: number }; b: { x: number; y: number } }, ctrl: { x: number; y: number }) => { + ctx.strokeStyle = strokeColor(buttonRef.current); + ctx.lineWidth = size; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.beginPath(); + ctx.moveTo(c.a.x, c.a.y); + if (c.phase === 1) ctx.lineTo(ctrl.x, ctrl.y); + else ctx.quadraticCurveTo(ctrl.x, ctrl.y, c.b.x, c.b.y); + ctx.stroke(); + }; + + const handleCurveDown = (event: ReactPointerEvent) => { + const canvas = canvasRef.current; + const ctx = getCtx(); + if (!canvas || !ctx) return; + buttonRef.current = event.button; + if (!curveRef.current) { + pushUndo(ctx); + const pos = getPos(event); + curveRef.current = { phase: 1, a: pos, b: pos, base: ctx.getImageData(0, 0, canvas.width, canvas.height) }; + } + curveDraggingRef.current = true; + }; + + const handleCurveMove = (event: ReactPointerEvent) => { + const ctx = getCtx(); + const c = curveRef.current; + if (!ctx || !c) return; + ctx.putImageData(c.base, 0, 0); + strokeCurve(ctx, c, getPos(event)); + }; + + const handleCurveUp = (event: ReactPointerEvent) => { + if (!curveDraggingRef.current) return; + curveDraggingRef.current = false; + const ctx = getCtx(); + const c = curveRef.current; + if (!ctx || !c) return; + const pos = getPos(event); + ctx.putImageData(c.base, 0, 0); + if (c.phase === 1) { + c.b = pos; + strokeCurve(ctx, c, c.b); + c.phase = 2; + } else { + strokeCurve(ctx, c, pos); + curveRef.current = null; + } + }; + + // Text: drag a box, then type into the overlaid textarea; the text is stamped + // onto the canvas when it loses focus / the tool changes. + const commitText = () => { + const box = textBox; + const ctx = getCtx(); + if (box && ctx && textValue.length) { + pushUndo(ctx); + ctx.fillStyle = fgColor; + ctx.textBaseline = "top"; + ctx.font = `${TEXT_SIZE}px sans-serif`; + const lineH = Math.round(TEXT_SIZE * 1.3); + let y = box.y + 2; + for (const para of textValue.split("\n")) { + let line = ""; + for (const word of para.split(" ")) { + const test = line ? `${line} ${word}` : word; + if (ctx.measureText(test).width > box.w - 4 && line) { + ctx.fillText(line, box.x + 2, y); + y += lineH; + line = word; + } else { + line = test; + } + } + ctx.fillText(line, box.x + 2, y); + y += lineH; + } + } + setTextBox(null); + setTextValue(""); + setTextEditing(false); + }; + + const handleTextDown = (event: ReactPointerEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + canvas.setPointerCapture(event.pointerId); + const pos = getPos(event); + textStartRef.current = pos; + textDefiningRef.current = true; + setTextEditing(false); + setTextBox({ x: pos.x, y: pos.y, w: 0, h: 0 }); + }; + + const handleTextMove = (event: ReactPointerEvent) => { + if (!textDefiningRef.current) return; + const pos = getPos(event); + setTextBox({ + x: Math.min(pos.x, textStartRef.current.x), + y: Math.min(pos.y, textStartRef.current.y), + w: Math.abs(pos.x - textStartRef.current.x), + h: Math.abs(pos.y - textStartRef.current.y), + }); + }; + + const handleTextUp = () => { + if (!textDefiningRef.current) return; + textDefiningRef.current = false; + if (textBox && textBox.w > 8 && textBox.h > 8) setTextEditing(true); + else setTextBox(null); + }; + // Selection. Select drags a rectangle; Free-Form Select traces a freehand // lasso (its marquee becomes the bounding box once complete, but only the // pixels inside the lasso are lifted). Drag inside to move; Delete clears. @@ -510,13 +650,13 @@ const Paint = () => { } if (isSelect) { handleSelectDown(event); return; } if (tool === "polygon") { handlePolygonDown(event); return; } + if (tool === "curve") { handleCurveDown(event); return; } + if (tool === "text") { handleTextDown(event); return; } if (tool === "magnifier") { // Left-click zooms in (1→2→4→8→1), right-click zooms back out setZoom((z) => (event.button === 2 ? (z <= 1 ? 1 : z / 2) : (z >= 8 ? 1 : z * 2))); return; } - // Text is present in the toolbox but not yet interactive. - if (tool === "text") return; canvas.setPointerCapture(event.pointerId); buttonRef.current = event.button; @@ -551,6 +691,8 @@ const Paint = () => { if (polyDraggingRef.current && polyPointsRef.current) drawPolygon(ctx, pos, buttonRef.current, false); return; } + if (tool === "curve") { if (curveDraggingRef.current) handleCurveMove(event); return; } + if (tool === "text") { if (textDefiningRef.current) handleTextMove(event); return; } if (!drawingRef.current) return; if (isFreehand) { @@ -567,6 +709,8 @@ const Paint = () => { const endStroke = (event?: ReactPointerEvent) => { if (isSelect) { handleSelectUp(); return; } if (tool === "polygon") { if (event) handlePolygonUp(event); return; } + if (tool === "curve") { if (event) handleCurveUp(event); return; } + if (tool === "text") { handleTextUp(); return; } drawingRef.current = false; startRef.current = null; lastRef.current = null; @@ -747,6 +891,21 @@ const Paint = () => { style={{ left: selection.x, top: selection.y, width: selection.w, height: selection.h }} /> )} + {textBox && (textEditing ? ( +