diff --git a/README.md b/README.md index 65d8465..1f019c0 100644 --- a/README.md +++ b/README.md @@ -7,22 +7,24 @@ An authentic recreation of Windows XP, created using **React** and **Typescript* - Functional Taskbar and Start Menu - Movable Desktop Icons - Movable & resizable windows -- Login Screen +- Login, Shutdown and boot up sequences - **Notepad** -- Browsable **File Explorer** with back and forward functionality +- **File Explorer** - **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)** -- **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 +- **Paint** +- **Clippy** ## Roadmap 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 +- Doom? +- Media Player +- Picture Viewer ## Demo diff --git a/frontend/public/icon__paint--large.png b/frontend/public/icon__paint--large.png new file mode 100644 index 0000000..8774e86 Binary files /dev/null and b/frontend/public/icon__paint--large.png differ diff --git a/frontend/public/spritemap__paint-tools.png b/frontend/public/spritemap__paint-tools.png new file mode 100644 index 0000000..6efe3a9 Binary files /dev/null and b/frontend/public/spritemap__paint-tools.png differ diff --git a/frontend/src/components/Applications/FileExplorer/FileExplorer.tsx b/frontend/src/components/Applications/FileExplorer/FileExplorer.tsx index ac9a05e..83d1d7d 100644 --- a/frontend/src/components/Applications/FileExplorer/FileExplorer.tsx +++ b/frontend/src/components/Applications/FileExplorer/FileExplorer.tsx @@ -14,7 +14,8 @@ const Applications = applicationsJSON as unknown as Record; const Files = filesJSON as unknown as Record; const FileExplorer = ({ appId }: Record) => { - const { currentWindows, recycledItems, dispatch } = useContext(); + const { currentWindows, recycledItems, savedImages, dispatch } = useContext(); + const recycledImages = savedImages.filter((image) => image.recycled); const [selectedItem, setSelectedItem] = useState(null); const [isBackDisabled, setIsBackDisabled] = useState(true); const [isForwardDisabled, setIsForwardDisabled] = useState(true); @@ -36,6 +37,12 @@ const FileExplorer = ({ appId }: Record) => { const emptyRecycleBinHandler = () => { dispatch({ type: "SET_RECYCLED_ITEMS", payload: [] }); + if (recycledImages.length) dispatch({ type: "SET_SAVED_IMAGES", payload: savedImages.map((image) => image.recycled ? { ...image, recycled: false } : image) }); + playSound("recycle", true); + }; + + const restoreSavedImageHandler = (id: string) => { + dispatch({ type: "SET_SAVED_IMAGES", payload: savedImages.map((image) => image.id === id ? { ...image, recycled: false } : image) }); playSound("recycle", true); }; @@ -194,7 +201,7 @@ const FileExplorer = ({ appId }: Record) => {
  • - @@ -270,6 +277,15 @@ const FileExplorer = ({ appId }: Record) => { ); })} + {appId === "recycleBin" && recycledImages.map((image) => { + const isActive = (selectedItem === image.id); + return ( + + ); + })} {appId === "computer" &&

    Hard Disk Drives

    } 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..d0ffa43 --- /dev/null +++ b/frontend/src/components/Applications/Paint/Paint.module.scss @@ -0,0 +1,544 @@ +// Selected-option highlight — matches the app's menu selection per theme +@mixin menu-selection { + background: #4069bf; + color: #fff; + + @include theme-color("green") { + background: #95a075; + } + + @include theme-color("silver") { + background: #8788a1; + } +} + +.paint { + background: #ece9d8; + color: #000; + outline: none; + font-family: Tahoma, "Trebuchet MS", sans-serif; + font-size: 1.1rem; + + @include theme-color("green") { + background: #e9ead2; + } + + @include theme-color("silver") { + background: #ece9e1; + } + + &, + * { + box-sizing: border-box; + } +} + +// 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; +} + +// ---- Toolbox --------------------------------------------------------------- +.toolbox { + width: 5.6rem; + flex-shrink: 0; + display: flex; + flex-direction: column; + padding: 0.3rem; + 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.1rem; +} + +.toolButton { + width: 2.4rem; + height: 2.2rem; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + cursor: pointer; + color: #000; + background: transparent; + // Flat until hovered/selected, like the XP toolbar + border: 0.1rem solid transparent; + + &:hover { + border-color: #fff #808080 #808080 #fff; + } + + &[data-active="true"] { + border-color: #808080 #fff #fff #808080; + background: #faf9f4; + + @include theme-color("green") { + background: #f1f2e3; + } + + @include theme-color("silver") { + background: #f3f3f7; + } + } +} + +// 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 — fixed size for every tool; content fits in +.options { + height: 5.6rem; + margin-top: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + // No padding so a full-bleed selection (zoom rows) can reach the edges; the + // other option sets are centred and have their own spacing + padding: 0; + 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; + } +} + +// Each row hugs its line plus a constant padding, with a fixed gap, so the space +// between the lines stays equal (a fixed-height row would close up as lines grow) +.sizes { + display: flex; + flex-direction: column; + gap: 0.3rem; + width: 4.2rem; +} + +.sizeOption { + display: flex; + align-items: center; + padding: 0.15rem 0.1rem; + background: transparent; + border: none; + cursor: pointer; + + span { + display: block; + width: 100%; + min-height: 0.1rem; + background: #000; + } + + &[data-active="true"] { + @include menu-selection; + + span { + background: #fff; + } + } +} + +// Shared look for the icon-style option buttons. Selected = solid blue fill; the +// icon (drawn in currentColor) turns white, while fixed greys stay grey. +@mixin option-button { + display: flex; + align-items: center; + justify-content: center; + padding: 0; + cursor: pointer; + color: #000; + background: transparent; + border: 0.1rem solid transparent; + + svg { + display: block; + } + + &[data-active="true"] { + border-color: transparent; + @include menu-selection; + } +} + +// Eraser: four square sizes, one per row. Each button hugs its square and the +// gap between buttons is fixed, so the spacing between the squares is constant +// (a fixed-height row would make the gaps shrink as the squares grow). +.eraserOpts { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + height: 100%; +} + +.eraserOpt { + display: block; + padding: 0; + cursor: pointer; + background: transparent; + border: none; + + span { + display: block; + background: #000; + border: 0.1rem solid transparent; + } + + &[data-active="true"] span { + background: #fff; + border-color: #4069bf; + + @include theme-color("green") { + border-color: #95a075; + } + + @include theme-color("silver") { + border-color: #8788a1; + } + } +} + +// Magnifier zoom-level options: a black square + label; selected = blue overlay +// that fills the row width +.zooms { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +.zoomOption { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.3rem; + width: 100%; + font-size: 0.85rem; + line-height: 1; + color: #000; + cursor: pointer; + background: transparent; + border: none; + + &[data-active="true"] { + @include menu-selection; + + .zoomSq { + background: #fff; + } + } +} + +// Fixed-width slot keeps the labels aligned while the squares vary in size +.zoomSqSlot { + display: flex; + align-items: center; + justify-content: center; + width: 0.9rem; + flex-shrink: 0; +} + +.zoomSq { + background: #000; +} + +// Brush nib shapes: 3 circles, 3 squares, 3 right diagonals, 3 left diagonals +// — 3 per row, 4 rows +.brushOpts { + display: grid; + grid-template-columns: repeat(3, 1.2rem); + gap: 0.08rem; +} + +.brushOpt { + width: 1.2rem; + height: 1.2rem; + @include option-button; + + svg { + width: 1rem; + height: 1rem; + } +} + +// Airbrush: small + medium on one row, large beneath (centred) +.sprayOpts { + display: flex; + flex-wrap: wrap; + gap: 0.2rem; + justify-content: center; + width: 4.4rem; +} + +.sprayOpt { + width: 2rem; + height: 2rem; + @include option-button; + + svg { + width: 1.6rem; + height: 1.6rem; + } +} + +// Shape fill styles: border-only, border+fill, fill-only — wide, full-width rows +.fillOpts { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +.fillOpt { + flex: 1; + width: 100%; + @include option-button; + + svg { + width: 4rem; + height: 1.2rem; + } +} + +// ---- Canvas ---------------------------------------------------------------- +.canvasArea { + position: relative; + flex: 1; + min-width: 0; + min-height: 0; + // 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 { + position: absolute; + inset: 0; +} + +// The grey scrollable region the bitmap floats in +.scrollViewport { + padding: 0.3rem; + background: #808080; + + @include theme-color("silver") { + background: #7e8088; + } +} + +.canvasWrap { + position: relative; + display: inline-block; + line-height: 0; + // Room for the handles to sit fully outside the bitmap without being clipped + margin: 0 0.8rem 0.8rem 0; +} + +.canvas { + display: block; + background: #fff; + cursor: crosshair; + touch-action: none; + box-shadow: 0 0 0 0.1rem #000; + // Scaled up via CSS width/height when zoomed (not CSS `zoom`, which would also + // magnify and blur the cursor); pixelated keeps the magnified bitmap crisp. + image-rendering: pixelated; +} + +// 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.4rem; + height: 0.4rem; + background: #2f6bf6; + touch-action: none; +} + +.handleRight { + top: 50%; + right: -0.5rem; + transform: translateY(-50%); + cursor: ew-resize; +} + +.handleBottom { + bottom: -0.5rem; + left: 50%; + transform: translateX(-50%); + cursor: ns-resize; +} + +.handleCorner { + 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; +} + +// 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; + align-items: center; + gap: 0.6rem; + padding: 0.3rem 0.4rem; + 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; +} + +// ---- 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: 1.6rem; + flex-shrink: 0; + align-self: flex-end; +} diff --git a/frontend/src/components/Applications/Paint/Paint.tsx b/frontend/src/components/Applications/Paint/Paint.tsx new file mode 100644 index 0000000..4b730ab --- /dev/null +++ b/frontend/src/components/Applications/Paint/Paint.tsx @@ -0,0 +1,1232 @@ +import { useEffect, useRef, useState } from "react"; +import { useContext } from "../../../context/context"; +import { closeWindow, generateUniqueId } from "../../../utils/general"; +import WindowMenu from "../../WindowMenu/WindowMenu"; +import XPScrollbars from "../../XPScrollbars/XPScrollbars"; +import styles from "./Paint.module.scss"; +import type { WindowMenuDef } from "../../WindowMenu/WindowMenu"; +import type { PointerEvent as ReactPointerEvent, KeyboardEvent as ReactKeyboardEvent } from "react"; + +interface PaintMenuHandlers { + exit: () => void; + saveToDesktop: () => void; + saveToComputer: () => void; +} + +// The full Paint menu bar. Save (→ desktop icon), Save to Computer (→ download) +// and Exit are wired; the rest are disabled for now (dropdowns still open to +// show the greyed options). +const buildPaintMenus = (handlers: PaintMenuHandlers): WindowMenuDef[] => [ + { label: "File", items: [ + { label: "New", shortcut: "Ctrl+N", disabled: true }, + { label: "Open...", shortcut: "Ctrl+O", disabled: true }, + { label: "Save", shortcut: "Ctrl+S", onClick: handlers.saveToDesktop }, + { label: "Save to Computer...", onClick: handlers.saveToComputer }, + { separator: true }, + { label: "Print Preview", disabled: true }, + { label: "Page Setup...", disabled: true }, + { label: "Print...", shortcut: "Ctrl+P", disabled: true }, + { separator: true }, + { label: "Set As Background (Tiled)", disabled: true }, + { label: "Set As Background (Centered)", disabled: true }, + { separator: true }, + { label: "Exit", onClick: handlers.exit }, + ] }, + { label: "Edit", items: [ + { label: "Undo", shortcut: "Ctrl+Z", disabled: true }, + { label: "Repeat", shortcut: "F4", disabled: true }, + { separator: true }, + { label: "Cut", shortcut: "Ctrl+X", disabled: true }, + { label: "Copy", shortcut: "Ctrl+C", disabled: true }, + { label: "Paste", shortcut: "Ctrl+V", disabled: true }, + { label: "Clear Selection", shortcut: "Del", disabled: true }, + { label: "Select All", shortcut: "Ctrl+A", disabled: true }, + { separator: true }, + { label: "Copy To...", disabled: true }, + { label: "Paste From...", disabled: true }, + ] }, + { label: "View", items: [ + { label: "Tool Box", shortcut: "Ctrl+T", disabled: true }, + { label: "Color Box", shortcut: "Ctrl+L", disabled: true }, + { label: "Status Bar", disabled: true }, + { label: "Text Toolbar", disabled: true }, + { separator: true }, + { label: "Zoom", disabled: true }, + { label: "View Bitmap", shortcut: "Ctrl+F", disabled: true }, + ] }, + { label: "Image", items: [ + { label: "Flip/Rotate...", shortcut: "Ctrl+R", disabled: true }, + { label: "Stretch/Skew...", shortcut: "Ctrl+W", disabled: true }, + { label: "Invert Colors", shortcut: "Ctrl+I", disabled: true }, + { label: "Attributes...", shortcut: "Ctrl+E", disabled: true }, + { label: "Clear Image", shortcut: "Ctrl+Shft+N", disabled: true }, + { label: "Draw Opaque", disabled: true }, + ] }, + { label: "Colors", items: [ + { label: "Edit Colors...", disabled: true }, + ] }, + { label: "Help", items: [ + { label: "Help Topics", disabled: true }, + { separator: true }, + { label: "About Paint", disabled: true }, + ] }, +]; + +type Tool = + | "freeSelect" | "select" | "eraser" | "fill" | "eyedropper" | "magnifier" + | "pencil" | "brush" | "airbrush" | "text" | "line" | "curve" + | "rectangle" | "polygon" | "ellipse" | "roundRectangle"; + +interface ToolDef { + id: Tool; + title: string; +} + +// Tools in the same left-to-right order as spritemap__paint-tools.png. +const TOOLS: ToolDef[] = [ + { 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). 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, 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], +]; + +// Per-tool option sets shown in the options box. +const ERASER_SIZES = [4, 6, 9, 13]; +const ZOOMS = [1, 2, 6, 8]; +const SPRAYS = [{ id: "s", r: 4 }, { id: "m", r: 8 }, { id: "l", r: 13 }]; +const SHAPE_FILLS = ["stroke", "both", "fill"] as const; +type BrushKind = "circle" | "square" | "diag" | "diag2"; +const BRUSHES: Array<{ id: string; kind: BrushKind; v: number }> = [ + { id: "c-l", kind: "circle", v: 4 }, { id: "c-m", kind: "circle", v: 2.5 }, { id: "c-s", kind: "circle", v: 1.2 }, + { id: "s-l", kind: "square", v: 8 }, { id: "s-m", kind: "square", v: 5 }, { id: "s-s", kind: "square", v: 2 }, + { id: "dr-l", kind: "diag", v: 11 }, { id: "dr-m", kind: "diag", v: 8 }, { id: "dr-s", kind: "diag", v: 5 }, + { id: "dl-l", kind: "diag2", v: 11 }, { id: "dl-m", kind: "diag2", v: 8 }, { id: "dl-s", kind: "diag2", v: 5 }, +]; + +const SHAPE_TOOLS = new Set(["rectangle", "polygon", "ellipse", "roundRectangle"]); + +// The five tool cursors are generated at runtime as monochrome (greyscale) +// versions of their spritemap icons. Each entry is [tool, sprite cell index, +// [hotspot x, hotspot y] as 0–1 fractions of the rendered cursor]. +const CURSOR_CONFIG: Array<[Tool, number, [number, number]]> = [ + ["pencil", 6, [0.12, 0.9]], + ["fill", 3, [0.14, 0.88]], + ["eyedropper", 4, [0.1, 0.9]], + ["magnifier", 5, [0.4, 0.4]], + ["airbrush", 8, [0.72, 0.42]], +]; +const CURSOR_PX = 24; + +// 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 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 +// (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]; +}; + +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); +}; + +interface PaintProps { + id?: string | number; + content?: unknown; +} + +const Paint = ({ id, content }: PaintProps) => { + const { currentWindows, savedImages, dispatch } = useContext(); + 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); + const [eraserSize, setEraserSize] = useState(6); + const [brushId, setBrushId] = useState("c-m"); + const [sprayId, setSprayId] = useState("m"); + const [shapeFill, setShapeFill] = useState<(typeof SHAPE_FILLS)[number]>("stroke"); + const [zoom, setZoom] = useState(1); + const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); + const [toolCursors, setToolCursors] = useState>>({}); + 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); + + // 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([]); + + // 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); + + // 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); + const polyLastClickRef = useRef<{ t: number; x: number; y: number } | null>(null); + const polyDraggingRef = useRef(false); + + // Curve: lay a straight line (a -> b), then bend it with a control point, + // keeping the endpoints fixed. Each step works by click-drag or click-to-point. + // b is null while the line's end is still being placed; base = the canvas + // before the curve started. + const curveRef = useRef<{ a: { x: number; y: number }; b: { x: number; y: number } | null; base: ImageData } | null>(null); + + // 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. + 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 - 28); + const h = Math.floor(area.clientHeight - 28); + if (w <= 1 || h <= 1) return; + + // Reopened from a saved file: load the image at its own size + if (typeof content === "string" && content.startsWith("data:image")) { + const img = new Image(); + img.onload = () => { + canvas.width = img.width; + canvas.height = img.height; + canvas.getContext("2d")?.drawImage(img, 0, 0); + setCanvasSize({ w: img.width, h: img.height }); + }; + img.src = content; + } else { + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, w, h); + } + setCanvasSize({ w, h }); + } + initedRef.current = true; + observer.disconnect(); + }); + observer.observe(area); + return () => observer.disconnect(); + }, [content]); + + // Build monochrome (greyscale) cursors from the toolbox spritemap once, so the + // active tool's icon doubles as the canvas cursor. + useEffect(() => { + const img = new Image(); + img.onload = () => { + const generated: Partial> = {}; + for (const [toolId, cellIdx, [hx, hy]] of CURSOR_CONFIG) { + const [sx, sy, sw, sh] = CELLS[cellIdx]; + const scale = CURSOR_PX / Math.max(sw, sh); + const cw = Math.max(1, Math.round(sw * scale)); + const ch = Math.max(1, Math.round(sh * scale)); + const off = document.createElement("canvas"); + off.width = cw; + off.height = ch; + const octx = off.getContext("2d"); + if (!octx) continue; + octx.drawImage(img, sx, sy, sw, sh, 0, 0, cw, ch); + const pixels = octx.getImageData(0, 0, cw, ch); + const d = pixels.data; + for (let i = 0; i < d.length; i += 4) { + const lum = Math.round(0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]); + d[i] = d[i + 1] = d[i + 2] = lum; + } + octx.putImageData(pixels, 0, 0); + generated[toolId] = `url("${off.toDataURL()}") ${Math.round(hx * cw)} ${Math.round(hy * ch)}, crosshair`; + } + setToolCursors(generated); + }; + img.src = "/spritemap__paint-tools.png"; + }, []); + + // 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; + } + // Leaving the polygon tool commits the segments drawn so far (they're + // already on the canvas) — just drop the in-progress state, don't erase. + if (tool !== "polygon" && polyBaseRef.current) { + polyPointsRef.current = null; + polyBaseRef.current = null; + polyDraggingRef.current = false; + polyLastClickRef.current = null; + } + // Leaving the curve tool finalises whatever's already drawn + if (tool !== "curve") { + curveRef.current = null; + } + }, [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) => { + 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); + }; + + // Save to Computer: download the bitmap to the real machine + 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(); + }; + + // Save: drop a re-openable icon on the XP desktop holding this image + const saveToDesktop = () => { + const canvas = canvasRef.current; + if (!canvas) return; + const count = savedImages.length + 1; + const name = count === 1 ? "untitled.png" : `untitled (${count}).png`; + dispatch({ type: "SET_SAVED_IMAGES", payload: [...savedImages, { id: generateUniqueId(), name, dataUrl: canvas.toDataURL("image/png") }] }); + }; + + const exit = () => { + if (id !== undefined) closeWindow(id, currentWindows, dispatch); + }; + + const strokeColor = (button: number) => (button === 2 ? bgColor : fgColor); + + // Stamp a single brush/eraser nib at a point + const stampNib = (ctx: CanvasRenderingContext2D, x: number, y: number, color: string, kind: BrushKind, v: number) => { + if (kind === "circle") { + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(x, y, v, 0, Math.PI * 2); + ctx.fill(); + } else if (kind === "square") { + ctx.fillStyle = color; + ctx.fillRect(Math.round(x - v / 2), Math.round(y - v / 2), v, v); + } else { + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.lineCap = "round"; + ctx.beginPath(); + const h = v / 2; + if (kind === "diag") { ctx.moveTo(x - h, y + h); ctx.lineTo(x + h, y - h); } // right-leaning / + else { ctx.moveTo(x - h, y - h); ctx.lineTo(x + h, y + h); } // left-leaning \ + ctx.stroke(); + } + }; + + // Freehand draw (pencil / brush / eraser). Pencil is a 1px line; brush and + // eraser stamp their nib densely along the segment. + const drawSegment = (ctx: CanvasRenderingContext2D, from: { x: number; y: number }, to: { x: number; y: number }, button: number) => { + if (tool === "pencil") { + ctx.strokeStyle = strokeColor(button); + ctx.lineWidth = 1; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.beginPath(); + ctx.moveTo(from.x, from.y); + ctx.lineTo(to.x, to.y); + ctx.stroke(); + return; + } + const steps = Math.max(1, Math.ceil(Math.hypot(to.x - from.x, to.y - from.y))); + const brush = BRUSHES.find((b) => b.id === brushId) ?? BRUSHES[1]; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const x = from.x + (to.x - from.x) * t; + const y = from.y + (to.y - from.y) * t; + if (tool === "eraser") stampNib(ctx, x, y, bgColor, "square", eraserSize); + else stampNib(ctx, x, y, strokeColor(button), brush.kind, brush.v); + } + }; + + const spray = (ctx: CanvasRenderingContext2D, at: { x: number; y: number }, button: number) => { + ctx.fillStyle = strokeColor(button); + const radius = (SPRAYS.find((s) => s.id === sprayId) ?? SPRAYS[1]).r; + const count = Math.round(radius * 1.6); + for (let i = 0; i < count; 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); + } + }; + + // Border colour is the draw colour; fill is the opposite (bg) colour, except + // the fill-only style which fills with the draw colour. + const fillShape = (ctx: CanvasRenderingContext2D, button: number) => { + if (shapeFill === "stroke") return; + ctx.fillStyle = shapeFill === "fill" ? strokeColor(button) : (button === 2 ? fgColor : bgColor); + ctx.fill(); + }; + + const drawShape = (ctx: CanvasRenderingContext2D, from: { x: number; y: number }, to: { x: number; y: number }, button: number) => { + ctx.lineWidth = size; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.beginPath(); + let closed = true; + if (tool === "rectangle") { + 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 { + ctx.moveTo(from.x, from.y); + ctx.lineTo(to.x, to.y); + closed = false; + } + if (closed) fillShape(ctx, button); + if (!closed || shapeFill !== "fill") { + ctx.strokeStyle = strokeColor(button); + ctx.stroke(); + } + }; + + const isFreehand = tool === "pencil" || tool === "brush" || tool === "eraser"; + 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 + // 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(); + fillShape(ctx, button); + } + if (!close || shapeFill !== "fill") { + ctx.strokeStyle = strokeColor(button); + 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(); + if (!canvas || !ctx) return; + const pos = getPos(event); + buttonRef.current = event.button; + + // 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) { + 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); + polyPointsRef.current = [pos]; + } + 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 = () => { + const ctx = getCtx(); + if (ctx && polyPointsRef.current && polyPointsRef.current.length >= 2) { + drawPolygon(ctx, null, buttonRef.current, true); + } + polyPointsRef.current = null; + polyBaseRef.current = null; + }; + + // Redraws the in-progress curve from its saved base: a straight line to the + // cursor while the end point is still being placed, otherwise a quadratic + // curve through the cursor as its control point (endpoints fixed). + const drawCurve = (ctx: CanvasRenderingContext2D, c: { a: { x: number; y: number }; b: { x: number; y: number } | null; base: ImageData }, cursor: { x: number; y: number }, button: number) => { + ctx.putImageData(c.base, 0, 0); + ctx.strokeStyle = strokeColor(button); + ctx.lineWidth = size; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.beginPath(); + ctx.moveTo(c.a.x, c.a.y); + if (!c.b) ctx.lineTo(cursor.x, cursor.y); + else ctx.quadraticCurveTo(cursor.x, cursor.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; + // First press anchors the start point; later presses just begin a drag + // whose release commits the next point (handled in handleCurveUp). + if (!curveRef.current) { + pushUndo(ctx); + const pos = getPos(event); + curveRef.current = { a: pos, b: null, base: ctx.getImageData(0, 0, canvas.width, canvas.height) }; + } + }; + + const handleCurveMove = (event: ReactPointerEvent) => { + const ctx = getCtx(); + const c = curveRef.current; + if (!ctx || !c) return; + // Preview follows the cursor whether or not the button is held, so click + // -to-point works the same as click-drag. + drawCurve(ctx, c, getPos(event), buttonRef.current); + }; + + const handleCurveUp = (event: ReactPointerEvent) => { + const ctx = getCtx(); + const c = curveRef.current; + if (!ctx || !c) return; + const pos = getPos(event); + if (!c.b) { + // Placing the line's end: a release away from the start (a drag, or the + // second click) locks it in; a click on the start waits for the end. + if (pos.x !== c.a.x || pos.y !== c.a.y) c.b = pos; + drawCurve(ctx, c, c.b ?? pos, buttonRef.current); + } else { + // Bending done: this release is the control point — commit the curve. + drawCurve(ctx, c, pos, buttonRef.current); + 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. + 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; + const ctx = getCtx(); + if (!canvas || !ctx) return; + rootRef.current?.focus(); + 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; + } + 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") { + // Cycle through the zoom levels (right-click steps back down) + setZoom((z) => { + const i = ZOOMS.indexOf(z); + const next = (event.button === 2 ? i - 1 : i + 1) + ZOOMS.length; + return ZOOMS[next % ZOOMS.length]; + }); + return; + } + + canvas.setPointerCapture(event.pointerId); + buttonRef.current = event.button; + 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 (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) => { + const ctx = getCtx(); + if (!ctx) return; + const pos = getPos(event); + setCursor(pos); + if (isSelect) { handleSelectMove(event); 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 (tool === "curve") { if (curveRef.current) handleCurveMove(event); return; } + if (tool === "text") { if (textDefiningRef.current) handleTextMove(event); return; } + if (!drawingRef.current) return; + + if (isFreehand) { + if (lastRef.current) drawSegment(ctx, lastRef.current, pos, buttonRef.current); + lastRef.current = pos; + } 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); + } + }; + + 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; + 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) / 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"; + ctx.fillRect(0, 0, w, h); + ctx.putImageData(r.snapshot, 0, 0); + setCanvasSize({ w, h }); + }; + + const handleResizeUp = (event: ReactPointerEvent) => { + if (!resizeRef.current) return; + event.currentTarget.releasePointerCapture(event.pointerId); + resizeRef.current = null; + }; + + 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(); } + else if (key === "s") { event.preventDefault(); saveImage(); } + else if (key === "n") { event.preventDefault(); clearCanvas(); } + }; + + // Option-box icons (inline SVG, no sprites) + const brushIcon = (b: { kind: BrushKind; v: number }) => { + if (b.kind === "circle") return ; + if (b.kind === "square") return ; + const h = b.v / 2; + return b.kind === "diag" + ? + : ; + }; + const sprayIcon = (r: number) => { + const n = Math.round(r * 1.4); + return Array.from({ length: n }, (_, i) => { + const a = i * 2.39996323; + const d = (r / 13) * 8 * Math.sqrt(i / n); + return ; + }); + }; + // Outline uses currentColor (black → white when selected); the grey fill stays grey + const fillIcon = (f: string) => { + if (f === "stroke") return ; + if (f === "both") return ; + return ; + }; + + const canvasCursor = toolCursors[tool]; + + return ( +
    +
    + +
    + +
    +
    +
    + {TOOLS.map((t, i) => ( + + ))} +
    +
    + {tool === "eraser" && ( +
    + {ERASER_SIZES.map((s) => ( + + ))} +
    + )} + {tool === "magnifier" && ( +
    + {ZOOMS.map((z, i) => ( + + ))} +
    + )} + {tool === "brush" && ( +
    + {BRUSHES.map((b) => ( + + ))} +
    + )} + {tool === "airbrush" && ( +
    + {SPRAYS.map((s) => ( + + ))} +
    + )} + {(tool === "line" || tool === "curve") && ( +
    + {SIZES.map((s) => ( + + ))} +
    + )} + {SHAPE_TOOLS.has(tool) && ( +
    + {SHAPE_FILLS.map((f) => ( + + ))} +
    + )} +
    +
    + +
    + +
    + 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]) => ( + + ))} + {selection && selection.w > 0 && selection.h > 0 && ( +
    + )} + {textBox && (textEditing ? ( +