diff --git a/README.md b/README.md index 88605445..bab24607 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ## Key Features -- **49 image tools** - Resize, crop, compress, convert, watermark, color adjust, vectorize, create GIFs, find duplicates, generate passport photos, and more +- **49 image tools** - Resize, crop, compress, convert, watermark, color adjust, vectorize, create GIFs, find duplicates, generate passport photos, and more. Supports 55+ input formats (including 23 camera RAW formats) and 14 output formats - **Local AI** - Remove backgrounds, upscale images, restore and colorize old photos, erase objects, blur faces, enhance faces, extract text (OCR). All on your hardware - no internet required - **Pipelines** - Chain tools into reusable workflows with unlimited steps. Batch process unlimited images at once - **REST API** - Every tool available via API with API key auth. Interactive docs at `/api/docs` @@ -59,6 +59,7 @@ For Docker Compose, persistent storage, and other setup options, see the [Gettin - [Getting Started](https://docs.snapotter.com/guide/getting-started) - [Configuration](https://docs.snapotter.com/guide/configuration) - [Deployment](https://docs.snapotter.com/guide/deployment) +- [Supported Formats](https://docs.snapotter.com/guide/supported-formats) - [Docker Tags](https://docs.snapotter.com/guide/docker-tags) - [REST API](https://docs.snapotter.com/api/rest) - [AI Engine](https://docs.snapotter.com/api/ai) diff --git a/apps/api/src/lib/file-validation.ts b/apps/api/src/lib/file-validation.ts index 921d32b9..ebf8bf6f 100644 --- a/apps/api/src/lib/file-validation.ts +++ b/apps/api/src/lib/file-validation.ts @@ -20,6 +20,17 @@ const SUPPORTED_INPUT_FORMATS = new Set([ "psd", "exr", "hdr", + "jp2", + "qoi", + "eps", + "dds", + "cur", + "dpx", + "fits", + "ppm", + "pgm", + "pbm", + "pfm", ]); interface MagicEntry { @@ -38,6 +49,19 @@ const MAGIC_BYTES: MagicEntry[] = [ { bytes: [0x4d, 0x4d, 0x00, 0x2a], offset: 0, format: "tiff" }, { bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, format: "avif" }, // ftyp box; verified below { bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, format: "heif" }, // ftyp box; verified below + { bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, format: "cr3" }, // ftyp box; verified below + // Fujifilm RAF: "FUJIFILMCCD-RAW" at offset 0 + { + bytes: [ + 0x46, 0x55, 0x4a, 0x49, 0x46, 0x49, 0x4c, 0x4d, 0x43, 0x43, 0x44, 0x2d, 0x52, 0x41, 0x57, + ], + offset: 0, + format: "raw", + }, + // Sigma X3F: "FOVb" at offset 0 + { bytes: [0x46, 0x4f, 0x56, 0x62], offset: 0, format: "raw" }, + // Minolta MRW: "\x00MRM" at offset 0 + { bytes: [0x00, 0x4d, 0x52, 0x4d], offset: 0, format: "raw" }, // JXL ISOBMFF container { bytes: [0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20], offset: 0, format: "jxl" }, // JXL raw codestream @@ -49,6 +73,47 @@ const MAGIC_BYTES: MagicEntry[] = [ // OpenEXR { bytes: [0x76, 0x2f, 0x31, 0x01], offset: 0, format: "exr" }, // TGA has no reliable magic bytes — detected by extension only + // JPEG 2000 JP2 box signature (NOT ISOBMFF) + { + bytes: [0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20, 0x0d, 0x0a, 0x87, 0x0a], + offset: 0, + format: "jp2", + }, + // JPEG 2000 raw codestream (J2K/J2C) + { bytes: [0xff, 0x4f, 0xff, 0x51], offset: 0, format: "jp2" }, + // QOI: "qoif" at offset 0 + { bytes: [0x71, 0x6f, 0x69, 0x66], offset: 0, format: "qoi" }, + // DDS: "DDS " at offset 0 + { bytes: [0x44, 0x44, 0x53, 0x20], offset: 0, format: "dds" }, + // CUR: Windows cursor (ICO variant, byte 3 = 0x02 vs ICO's 0x01) + { bytes: [0x00, 0x00, 0x02, 0x00], offset: 0, format: "cur" }, + // DPX forward: "SDPX" + { bytes: [0x53, 0x44, 0x50, 0x58], offset: 0, format: "dpx" }, + // DPX reverse: "XPDS" + { bytes: [0x58, 0x50, 0x44, 0x53], offset: 0, format: "dpx" }, + // Cineon + { bytes: [0x80, 0x2a, 0x5f, 0xd7], offset: 0, format: "dpx" }, + // FITS: "SIMPLE" at offset 0 + { bytes: [0x53, 0x49, 0x4d, 0x50, 0x4c, 0x45], offset: 0, format: "fits" }, + // EPS ASCII header: "%!PS-Adobe" + { + bytes: [0x25, 0x21, 0x50, 0x53, 0x2d, 0x41, 0x64, 0x6f, 0x62, 0x65], + offset: 0, + format: "eps", + }, + // EPS binary (DOS EPS) + { bytes: [0xc5, 0xd0, 0xd3, 0xc6], offset: 0, format: "eps" }, + // Netpbm: P1-P7 headers (these MUST go AFTER the PNG entry to avoid false matches on 0x50) + { bytes: [0x50, 0x31], offset: 0, format: "pbm" }, + { bytes: [0x50, 0x34], offset: 0, format: "pbm" }, + { bytes: [0x50, 0x32], offset: 0, format: "pgm" }, + { bytes: [0x50, 0x35], offset: 0, format: "pgm" }, + { bytes: [0x50, 0x33], offset: 0, format: "ppm" }, + { bytes: [0x50, 0x36], offset: 0, format: "ppm" }, + { bytes: [0x50, 0x37], offset: 0, format: "ppm" }, + // PFM (Portable FloatMap) + { bytes: [0x50, 0x46], offset: 0, format: "pfm" }, + { bytes: [0x50, 0x66], offset: 0, format: "pfm" }, ]; export interface ValidationResult { @@ -64,10 +129,50 @@ export interface ValidationError { } /** Camera RAW extensions that share TIFF magic bytes. */ -const RAW_EXTENSIONS = new Set(["dng", "cr2", "nef", "arw", "orf", "rw2"]); +const RAW_EXTENSIONS = new Set([ + "dng", + "cr2", + "cr3", + "nef", + "nrw", + "arw", + "orf", + "rw2", + "raf", + "pef", + "3fr", + "iiq", + "srw", + "x3f", + "rwl", + "gpr", + "fff", + "mrw", + "mef", + "kdc", + "dcr", + "erf", + "ptx", +]); /** Formats that Sharp cannot decode natively — skip dimension check. */ -const CLI_DECODED_FORMATS = new Set(["raw", "ico", "tga", "psd", "exr", "hdr", "bmp", "jxl"]); +const CLI_DECODED_FORMATS = new Set([ + "raw", + "ico", + "tga", + "psd", + "exr", + "hdr", + "bmp", + "jxl", + "jp2", + "qoi", + "eps", + "dds", + "cur", + "dpx", + "fits", +]); /** * Check whether a file extension corresponds to a Camera RAW format. @@ -121,6 +226,18 @@ export async function validateImageBuffer( detectedFormat = "tga"; } + // SVGZ: gzip-compressed SVG, detected by extension + gzip magic + if (!detectedFormat && ext === "svgz") { + if (buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b) { + detectedFormat = "svg"; + } + } + + // APNG: Sharp handles as PNG first frame. Accept .apng extension. + if (!detectedFormat && ext === "apng") { + detectedFormat = "png"; + } + if (!detectedFormat) { return { valid: false, reason: "Unrecognized image format" }; } @@ -219,6 +336,13 @@ function detectMagicBytes(buffer: Buffer): string | null { const brand = buffer.slice(8, 12).toString("ascii"); if (!["heic", "heix", "mif1", "msf1", "hevc", "hevx"].includes(brand)) continue; } + // For ftyp, verify CR3 brand at bytes 8-11. + if (entry.format === "cr3") { + if (buffer.length < 12) continue; + const brand = buffer.slice(8, 12).toString("ascii"); + if (brand !== "crx ") continue; + return "raw"; // CR3 is a RAW format, routed through decodeRaw() + } return entry.format; } } diff --git a/apps/api/src/lib/format-decoders.ts b/apps/api/src/lib/format-decoders.ts index 49ba3965..c5031d58 100644 --- a/apps/api/src/lib/format-decoders.ts +++ b/apps/api/src/lib/format-decoders.ts @@ -4,11 +4,31 @@ import { readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; +import sharp from "sharp"; const execFileAsync = promisify(execFile); /** Formats that need external CLI tools (not decodable by Sharp). */ -const CLI_DECODED_FORMATS = new Set(["raw", "ico", "tga", "psd", "exr", "hdr", "bmp", "jxl"]); +const CLI_DECODED_FORMATS = new Set([ + "raw", + "ico", + "tga", + "psd", + "exr", + "hdr", + "bmp", + "jxl", + "jp2", + "qoi", + "eps", + "dds", + "cur", + "dpx", + "ppm", + "pgm", + "pbm", + "fits", +]); export function needsCliDecode(format: string): boolean { return CLI_DECODED_FORMATS.has(format); @@ -17,11 +37,22 @@ export function needsCliDecode(format: string): boolean { /** * Main entry point - routes to the right decoder based on format. * Returns a PNG buffer that Sharp can process downstream. + * + * @param buffer - The raw file buffer + * @param format - The detected format string (e.g. "raw", "psd", "ico") + * @param ext - Optional original file extension (e.g. "cr3", "nef"). + * Passed to decodeRaw so the temp file uses the correct + * extension, which helps ExifTool and ImageMagick identify + * the RAW variant. */ -export async function decodeToSharpCompat(buffer: Buffer, format: string): Promise { +export async function decodeToSharpCompat( + buffer: Buffer, + format: string, + ext?: string, +): Promise { switch (format) { case "raw": - return decodeRaw(buffer); + return decodeRaw(buffer, ext); case "ico": return decodeIco(buffer); case "psd": @@ -36,6 +67,24 @@ export async function decodeToSharpCompat(buffer: Buffer, format: string): Promi return decodeBmp(buffer); case "jxl": return decodeJxl(buffer); + case "jp2": + return decodeJp2(buffer); + case "eps": + return decodeEps(buffer); + case "dds": + return decodeDds(buffer); + case "cur": + return decodeIco(buffer); // CUR is structurally identical to ICO + case "dpx": + return decodeDpx(buffer); + case "fits": + return decodeFits(buffer); + case "qoi": + return decodeQoi(buffer); + case "ppm": + case "pgm": + case "pbm": + return decodeNetpbm(buffer, format); default: return buffer; } @@ -84,16 +133,47 @@ async function decodeIco(buffer: Buffer): Promise { } } -// ── RAW decoder (ImageMagick with LibRaw delegate) ───────────── +// ── RAW decoder (ExifTool-first, ImageMagick fallback) ────────── +// +// Strategy: Many camera RAW files (CR2, CR3, NEF, ARW, etc.) embed a +// full-size JPEG preview. ExifTool can extract it near-instantly with +// `-b -JpgFromRaw`. This is faster and more reliable than ImageMagick's +// LibRaw delegate, which may not support newer formats like CR3. +// +// If ExifTool extraction fails (no embedded JPEG, or exiftool not +// installed), we fall back to ImageMagick + LibRaw. -async function decodeRaw(buffer: Buffer): Promise { - const cmd = await findMagickCmd(); +async function decodeRaw(buffer: Buffer, ext?: string): Promise { const id = randomUUID(); - const inputPath = join(tmpdir(), `raw-in-${id}.dng`); + // Use the original extension so ExifTool / ImageMagick can identify the RAW variant. + const suffix = ext ? `.${ext.replace(/^\./, "")}` : ".dng"; + const inputPath = join(tmpdir(), `raw-in-${id}${suffix}`); const outputPath = join(tmpdir(), `raw-out-${id}.png`); try { await writeFile(inputPath, buffer); + + // Attempt 1: ExifTool embedded JPEG extraction (fast path) + try { + const { stdout } = await execFileAsync("exiftool", ["-b", "-JpgFromRaw", inputPath], { + encoding: "buffer", + maxBuffer: 50 * 1024 * 1024, + timeout: 30_000, + } as never); + // stdout is a Buffer when encoding is "buffer" + const jpegBuf = stdout as unknown as Buffer; + if (jpegBuf && jpegBuf.length > 1000) { + // Verify it starts with JPEG SOI marker + if (jpegBuf[0] === 0xff && jpegBuf[1] === 0xd8) { + return jpegBuf; + } + } + } catch { + // ExifTool not available or no embedded JPEG -- fall through + } + + // Attempt 2: ImageMagick + LibRaw delegate (full decode) + const cmd = await findMagickCmd(); await execFileAsync( cmd, magickArgs(cmd, [inputPath, "-colorspace", "sRGB", "-auto-orient", `png:${outputPath}`]), @@ -242,3 +322,164 @@ async function decodeJxl(buffer: Buffer): Promise { await rm(outputPath, { force: true }).catch(() => {}); } } + +// ── JPEG 2000 decoder (opj_decompress-first, ImageMagick fallback) ── + +async function decodeJp2(buffer: Buffer): Promise { + const id = randomUUID(); + const inputPath = join(tmpdir(), `jp2-in-${id}.jp2`); + const outputPath = join(tmpdir(), `jp2-out-${id}.png`); + try { + await writeFile(inputPath, buffer); + try { + await execFileAsync("opj_decompress", ["-i", inputPath, "-o", outputPath], { + timeout: 60_000, + }); + return await readFile(outputPath); + } catch { + // opj_decompress not available, fall back to ImageMagick + } + const cmd = await findMagickCmd(); + await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), { + timeout: 120_000, + }); + return await readFile(outputPath); + } finally { + await rm(inputPath, { force: true }).catch(() => {}); + await rm(outputPath, { force: true }).catch(() => {}); + } +} + +// ── EPS decoder (ImageMagick + Ghostscript delegate) ── + +const MAX_EPS_SIZE = 50 * 1024 * 1024; + +async function decodeEps(buffer: Buffer): Promise { + if (buffer.length > MAX_EPS_SIZE) { + throw new Error( + `EPS file too large (${(buffer.length / 1024 / 1024).toFixed(1)}MB, limit: 50MB)`, + ); + } + const cmd = await findMagickCmd(); + const id = randomUUID(); + const inputPath = join(tmpdir(), `eps-in-${id}.eps`); + const outputPath = join(tmpdir(), `eps-out-${id}.png`); + try { + await writeFile(inputPath, buffer); + await execFileAsync( + cmd, + magickArgs(cmd, [ + "-density", + "300", + "-define", + "gs:MaxBitmap=500000000", + inputPath, + "-colorspace", + "sRGB", + `png:${outputPath}`, + ]), + { timeout: 30_000 }, + ); + return await readFile(outputPath); + } finally { + await rm(inputPath, { force: true }).catch(() => {}); + await rm(outputPath, { force: true }).catch(() => {}); + } +} + +// ── DDS decoder ── + +async function decodeDds(buffer: Buffer): Promise { + const cmd = await findMagickCmd(); + const id = randomUUID(); + const inputPath = join(tmpdir(), `dds-in-${id}.dds`); + const outputPath = join(tmpdir(), `dds-out-${id}.png`); + try { + await writeFile(inputPath, buffer); + await execFileAsync(cmd, magickArgs(cmd, [`${inputPath}[0]`, `png:${outputPath}`]), { + timeout: 120_000, + }); + return await readFile(outputPath); + } finally { + await rm(inputPath, { force: true }).catch(() => {}); + await rm(outputPath, { force: true }).catch(() => {}); + } +} + +// ── DPX / Cineon decoder ── + +async function decodeDpx(buffer: Buffer): Promise { + const cmd = await findMagickCmd(); + const id = randomUUID(); + const inputPath = join(tmpdir(), `dpx-in-${id}.dpx`); + const outputPath = join(tmpdir(), `dpx-out-${id}.png`); + try { + await writeFile(inputPath, buffer); + await execFileAsync( + cmd, + magickArgs(cmd, [inputPath, "-colorspace", "sRGB", `png:${outputPath}`]), + { timeout: 120_000 }, + ); + return await readFile(outputPath); + } finally { + await rm(inputPath, { force: true }).catch(() => {}); + await rm(outputPath, { force: true }).catch(() => {}); + } +} + +// ── FITS decoder ── + +async function decodeFits(buffer: Buffer): Promise { + const cmd = await findMagickCmd(); + const id = randomUUID(); + const inputPath = join(tmpdir(), `fits-in-${id}.fits`); + const outputPath = join(tmpdir(), `fits-out-${id}.png`); + try { + await writeFile(inputPath, buffer); + await execFileAsync( + cmd, + magickArgs(cmd, [inputPath, "-normalize", "-colorspace", "sRGB", `png:${outputPath}`]), + { timeout: 120_000 }, + ); + return await readFile(outputPath); + } finally { + await rm(inputPath, { force: true }).catch(() => {}); + await rm(outputPath, { force: true }).catch(() => {}); + } +} + +// ── QOI decoder ── + +async function decodeQoi(buffer: Buffer): Promise { + const { qoiDecode } = await import("@snapotter/image-engine"); + const { header, pixels } = qoiDecode(new Uint8Array(buffer)); + return sharp(Buffer.from(pixels), { + raw: { width: header.width, height: header.height, channels: 4 }, + }) + .png() + .toBuffer(); +} + +// ── Netpbm (PPM/PGM/PBM) decoder ── + +async function decodeNetpbm(buffer: Buffer, format: string): Promise { + try { + return await sharp(buffer).png().toBuffer(); + } catch { + const cmd = await findMagickCmd(); + const id = randomUUID(); + const ext = format === "pgm" ? "pgm" : format === "pbm" ? "pbm" : "ppm"; + const inputPath = join(tmpdir(), `netpbm-in-${id}.${ext}`); + const outputPath = join(tmpdir(), `netpbm-out-${id}.png`); + try { + await writeFile(inputPath, buffer); + await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), { + timeout: 120_000, + }); + return await readFile(outputPath); + } finally { + await rm(inputPath, { force: true }).catch(() => {}); + await rm(outputPath, { force: true }).catch(() => {}); + } + } +} diff --git a/apps/api/src/lib/format-encoders.ts b/apps/api/src/lib/format-encoders.ts new file mode 100644 index 00000000..5e5c5e92 --- /dev/null +++ b/apps/api/src/lib/format-encoders.ts @@ -0,0 +1,105 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { qoiEncode } from "@snapotter/image-engine"; +import sharp from "sharp"; + +const execFileAsync = promisify(execFile); + +let cachedMagickCmd: string | null = null; + +async function findMagickCmd(): Promise { + if (cachedMagickCmd) return cachedMagickCmd; + for (const cmd of ["magick", "convert"]) { + try { + await execFileAsync(cmd, ["--version"], { timeout: 5_000 }); + cachedMagickCmd = cmd; + return cmd; + } catch { + /* try next */ + } + } + throw new Error("No ImageMagick found."); +} + +function magickArgs(cmd: string, args: string[]): string[] { + return cmd === "magick" ? ["convert", ...args] : args; +} + +export async function encodeBmp(inputBuffer: Buffer): Promise { + const cmd = await findMagickCmd(); + const id = randomUUID(); + const inputPath = join(tmpdir(), `bmp-enc-in-${id}.png`); + const outputPath = join(tmpdir(), `bmp-enc-out-${id}.bmp`); + try { + const pngBuffer = await sharp(inputBuffer).png().toBuffer(); + await writeFile(inputPath, pngBuffer); + await execFileAsync(cmd, magickArgs(cmd, [inputPath, `bmp3:${outputPath}`]), { + timeout: 60_000, + }); + return await readFile(outputPath); + } finally { + await rm(inputPath, { force: true }).catch(() => {}); + await rm(outputPath, { force: true }).catch(() => {}); + } +} + +export async function encodeIco(inputBuffer: Buffer): Promise { + const cmd = await findMagickCmd(); + const id = randomUUID(); + const inputPath = join(tmpdir(), `ico-enc-in-${id}.png`); + const outputPath = join(tmpdir(), `ico-enc-out-${id}.ico`); + try { + const pngBuffer = await sharp(inputBuffer) + .resize(256, 256, { fit: "inside", withoutEnlargement: true }) + .png() + .toBuffer(); + await writeFile(inputPath, pngBuffer); + await execFileAsync(cmd, magickArgs(cmd, [inputPath, `ico:${outputPath}`]), { + timeout: 60_000, + }); + return await readFile(outputPath); + } finally { + await rm(inputPath, { force: true }).catch(() => {}); + await rm(outputPath, { force: true }).catch(() => {}); + } +} + +export async function encodeJp2(inputBuffer: Buffer, quality?: number): Promise { + const id = randomUUID(); + const inputPath = join(tmpdir(), `jp2-enc-in-${id}.png`); + const outputPath = join(tmpdir(), `jp2-enc-out-${id}.jp2`); + try { + const pngBuffer = await sharp(inputBuffer).png().toBuffer(); + await writeFile(inputPath, pngBuffer); + try { + const rate = quality ? String(Math.max(1, Math.round(quality / 10))) : "5"; + await execFileAsync("opj_compress", ["-i", inputPath, "-o", outputPath, "-r", rate], { + timeout: 60_000, + }); + return await readFile(outputPath); + } catch { + /* fall back to ImageMagick */ + } + const cmd = await findMagickCmd(); + const q = quality ? ["-quality", String(quality)] : []; + await execFileAsync(cmd, magickArgs(cmd, [inputPath, ...q, `jp2:${outputPath}`]), { + timeout: 60_000, + }); + return await readFile(outputPath); + } finally { + await rm(inputPath, { force: true }).catch(() => {}); + await rm(outputPath, { force: true }).catch(() => {}); + } +} + +export async function encodeQoi(inputBuffer: Buffer): Promise { + const { data, info } = await sharp(inputBuffer).ensureAlpha().raw().toBuffer({ + resolveWithObject: true, + }); + const encoded = qoiEncode(new Uint8Array(data), info.width, info.height, 4); + return Buffer.from(encoded); +} diff --git a/apps/api/src/lib/output-format.ts b/apps/api/src/lib/output-format.ts index 81ecbb08..64e55361 100644 --- a/apps/api/src/lib/output-format.ts +++ b/apps/api/src/lib/output-format.ts @@ -18,14 +18,14 @@ const FORMAT_MAP: Record< tiff: { format: "tiff", extension: "tiff", contentType: "image/tiff" }, avif: { format: "avif", extension: "avif", contentType: "image/avif" }, heif: { format: "avif", extension: "avif", contentType: "image/avif" }, - jxl: { format: "png", extension: "png", contentType: "image/png" }, + jxl: { format: "jxl" as keyof sharp.FormatEnum, extension: "jxl", contentType: "image/jxl" }, }; const DEFAULT_QUALITY = 95; const PNG_FALLBACK = FORMAT_MAP.png; /** Formats that have no Sharp output encoder — fall back to PNG. */ -const PNG_FALLBACK_FORMATS = new Set(["svg", "bmp", "raw", "tga", "psd", "exr", "hdr", "ico"]); +const PNG_FALLBACK_FORMATS = new Set(["svg", "raw", "tga", "psd", "exr", "hdr"]); /** * Detect the input image format and return matching output config. diff --git a/apps/api/src/lib/svg-sanitize.ts b/apps/api/src/lib/svg-sanitize.ts index 841a7908..c7fe95c8 100644 --- a/apps/api/src/lib/svg-sanitize.ts +++ b/apps/api/src/lib/svg-sanitize.ts @@ -1,3 +1,4 @@ +import { gunzipSync } from "node:zlib"; import { env } from "../config.js"; /** @@ -36,6 +37,24 @@ export function sanitizeSvg(buffer: Buffer): Buffer { return Buffer.from(svg, "utf-8"); } +const MAX_SVGZ_DECOMPRESSED_SIZE = 50 * 1024 * 1024; + +/** + * Decompress an SVGZ (gzip-compressed SVG) buffer. + * Returns the buffer unchanged if it is not gzip-compressed. + * Throws on decompression bomb or invalid SVG content. + */ +export function decompressSvgz(buffer: Buffer): Buffer { + if (buffer.length < 2 || buffer[0] !== 0x1f || buffer[1] !== 0x8b) { + return buffer; + } + const decompressed = gunzipSync(buffer, { maxOutputLength: MAX_SVGZ_DECOMPRESSED_SIZE }); + if (!isSvgBuffer(decompressed)) { + throw new Error("SVGZ file does not contain valid SVG content after decompression"); + } + return decompressed; +} + /** * Check whether a buffer looks like SVG content. * Examines the first 4KB for an (app: FastifyInstance, config: ToolRouteConfig // Decode CLI-decoded formats (RAW, PSD, TGA, EXR, HDR) via external tools. // The decoded buffer is PNG, so update the filename extension to match. + // Pass the original file extension so RAW decoder can use the correct + // temp file suffix (e.g. .cr3, .nef) for format identification. if (needsCliDecode(validation.format)) { try { - fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format); + const fileExt = filename.split(".").pop()?.toLowerCase(); + fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format, fileExt); const ext = filename.match(/\.[^.]+$/)?.[0]; if (ext) filename = `${filename.slice(0, -ext.length)}.png`; } catch (err) { @@ -209,6 +212,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig const isSvg = validation.format === "svg"; if (isSvg) { try { + fileBuffer = decompressSvgz(fileBuffer); fileBuffer = sanitizeSvg(fileBuffer); } catch (err) { return reply.status(400).send({ @@ -314,6 +318,18 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig "image/bmp": ".bmp", "image/heic": ".heic", "image/heif": ".heif", + "image/jxl": ".jxl", + "image/x-icon": ".ico", + "image/vnd.adobe.photoshop": ".psd", + "image/x-exr": ".exr", + "image/vnd.radiance": ".hdr", + "image/x-targa": ".tga", + "image/jp2": ".jp2", + "image/qoi": ".qoi", + "application/postscript": ".eps", + "image/vnd.ms-dds": ".dds", + "image/x-dpx": ".dpx", + "image/fits": ".fits", }; const expectedExt = CONTENT_TYPE_TO_EXT[result.contentType]; if (expectedExt) { diff --git a/apps/api/src/routes/tools/collage.ts b/apps/api/src/routes/tools/collage.ts index 74170b8a..00fad6a9 100644 --- a/apps/api/src/routes/tools/collage.ts +++ b/apps/api/src/routes/tools/collage.ts @@ -333,7 +333,7 @@ const settingsSchema = z.object({ cornerRadius: z.number().min(0).max(500).default(0), backgroundColor: z.string().default("#FFFFFF"), aspectRatio: z.string().default("free"), - outputFormat: z.enum(["png", "jpeg", "webp", "avif"]).default("png"), + outputFormat: z.enum(["png", "jpeg", "webp", "avif", "jxl"]).default("png"), quality: z.number().min(1).max(100).default(90), }); @@ -642,6 +642,10 @@ export function registerCollage(app: FastifyInstance) { pipeline = pipeline.avif({ quality: settings.quality, effort: 4 }); outputExt = "avif"; break; + case "jxl": + pipeline = pipeline.jxl({ quality: settings.quality }); + outputExt = "jxl"; + break; default: pipeline = pipeline.png(); outputExt = "png"; diff --git a/apps/api/src/routes/tools/convert.ts b/apps/api/src/routes/tools/convert.ts index d0dd7925..a4c401c8 100644 --- a/apps/api/src/routes/tools/convert.ts +++ b/apps/api/src/routes/tools/convert.ts @@ -3,6 +3,7 @@ import { convert } from "@snapotter/image-engine"; import type { FastifyInstance } from "fastify"; import sharp from "sharp"; import { z } from "zod"; +import { encodeBmp, encodeIco, encodeJp2, encodeQoi } from "../../lib/format-encoders.js"; import { encodeHeic } from "../../lib/heic-converter.js"; import { isSvgBuffer } from "../../lib/svg-sanitize.js"; import { createToolRoute } from "../tool-factory.js"; @@ -16,10 +17,36 @@ const FORMAT_CONTENT_TYPES: Record = { gif: "image/gif", heic: "image/heic", heif: "image/heif", + jxl: "image/jxl", + bmp: "image/bmp", + ico: "image/x-icon", + jp2: "image/jp2", + qoi: "image/x-qoi", +}; + +const CLI_ENCODERS: Record Promise> = { + bmp: encodeBmp, + ico: encodeIco, + jp2: encodeJp2, + qoi: encodeQoi, }; const settingsSchema = z.object({ - format: z.enum(["jpg", "png", "webp", "avif", "tiff", "gif", "heic", "heif"]), + format: z.enum([ + "jpg", + "png", + "webp", + "avif", + "tiff", + "gif", + "heic", + "heif", + "jxl", + "bmp", + "ico", + "jp2", + "qoi", + ]), quality: z.number().min(1).max(100).optional(), }); @@ -28,6 +55,20 @@ export function registerConvert(app: FastifyInstance) { toolId: "convert", settingsSchema, process: async (inputBuffer, settings, filename) => { + // CLI-encoded formats bypass Sharp entirely + const cliEncoder = CLI_ENCODERS[settings.format]; + if (cliEncoder) { + const outputBuffer = await cliEncoder(inputBuffer, settings.quality); + const ext = extname(filename); + const baseName = ext ? filename.slice(0, -ext.length) : filename; + const contentType = FORMAT_CONTENT_TYPES[settings.format] || "application/octet-stream"; + return { + buffer: outputBuffer, + filename: `${baseName}.${settings.format}`, + contentType, + }; + } + const sharpOpts = isSvgBuffer(inputBuffer) ? { density: 300 } : undefined; const image = sharp(inputBuffer, sharpOpts); diff --git a/apps/api/src/routes/tools/erase-object.ts b/apps/api/src/routes/tools/erase-object.ts index a5677001..9f6997c6 100644 --- a/apps/api/src/routes/tools/erase-object.ts +++ b/apps/api/src/routes/tools/erase-object.ts @@ -26,13 +26,14 @@ const EXT_MAP: Record = { avif: "avif", heic: "heic", heif: "heif", + jxl: "jxl", }; const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]); const settingsSchema = z.object({ format: z - .enum(["auto", "png", "jpg", "jpeg", "webp", "tiff", "gif", "avif", "heic", "heif"]) + .enum(["auto", "png", "jpg", "jpeg", "webp", "tiff", "gif", "avif", "heic", "heif", "jxl"]) .default("auto"), quality: z.number().int().min(1).max(100).default(95), }); @@ -203,7 +204,7 @@ export function registerEraseObject(app: FastifyInstance) { ); // Convert to the requested output format using Sharp - const needsNodeConversion = ["heic", "heif", "avif"].includes(format); + const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format); let outputBuffer: Buffer; let finalFormat = format; @@ -211,6 +212,9 @@ export function registerEraseObject(app: FastifyInstance) { if (format === "heic" || format === "heif") { outputBuffer = await encodeHeic(resultBuffer, quality); finalFormat = format; + } else if (format === "jxl") { + outputBuffer = await sharp(resultBuffer).jxl({ quality }).toBuffer(); + finalFormat = "jxl"; } else { outputBuffer = await sharp(resultBuffer).avif({ quality }).toBuffer(); finalFormat = "avif"; diff --git a/apps/api/src/routes/tools/image-to-base64.ts b/apps/api/src/routes/tools/image-to-base64.ts index c0a83dc1..a01b2a41 100644 --- a/apps/api/src/routes/tools/image-to-base64.ts +++ b/apps/api/src/routes/tools/image-to-base64.ts @@ -6,7 +6,7 @@ import { formatZodErrors } from "../../lib/errors.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; const settingsSchema = z.object({ - outputFormat: z.enum(["original", "jpeg", "png", "webp", "avif"]).default("original"), + outputFormat: z.enum(["original", "jpeg", "png", "webp", "avif", "jxl"]).default("original"), quality: z.number().int().min(1).max(100).default(80), maxWidth: z.number().int().min(0).default(0), maxHeight: z.number().int().min(0).default(0), @@ -145,6 +145,10 @@ export function registerImageToBase64(app: FastifyInstance) { outputBuffer = await pipeline.avif({ quality: opts.quality, effort: 4 }).toBuffer(); mimeType = "image/avif"; break; + case "jxl": + outputBuffer = await pipeline.jxl({ quality: opts.quality }).toBuffer(); + mimeType = "image/jxl"; + break; default: outputBuffer = await pipeline.toBuffer(); mimeType = detectMimeType(ext); diff --git a/apps/api/src/routes/tools/noise-removal.ts b/apps/api/src/routes/tools/noise-removal.ts index fc868d92..9071251e 100644 --- a/apps/api/src/routes/tools/noise-removal.ts +++ b/apps/api/src/routes/tools/noise-removal.ts @@ -21,7 +21,7 @@ const settingsSchema = z.object({ strength: z.union([z.number(), z.string()]).transform(Number).default(50), detailPreservation: z.union([z.number(), z.string()]).transform(Number).default(50), colorNoise: z.union([z.number(), z.string()]).transform(Number).default(30), - format: z.enum(["original", "png", "jpeg", "webp", "avif"]).default("original"), + format: z.enum(["original", "png", "jpeg", "webp", "avif", "jxl"]).default("original"), quality: z.union([z.number(), z.string()]).transform(Number).default(90), }); @@ -198,7 +198,7 @@ export function registerNoiseRemoval(app: FastifyInstance) { strength: z.union([z.number(), z.string()]).transform(Number).default(50), detailPreservation: z.union([z.number(), z.string()]).transform(Number).default(50), colorNoise: z.union([z.number(), z.string()]).transform(Number).default(30), - format: z.enum(["original", "png", "jpeg", "webp", "avif"]).default("original"), + format: z.enum(["original", "png", "jpeg", "webp", "avif", "jxl"]).default("original"), quality: z.union([z.number(), z.string()]).transform(Number).default(90), }), process: async (inputBuffer, settings, filename) => { diff --git a/apps/api/src/routes/tools/optimize-for-web.ts b/apps/api/src/routes/tools/optimize-for-web.ts index 7c2df931..2f955bc5 100644 --- a/apps/api/src/routes/tools/optimize-for-web.ts +++ b/apps/api/src/routes/tools/optimize-for-web.ts @@ -17,6 +17,7 @@ const FORMAT_CONTENT_TYPES: Record = { jpeg: "image/jpeg", avif: "image/avif", png: "image/png", + jxl: "image/jxl", }; const FORMAT_EXTENSIONS: Record = { @@ -24,10 +25,11 @@ const FORMAT_EXTENSIONS: Record = { jpeg: "jpg", avif: "avif", png: "png", + jxl: "jxl", }; const settingsSchema = z.object({ - format: z.enum(["webp", "jpeg", "avif", "png"]).default("webp"), + format: z.enum(["webp", "jpeg", "avif", "png", "jxl"]).default("webp"), quality: z.number().min(1).max(100).default(80), maxWidth: z.number().positive().optional(), maxHeight: z.number().positive().optional(), diff --git a/apps/api/src/routes/tools/pdf-to-image.ts b/apps/api/src/routes/tools/pdf-to-image.ts index 43180b65..8e6fcd9d 100644 --- a/apps/api/src/routes/tools/pdf-to-image.ts +++ b/apps/api/src/routes/tools/pdf-to-image.ts @@ -14,7 +14,9 @@ import { createWorkspace } from "../../lib/workspace.js"; // ── Settings schema ────────────────────────────────────────────── const settingsSchema = z.object({ - format: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heic", "heif"]).default("png"), + format: z + .enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heic", "heif", "jxl"]) + .default("png"), dpi: z.number().min(36).max(2400).default(150), quality: z.number().min(1).max(100).default(85), colorMode: z.enum(["color", "grayscale", "bw"]).default("color"), @@ -86,6 +88,7 @@ const FORMAT_EXT: Record = { gif: ".gif", heic: ".heic", heif: ".heif", + jxl: ".jxl", }; async function convertWithSharp( @@ -114,6 +117,8 @@ async function convertWithSharp( return s.tiff().toBuffer(); case "gif": return s.gif().toBuffer(); + case "jxl": + return s.jxl({ quality }).toBuffer(); case "heic": case "heif": { const pngBuf = await s.png().toBuffer(); diff --git a/apps/api/src/routes/tools/split.ts b/apps/api/src/routes/tools/split.ts index a29053a6..6a062790 100644 --- a/apps/api/src/routes/tools/split.ts +++ b/apps/api/src/routes/tools/split.ts @@ -15,7 +15,7 @@ const settingsSchema = z.object({ rows: z.number().min(1).max(100).default(3), tileWidth: z.number().min(10).optional(), tileHeight: z.number().min(10).optional(), - outputFormat: z.enum(["original", "png", "jpg", "webp", "avif"]).default("original"), + outputFormat: z.enum(["original", "png", "jpg", "webp", "avif", "jxl"]).default("original"), quality: z.number().min(1).max(100).default(90), }); @@ -31,6 +31,7 @@ function resolveOutputFormat( jpg: { sharpFormat: "jpeg", ext: ".jpg" }, webp: { sharpFormat: "webp", ext: ".webp" }, avif: { sharpFormat: "avif", ext: ".avif" }, + jxl: { sharpFormat: "jxl", ext: ".jxl" }, }; return map[outputFormat] ?? { sharpFormat: null, ext: originalExt }; } diff --git a/apps/api/src/routes/tools/stitch.ts b/apps/api/src/routes/tools/stitch.ts index a5282945..fb7d189c 100644 --- a/apps/api/src/routes/tools/stitch.ts +++ b/apps/api/src/routes/tools/stitch.ts @@ -24,7 +24,7 @@ const settingsSchema = z.object({ .string() .regex(/^#[0-9a-fA-F]{6}$/) .default("#FFFFFF"), - format: z.enum(["png", "jpeg", "webp", "avif"]).default("png"), + format: z.enum(["png", "jpeg", "webp", "avif", "jxl"]).default("png"), quality: z.number().min(1).max(100).default(90), }); @@ -203,6 +203,8 @@ export function registerStitch(app: FastifyInstance) { pipeline = pipeline.webp({ quality: settings.quality }); } else if (settings.format === "avif") { pipeline = pipeline.avif({ quality: settings.quality, effort: 4 }); + } else if (settings.format === "jxl") { + pipeline = pipeline.jxl({ quality: settings.quality }); } else { pipeline = pipeline.png(); } @@ -235,6 +237,8 @@ export function registerStitch(app: FastifyInstance) { result = await sharp(result).webp({ quality: settings.quality }).toBuffer(); } else if (settings.format === "avif") { result = await sharp(result).avif({ quality: settings.quality, effort: 4 }).toBuffer(); + } else if (settings.format === "jxl") { + result = await sharp(result).jxl({ quality: settings.quality }).toBuffer(); } } diff --git a/apps/api/src/routes/tools/svg-to-raster.ts b/apps/api/src/routes/tools/svg-to-raster.ts index 5761e560..9e340671 100644 --- a/apps/api/src/routes/tools/svg-to-raster.ts +++ b/apps/api/src/routes/tools/svg-to-raster.ts @@ -26,7 +26,7 @@ const settingsSchema = z.object({ .string() .regex(/^#[0-9a-fA-F]{6,8}$/) .default("#00000000"), - outputFormat: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heif"]).default("png"), + outputFormat: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heif", "jxl"]).default("png"), }); interface ParsedSvgFile { @@ -77,6 +77,10 @@ async function convertSvg( buffer = await image.gif().toBuffer(); ext = "gif"; break; + case "jxl": + buffer = await image.jxl({ quality: settings.quality }).toBuffer(); + ext = "jxl"; + break; case "heif": { const pngBuffer = await image.png().toBuffer(); buffer = await encodeHeic(pngBuffer, settings.quality); diff --git a/apps/api/src/routes/tools/upscale.ts b/apps/api/src/routes/tools/upscale.ts index 7468ef4b..a82798b9 100644 --- a/apps/api/src/routes/tools/upscale.ts +++ b/apps/api/src/routes/tools/upscale.ts @@ -158,7 +158,7 @@ export function registerUpscale(app: FastifyInstance) { // The result will be delivered via the SSE progress channel. reply.status(202).send({ jobId: progressJobId, async: true }); - const needsNodeConversion = ["heic", "heif", "avif"].includes(format); + const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format); const pythonFormat = needsNodeConversion ? "png" : format; const onProgress = (percent: number, stage: string) => { @@ -185,6 +185,9 @@ export function registerUpscale(app: FastifyInstance) { if (format === "heic" || format === "heif") { outputBuffer = await encodeHeic(result.buffer, outputQuality); finalFormat = format; + } else if (format === "jxl") { + outputBuffer = await sharp(result.buffer).jxl({ quality: outputQuality }).toBuffer(); + finalFormat = "jxl"; } else if (format === "avif") { outputBuffer = await sharp(result.buffer).avif({ quality: outputQuality }).toBuffer(); finalFormat = "avif"; @@ -201,6 +204,7 @@ export function registerUpscale(app: FastifyInstance) { avif: "avif", heic: "heic", heif: "heif", + jxl: "jxl", }; const ext = EXT_MAP[finalFormat] || "png"; const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_${scale}x.${ext}`; diff --git a/apps/docs/.vitepress/config.mts b/apps/docs/.vitepress/config.mts index 0f31aa8b..f41ec3de 100644 --- a/apps/docs/.vitepress/config.mts +++ b/apps/docs/.vitepress/config.mts @@ -74,6 +74,7 @@ export default defineConfig({ { text: "Configuration", link: "/guide/configuration" }, { text: "Database", link: "/guide/database" }, { text: "Deployment", link: "/guide/deployment" }, + { text: "Supported Formats", link: "/guide/supported-formats" }, { text: "Hardware requirements", link: "/guide/deployment#hardware-requirements" }, { text: "Docker tags", link: "/guide/docker-tags" }, { text: "Developer guide", link: "/guide/developer" }, diff --git a/apps/docs/api/image-engine.md b/apps/docs/api/image-engine.md index 352d558b..57f86102 100644 --- a/apps/docs/api/image-engine.md +++ b/apps/docs/api/image-engine.md @@ -52,7 +52,7 @@ Change the image format. | Parameter | Type | Description | |---|---|---| -| `format` | string | Target format: `jpeg`, `png`, `webp`, `avif`, `tiff`, `gif`, `heic` | +| `format` | string | Target format: `jpeg`, `png`, `webp`, `avif`, `tiff`, `gif`, `jxl`, `heic`, `heif`, `bmp`, `ico`, `jp2`, `qoi` | | `quality` | number | Compression quality (1-100, applies to lossy formats) | ### compress @@ -100,9 +100,11 @@ Adjust individual RGB color channels. ## Format detection -The engine detects input formats automatically from file headers, not just file extensions. This means a `.jpg` file that is actually a PNG will be handled correctly. +The engine detects input formats automatically from file headers, not just file extensions. This means a `.jpg` file that is actually a PNG will be handled correctly. Detection uses a multi-layer approach: magic bytes first, then file extension as fallback. -Supported input formats: JPEG, PNG, WebP, AVIF, TIFF, GIF, HEIC/HEIF, SVG, RAW (via libraw). +SnapOtter supports **55+ input formats** and **14 output formats**, including 23 camera RAW formats from 20+ brands, professional formats (PSD, EPS, OpenEXR, HDR), modern codecs (JPEG XL, AVIF, HEIC, QOI, JPEG 2000), and scientific/gaming formats (FITS, DDS). Decoding is handled by Sharp natively where possible, with automatic fallback to ImageMagick, LibRaw, and specialized CLI decoders. + +See the [Supported Formats](/guide/supported-formats) page for the complete list. ## Metadata extraction diff --git a/apps/docs/guide/deployment.md b/apps/docs/guide/deployment.md index b00a4272..35afe82d 100644 --- a/apps/docs/guide/deployment.md +++ b/apps/docs/guide/deployment.md @@ -241,25 +241,9 @@ The server scales linearly with no errors or crashes up to 20 concurrent request ### Supported Image Formats -| Format | Read | Write | Notes | -|---|---|---|---| -| JPEG | Yes | Yes | | -| PNG | Yes | Yes | | -| WebP | Yes | Yes | | -| AVIF | Yes | Yes | Encode is CPU-intensive (~5s on 4 cores for a large image) | -| GIF | Yes | Yes | Animated GIF supported | -| TIFF | Yes | Yes | Multi-page supported | -| SVG | Yes | No | Rasterized on input, sanitized for security | -| HEIC | Yes | No | Decoded via heif-dec (~0.4s) | -| HEIF | Yes | No | Very slow decode (~15s) | -| DNG (RAW) | Yes (Linux) | No | Decoded via dcraw, not available on macOS | -| PSD | Yes | No | Decoded via ImageMagick | -| HDR | Yes | No | Tone-mapped on decode | -| TGA | Yes | No | Decoded via ImageMagick | -| ICO | Yes | Yes | Via favicon tool | -| PDF | Yes | Yes | Via pdf-to-image / image-to-pdf tools | +SnapOtter supports **55+ input formats** and **14 output formats**, including RAW files from 20+ camera brands, professional formats (PSD, EPS, OpenEXR, HDR), modern codecs (JPEG XL, AVIF, HEIC, QOI), and scientific/gaming formats (FITS, DDS). -Not supported: BMP (V4/V5 headers), JPEG XL (JXL), EXR (missing decode delegate in Docker image). +See the [complete format list](/guide/supported-formats) for details on every supported format, decoder used, and available quality controls. ### Known Limitations diff --git a/apps/docs/guide/supported-formats.md b/apps/docs/guide/supported-formats.md new file mode 100644 index 00000000..07174fc6 --- /dev/null +++ b/apps/docs/guide/supported-formats.md @@ -0,0 +1,112 @@ +# Supported Image Formats + +SnapOtter supports 55+ image formats for input and 14 formats for output. + +## Input Formats + +### Web Standards (9) + +| Format | Extensions | Decoder | Notes | +|--------|-----------|---------|-------| +| JPEG | .jpg, .jpeg | Sharp (native) | | +| PNG | .png | Sharp (native) | APNG first-frame extracted | +| WebP | .webp | Sharp (native) | | +| GIF | .gif | Sharp (native) | Animated supported | +| AVIF | .avif | Sharp (native) | | +| SVG | .svg | Sharp (librsvg) | Sanitized for XXE/SSRF | +| SVGZ | .svgz | gunzip + Sharp | Gzip bomb protection | +| APNG | .apng | Sharp (native) | First frame only | +| JPEG XL | .jxl | djxl / ImageMagick | Two-tier fallback | + +### Professional (7) + +| Format | Extensions | Decoder | Notes | +|--------|-----------|---------|-------| +| TIFF | .tiff, .tif | Sharp (native) | Multi-page supported | +| PSD | .psd | ImageMagick | Flattened composite | +| EPS | .eps, .epsf | ImageMagick + Ghostscript | 300dpi rasterization, security hardened | +| OpenEXR | .exr | ImageMagick | Linear-to-sRGB conversion | +| Radiance HDR | .hdr | ImageMagick | Linear-to-sRGB conversion | +| DPX | .dpx | ImageMagick | Log-to-sRGB conversion | +| Cineon | .cin | ImageMagick | Film/VFX format | + +### Camera RAW (23) + +| Format | Extensions | Camera Brand | Decoder | +|--------|-----------|-------------|---------| +| DNG | .dng | Adobe (universal) | exiftool / ImageMagick + LibRaw | +| CR2 | .cr2 | Canon (pre-2018) | exiftool / ImageMagick + LibRaw | +| CR3 | .cr3 | Canon (2018+) | exiftool / ImageMagick + LibRaw | +| NEF | .nef | Nikon | exiftool / ImageMagick + LibRaw | +| NRW | .nrw | Nikon (Coolpix) | exiftool / ImageMagick + LibRaw | +| ARW | .arw | Sony | exiftool / ImageMagick + LibRaw | +| ORF | .orf | Olympus | exiftool / ImageMagick + LibRaw | +| RW2 | .rw2 | Panasonic | exiftool / ImageMagick + LibRaw | +| RAF | .raf | Fujifilm | exiftool / ImageMagick + LibRaw | +| PEF | .pef | Pentax/Ricoh | exiftool / ImageMagick + LibRaw | +| 3FR | .3fr | Hasselblad | exiftool / ImageMagick + LibRaw | +| IIQ | .iiq | Phase One | exiftool / ImageMagick + LibRaw | +| SRW | .srw | Samsung | exiftool / ImageMagick + LibRaw | +| X3F | .x3f | Sigma | exiftool / ImageMagick + LibRaw | +| RWL | .rwl | Leica | exiftool / ImageMagick + LibRaw | +| GPR | .gpr | GoPro | exiftool / ImageMagick + LibRaw | +| FFF | .fff | Hasselblad (legacy) | exiftool / ImageMagick + LibRaw | +| MRW | .mrw | Minolta | exiftool / ImageMagick + LibRaw | +| MEF | .mef | Mamiya | exiftool / ImageMagick + LibRaw | +| KDC | .kdc | Kodak | exiftool / ImageMagick + LibRaw | +| DCR | .dcr | Kodak | exiftool / ImageMagick + LibRaw | +| ERF | .erf | Epson | exiftool / ImageMagick + LibRaw | +| PTX | .ptx | Pentax (compact) | exiftool / ImageMagick + LibRaw | + +### Modern Formats (3) + +| Format | Extensions | Decoder | Notes | +|--------|-----------|---------|-------| +| JPEG 2000 | .jp2, .j2k, .j2c, .jpc, .jpf, .jpx | opj_decompress / ImageMagick | Digital cinema, medical imaging | +| QOI | .qoi | Inline TypeScript codec | Game dev, embedded systems | +| HEIC/HEIF | .heic, .heif | heif-convert / heif-dec | iPhone photos | + +### Legacy/System (4) + +| Format | Extensions | Decoder | Notes | +|--------|-----------|---------|-------| +| BMP | .bmp | ImageMagick | | +| ICO | .ico | ImageMagick | Largest layer extracted | +| CUR | .cur | ImageMagick | Windows cursor (ICO variant) | +| TGA | .tga | ImageMagick | Extension-only detection | + +### Scientific and Gaming (2) + +| Format | Extensions | Decoder | Notes | +|--------|-----------|---------|-------| +| FITS | .fits, .fit, .fts | ImageMagick | Astronomy (NASA standard) | +| DDS | .dds | ImageMagick | Game textures (DirectX) | + +### Interchange (6) + +| Format | Extensions | Decoder | Notes | +|--------|-----------|---------|-------| +| PPM | .ppm | Sharp (native) | Color pixmap | +| PGM | .pgm | Sharp (native) | Grayscale | +| PBM | .pbm | Sharp (native) | 1-bit bitmap | +| PNM | .pnm | Sharp (native) | Umbrella format | +| PAM | .pam | Sharp (native) | Arbitrary map | +| PFM | .pfm | Sharp (native) | Float map | + +## Output Formats (14) + +| Format | Encoder | Quality Control | Available In | +|--------|---------|----------------|-------------| +| JPEG | Sharp native | 1-100 | All tools | +| PNG | Sharp native | Compression 0-9 | All tools | +| WebP | Sharp native | 1-100 | All tools | +| AVIF | Sharp native | 1-100 | All tools | +| TIFF | Sharp native | 1-100 | Full conversion tools | +| GIF | Sharp native | 1-100 | Full conversion tools | +| JXL | Sharp native | 1-100 | All tools | +| HEIC | heif-enc CLI | 1-100 | Full conversion tools | +| HEIF | heif-enc CLI | 1-100 | Full conversion tools | +| BMP | ImageMagick CLI | Lossless | Convert tool | +| ICO | ImageMagick CLI | Lossless | Convert tool | +| JP2 | opj_compress CLI | Compression ratio | Convert tool | +| QOI | Inline codec | Lossless | Convert tool | diff --git a/apps/web/package.json b/apps/web/package.json index d0db389a..0c2fefeb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,25 +12,31 @@ "clean": "rm -rf dist" }, "dependencies": { - "@snapotter/shared": "workspace:*", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@sentry/react": "^10.49.0", + "@snapotter/shared": "workspace:*", "@use-gesture/react": "^10.3.1", "clsx": "^2.1.0", "fflate": "^0.8.2", "jszip": "^3.10.1", + "konva": "^10", "leaflet": "^1.9.4", "lucide-react": "^0.469.0", "posthog-js": "^1.370.0", "qr-code-styling": "^1.9.2", "react": "^19.0.0", + "react-colorful": "^5", "react-dom": "^19.0.0", + "react-hotkeys-hook": "^5", "react-image-crop": "^11.0.10", + "react-konva": "^19", "react-router-dom": "^7.1.0", "sonner": "^2.0.7", "tailwind-merge": "^2.6.0", + "use-image": "^1", + "zundo": "^2", "zustand": "^5.0.0" }, "devDependencies": { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index cfc52b47..55ce7e52 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,6 +4,7 @@ import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-route import { Toaster } from "sonner"; import { ConnectionMonitor } from "./components/common/connection-monitor"; import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider"; +import { AppLayout } from "./components/layout/app-layout"; import { useAuth } from "./hooks/use-auth"; import { identify, initAnalytics, setAnalyticsConsent } from "./lib/analytics"; import { useAnalyticsStore } from "./stores/analytics-store"; @@ -28,6 +29,9 @@ const PrivacyPolicyPage = lazy(() => const AnalyticsConsentPage = lazy(() => import("./pages/analytics-consent-page").then((m) => ({ default: m.AnalyticsConsentPage })), ); +const EditorPage = lazy(() => + import("./pages/editor-page").then((m) => ({ default: m.EditorPage })), +); const ToolPage = lazy(() => import("./pages/tool-page").then((m) => ({ default: m.ToolPage }))); class ErrorBoundary extends Component< @@ -224,6 +228,14 @@ export function App() { } /> } /> } /> + + + + } + /> } /> } /> diff --git a/apps/web/src/components/common/dropzone.tsx b/apps/web/src/components/common/dropzone.tsx index 02d4bb1b..efd05c91 100644 --- a/apps/web/src/components/common/dropzone.tsx +++ b/apps/web/src/components/common/dropzone.tsx @@ -16,7 +16,7 @@ interface DropzoneProps { // Append explicit extensions so they are selectable. function expandAccept(accept?: string): string | undefined { if (!accept?.includes("image/*")) return accept; - return `${accept},.heic,.heif,.hif,.jxl,.ico,.dng,.cr2,.nef,.arw,.orf,.rw2,.tga,.psd,.exr,.hdr`; + return `${accept},.heic,.heif,.hif,.jxl,.ico,.dng,.cr2,.cr3,.nef,.nrw,.arw,.orf,.rw2,.raf,.pef,.3fr,.iiq,.srw,.x3f,.rwl,.gpr,.fff,.mrw,.mef,.kdc,.dcr,.erf,.ptx,.tga,.psd,.exr,.hdr,.svgz,.jp2,.j2k,.j2c,.jpc,.jpf,.jpx,.qoi,.eps,.epsf,.dds,.cur,.apng,.dpx,.cin,.fits,.fit,.fts,.ppm,.pgm,.pbm,.pnm,.pam,.pfm`; } export function Dropzone({ diff --git a/apps/web/src/components/common/review-panel.tsx b/apps/web/src/components/common/review-panel.tsx index a0c38f76..b0bde0f7 100644 --- a/apps/web/src/components/common/review-panel.tsx +++ b/apps/web/src/components/common/review-panel.tsx @@ -1,7 +1,15 @@ import { TOOLS } from "@snapotter/shared"; -import { ArrowRight, ChevronDown, ChevronRight, Download, FileImage, Undo2 } from "lucide-react"; +import { + ArrowRight, + ChevronDown, + ChevronRight, + Download, + FileImage, + PenTool, + Undo2, +} from "lucide-react"; import { useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { formatFileSize, triggerDownload } from "@/lib/download"; import { ICON_MAP } from "@/lib/icon-map"; import { getSuggestedTools } from "@/lib/suggested-tools"; @@ -103,6 +111,15 @@ export function ReviewPanel({ + {/* Open in Editor */} + + + Open in Editor + + {/* Suggested tools */} {suggestedTools.length > 0 && (
diff --git a/apps/web/src/components/editor/common/canvas-resize-dialog.tsx b/apps/web/src/components/editor/common/canvas-resize-dialog.tsx new file mode 100644 index 00000000..5d1b64c9 --- /dev/null +++ b/apps/web/src/components/editor/common/canvas-resize-dialog.tsx @@ -0,0 +1,177 @@ +import { X } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import type { AnchorPosition } from "@/types/editor"; + +// --------------------------------------------------------------------------- +// CanvasResizeDialog -- modal with W/H, 9-point anchor grid, fill color +// --------------------------------------------------------------------------- + +const ANCHOR_POSITIONS: AnchorPosition[] = [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right", +]; + +export function CanvasResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const canvasSize = useEditorStore((s) => s.canvasSize); + const resizeCanvas = useEditorStore((s) => s.resizeCanvas); + + const [width, setWidth] = useState(canvasSize.width); + const [height, setHeight] = useState(canvasSize.height); + const [anchor, setAnchor] = useState("center"); + const [fill, setFill] = useState("#ffffff"); + + useEffect(() => { + if (open) { + setWidth(canvasSize.width); + setHeight(canvasSize.height); + } + }, [open, canvasSize]); + + const handleApply = useCallback(() => { + resizeCanvas(width, height, anchor, fill); + onClose(); + }, [width, height, anchor, fill, resizeCanvas, onClose]); + + if (!open) return null; + + const inputCn = cn( + "h-8 w-full rounded border border-border bg-card px-2 text-sm text-foreground", + "focus:border-primary focus:outline-none", + ); + + return ( +
+
+ {/* Header */} +
+

Canvas Size

+ +
+ + {/* Body */} +
+ {/* Current size info */} +

+ Current: {canvasSize.width} x {canvasSize.height} px +

+ + {/* Width / Height */} +
+
+ + setWidth(Math.max(1, Number(e.target.value) || 1))} + className={inputCn} + /> +
+
+ + setHeight(Math.max(1, Number(e.target.value) || 1))} + className={inputCn} + /> +
+
+ + {/* Anchor grid */} +
+

Anchor:

+
+ {ANCHOR_POSITIONS.map((pos) => ( +
+
+ + {/* Background fill */} +
+ + setFill(e.target.value)} + className="h-7 w-7 cursor-pointer rounded border border-border" + /> + setFill(e.target.value)} + className={cn( + "h-7 w-20 rounded border border-border bg-card px-1.5 text-xs text-foreground", + "focus:border-primary focus:outline-none", + )} + /> +
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/common/color-swatch.tsx b/apps/web/src/components/editor/common/color-swatch.tsx new file mode 100644 index 00000000..f5b90fa9 --- /dev/null +++ b/apps/web/src/components/editor/common/color-swatch.tsx @@ -0,0 +1,68 @@ +// apps/web/src/components/editor/common/color-swatch.tsx + +import { cn } from "@/lib/utils"; + +type SwatchSize = "sm" | "md" | "lg"; + +const SIZE_CLASSES: Record = { + sm: "w-5 h-5", + md: "w-7 h-7", + lg: "w-9 h-9", +}; + +// Checkerboard pattern for transparent colors +const CHECKERBOARD = + "repeating-conic-gradient(rgba(128,128,128,0.3) 0% 25%, transparent 0% 50%) 0 0 / 8px 8px"; + +interface ColorSwatchProps { + color: string; + size?: SwatchSize; + active?: boolean; + showBorder?: boolean; + onClick?: () => void; + className?: string; + label?: string; + "data-testid"?: string; +} + +export function ColorSwatch({ + color, + size = "md", + active, + showBorder = true, + onClick, + className, + label, + ...dataProps +}: ColorSwatchProps) { + const isTransparent = + color.length === 9 || + color.length === 5 || + color.toLowerCase().includes("rgba") || + color.toLowerCase().includes("hsla") || + color === "transparent"; + + return ( + + ); +} diff --git a/apps/web/src/components/editor/common/context-menu.tsx b/apps/web/src/components/editor/common/context-menu.tsx new file mode 100644 index 00000000..43dea68f --- /dev/null +++ b/apps/web/src/components/editor/common/context-menu.tsx @@ -0,0 +1,278 @@ +import { + ArrowDown, + ArrowUp, + ClipboardPaste, + Copy, + CopyPlus, + ImageIcon, + Maximize, + MousePointer, + Scissors, + Trash2, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; + +// --------------------------------------------------------------------------- +// Context menu state +// --------------------------------------------------------------------------- + +interface MenuPosition { + x: number; + y: number; +} + +interface MenuItem { + label: string; + icon: React.ComponentType<{ className?: string }>; + shortcut?: string; + action: () => void; + disabled?: boolean; + dividerAfter?: boolean; +} + +// --------------------------------------------------------------------------- +// Hook: useContextMenu +// --------------------------------------------------------------------------- + +export function useContextMenu() { + const [position, setPosition] = useState(null); + const [menuType, setMenuType] = useState<"object" | "canvas">("canvas"); + + const handleContextMenu = useCallback((e: React.MouseEvent, hasSelectedObject: boolean) => { + e.preventDefault(); + setPosition({ x: e.clientX, y: e.clientY }); + setMenuType(hasSelectedObject ? "object" : "canvas"); + }, []); + + const close = useCallback(() => { + setPosition(null); + }, []); + + return { position, menuType, handleContextMenu, close }; +} + +// --------------------------------------------------------------------------- +// ContextMenu component +// --------------------------------------------------------------------------- + +export function ContextMenu({ + position, + menuType, + onClose, + onCanvasResize, + onImageResize, +}: { + position: MenuPosition; + menuType: "object" | "canvas"; + onClose: () => void; + onCanvasResize?: () => void; + onImageResize?: () => void; +}) { + const menuRef = useRef(null); + + const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds); + const copyObjects = useEditorStore((s) => s.copyObjects); + const cutObjects = useEditorStore((s) => s.cutObjects); + const pasteObjects = useEditorStore((s) => s.pasteObjects); + const removeObjects = useEditorStore((s) => s.removeObjects); + const copyObjectsFn = useEditorStore((s) => s.copyObjects); + const pasteObjectsFn = useEditorStore((s) => s.pasteObjects); + const bringToFront = useEditorStore((s) => s.bringToFront); + const bringForward = useEditorStore((s) => s.bringForward); + const sendBackward = useEditorStore((s) => s.sendBackward); + const sendToBack = useEditorStore((s) => s.sendToBack); + const clipboard = useEditorStore((s) => s.clipboard); + const setSelection = useEditorStore((s) => s.setSelection); + const canvasSize = useEditorStore((s) => s.canvasSize); + + // Close on click outside or Escape + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", handleClick); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handleClick); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [onClose]); + + const objectItems: MenuItem[] = [ + { + label: "Cut", + icon: Scissors, + shortcut: "Ctrl+X", + action: () => { + cutObjects(); + onClose(); + }, + }, + { + label: "Copy", + icon: Copy, + shortcut: "Ctrl+C", + action: () => { + copyObjects(); + onClose(); + }, + }, + { + label: "Paste", + icon: ClipboardPaste, + shortcut: "Ctrl+V", + action: () => { + pasteObjects(); + onClose(); + }, + disabled: !clipboard || clipboard.length === 0, + }, + { + label: "Duplicate", + icon: CopyPlus, + shortcut: "Ctrl+D", + action: () => { + copyObjectsFn(); + pasteObjectsFn(); + onClose(); + }, + dividerAfter: true, + }, + { + label: "Bring to Front", + icon: ArrowUp, + action: () => { + for (const id of selectedObjectIds) bringToFront(id); + onClose(); + }, + }, + { + label: "Bring Forward", + icon: ArrowUp, + action: () => { + for (const id of selectedObjectIds) bringForward(id); + onClose(); + }, + }, + { + label: "Send Backward", + icon: ArrowDown, + action: () => { + for (const id of selectedObjectIds) sendBackward(id); + onClose(); + }, + }, + { + label: "Send to Back", + icon: ArrowDown, + action: () => { + for (const id of selectedObjectIds) sendToBack(id); + onClose(); + }, + dividerAfter: true, + }, + { + label: "Delete", + icon: Trash2, + shortcut: "Del", + action: () => { + removeObjects(selectedObjectIds); + onClose(); + }, + }, + ]; + + const canvasItems: MenuItem[] = [ + { + label: "Paste", + icon: ClipboardPaste, + shortcut: "Ctrl+V", + action: () => { + pasteObjects(); + onClose(); + }, + disabled: !clipboard || clipboard.length === 0, + }, + { + label: "Select All", + icon: MousePointer, + shortcut: "Ctrl+A", + action: () => { + setSelection({ + type: "rect", + points: [], + bounds: { + x: 0, + y: 0, + width: canvasSize.width, + height: canvasSize.height, + }, + }); + onClose(); + }, + dividerAfter: true, + }, + { + label: "Canvas Size...", + icon: Maximize, + action: () => { + onCanvasResize?.(); + onClose(); + }, + }, + { + label: "Image Size...", + icon: ImageIcon, + action: () => { + onImageResize?.(); + onClose(); + }, + }, + ]; + + const items = menuType === "object" ? objectItems : canvasItems; + + // Adjust position to stay within viewport + const adjustedX = Math.min(position.x, window.innerWidth - 220); + const adjustedY = Math.min(position.y, window.innerHeight - items.length * 36); + + return ( +
+ {items.map((item) => ( +
+ + {item.dividerAfter &&
} +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/editor/common/custom-cursor.tsx b/apps/web/src/components/editor/common/custom-cursor.tsx new file mode 100644 index 00000000..f5813946 --- /dev/null +++ b/apps/web/src/components/editor/common/custom-cursor.tsx @@ -0,0 +1,87 @@ +// apps/web/src/components/editor/common/custom-cursor.tsx +import { useEditorStore } from "@/stores/editor-store"; +import type { ToolType } from "@/types/editor"; + +const TOOL_CURSORS: Record = { + move: "default", + "marquee-rect": "crosshair", + "marquee-ellipse": "crosshair", + "lasso-free": "crosshair", + "lasso-poly": "crosshair", + "magic-wand": "crosshair", + crop: "crosshair", + eyedropper: "crosshair", + brush: "none", + eraser: "none", + pencil: "none", + "clone-stamp": "none", + dodge: "none", + burn: "none", + sponge: "none", + "blur-brush": "none", + "sharpen-brush": "none", + smudge: "none", + fill: "crosshair", + gradient: "crosshair", + "shape-rect": "crosshair", + "shape-ellipse": "crosshair", + "shape-line": "crosshair", + "shape-arrow": "crosshair", + "shape-polygon": "crosshair", + "shape-star": "crosshair", + text: "text", + hand: "grab", + zoom: "zoom-in", + transform: "default", +}; + +const BRUSH_CURSOR_TOOLS = new Set([ + "brush", + "eraser", + "pencil", + "clone-stamp", + "dodge", + "burn", + "sponge", + "blur-brush", + "sharpen-brush", + "smudge", +]); + +export function useEditorCursor(): string { + const activeTool = useEditorStore((s) => s.activeTool); + const isSpaceHeld = useEditorStore((s) => s.isSpaceHeld); + if (isSpaceHeld) return "grab"; + return TOOL_CURSORS[activeTool] || "default"; +} + +interface BrushCursorOverlayProps { + containerRef: React.RefObject; + screenCursor: { x: number; y: number }; +} + +export function BrushCursorOverlay({ screenCursor }: BrushCursorOverlayProps) { + const activeTool = useEditorStore((s) => s.activeTool); + const brushSize = useEditorStore((s) => s.brushSize); + const zoom = useEditorStore((s) => s.zoom); + + if (!BRUSH_CURSOR_TOOLS.has(activeTool)) return null; + + const displaySize = brushSize * zoom; + const isEraser = activeTool === "eraser"; + + return ( +
+ ); +} diff --git a/apps/web/src/components/editor/common/export-dialog.tsx b/apps/web/src/components/editor/common/export-dialog.tsx new file mode 100644 index 00000000..690deb9c --- /dev/null +++ b/apps/web/src/components/editor/common/export-dialog.tsx @@ -0,0 +1,770 @@ +// apps/web/src/components/editor/common/export-dialog.tsx + +import { + Check, + ClipboardCopy, + Download, + FileDown, + FileUp, + Lock, + Save, + Unlock, + X, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { editorStageRefHolder } from "@/components/editor/editor-canvas"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import type { + AdjustmentValues, + CanvasObject, + EditorLayer, + FilterConfig, + Guide, +} from "@/types/editor"; + +type ExportFormat = "png" | "jpeg" | "webp" | "avif" | "tiff" | "gif" | "jxl"; + +interface ExportSettings { + format: ExportFormat; + quality: number; + width: number; + height: number; + lockAspect: boolean; + transparent: boolean; +} + +const FORMAT_OPTIONS: { + value: ExportFormat; + label: string; + supportsTransparency: boolean; + needsServerConvert: boolean; +}[] = [ + { value: "png", label: "PNG", supportsTransparency: true, needsServerConvert: false }, + { value: "jpeg", label: "JPEG", supportsTransparency: false, needsServerConvert: false }, + { value: "webp", label: "WebP", supportsTransparency: true, needsServerConvert: false }, + { value: "avif", label: "AVIF", supportsTransparency: true, needsServerConvert: true }, + { value: "tiff", label: "TIFF", supportsTransparency: true, needsServerConvert: true }, + { value: "gif", label: "GIF", supportsTransparency: true, needsServerConvert: true }, + { value: "jxl", label: "JXL", supportsTransparency: true, needsServerConvert: true }, +]; + +function getMimeType(format: ExportFormat): string { + const mimes: Record = { + png: "image/png", + jpeg: "image/jpeg", + webp: "image/webp", + avif: "image/avif", + tiff: "image/tiff", + gif: "image/gif", + jxl: "image/jxl", + }; + return mimes[format]; +} + +export function ExportDialog({ onClose }: { onClose: () => void }) { + const canvasSize = useEditorStore((s) => s.canvasSize); + const markClean = useEditorStore((s) => s.markClean); + + const [settings, setSettings] = useState({ + format: "png", + quality: 92, + width: canvasSize.width, + height: canvasSize.height, + lockAspect: true, + transparent: true, + }); + const [previewUrl, setPreviewUrl] = useState(null); + const [copyStatus, setCopyStatus] = useState<"idle" | "copied">("idle"); + + const aspectRatio = canvasSize.width / canvasSize.height; + const dialogRef = useRef(null); + + // Issue #6: Use Konva stage ref for proper export instead of DOM query + const generatePreview = useCallback(() => { + const stage = editorStageRefHolder.current; + if (!stage) return; + + const maxPreview = 200; + const scale = Math.min(maxPreview / canvasSize.width, maxPreview / canvasSize.height); + + // For server-convert formats the Canvas API cannot produce a preview, + // so fall back to PNG for the thumbnail. + const fmtOpt = FORMAT_OPTIONS.find((o) => o.value === settings.format); + const previewMime = fmtOpt?.needsServerConvert ? "image/png" : getMimeType(settings.format); + + const url = stage.toDataURL({ + pixelRatio: scale, + mimeType: previewMime, + quality: settings.quality / 100, + x: 0, + y: 0, + width: canvasSize.width, + height: canvasSize.height, + }); + setPreviewUrl(url); + }, [canvasSize, settings.format, settings.quality]); + + // Generate preview thumbnail on format/transparency change + useEffect(() => { + generatePreview(); + }, [generatePreview]); + + // Handle width change with aspect lock + const handleWidthChange = useCallback( + (w: number) => { + const newWidth = Math.max(1, w); + if (settings.lockAspect) { + setSettings((prev) => ({ + ...prev, + width: newWidth, + height: Math.round(newWidth / aspectRatio), + })); + } else { + setSettings((prev) => ({ ...prev, width: newWidth })); + } + }, + [settings.lockAspect, aspectRatio], + ); + + // Handle height change with aspect lock + const handleHeightChange = useCallback( + (h: number) => { + const newHeight = Math.max(1, h); + if (settings.lockAspect) { + setSettings((prev) => ({ + ...prev, + height: newHeight, + width: Math.round(newHeight * aspectRatio), + })); + } else { + setSettings((prev) => ({ ...prev, height: newHeight })); + } + }, + [settings.lockAspect, aspectRatio], + ); + + // Issue #6: Export using Konva stage.toDataURL for correct output + const handleExport = useCallback(() => { + const stage = editorStageRefHolder.current; + if (!stage) return; + + const pixelRatio = settings.width / canvasSize.width; + + // Server-side convert for formats the Canvas API cannot produce + const formatOption = FORMAT_OPTIONS.find((o) => o.value === settings.format); + if (formatOption?.needsServerConvert) { + let stageCanvas: HTMLCanvasElement; + if (!settings.transparent || settings.format === "jpeg") { + const raw = stage.toCanvas({ + pixelRatio, + x: 0, + y: 0, + width: canvasSize.width, + height: canvasSize.height, + }); + const exportCanvas = document.createElement("canvas"); + exportCanvas.width = raw.width; + exportCanvas.height = raw.height; + const ctx = exportCanvas.getContext("2d"); + if (!ctx) return; + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height); + ctx.drawImage(raw, 0, 0); + stageCanvas = exportCanvas; + } else { + stageCanvas = stage.toCanvas({ + pixelRatio, + x: 0, + y: 0, + width: canvasSize.width, + height: canvasSize.height, + }); + } + + stageCanvas.toBlob(async (blob) => { + if (!blob) return; + const formData = new FormData(); + formData.append("file", blob, "export.png"); + formData.append( + "settings", + JSON.stringify({ format: settings.format, quality: settings.quality }), + ); + try { + const res = await fetch("/api/v1/tools/convert", { + method: "POST", + body: formData, + }); + if (!res.ok) throw new Error("Server convert failed"); + const json = await res.json(); + if (json.downloadUrl) { + const a = document.createElement("a"); + a.href = json.downloadUrl; + a.download = `export.${settings.format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + markClean(); + } + } catch (err) { + console.error("Server-side export failed:", err); + } + }, "image/png"); + return; + } + + let dataUrl: string; + + if (!settings.transparent || settings.format === "jpeg") { + // Create canvas with white background for non-transparent exports + const stageCanvas = stage.toCanvas({ + pixelRatio, + x: 0, + y: 0, + width: canvasSize.width, + height: canvasSize.height, + }); + const exportCanvas = document.createElement("canvas"); + exportCanvas.width = stageCanvas.width; + exportCanvas.height = stageCanvas.height; + const ctx = exportCanvas.getContext("2d"); + if (!ctx) return; + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height); + ctx.drawImage(stageCanvas, 0, 0); + dataUrl = exportCanvas.toDataURL( + `image/${settings.format === "jpeg" ? "jpeg" : "png"}`, + settings.format === "jpeg" ? settings.quality / 100 : undefined, + ); + } else { + dataUrl = stage.toDataURL({ + pixelRatio, + mimeType: getMimeType(settings.format), + quality: settings.format === "png" ? undefined : settings.quality / 100, + x: 0, + y: 0, + width: canvasSize.width, + height: canvasSize.height, + }); + } + + // Convert data URL to blob for download + fetch(dataUrl) + .then((res) => res.blob()) + .then((blob) => { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `export.${settings.format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + markClean(); + }) + .catch((err) => { + console.error("Export failed:", err); + }); + }, [settings, canvasSize, markClean]); + + // Issue #6: Copy to clipboard using Konva stage + const handleCopyToClipboard = useCallback(async () => { + const stage = editorStageRefHolder.current; + if (!stage) return; + + const pixelRatio = settings.width / canvasSize.width; + const dataUrl = stage.toDataURL({ + pixelRatio, + mimeType: "image/png", + x: 0, + y: 0, + width: canvasSize.width, + height: canvasSize.height, + }); + + try { + const res = await fetch(dataUrl); + const blob = await res.blob(); + await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); + setCopyStatus("copied"); + setTimeout(() => setCopyStatus("idle"), 2000); + } catch (err) { + console.error("Copy to clipboard failed:", err); + } + }, [settings, canvasSize]); + + // Project save (.snapotter file) + const handleSaveProject = useCallback(() => { + const state = useEditorStore.getState(); + const projectData = { + version: 1, + canvasSize: state.canvasSize, + layers: state.layers, + objects: state.objects, + adjustments: state.adjustments, + filters: state.filters, + guides: state.guides, + sourceImageUrl: state.sourceImageUrl, + sourceImageSize: state.sourceImageSize, + foregroundColor: state.foregroundColor, + backgroundColor: state.backgroundColor, + }; + + const json = JSON.stringify(projectData, null, 2); + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "project.snapotter"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + markClean(); + }, [markClean]); + + // Project load (.snapotter file) + const handleLoadProject = useCallback(() => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".snapotter,.json"; + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + try { + const data = JSON.parse(reader.result as string); + if (!data.version || !data.canvasSize) return; + + const store = useEditorStore.getState(); + const setState = useEditorStore.setState; + + setState({ + canvasSize: data.canvasSize, + layers: data.layers || store.layers, + objects: data.objects || [], + adjustments: data.adjustments || store.adjustments, + filters: data.filters || store.filters, + guides: data.guides || [], + sourceImageUrl: data.sourceImageUrl || null, + sourceImageSize: data.sourceImageSize || null, + foregroundColor: data.foregroundColor || "#000000", + backgroundColor: data.backgroundColor || "#ffffff", + isDirty: false, + lastAction: "Load Project", + _historyVersion: store._historyVersion + 1, + }); + + onClose(); + } catch { + // Invalid project file + } + }; + reader.readAsText(file); + }; + input.click(); + }, [onClose]); + + // Close on Escape + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [onClose]); + + // Close on backdrop click + const handleBackdropClick = useCallback( + (e: React.MouseEvent) => { + if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) { + onClose(); + } + }, + [onClose], + ); + + const supportsQuality = settings.format !== "png"; + const supportsTransparency = settings.format !== "jpeg"; + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: modal backdrop click-to-dismiss uses Escape as keyboard equivalent +
+
+ {/* Header */} +
+

Export Image

+ +
+ + {/* Body */} +
+ {/* Preview */} + {previewUrl && ( +
+ Export preview +
+ )} + + {/* Format */} +
+ Format +
+ {FORMAT_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* Quality */} + {supportsQuality && ( +
+
+ Quality + + {settings.quality}% + +
+ + setSettings((prev) => ({ ...prev, quality: Number.parseInt(e.target.value, 10) })) + } + className={cn( + "w-full h-1.5 appearance-none rounded-full bg-muted", + "[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3", + "[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:cursor-pointer", + )} + /> +
+ )} + + {/* Dimensions */} +
+ + Dimensions + +
+
+ handleWidthChange(Number.parseInt(e.target.value, 10) || 1)} + className="w-full px-2 py-1 text-xs bg-muted rounded border border-border text-foreground outline-none focus:border-primary" + min={1} + /> + Width +
+ +
+ handleHeightChange(Number.parseInt(e.target.value, 10) || 1)} + className="w-full px-2 py-1 text-xs bg-muted rounded border border-border text-foreground outline-none focus:border-primary" + min={1} + /> + Height +
+
+ +
+ + {/* Transparent background */} + {supportsTransparency && ( + + )} +
+ + {/* Footer actions */} +
+ {/* Primary export actions */} +
+ + +
+ + {/* Project save/load */} +
+ + +
+
+
+
+ ); +} + +// ---- Autosave utilities (Feature 44) ---- + +const AUTOSAVE_KEY = "snapotter-editor-autosave"; +const AUTOSAVE_INTERVAL_MS = 60_000; + +interface AutosaveState { + canvasSize: { width: number; height: number }; + layers: EditorLayer[]; + objects: CanvasObject[]; + adjustments: AdjustmentValues; + filters: FilterConfig[]; + guides: Guide[]; + sourceImageUrl: string | null; + sourceImageSize: { width: number; height: number } | null; + foregroundColor: string; + backgroundColor: string; +} + +interface AutosaveData { + version: 1; + timestamp: number; + state: AutosaveState; +} + +export function saveEditorState(): void { + try { + const s = useEditorStore.getState(); + const data: AutosaveData = { + version: 1, + timestamp: Date.now(), + state: { + canvasSize: s.canvasSize, + layers: s.layers, + objects: s.objects, + adjustments: s.adjustments, + filters: s.filters, + guides: s.guides, + sourceImageUrl: s.sourceImageUrl, + sourceImageSize: s.sourceImageSize, + foregroundColor: s.foregroundColor, + backgroundColor: s.backgroundColor, + }, + }; + localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data)); + useEditorStore.setState({ lastAutoSave: Date.now() }); + } catch { + // localStorage might be full or unavailable + } +} + +export function loadAutosaveState(): AutosaveData | null { + try { + const raw = localStorage.getItem(AUTOSAVE_KEY); + if (!raw) return null; + const data = JSON.parse(raw) as AutosaveData; + if (data.version !== 1 || !data.state?.canvasSize) return null; + return data; + } catch { + return null; + } +} + +export function clearAutosave(): void { + try { + localStorage.removeItem(AUTOSAVE_KEY); + } catch { + // ignore + } +} + +export function restoreAutosave(data: AutosaveData): void { + const store = useEditorStore.getState(); + useEditorStore.setState({ + ...data.state, + isDirty: true, + lastAction: "Restore Autosave", + _historyVersion: store._historyVersion + 1, + }); +} + +/** + * Hook to run autosave on an interval. Call this in EditorPage. + * Returns recovery state if found on mount. + */ +export function useAutosave(): { + recoveryData: AutosaveData | null; + dismissRecovery: () => void; + restoreRecovery: () => void; +} { + const [recoveryData, setRecoveryData] = useState(null); + const isDirty = useEditorStore((s) => s.isDirty); + const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); + + // Check for recovery on mount + useEffect(() => { + const data = loadAutosaveState(); + if (data) { + setRecoveryData(data); + } + }, []); + + // Autosave interval + useEffect(() => { + if (!sourceImageUrl) return; + + const timer = setInterval(() => { + if (isDirty) { + if (typeof requestIdleCallback === "function") { + requestIdleCallback(() => saveEditorState()); + } else { + saveEditorState(); + } + } + }, AUTOSAVE_INTERVAL_MS); + + return () => clearInterval(timer); + }, [isDirty, sourceImageUrl]); + + const dismissRecovery = useCallback(() => { + clearAutosave(); + setRecoveryData(null); + }, []); + + const handleRestore = useCallback(() => { + if (recoveryData) { + restoreAutosave(recoveryData); + clearAutosave(); + setRecoveryData(null); + } + }, [recoveryData]); + + return { recoveryData, dismissRecovery, restoreRecovery: handleRestore }; +} + +/** + * Recovery banner component for display at the top of the editor. + */ +export function AutosaveRecoveryBanner({ + data, + onRestore, + onDiscard, +}: { + data: AutosaveData; + onRestore: () => void; + onDiscard: () => void; +}) { + const timeStr = new Date(data.timestamp).toLocaleString(); + + return ( +
+ + Recovered unsaved work from {timeStr}. +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/editor/common/fill-dialog.tsx b/apps/web/src/components/editor/common/fill-dialog.tsx new file mode 100644 index 00000000..bf66d3a4 --- /dev/null +++ b/apps/web/src/components/editor/common/fill-dialog.tsx @@ -0,0 +1,217 @@ +// apps/web/src/components/editor/common/fill-dialog.tsx + +import { useCallback, useEffect, useState } from "react"; +import { cn, generateId } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import type { CanvasObject } from "@/types/editor"; + +type FillContent = "foreground" | "background" | "color" | "white" | "black" | "50gray"; + +interface FillDialogProps { + open: boolean; + onClose: () => void; +} + +const FILL_PRESETS: { value: FillContent; label: string }[] = [ + { value: "foreground", label: "Foreground Color" }, + { value: "background", label: "Background Color" }, + { value: "color", label: "Color..." }, + { value: "white", label: "White" }, + { value: "black", label: "Black" }, + { value: "50gray", label: "50% Gray" }, +]; + +function resolveColor( + content: FillContent, + customColor: string, + foreground: string, + background: string, +): string { + switch (content) { + case "foreground": + return foreground; + case "background": + return background; + case "color": + return customColor; + case "white": + return "#ffffff"; + case "black": + return "#000000"; + case "50gray": + return "#808080"; + } +} + +export function FillDialog({ open, onClose }: FillDialogProps) { + const [content, setContent] = useState("foreground"); + const [customColor, setCustomColor] = useState("#ff0000"); + const [opacity, setOpacity] = useState(100); + + const foregroundColor = useEditorStore((s) => s.foregroundColor); + const backgroundColor = useEditorStore((s) => s.backgroundColor); + const canvasSize = useEditorStore((s) => s.canvasSize); + const activeLayerId = useEditorStore((s) => s.activeLayerId); + const addObject = useEditorStore((s) => s.addObject); + + // Close on Escape + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onClose]); + + const handleFill = useCallback(() => { + const fillColor = resolveColor(content, customColor, foregroundColor, backgroundColor); + + // Create a canvas with the solid fill + const canvas = document.createElement("canvas"); + canvas.width = canvasSize.width; + canvas.height = canvasSize.height; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.globalAlpha = opacity / 100; + ctx.fillStyle = fillColor; + ctx.fillRect(0, 0, canvasSize.width, canvasSize.height); + + const dataUrl = canvas.toDataURL(); + + const obj: CanvasObject = { + id: generateId(), + type: "image", + layerId: activeLayerId, + attrs: { + x: 0, + y: 0, + width: canvasSize.width, + height: canvasSize.height, + rotation: 0, + opacity: 1, + src: dataUrl, + }, + }; + + addObject(obj); + onClose(); + }, [ + content, + customColor, + foregroundColor, + backgroundColor, + canvasSize, + activeLayerId, + opacity, + addObject, + onClose, + ]); + + if (!open) return null; + + return ( +
+
+

Fill

+ + {/* Content selector */} +
+ Contents + +
+ + {/* Custom color picker (only when "color" selected) */} + {content === "color" && ( +
+ Custom Color + setCustomColor(e.target.value)} + className="w-full h-8 border border-border rounded cursor-pointer" + /> +
+ )} + + {/* Opacity */} +
+ Opacity +
+ setOpacity(Number(e.target.value))} + className="flex-1 h-1 accent-primary" + /> + setOpacity(Number(e.target.value))} + className="w-14 h-7 text-xs text-center bg-muted border border-border rounded px-1" + /> + % +
+
+ + {/* Color preview */} +
+ Preview: +
+
+ + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/common/font-loader.ts b/apps/web/src/components/editor/common/font-loader.ts new file mode 100644 index 00000000..fb743909 --- /dev/null +++ b/apps/web/src/components/editor/common/font-loader.ts @@ -0,0 +1,81 @@ +// apps/web/src/components/editor/common/font-loader.ts + +const SYSTEM_FONTS = [ + "Arial", + "Helvetica", + "Georgia", + "Times New Roman", + "Verdana", + "Courier New", + "Trebuchet MS", + "Impact", + "Comic Sans MS", +] as const; + +const GOOGLE_FONTS = [ + "Inter", + "Roboto", + "Open Sans", + "Lato", + "Montserrat", + "Poppins", + "Source Sans 3", + "Playfair Display", + "Merriweather", + "Raleway", + "Oswald", + "Nunito", + "Ubuntu", + "PT Sans", + "Fira Sans", + "Work Sans", + "Barlow", + "DM Sans", + "Space Grotesk", + "Bebas Neue", + "Caveat", + "Pacifico", + "Dancing Script", + "Permanent Marker", + "Press Start 2P", +] as const; + +const loadedFonts = new Set(); + +export function isSystemFont(name: string): boolean { + return (SYSTEM_FONTS as readonly string[]).includes(name); +} + +export function getAllFonts(): { system: string[]; google: string[] } { + return { + system: [...SYSTEM_FONTS], + google: [...GOOGLE_FONTS], + }; +} + +export async function loadGoogleFont(name: string): Promise { + if (isSystemFont(name) || loadedFonts.has(name)) return; + + const slug = name.replace(/ /g, "+"); + const url = `https://fonts.googleapis.com/css2?family=${slug}:wght@400;700&display=swap`; + + // Add the stylesheet link so the browser fetches the font files + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = url; + document.head.appendChild(link); + + // Use the CSS Font Loading API to detect when the font is actually ready + try { + await document.fonts.load(`16px "${name}"`); + loadedFonts.add(name); + } catch { + // Font may still load via the stylesheet even if the API rejects; + // mark as loaded so we don't retry endlessly. + loadedFonts.add(name); + } +} + +export function isFontLoaded(name: string): boolean { + return isSystemFont(name) || loadedFonts.has(name); +} diff --git a/apps/web/src/components/editor/common/guide-lines.tsx b/apps/web/src/components/editor/common/guide-lines.tsx new file mode 100644 index 00000000..c87fb5c9 --- /dev/null +++ b/apps/web/src/components/editor/common/guide-lines.tsx @@ -0,0 +1,110 @@ +import type Konva from "konva"; +import { useCallback, useRef } from "react"; +import { Group, Line } from "react-konva"; +import { useEditorStore } from "@/stores/editor-store"; + +// --------------------------------------------------------------------------- +// GuideLines -- draggable guide lines rendered as Konva.Line +// --------------------------------------------------------------------------- + +const GUIDE_COLOR = "#22d3ee"; // cyan-400 +const GUIDE_WIDTH = 1; + +export function GuideLines() { + const guides = useEditorStore((s) => s.guides); + const showGuides = useEditorStore((s) => s.guidesVisible); + const canvasSize = useEditorStore((s) => s.canvasSize); + const updateGuide = useEditorStore((s) => s.updateGuide); + const removeGuide = useEditorStore((s) => s.removeGuide); + + if (!showGuides || guides.length === 0) return null; + + return ( + + {guides.map((guide) => ( + updateGuide(guide.id, pos)} + onRemove={() => removeGuide(guide.id)} + /> + ))} + + ); +} + +// --------------------------------------------------------------------------- +// DraggableGuide -- individual guide line +// --------------------------------------------------------------------------- + +function DraggableGuide({ + id, + orientation, + position, + canvasWidth, + canvasHeight, + onPositionChange, + onRemove, +}: { + id: string; + orientation: "horizontal" | "vertical"; + position: number; + canvasWidth: number; + canvasHeight: number; + onPositionChange: (pos: number) => void; + onRemove: () => void; +}) { + const lineRef = useRef(null); + + const isHorizontal = orientation === "horizontal"; + + const points = isHorizontal + ? [0, position, canvasWidth, position] + : [position, 0, position, canvasHeight]; + + const handleDragEnd = useCallback( + (e: Konva.KonvaEventObject) => { + const node = e.target; + if (isHorizontal) { + const newY = node.y() + position; + node.y(0); // reset drag offset + onPositionChange(newY); + } else { + const newX = node.x() + position; + node.x(0); + onPositionChange(newX); + } + }, + [isHorizontal, position, onPositionChange], + ); + + const handleDblClick = useCallback(() => { + onRemove(); + }, [onRemove]); + + return ( + { + // Constrain drag to the guide's axis + if (isHorizontal) { + return { x: 0, y: pos.y }; + } + return { x: pos.x, y: 0 }; + }} + onDragEnd={handleDragEnd} + onDblClick={handleDblClick} + hitStrokeWidth={8} + /> + ); +} diff --git a/apps/web/src/components/editor/common/icon-button.tsx b/apps/web/src/components/editor/common/icon-button.tsx new file mode 100644 index 00000000..04e503a1 --- /dev/null +++ b/apps/web/src/components/editor/common/icon-button.tsx @@ -0,0 +1,52 @@ +// apps/web/src/components/editor/common/icon-button.tsx +import type { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface IconButtonProps { + icon: LucideIcon; + label: string; + shortcut?: string; + active?: boolean; + disabled?: boolean; + size?: number; + onClick?: () => void; + onContextMenu?: (e: React.MouseEvent) => void; + className?: string; + "data-testid"?: string; + "data-tool"?: string; + "data-tool-active"?: string; +} + +export function IconButton({ + icon: Icon, + label, + shortcut, + active, + disabled, + size = 18, + onClick, + onContextMenu, + className, + ...dataProps +}: IconButtonProps) { + return ( + + ); +} diff --git a/apps/web/src/components/editor/common/image-resize-dialog.tsx b/apps/web/src/components/editor/common/image-resize-dialog.tsx new file mode 100644 index 00000000..1a1f839c --- /dev/null +++ b/apps/web/src/components/editor/common/image-resize-dialog.tsx @@ -0,0 +1,199 @@ +import { Lock, Unlock, X } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; + +type ResampleMethod = "nearest" | "bilinear" | "bicubic" | "lanczos"; + +// --------------------------------------------------------------------------- +// ImageResizeDialog -- modal with W/H, aspect lock, resampling method +// --------------------------------------------------------------------------- + +const RESAMPLE_METHODS: { value: ResampleMethod; label: string }[] = [ + { value: "nearest", label: "Nearest Neighbor (fast)" }, + { value: "bilinear", label: "Bilinear" }, + { value: "bicubic", label: "Bicubic (smooth)" }, + { value: "lanczos", label: "Lanczos (sharp)" }, +]; + +export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const canvasSize = useEditorStore((s) => s.canvasSize); + const resizeImage = useEditorStore((s) => s.resizeImage); + + const [width, setWidth] = useState(canvasSize.width); + const [height, setHeight] = useState(canvasSize.height); + const [lockAspect, setLockAspect] = useState(true); + const [resample, setResample] = useState("bicubic"); + + const aspectRatio = canvasSize.width / canvasSize.height; + + // Sync when dialog opens + useEffect(() => { + if (open) { + setWidth(canvasSize.width); + setHeight(canvasSize.height); + } + }, [open, canvasSize]); + + const handleWidthChange = useCallback( + (e: React.ChangeEvent) => { + const w = Math.max(1, Number(e.target.value) || 1); + setWidth(w); + if (lockAspect) { + setHeight(Math.round(w / aspectRatio)); + } + }, + [lockAspect, aspectRatio], + ); + + const handleHeightChange = useCallback( + (e: React.ChangeEvent) => { + const h = Math.max(1, Number(e.target.value) || 1); + setHeight(h); + if (lockAspect) { + setWidth(Math.round(h * aspectRatio)); + } + }, + [lockAspect, aspectRatio], + ); + + const handleApply = useCallback(() => { + resizeImage(width, height, resample); + onClose(); + }, [width, height, resample, resizeImage, onClose]); + + const pctWidth = canvasSize.width > 0 ? ((width / canvasSize.width) * 100).toFixed(1) : "100.0"; + const pctHeight = + canvasSize.height > 0 ? ((height / canvasSize.height) * 100).toFixed(1) : "100.0"; + + if (!open) return null; + + const inputCn = cn( + "h-8 w-full rounded border border-border bg-card px-2 text-sm text-foreground", + "focus:border-primary focus:outline-none", + ); + + return ( +
+
+ {/* Header */} +
+

Image Size

+ +
+ + {/* Body */} +
+ {/* Current size info */} +

+ Original: {canvasSize.width} x {canvasSize.height} px +

+ + {/* Width / Lock / Height */} +
+
+ + +

{pctWidth}%

+
+ + + +
+ + +

{pctHeight}%

+
+
+ + {/* Resampling method */} +
+ + +
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/common/loading-overlay.tsx b/apps/web/src/components/editor/common/loading-overlay.tsx new file mode 100644 index 00000000..21faa990 --- /dev/null +++ b/apps/web/src/components/editor/common/loading-overlay.tsx @@ -0,0 +1,55 @@ +// apps/web/src/components/editor/common/loading-overlay.tsx +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; + +export function LoadingOverlay() { + const loadingState = useEditorStore((s) => s.loadingState); + const setLoadingState = useEditorStore((s) => s.setLoadingState); + + if (!loadingState) return null; + + return ( +
+
+ {/* Operation name */} +

{loadingState.operation}

+ + {/* Progress bar */} + {loadingState.progress !== null && ( +
+
= 0 + ? { width: `${Math.min(100, loadingState.progress)}%` } + : undefined + } + /> +
+ )} + + {/* Indeterminate spinner when no progress */} + {loadingState.progress === null && ( +
+ )} + + {/* Cancel button */} + {loadingState.cancellable && ( + + )} +
+
+ ); +} diff --git a/apps/web/src/components/editor/common/new-document-dialog.tsx b/apps/web/src/components/editor/common/new-document-dialog.tsx new file mode 100644 index 00000000..c0cbe727 --- /dev/null +++ b/apps/web/src/components/editor/common/new-document-dialog.tsx @@ -0,0 +1,166 @@ +// apps/web/src/components/editor/common/new-document-dialog.tsx +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; + +const PRESETS = [ + { label: "Custom", width: 1920, height: 1080 }, + { label: "1920x1080 (HD)", width: 1920, height: 1080 }, + { label: "3840x2160 (4K)", width: 3840, height: 2160 }, + { label: "1080x1080 (Instagram)", width: 1080, height: 1080 }, + { label: "1200x628 (Facebook)", width: 1200, height: 628 }, + { label: "800x600", width: 800, height: 600 }, + { label: "1280x720", width: 1280, height: 720 }, +]; + +const BACKGROUNDS = ["White", "Black", "Transparent"] as const; + +interface NewDocumentDialogProps { + open: boolean; + onClose: () => void; +} + +export function NewDocumentDialog({ open, onClose }: NewDocumentDialogProps) { + const [width, setWidth] = useState(1920); + const [height, setHeight] = useState(1080); + const [preset, setPreset] = useState("1920x1080 (HD)"); + const [background, setBackground] = useState<(typeof BACKGROUNDS)[number]>("White"); + const loadImage = useEditorStore((s) => s.loadImage); + + if (!open) return null; + + const handlePresetChange = (e: React.ChangeEvent) => { + const selected = PRESETS.find((p) => p.label === e.target.value); + if (selected) { + setPreset(selected.label); + if (selected.label !== "Custom") { + setWidth(selected.width); + setHeight(selected.height); + } + } + }; + + const handleCreate = () => { + const validWidth = Math.max(1, Math.min(10000, width)); + const validHeight = Math.max(1, Math.min(10000, height)); + const canvas = document.createElement("canvas"); + canvas.width = validWidth; + canvas.height = validHeight; + const ctx = canvas.getContext("2d"); + if (ctx) { + if (background === "White") { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, validWidth, validHeight); + } else if (background === "Black") { + ctx.fillStyle = "#000000"; + ctx.fillRect(0, 0, validWidth, validHeight); + } + } + const url = canvas.toDataURL("image/png"); + loadImage(url, validWidth, validHeight); + onClose(); + }; + + return ( +
+
+

New Document

+ +
+
+ + +
+ +
+
+ + { + setWidth(Number(e.target.value)); + setPreset("Custom"); + }} + className="w-full mt-1 px-2 py-1.5 bg-muted border border-border rounded text-sm text-foreground" + min={1} + max={10000} + /> +
+
+ + { + setHeight(Number(e.target.value)); + setPreset("Custom"); + }} + className="w-full mt-1 px-2 py-1.5 bg-muted border border-border rounded text-sm text-foreground" + min={1} + max={10000} + /> +
+
+ +
+ Background +
+ {BACKGROUNDS.map((bg) => ( + + ))} +
+
+
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/common/rulers.tsx b/apps/web/src/components/editor/common/rulers.tsx new file mode 100644 index 00000000..cd4fc4df --- /dev/null +++ b/apps/web/src/components/editor/common/rulers.tsx @@ -0,0 +1,253 @@ +import { useCallback, useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; + +// --------------------------------------------------------------------------- +// Ruler configuration +// --------------------------------------------------------------------------- + +const RULER_SIZE = 20; // px +const TICK_COLOR = "var(--color-muted-foreground)"; +const BG_COLOR = "var(--color-card)"; +const TEXT_COLOR = "var(--color-muted-foreground)"; + +// --------------------------------------------------------------------------- +// Helper: pick tick spacing based on zoom level +// --------------------------------------------------------------------------- + +function getTickInterval(zoom: number): { major: number; minor: number } { + // Adjust tick spacing so labels don't overlap at any zoom + if (zoom >= 4) return { major: 25, minor: 5 }; + if (zoom >= 2) return { major: 50, minor: 10 }; + if (zoom >= 1) return { major: 100, minor: 10 }; + if (zoom >= 0.5) return { major: 200, minor: 50 }; + if (zoom >= 0.25) return { major: 500, minor: 100 }; + return { major: 1000, minor: 200 }; +} + +// --------------------------------------------------------------------------- +// HorizontalRuler +// --------------------------------------------------------------------------- + +export function HorizontalRuler() { + const canvasRef = useRef(null); + const zoom = useEditorStore((s) => s.zoom); + const panOffset = useEditorStore((s) => s.panOffset); + const canvasSize = useEditorStore((s) => s.canvasSize); + const showRulers = useEditorStore((s) => s.rulersVisible); + const addGuide = useEditorStore((s) => s.addGuide); + + const draw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const w = canvas.clientWidth; + const h = RULER_SIZE; + canvas.width = w * dpr; + canvas.height = h * dpr; + ctx.scale(dpr, dpr); + + // Background + ctx.fillStyle = BG_COLOR; + ctx.fillRect(0, 0, w, h); + + const { major, minor } = getTickInterval(zoom); + const startPx = -panOffset.x / zoom; + const endPx = (w - panOffset.x) / zoom; + const startTick = Math.floor(startPx / minor) * minor; + + ctx.strokeStyle = TICK_COLOR; + ctx.fillStyle = TEXT_COLOR; + ctx.font = "9px sans-serif"; + ctx.textBaseline = "top"; + + for (let t = startTick; t <= endPx + minor; t += minor) { + const screenX = t * zoom + panOffset.x; + if (screenX < 0 || screenX > w) continue; + + const isMajor = t % major === 0; + const tickHeight = isMajor ? 12 : 6; + + ctx.beginPath(); + ctx.moveTo(screenX, h); + ctx.lineTo(screenX, h - tickHeight); + ctx.lineWidth = isMajor ? 0.8 : 0.4; + ctx.stroke(); + + if (isMajor) { + ctx.fillText(String(Math.round(t)), screenX + 2, 2); + } + } + + // Bottom border + ctx.strokeStyle = TICK_COLOR; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(0, h - 0.5); + ctx.lineTo(w, h - 0.5); + ctx.stroke(); + // canvasSize referenced to trigger redraw when canvas dimensions change + void canvasSize; + }, [zoom, panOffset, canvasSize]); + + useEffect(() => { + draw(); + }, [draw]); + + useEffect(() => { + const handleResize = () => draw(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [draw]); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + // Drag from ruler to create a horizontal guide + const startY = e.clientY; + const onMove = (me: MouseEvent) => { + if (Math.abs(me.clientY - startY) > 10) { + const canvasY = (me.clientY - startY) / zoom; + addGuide("horizontal", canvasY); + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + } + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [zoom, addGuide], + ); + + if (!showRulers) return null; + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// VerticalRuler +// --------------------------------------------------------------------------- + +export function VerticalRuler() { + const canvasRef = useRef(null); + const zoom = useEditorStore((s) => s.zoom); + const panOffset = useEditorStore((s) => s.panOffset); + const canvasSize = useEditorStore((s) => s.canvasSize); + const showRulers = useEditorStore((s) => s.rulersVisible); + const addGuide = useEditorStore((s) => s.addGuide); + + const draw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const w = RULER_SIZE; + const h = canvas.clientHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + ctx.scale(dpr, dpr); + + ctx.fillStyle = BG_COLOR; + ctx.fillRect(0, 0, w, h); + + const { major, minor } = getTickInterval(zoom); + const startPx = -panOffset.y / zoom; + const endPx = (h - panOffset.y) / zoom; + const startTick = Math.floor(startPx / minor) * minor; + + ctx.strokeStyle = TICK_COLOR; + ctx.fillStyle = TEXT_COLOR; + ctx.font = "9px sans-serif"; + + for (let t = startTick; t <= endPx + minor; t += minor) { + const screenY = t * zoom + panOffset.y; + if (screenY < 0 || screenY > h) continue; + + const isMajor = t % major === 0; + const tickWidth = isMajor ? 12 : 6; + + ctx.beginPath(); + ctx.moveTo(w, screenY); + ctx.lineTo(w - tickWidth, screenY); + ctx.lineWidth = isMajor ? 0.8 : 0.4; + ctx.stroke(); + + if (isMajor) { + ctx.save(); + ctx.translate(3, screenY + 2); + ctx.rotate(-Math.PI / 2); + ctx.textBaseline = "top"; + ctx.fillText(String(Math.round(t)), 0, 0); + ctx.restore(); + } + } + + // Right border + ctx.strokeStyle = TICK_COLOR; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(w - 0.5, 0); + ctx.lineTo(w - 0.5, h); + ctx.stroke(); + void canvasSize; + }, [zoom, panOffset, canvasSize]); + + useEffect(() => { + draw(); + }, [draw]); + + useEffect(() => { + const handleResize = () => draw(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [draw]); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + const startX = e.clientX; + const onMove = (me: MouseEvent) => { + if (Math.abs(me.clientX - startX) > 10) { + const canvasX = (me.clientX - startX) / zoom; + addGuide("vertical", canvasX); + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + } + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [zoom, addGuide], + ); + + if (!showRulers) return null; + + return ( + + ); +} + +export { RULER_SIZE }; diff --git a/apps/web/src/components/editor/common/slider-row.tsx b/apps/web/src/components/editor/common/slider-row.tsx new file mode 100644 index 00000000..4c3b68f9 --- /dev/null +++ b/apps/web/src/components/editor/common/slider-row.tsx @@ -0,0 +1,52 @@ +// apps/web/src/components/editor/common/slider-row.tsx + +import { cn } from "@/lib/utils"; + +interface SliderRowProps { + label: string; + value: number; + min: number; + max: number; + step?: number; + onChange: (value: number) => void; + className?: string; +} + +export function SliderRow({ + label, + value, + min, + max, + step = 1, + onChange, + className, +}: SliderRowProps) { + return ( +
+ {label} + onChange(Number(e.target.value))} + className="flex-1 h-1 accent-primary cursor-pointer" + /> + { + const v = Number(e.target.value); + if (!Number.isNaN(v)) { + onChange(Math.max(min, Math.min(max, v))); + } + }} + className="w-14 px-1 py-0.5 text-xs text-right bg-muted border border-border rounded text-foreground" + /> +
+ ); +} diff --git a/apps/web/src/components/editor/common/smart-guides.tsx b/apps/web/src/components/editor/common/smart-guides.tsx new file mode 100644 index 00000000..c9fe8c05 --- /dev/null +++ b/apps/web/src/components/editor/common/smart-guides.tsx @@ -0,0 +1,212 @@ +import { Group, Line } from "react-konva"; +import { useEditorStore } from "@/stores/editor-store"; +export interface SmartGuide { + orientation: "horizontal" | "vertical"; + position: number; + type: "edge" | "center" | "canvas"; +} + +// --------------------------------------------------------------------------- +// Smart guide calculation utilities (exported for testing) +// --------------------------------------------------------------------------- + +const SNAP_THRESHOLD = 5; + +interface ObjectBounds { + id: string; + x: number; + y: number; + width: number; + height: number; +} + +export function findAlignmentGuides( + dragging: ObjectBounds, + others: ObjectBounds[], + canvasWidth: number, + canvasHeight: number, + threshold = SNAP_THRESHOLD, +): SmartGuide[] { + const guides: SmartGuide[] = []; + + const dragEdges = { + left: dragging.x, + right: dragging.x + dragging.width, + centerX: dragging.x + dragging.width / 2, + top: dragging.y, + bottom: dragging.y + dragging.height, + centerY: dragging.y + dragging.height / 2, + }; + + // Canvas alignment + const canvasTargets = [ + { pos: 0, orient: "vertical" as const, type: "canvas" as const }, + { + pos: canvasWidth / 2, + orient: "vertical" as const, + type: "canvas" as const, + }, + { pos: canvasWidth, orient: "vertical" as const, type: "canvas" as const }, + { pos: 0, orient: "horizontal" as const, type: "canvas" as const }, + { + pos: canvasHeight / 2, + orient: "horizontal" as const, + type: "canvas" as const, + }, + { + pos: canvasHeight, + orient: "horizontal" as const, + type: "canvas" as const, + }, + ]; + + for (const ct of canvasTargets) { + const edges = + ct.orient === "vertical" + ? [dragEdges.left, dragEdges.centerX, dragEdges.right] + : [dragEdges.top, dragEdges.centerY, dragEdges.bottom]; + + for (const edge of edges) { + if (Math.abs(edge - ct.pos) < threshold) { + guides.push({ + orientation: ct.orient, + position: ct.pos, + type: ct.type, + }); + } + } + } + + // Object-to-object alignment + for (const other of others) { + if (other.id === dragging.id) continue; + + const otherEdges = { + left: other.x, + right: other.x + other.width, + centerX: other.x + other.width / 2, + top: other.y, + bottom: other.y + other.height, + centerY: other.y + other.height / 2, + }; + + // Vertical guides (x-axis alignment) + const vPairs: [number, number, "edge" | "center"][] = [ + [dragEdges.left, otherEdges.left, "edge"], + [dragEdges.left, otherEdges.right, "edge"], + [dragEdges.right, otherEdges.left, "edge"], + [dragEdges.right, otherEdges.right, "edge"], + [dragEdges.centerX, otherEdges.centerX, "center"], + ]; + + for (const [dragVal, otherVal, type] of vPairs) { + if (Math.abs(dragVal - otherVal) < threshold) { + guides.push({ orientation: "vertical", position: otherVal, type }); + } + } + + // Horizontal guides (y-axis alignment) + const hPairs: [number, number, "edge" | "center"][] = [ + [dragEdges.top, otherEdges.top, "edge"], + [dragEdges.top, otherEdges.bottom, "edge"], + [dragEdges.bottom, otherEdges.top, "edge"], + [dragEdges.bottom, otherEdges.bottom, "edge"], + [dragEdges.centerY, otherEdges.centerY, "center"], + ]; + + for (const [dragVal, otherVal, type] of hPairs) { + if (Math.abs(dragVal - otherVal) < threshold) { + guides.push({ orientation: "horizontal", position: otherVal, type }); + } + } + } + + return guides; +} + +export function snapToGuides( + pos: { x: number; y: number }, + size: { width: number; height: number }, + guides: SmartGuide[], + threshold = SNAP_THRESHOLD, +): { x: number; y: number } { + let { x, y } = pos; + + for (const g of guides) { + if (g.orientation === "vertical") { + // Snap left edge, center, or right edge + if (Math.abs(x - g.position) < threshold) { + x = g.position; + } else if (Math.abs(x + size.width / 2 - g.position) < threshold) { + x = g.position - size.width / 2; + } else if (Math.abs(x + size.width - g.position) < threshold) { + x = g.position - size.width; + } + } else { + if (Math.abs(y - g.position) < threshold) { + y = g.position; + } else if (Math.abs(y + size.height / 2 - g.position) < threshold) { + y = g.position - size.height / 2; + } else if (Math.abs(y + size.height - g.position) < threshold) { + y = g.position - size.height; + } + } + } + + return { x, y }; +} + +// --------------------------------------------------------------------------- +// SmartGuidesOverlay -- renders temporary guide lines during drag +// --------------------------------------------------------------------------- + +const GUIDE_COLORS: Record = { + edge: "#f43f5e", + center: "#8b5cf6", + canvas: "#22c55e", +}; + +export function SmartGuidesOverlay({ guides }: { guides: SmartGuide[] }) { + const canvasSize = useEditorStore((s) => s.canvasSize); + + if (guides.length === 0) return null; + + // Deduplicate guides by position + orientation + const seen = new Set(); + const unique = guides.filter((g) => { + const key = `${g.orientation}-${g.position}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + return ( + + {unique.map((g) => { + const color = GUIDE_COLORS[g.type]; + if (g.orientation === "vertical") { + return ( + + ); + } + return ( + + ); + })} + + ); +} diff --git a/apps/web/src/components/editor/common/welcome-screen.tsx b/apps/web/src/components/editor/common/welcome-screen.tsx new file mode 100644 index 00000000..1046630f --- /dev/null +++ b/apps/web/src/components/editor/common/welcome-screen.tsx @@ -0,0 +1,104 @@ +// apps/web/src/components/editor/common/welcome-screen.tsx + +import { FilePlus, ImagePlus } from "lucide-react"; +import { useCallback, useState } from "react"; +import { useEditorStore } from "@/stores/editor-store"; +import { NewDocumentDialog } from "./new-document-dialog"; + +const ACCEPTED_TYPES = ".png,.jpg,.jpeg,.webp,.gif,.bmp,.tiff,.svg,.avif,.svgz"; + +export function WelcomeScreen() { + const [showNewDoc, setShowNewDoc] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const loadImage = useEditorStore((s) => s.loadImage); + + const handleFile = useCallback( + (file: File) => { + if (!file.type.startsWith("image/")) { + return; + } + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + loadImage(url, img.naturalWidth, img.naturalHeight); + }; + img.src = url; + }, + [loadImage], + ); + + const handleOpenFile = () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ACCEPTED_TYPES; + input.onchange = () => { + const file = input.files?.[0]; + if (file) handleFile(file); + }; + input.click(); + }; + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); + }, + [handleFile], + ); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(true); + }; + + const handleDragLeave = () => setIsDragOver(false); + + return ( + <> +
+
+
+

Image Editor

+

Drop an image here to get started

+
+ +
+ + + +
+ +

Or paste from clipboard (Ctrl+V)

+
+
+ + setShowNewDoc(false)} /> + + ); +} diff --git a/apps/web/src/components/editor/editor-canvas.tsx b/apps/web/src/components/editor/editor-canvas.tsx new file mode 100644 index 00000000..c31260a0 --- /dev/null +++ b/apps/web/src/components/editor/editor-canvas.tsx @@ -0,0 +1,813 @@ +// apps/web/src/components/editor/editor-canvas.tsx + +import type Konva from "konva"; +import KonvaFilters from "konva"; +import type React from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Arrow, + Ellipse, + Group, + Image as KonvaImage, + Layer, + Line, + Rect, + RegularPolygon, + Shape, + Stage, + Star, + Text, +} from "react-konva"; +import useImage from "use-image"; +import { useCanvasZoom } from "@/hooks/use-canvas-zoom"; +import { useEditorStore } from "@/stores/editor-store"; +import type { AdjustmentValues, CanvasObject, FilterConfig, ImageAttrs } from "@/types/editor"; +import { ContextMenu, useContextMenu } from "./common/context-menu"; +import { BrushCursorOverlay, useEditorCursor } from "./common/custom-cursor"; +import { LoadingOverlay } from "./common/loading-overlay"; +import { useBrushTool } from "./tools/brush-tool"; +import { CropOverlay } from "./tools/crop-tool"; +import { useEraserTool } from "./tools/eraser-tool"; +import { useFillTool } from "./tools/fill-tool"; +import { useGradientTool } from "./tools/gradient-tool"; +import { MoveToolTransformer, useMoveTool } from "./tools/move-tool"; +import { SelectionOverlay, useSelectionTool } from "./tools/selection-tool"; +import { useShapeTool } from "./tools/shape-tool"; +import { useTextTool } from "./tools/text-tool"; +import { TransformToolTransformer, useTransformTool } from "./tools/transform-tool"; + +// Module-level stage ref for export dialog access (Issue #6) +export const editorStageRefHolder: { current: Konva.Stage | null } = { + current: null, +}; + +const CHECKERBOARD_SIZE = 20; +const CHECKERBOARD_CSS = ` + repeating-conic-gradient( + rgba(128, 128, 128, 0.15) 0% 25%, + transparent 0% 50% + ) +`; + +// --------------------------------------------------------------------------- +// Source Image Component (Issue #14) +// --------------------------------------------------------------------------- + +function SourceImage({ + url, + adjustments, + filters, +}: { + url: string; + adjustments: AdjustmentValues; + filters: FilterConfig[]; +}) { + const [image] = useImage(url); + const imageRef = useRef(null); + + // Issue #12: Apply adjustments/filters to the source image node + const hasActiveAdjustments = Object.values(adjustments).some((v) => v !== 0); + const hasActiveFilters = filters.some((f) => f.enabled); + + useEffect(() => { + const node = imageRef.current; + if (!node || !image) return; + + if (hasActiveAdjustments || hasActiveFilters) { + const konvaFilters: Array<((this: Konva.Node, imageData: ImageData) => void) | string> = []; + + if (adjustments.brightness !== 0) { + konvaFilters.push(KonvaFilters.Filters.Brighten); + node.brightness(adjustments.brightness / 100); + } + if (adjustments.contrast !== 0) { + konvaFilters.push(KonvaFilters.Filters.Contrast); + node.contrast(adjustments.contrast); + } + if (adjustments.hue !== 0 || adjustments.saturation !== 0 || adjustments.luminance !== 0) { + konvaFilters.push(KonvaFilters.Filters.HSL); + node.hue(adjustments.hue); + node.saturation(adjustments.saturation / 100); + node.luminance(adjustments.luminance / 100); + } + + // Apply enabled filters + for (const f of filters) { + if (!f.enabled) continue; + switch (f.type) { + case "blur": + konvaFilters.push(KonvaFilters.Filters.Blur); + node.blurRadius(f.params.radius ?? 0); + break; + case "grayscale": + konvaFilters.push(KonvaFilters.Filters.Grayscale); + break; + case "sepia": + konvaFilters.push(KonvaFilters.Filters.Sepia); + break; + case "invert": + konvaFilters.push(KonvaFilters.Filters.Invert); + break; + case "pixelate": + konvaFilters.push(KonvaFilters.Filters.Pixelate); + node.pixelSize(f.params.size ?? 1); + break; + case "emboss": + konvaFilters.push(KonvaFilters.Filters.Emboss); + node.embossStrength(f.params.strength ?? 0); + node.embossWhiteLevel(0.5); + node.embossBlend(true); + break; + case "posterize": + konvaFilters.push(KonvaFilters.Filters.Posterize); + node.levels(f.params.levels ?? 8); + break; + case "noise": + konvaFilters.push(KonvaFilters.Filters.Noise); + node.noise(f.params.amount ?? 0); + break; + case "solarize": + konvaFilters.push(KonvaFilters.Filters.Solarize); + break; + case "threshold": + konvaFilters.push(KonvaFilters.Filters.Threshold); + node.threshold((f.params.level ?? 0.5) * 255); + break; + case "kaleidoscope": + konvaFilters.push(KonvaFilters.Filters.Kaleidoscope); + node.kaleidoscopePower(f.params.power ?? 2); + node.kaleidoscopeAngle(f.params.angle ?? 0); + break; + } + } + + node.clearCache(); + node.cache(); + node.filters(konvaFilters); + node.getLayer()?.batchDraw(); + } else { + node.clearCache(); + node.filters([]); + node.getLayer()?.batchDraw(); + } + }, [image, adjustments, filters, hasActiveAdjustments, hasActiveFilters]); + + if (!image) return null; + + return ; +} + +// --------------------------------------------------------------------------- +// Image Object Component (Issue #4) +// --------------------------------------------------------------------------- + +function ImageObject({ + obj, + onClick, + onDragStart, + onDragMove, + onDragEnd, + onTransformEnd, + draggable, +}: { + obj: CanvasObject & { type: "image" }; + onClick?: (e: Konva.KonvaEventObject) => void; + onDragStart?: (e: Konva.KonvaEventObject) => void; + onDragMove?: (e: Konva.KonvaEventObject) => void; + onDragEnd?: (e: Konva.KonvaEventObject) => void; + onTransformEnd?: (e: Konva.KonvaEventObject) => void; + draggable: boolean; +}) { + const a = obj.attrs as ImageAttrs; + const [image] = useImage(a.src); + if (!image) return null; + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Canvas Object Renderer (Issue #3: wire move tool handlers) +// --------------------------------------------------------------------------- + +function CanvasObjectRenderer({ + obj, + isMoveTool, + onSelect, + onDragStart, + onDragMove, + onDragEnd, + onTransformEnd, +}: { + obj: CanvasObject; + isMoveTool: boolean; + onSelect?: (e: Konva.KonvaEventObject) => void; + onDragStart?: (e: Konva.KonvaEventObject) => void; + onDragMove?: (e: Konva.KonvaEventObject) => void; + onDragEnd?: (e: Konva.KonvaEventObject) => void; + onTransformEnd?: (e: Konva.KonvaEventObject) => void; +}) { + const draggable = isMoveTool; + + switch (obj.type) { + case "line": { + const a = obj.attrs; + return ( + + ); + } + case "rect": { + const a = obj.attrs; + return ( + + ); + } + case "ellipse": { + const a = obj.attrs; + return ( + + ); + } + case "text": { + const a = obj.attrs; + return ( + + ); + } + case "arrow": { + const a = obj.attrs; + return ( + + ); + } + case "polygon": { + const a = obj.attrs; + return ( + + ); + } + case "star": { + const a = obj.attrs; + return ( + + ); + } + case "image": + return ( + + ); + default: + return null; + } +} + +// --------------------------------------------------------------------------- +// Tool handler dispatcher +// --------------------------------------------------------------------------- + +function useActiveToolHandlers(stageRef: React.RefObject) { + const activeTool = useEditorStore((s) => s.activeTool); + const zoom = useEditorStore((s) => s.zoom); + const panOffset = useEditorStore((s) => s.panOffset); + + const brushTool = useBrushTool(); + const eraserTool = useEraserTool(); + const shapeTool = useShapeTool(); + const textTool = useTextTool(); + const fillTool = useFillTool(stageRef); + const gradientTool = useGradientTool(); + const moveTool = useMoveTool(); + const selectionTool = useSelectionTool(); + const transformTool = useTransformTool(); + + const selectionHandlers = useMemo( + () => ({ + handleMouseDown: (e: Konva.KonvaEventObject) => { + const stage = e.target.getStage(); + const pointer = stage?.getPointerPosition(); + if (!pointer) return; + const pos = { x: (pointer.x - panOffset.x) / zoom, y: (pointer.y - panOffset.y) / zoom }; + selectionTool.onMouseDown(pos, stage ?? undefined); + }, + handleMouseMove: (e: Konva.KonvaEventObject) => { + const pointer = e.target.getStage()?.getPointerPosition(); + if (!pointer) return; + const pos = { x: (pointer.x - panOffset.x) / zoom, y: (pointer.y - panOffset.y) / zoom }; + selectionTool.onMouseMove(pos); + }, + handleMouseUp: () => { + selectionTool.onMouseUp(); + }, + }), + [selectionTool, zoom, panOffset], + ); + + const handlers = useMemo(() => { + const toolMap: Record< + string, + { + handleMouseDown: (e: Konva.KonvaEventObject) => void; + handleMouseMove: (e: Konva.KonvaEventObject) => void; + handleMouseUp: (e: Konva.KonvaEventObject) => void; + } + > = { + brush: brushTool, + pencil: brushTool, + eraser: eraserTool, + "shape-rect": shapeTool, + "shape-ellipse": shapeTool, + "shape-line": shapeTool, + "shape-arrow": shapeTool, + "shape-polygon": shapeTool, + "shape-star": shapeTool, + text: textTool, + fill: fillTool, + gradient: gradientTool, + "marquee-rect": selectionHandlers, + "marquee-ellipse": selectionHandlers, + "lasso-free": selectionHandlers, + "lasso-poly": selectionHandlers, + "magic-wand": selectionHandlers, + }; + + return toolMap[activeTool] ?? null; + }, [ + activeTool, + brushTool, + eraserTool, + shapeTool, + textTool, + fillTool, + gradientTool, + selectionHandlers, + ]); + + return { handlers, moveTool, selectionTool, transformTool }; +} + +// --------------------------------------------------------------------------- +// Main Canvas Component +// --------------------------------------------------------------------------- + +export function EditorCanvas({ + onCanvasResize, + onImageResize, +}: { + onCanvasResize?: () => void; + onImageResize?: () => void; +} = {}) { + const containerRef = useRef(null); + const selectionLayerRef = useRef(null); + const { stageRef, handleWheel, fitToScreen } = useCanvasZoom(); + + const zoom = useEditorStore((s) => s.zoom); + const panOffset = useEditorStore((s) => s.panOffset); + const canvasSize = useEditorStore((s) => s.canvasSize); + const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); + const setCursorPosition = useEditorStore((s) => s.setCursorPosition); + const gridVisible = useEditorStore((s) => s.gridVisible); + const objects = useEditorStore((s) => s.objects); + const layers = useEditorStore((s) => s.layers); + const activeLayerId = useEditorStore((s) => s.activeLayerId); + const activeTool = useEditorStore((s) => s.activeTool); + const setPanOffset = useEditorStore((s) => s.setPanOffset); + const adjustments = useEditorStore((s) => s.adjustments); + const filters = useEditorStore((s) => s.filters); + + // Issue #10: Shortcuts moved to EditorPage, removed from here + const cursor = useEditorCursor(); + const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds); + const contextMenu = useContextMenu(); + + const { handlers, moveTool, selectionTool, transformTool } = useActiveToolHandlers(stageRef); + + const [stageWidth, setStageWidth] = useState(800); + const [stageHeight, setStageHeight] = useState(600); + + // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only sync of stable ref + useEffect(() => { + editorStageRefHolder.current = stageRef.current; + return () => { + editorStageRefHolder.current = null; + }; + }, []); + + // Issue #5: Track raw screen cursor position for brush overlay + const [screenCursor, setScreenCursor] = useState({ x: 0, y: 0 }); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const observer = new ResizeObserver((entries) => { + const { width, height } = entries[0].contentRect; + setStageWidth(width); + setStageHeight(height); + }); + observer.observe(container); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (sourceImageUrl && stageWidth > 0 && stageHeight > 0) { + fitToScreen(stageWidth, stageHeight, canvasSize.width, canvasSize.height); + } + }, [sourceImageUrl, stageWidth, stageHeight, canvasSize.width, canvasSize.height, fitToScreen]); + + // Group objects by layer + const objectsByLayer = useMemo(() => { + const grouped = new Map(); + for (const layer of layers) { + grouped.set(layer.id, []); + } + for (const obj of objects) { + const existing = grouped.get(obj.layerId); + if (existing) { + existing.push(obj); + } + } + return grouped; + }, [objects, layers]); + + const isMoveTool = activeTool === "move"; + + const handleMouseMove = useCallback( + (e: Konva.KonvaEventObject) => { + const stage = e.target.getStage(); + if (!stage) return; + const pointer = stage.getPointerPosition(); + if (!pointer) return; + const x = Math.round((pointer.x - panOffset.x) / zoom); + const y = Math.round((pointer.y - panOffset.y) / zoom); + setCursorPosition({ x, y }); + + // Forward to active tool + if (handlers) { + handlers.handleMouseMove(e); + } + }, + [zoom, panOffset, setCursorPosition, handlers], + ); + + const handleMouseDown = useCallback( + (e: Konva.KonvaEventObject) => { + // For move tool, handle stage click to deselect + if (activeTool === "move") { + moveTool.onStageClick(e); + } + + if (handlers) { + handlers.handleMouseDown(e); + } + }, + [handlers, activeTool, moveTool], + ); + + const handleMouseUp = useCallback( + (e: Konva.KonvaEventObject) => { + if (handlers) { + handlers.handleMouseUp(e); + } + }, + [handlers], + ); + + // Issue #5: Track screen-space cursor for brush overlay + const handleContainerMouseMove = useCallback((e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + setScreenCursor({ + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }, []); + + const checkerboardSize = CHECKERBOARD_SIZE * zoom; + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: canvas container tracks cursor for brush overlay +
contextMenu.handleContextMenu(e, selectedObjectIds.length > 0)} + > + { + if (activeTool === "hand") { + const stage = e.target.getStage(); + if (stage) { + setPanOffset({ x: stage.x(), y: stage.y() }); + } + } + }} + > + {/* Render objects grouped by layer */} + + {/* Issue #14: Render source image as background */} + {sourceImageUrl && ( + + )} + + {layers.map((layer) => { + const layerObjects = objectsByLayer.get(layer.id) ?? []; + if (!layer.visible) return null; + return ( + + {layerObjects.map((obj) => ( + + ))} + + ); + })} + + {/* Move tool transformer */} + {activeTool === "move" && ( + + )} + + {/* Transform tool transformer */} + {activeTool === "transform" && ( + + )} + + {/* Selection overlay (marching ants) */} + + + {/* Active selection preview (drawn while dragging) */} + {selectionTool.isDrawing && selectionTool.currentPoints.length >= 4 && ( + + )} + + + {/* Crop overlay layer */} + {activeTool === "crop" && ( + + + + )} + + {/* Grid overlay layer (Feature 49) - non-interactive */} + {(gridVisible || zoom >= 8) && ( + + = 8} + /> + + )} + + + + {contextMenu.position && ( + + )} +
+ ); +} + +function GridOverlay({ + canvasWidth, + canvasHeight, + zoom, + showGrid, + showPixelGrid, +}: { + canvasWidth: number; + canvasHeight: number; + zoom: number; + showGrid: boolean; + showPixelGrid: boolean; +}) { + return ( + { + ctx.beginPath(); + + if (showGrid) { + const spacing = 50; + ctx.strokeStyle = "rgba(128, 128, 128, 0.15)"; + ctx.lineWidth = 1 / zoom; + for (let x = spacing; x < canvasWidth; x += spacing) { + ctx.moveTo(x, 0); + ctx.lineTo(x, canvasHeight); + } + for (let y = spacing; y < canvasHeight; y += spacing) { + ctx.moveTo(0, y); + ctx.lineTo(canvasWidth, y); + } + ctx.stroke(); + } + + if (showPixelGrid) { + ctx.beginPath(); + ctx.strokeStyle = "rgba(128, 128, 128, 0.1)"; + ctx.lineWidth = 1 / zoom; + for (let x = 1; x < canvasWidth; x++) { + ctx.moveTo(x, 0); + ctx.lineTo(x, canvasHeight); + } + for (let y = 1; y < canvasHeight; y++) { + ctx.moveTo(0, y); + ctx.lineTo(canvasWidth, y); + } + ctx.stroke(); + } + }} + /> + ); +} diff --git a/apps/web/src/components/editor/editor-options-bar.tsx b/apps/web/src/components/editor/editor-options-bar.tsx new file mode 100644 index 00000000..96941029 --- /dev/null +++ b/apps/web/src/components/editor/editor-options-bar.tsx @@ -0,0 +1,85 @@ +// apps/web/src/components/editor/editor-options-bar.tsx + +import { useEditorStore } from "@/stores/editor-store"; +import type { ToolType } from "@/types/editor"; +import { BrushOptions } from "./options/brush-options"; +import { CloneStampOptions } from "./options/clone-stamp-options"; +import { CropOptions } from "./options/crop-options"; +import { DodgeBurnOptions } from "./options/dodge-burn-options"; +import { FillOptions } from "./options/fill-options"; +import { GradientOptions } from "./options/gradient-options"; +import { MoveOptions } from "./options/move-options"; +import { PixelBrushOptions } from "./options/pixel-brush-options"; +import { SelectionOptions } from "./options/selection-options"; +import { ShapeOptions } from "./options/shape-options"; +import { TextOptions } from "./options/text-options"; + +function getOptionsComponent(tool: ToolType): React.ComponentType | null { + switch (tool) { + case "move": + return MoveOptions; + case "marquee-rect": + case "marquee-ellipse": + case "lasso-free": + case "lasso-poly": + case "magic-wand": + return SelectionOptions; + case "crop": + return CropOptions; + case "brush": + case "eraser": + case "pencil": + return BrushOptions; + case "clone-stamp": + return CloneStampOptions; + case "dodge": + case "burn": + case "sponge": + return DodgeBurnOptions; + case "blur-brush": + case "sharpen-brush": + case "smudge": + return PixelBrushOptions; + case "fill": + return FillOptions; + case "gradient": + return GradientOptions; + case "shape-rect": + case "shape-ellipse": + case "shape-line": + case "shape-arrow": + case "shape-polygon": + case "shape-star": + return ShapeOptions; + case "text": + return TextOptions; + case "transform": + case "eyedropper": + case "hand": + case "zoom": + return null; + default: + return null; + } +} + +export function EditorOptionsBar() { + const activeTool = useEditorStore((s) => s.activeTool); + + const OptionsComponent = getOptionsComponent(activeTool); + + return ( +
+ + {activeTool + .replace(/-/g, " ") + .replace(/^shape /, "") + .replace(/\b\w/g, (c) => c.toUpperCase())} + +
+
+ {OptionsComponent && } +
+
+ ); +} diff --git a/apps/web/src/components/editor/editor-right-panel.tsx b/apps/web/src/components/editor/editor-right-panel.tsx new file mode 100644 index 00000000..7b307955 --- /dev/null +++ b/apps/web/src/components/editor/editor-right-panel.tsx @@ -0,0 +1,85 @@ +// apps/web/src/components/editor/editor-right-panel.tsx +import { ChevronRight } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import { AdjustmentsPanel } from "./panels/adjustments-panel"; +import { ColorPanel } from "./panels/color-panel"; +import { HistoryPanel } from "./panels/history-panel"; +import { LayersPanel } from "./panels/layers-panel"; +import { NavigatorPanel } from "./panels/navigator-panel"; + +const TABS = [ + { id: "layers" as const, label: "Layers" }, + { id: "adjustments" as const, label: "Adjustments" }, + { id: "history" as const, label: "History" }, +]; + +export function EditorRightPanel() { + const visible = useEditorStore((s) => s.rightPanelVisible); + const activeTab = useEditorStore((s) => s.rightPanelTab); + const setTab = useEditorStore((s) => s.setRightPanelTab); + const togglePanel = useEditorStore((s) => s.toggleRightPanel); + const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); + + if (!visible) { + return ( + + ); + } + + return ( +
+ {/* Navigator always visible when image loaded */} + {sourceImageUrl && } + + {/* Tabs */} +
+ {TABS.map((tab) => ( + + ))} + +
+ + {/* Tab content */} +
+ {activeTab === "layers" && } + {activeTab === "adjustments" && } + {activeTab === "history" && } +
+ + {/* Color panel always visible at bottom */} +
+ +
+
+ ); +} diff --git a/apps/web/src/components/editor/editor-status-bar.tsx b/apps/web/src/components/editor/editor-status-bar.tsx new file mode 100644 index 00000000..10538a3d --- /dev/null +++ b/apps/web/src/components/editor/editor-status-bar.tsx @@ -0,0 +1,43 @@ +// apps/web/src/components/editor/editor-status-bar.tsx +import { useEditorStore } from "@/stores/editor-store"; + +export function EditorStatusBar() { + const cursorPosition = useEditorStore((s) => s.cursorPosition); + const canvasSize = useEditorStore((s) => s.canvasSize); + const zoom = useEditorStore((s) => s.zoom); + const setZoom = useEditorStore((s) => s.setZoom); + const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); + + const zoomPercent = Math.round(zoom * 100); + + return ( +
+
+ {sourceImageUrl && ( + <> + X: {cursorPosition.x} + Y: {cursorPosition.y} + + )} +
+
+ {sourceImageUrl && `${canvasSize.width} x ${canvasSize.height} px`} +
+
+ { + const val = Number.parseFloat(e.target.value); + if (!Number.isNaN(val) && val > 0) setZoom(val / 100); + }} + className="w-14 bg-transparent text-right text-xs border-none outline-none" + min={0.01} + max={6400} + step={0.1} + /> + % +
+
+ ); +} diff --git a/apps/web/src/components/editor/editor-toolbar.tsx b/apps/web/src/components/editor/editor-toolbar.tsx new file mode 100644 index 00000000..9138e31c --- /dev/null +++ b/apps/web/src/components/editor/editor-toolbar.tsx @@ -0,0 +1,137 @@ +// apps/web/src/components/editor/editor-toolbar.tsx +// +// Toolbar layout, icons, and shortcuts follow the standard Photoshop +// convention so users coming from other editors feel at home. +import { + Blend, + BoxSelect, + Crop, + Droplet, + Droplets, + Eraser, + Fingerprint, + Flame, + Hand, + Hexagon, + Lasso, + Maximize2, + MousePointer2, + PaintBucket, + Paintbrush, + Pencil, + Pipette, + Stamp, + Sun, + Triangle, + Type, + Wand2, + ZoomIn, +} from "lucide-react"; +import { useEditorStore } from "@/stores/editor-store"; +import type { ToolType } from "@/types/editor"; +import { IconButton } from "./common/icon-button"; + +interface ToolGroup { + tools: { + tool: ToolType; + icon: typeof MousePointer2; + label: string; + shortcut: string; + }[]; +} + +const TOOL_GROUPS: ToolGroup[] = [ + { + tools: [ + { tool: "move", icon: MousePointer2, label: "Move", shortcut: "V" }, + { tool: "transform", icon: Maximize2, label: "Free Transform", shortcut: "Ctrl+T" }, + ], + }, + { + tools: [ + { tool: "marquee-rect", icon: BoxSelect, label: "Marquee", shortcut: "M" }, + { tool: "lasso-free", icon: Lasso, label: "Lasso", shortcut: "L" }, + { tool: "magic-wand", icon: Wand2, label: "Magic Wand", shortcut: "W" }, + ], + }, + { + tools: [ + { tool: "crop", icon: Crop, label: "Crop", shortcut: "C" }, + { tool: "eyedropper", icon: Pipette, label: "Eyedropper", shortcut: "I" }, + ], + }, + { + tools: [ + { tool: "brush", icon: Paintbrush, label: "Brush", shortcut: "B" }, + { tool: "pencil", icon: Pencil, label: "Pencil", shortcut: "N" }, + ], + }, + { + tools: [{ tool: "clone-stamp", icon: Stamp, label: "Clone Stamp", shortcut: "S" }], + }, + { + tools: [{ tool: "eraser", icon: Eraser, label: "Eraser", shortcut: "E" }], + }, + { + tools: [ + { tool: "fill", icon: PaintBucket, label: "Paint Bucket", shortcut: "G" }, + { tool: "gradient", icon: Blend, label: "Gradient", shortcut: "Shift+G" }, + ], + }, + { + tools: [ + { tool: "blur-brush", icon: Droplet, label: "Blur", shortcut: "" }, + { tool: "sharpen-brush", icon: Triangle, label: "Sharpen", shortcut: "" }, + { tool: "smudge", icon: Fingerprint, label: "Smudge", shortcut: "" }, + ], + }, + { + tools: [ + { tool: "dodge", icon: Sun, label: "Dodge", shortcut: "O" }, + { tool: "burn", icon: Flame, label: "Burn", shortcut: "Shift+O" }, + { tool: "sponge", icon: Droplets, label: "Sponge", shortcut: "Shift+O" }, + ], + }, + { + tools: [{ tool: "shape-rect", icon: Hexagon, label: "Shape", shortcut: "U" }], + }, + { + tools: [{ tool: "text", icon: Type, label: "Text", shortcut: "T" }], + }, + { + tools: [ + { tool: "hand", icon: Hand, label: "Hand", shortcut: "H" }, + { tool: "zoom", icon: ZoomIn, label: "Zoom", shortcut: "Z" }, + ], + }, +]; + +export function EditorToolbar() { + const activeTool = useEditorStore((s) => s.activeTool); + const setTool = useEditorStore((s) => s.setTool); + const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); + + return ( +
+ {TOOL_GROUPS.map((group, gi) => ( +
+ {gi > 0 &&
} + {group.tools.map((t) => ( + setTool(t.tool)} + data-testid={`tool-${t.tool}`} + data-tool={t.tool} + data-tool-active={String(activeTool === t.tool)} + /> + ))} +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/editor/options/brush-options.tsx b/apps/web/src/components/editor/options/brush-options.tsx new file mode 100644 index 00000000..c0c968bc --- /dev/null +++ b/apps/web/src/components/editor/options/brush-options.tsx @@ -0,0 +1,89 @@ +// apps/web/src/components/editor/options/brush-options.tsx + +import { useEditorStore } from "@/stores/editor-store"; +import type { ToolType } from "@/types/editor"; + +const BRUSH_OPTION_TOOLS = new Set(["brush", "eraser", "pencil"]); + +export function BrushOptions() { + const activeTool = useEditorStore((s) => s.activeTool); + const brushSize = useEditorStore((s) => s.brushSize); + const brushOpacity = useEditorStore((s) => s.brushOpacity); + const brushHardness = useEditorStore((s) => s.brushHardness); + const setBrushSize = useEditorStore((s) => s.setBrushSize); + const setBrushOpacity = useEditorStore((s) => s.setBrushOpacity); + const setBrushHardness = useEditorStore((s) => s.setBrushHardness); + + if (!BRUSH_OPTION_TOOLS.has(activeTool)) return null; + + return ( +
+ {/* Size */} + + + {/* Opacity */} + + + {/* Hardness (not for pencil -- pencil is always hard) */} + {activeTool !== "pencil" && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/editor/options/clone-stamp-options.tsx b/apps/web/src/components/editor/options/clone-stamp-options.tsx new file mode 100644 index 00000000..083da02f --- /dev/null +++ b/apps/web/src/components/editor/options/clone-stamp-options.tsx @@ -0,0 +1,99 @@ +// apps/web/src/components/editor/options/clone-stamp-options.tsx + +import { useEditorStore } from "@/stores/editor-store"; + +export function CloneStampOptions() { + const activeTool = useEditorStore((s) => s.activeTool); + const brushSize = useEditorStore((s) => s.brushSize); + const brushOpacity = useEditorStore((s) => s.brushOpacity); + const brushHardness = useEditorStore((s) => s.brushHardness); + const setBrushSize = useEditorStore((s) => s.setBrushSize); + const setBrushOpacity = useEditorStore((s) => s.setBrushOpacity); + const setBrushHardness = useEditorStore((s) => s.setBrushHardness); + const cloneAligned = useEditorStore((s) => s.cloneAligned); + const setCloneAligned = useEditorStore((s) => s.setCloneAligned); + + if (activeTool !== "clone-stamp") return null; + + return ( +
+ {/* Size */} + + + {/* Opacity */} + + + {/* Hardness */} + + + {/* Aligned */} + + + Alt+Click to set source +
+ ); +} diff --git a/apps/web/src/components/editor/options/crop-options.tsx b/apps/web/src/components/editor/options/crop-options.tsx new file mode 100644 index 00000000..05a5cc66 --- /dev/null +++ b/apps/web/src/components/editor/options/crop-options.tsx @@ -0,0 +1,177 @@ +import { ArrowLeftRight, Check, X } from "lucide-react"; +import { useCallback, useState } from "react"; +import { ASPECT_RATIOS } from "@/components/editor/tools/crop-tool"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; + +// --------------------------------------------------------------------------- +// CropOptions -- aspect ratio dropdown, W/H inputs, apply/cancel +// --------------------------------------------------------------------------- + +export function CropOptions() { + const cropState = useEditorStore((s) => s.cropState); + const setCropState = useEditorStore((s) => s.setCropState); + const applyCrop = useEditorStore((s) => s.applyCrop); + const [selectedRatio, setSelectedRatio] = useState("Free"); + + const handleAspectChange = useCallback( + (e: React.ChangeEvent) => { + const label = e.target.value; + setSelectedRatio(label); + if (!cropState) return; + + const preset = ASPECT_RATIOS.find((p) => p.label === label); + if (!preset || !preset.value) { + setCropState({ ...cropState, aspectRatio: null }); + return; + } + + const ratio = preset.value; + let w = cropState.width; + let h = w / ratio; + if (h > cropState.height * 1.5) { + h = cropState.height; + w = h * ratio; + } + setCropState({ ...cropState, width: w, height: h, aspectRatio: label }); + }, + [cropState, setCropState], + ); + + const handleWidthChange = useCallback( + (e: React.ChangeEvent) => { + if (!cropState) return; + const w = Math.max(1, Number(e.target.value) || 1); + setCropState({ ...cropState, width: w }); + }, + [cropState, setCropState], + ); + + const handleHeightChange = useCallback( + (e: React.ChangeEvent) => { + if (!cropState) return; + const h = Math.max(1, Number(e.target.value) || 1); + setCropState({ ...cropState, height: h }); + }, + [cropState, setCropState], + ); + + const handleSwap = useCallback(() => { + if (!cropState) return; + setCropState({ + ...cropState, + width: cropState.height, + height: cropState.width, + }); + }, [cropState, setCropState]); + + const handleApply = useCallback(() => { + applyCrop(); + }, [applyCrop]); + + const handleCancel = useCallback(() => { + setCropState(null); + }, [setCropState]); + + const inputCn = cn( + "h-6 w-16 rounded border border-border bg-card px-1.5 text-xs text-foreground", + "focus:border-primary focus:outline-none", + ); + + return ( +
+ {/* Aspect ratio dropdown */} +
+ + +
+ +
+ + {/* Width and Height inputs */} +
+ + +
+ + + +
+ + +
+ +
+ + {/* Apply / Cancel */} + + +
+ ); +} diff --git a/apps/web/src/components/editor/options/dodge-burn-options.tsx b/apps/web/src/components/editor/options/dodge-burn-options.tsx new file mode 100644 index 00000000..43ff6d25 --- /dev/null +++ b/apps/web/src/components/editor/options/dodge-burn-options.tsx @@ -0,0 +1,145 @@ +// apps/web/src/components/editor/options/dodge-burn-options.tsx + +import { useEditorStore } from "@/stores/editor-store"; +import type { ToolType } from "@/types/editor"; + +const DODGE_BURN_TOOLS = new Set(["dodge", "burn", "sponge"]); + +export function DodgeBurnOptions() { + const activeTool = useEditorStore((s) => s.activeTool); + const setTool = useEditorStore((s) => s.setTool); + const brushSize = useEditorStore((s) => s.brushSize); + const setBrushSize = useEditorStore((s) => s.setBrushSize); + const dodgeBurnRange = useEditorStore((s) => s.dodgeBurnRange); + const dodgeBurnExposure = useEditorStore((s) => s.dodgeBurnExposure); + const spongeMode = useEditorStore((s) => s.spongeMode); + const spongeFlow = useEditorStore((s) => s.spongeFlow); + const setDodgeBurnRange = useEditorStore((s) => s.setDodgeBurnRange); + const setDodgeBurnExposure = useEditorStore((s) => s.setDodgeBurnExposure); + const setSpongeMode = useEditorStore((s) => s.setSpongeMode); + const setSpongeFlow = useEditorStore((s) => s.setSpongeFlow); + + if (!DODGE_BURN_TOOLS.has(activeTool)) return null; + + const isDodgeBurn = activeTool === "dodge" || activeTool === "burn"; + + return ( +
+ {/* Tool toggle */} + + + {/* Size */} + + + {/* Range (dodge/burn only) */} + {isDodgeBurn && ( + + )} + + {/* Exposure (dodge/burn only) */} + {isDodgeBurn && ( + + )} + + {/* Sponge mode */} + {activeTool === "sponge" && ( + + )} + + {/* Flow (sponge only) */} + {activeTool === "sponge" && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/editor/options/eyedropper-options.tsx b/apps/web/src/components/editor/options/eyedropper-options.tsx new file mode 100644 index 00000000..6c3a275d --- /dev/null +++ b/apps/web/src/components/editor/options/eyedropper-options.tsx @@ -0,0 +1,110 @@ +// apps/web/src/components/editor/options/eyedropper-options.tsx + +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import { ColorSwatch } from "../common/color-swatch"; + +export type SampleSize = 1 | 3 | 5; + +const SAMPLE_SIZES: { label: string; value: SampleSize }[] = [ + { label: "Point (1x1)", value: 1 }, + { label: "3x3 Average", value: 3 }, + { label: "5x5 Average", value: 5 }, +]; + +interface EyedropperOptionsProps { + sampleSize: SampleSize; + onSampleSizeChange: (size: SampleSize) => void; + sampledColor: string | null; +} + +export function EyedropperOptions({ + sampleSize, + onSampleSizeChange, + sampledColor, +}: EyedropperOptionsProps) { + const foregroundColor = useEditorStore((s) => s.foregroundColor); + const [open, setOpen] = useState(false); + + const displayColor = sampledColor ?? foregroundColor; + + return ( +
+ {/* Sample size dropdown */} +
+ Sample: + + {open && ( + <> + {/* biome-ignore lint/a11y/noStaticElementInteractions: backdrop overlay for closing dropdown */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: escape handled via parent */} +
setOpen(false)} /> +
+ {SAMPLE_SIZES.map((s) => ( + + ))} +
+ + )} +
+ +
+ + {/* Current sampled color preview */} +
+ + {displayColor} +
+
+ ); +} diff --git a/apps/web/src/components/editor/options/fill-options.tsx b/apps/web/src/components/editor/options/fill-options.tsx new file mode 100644 index 00000000..52c43b28 --- /dev/null +++ b/apps/web/src/components/editor/options/fill-options.tsx @@ -0,0 +1,49 @@ +// apps/web/src/components/editor/options/fill-options.tsx + +import { useEditorStore } from "@/stores/editor-store"; + +export function FillOptions() { + const activeTool = useEditorStore((s) => s.activeTool); + const tolerance = useEditorStore((s) => s.fillTolerance); + const contiguous = useEditorStore((s) => s.fillContiguous); + const setFillTolerance = useEditorStore((s) => s.setFillTolerance); + const setFillContiguous = useEditorStore((s) => s.setFillContiguous); + + if (activeTool !== "fill") return null; + + return ( +
+ {/* Tolerance */} + + + {/* Contiguous */} + +
+ ); +} diff --git a/apps/web/src/components/editor/options/gradient-options.tsx b/apps/web/src/components/editor/options/gradient-options.tsx new file mode 100644 index 00000000..c34fe713 --- /dev/null +++ b/apps/web/src/components/editor/options/gradient-options.tsx @@ -0,0 +1,67 @@ +// apps/web/src/components/editor/options/gradient-options.tsx + +import { useEditorStore } from "@/stores/editor-store"; + +export function GradientOptions() { + const activeTool = useEditorStore((s) => s.activeTool); + const gradientType = useEditorStore((s) => s.gradientType); + const gradientOpacity = useEditorStore((s) => s.gradientOpacity); + const gradientReverse = useEditorStore((s) => s.gradientReverse); + const setGradientType = useEditorStore((s) => s.setGradientType); + const setGradientOpacity = useEditorStore((s) => s.setGradientOpacity); + const setGradientReverse = useEditorStore((s) => s.setGradientReverse); + + if (activeTool !== "gradient") return null; + + const opacityPercent = Math.round(gradientOpacity * 100); + + return ( +
+ {/* Type toggle */} + + + {/* Opacity */} + + + {/* Reverse */} + +
+ ); +} diff --git a/apps/web/src/components/editor/options/move-options.tsx b/apps/web/src/components/editor/options/move-options.tsx new file mode 100644 index 00000000..717865fd --- /dev/null +++ b/apps/web/src/components/editor/options/move-options.tsx @@ -0,0 +1,140 @@ +import { + AlignCenterHorizontal, + AlignCenterVertical, + AlignEndHorizontal, + AlignEndVertical, + AlignStartHorizontal, + AlignStartVertical, + ArrowLeftRight, + ArrowUpDown, +} from "lucide-react"; +import { alignObjects } from "@/components/editor/tools/move-tool"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; + +// --------------------------------------------------------------------------- +// MoveOptions -- alignment and distribute buttons in the options bar +// --------------------------------------------------------------------------- + +function OptionButton({ + icon: Icon, + label, + onClick, + disabled, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + +export function MoveOptions() { + const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds); + const objects = useEditorStore((s) => s.objects); + const updateObject = useEditorStore((s) => s.updateObject); + const canvasSize = useEditorStore((s) => s.canvasSize); + + const hasSelection = selectedObjectIds.length >= 1; + const hasThreeOrMore = selectedObjectIds.length >= 3; + + const handleAlign = ( + direction: + | "left" + | "center-h" + | "right" + | "top" + | "center-v" + | "bottom" + | "distribute-h" + | "distribute-v", + ) => { + alignObjects( + direction, + selectedObjectIds, + objects.map((o) => ({ id: o.id, attrs: o.attrs as unknown as Record })), + updateObject, + canvasSize, + ); + }; + + return ( +
+ Align: + + handleAlign("left")} + disabled={!hasSelection} + /> + handleAlign("center-h")} + disabled={!hasSelection} + /> + handleAlign("right")} + disabled={!hasSelection} + /> + +
+ + handleAlign("top")} + disabled={!hasSelection} + /> + handleAlign("center-v")} + disabled={!hasSelection} + /> + handleAlign("bottom")} + disabled={!hasSelection} + /> + +
+ + Distribute: + + handleAlign("distribute-h")} + disabled={!hasThreeOrMore} + /> + handleAlign("distribute-v")} + disabled={!hasThreeOrMore} + /> +
+ ); +} diff --git a/apps/web/src/components/editor/options/pixel-brush-options.tsx b/apps/web/src/components/editor/options/pixel-brush-options.tsx new file mode 100644 index 00000000..86bfcc7e --- /dev/null +++ b/apps/web/src/components/editor/options/pixel-brush-options.tsx @@ -0,0 +1,78 @@ +// apps/web/src/components/editor/options/pixel-brush-options.tsx + +import { useEditorStore } from "@/stores/editor-store"; +import type { ToolType } from "@/types/editor"; + +const PIXEL_BRUSH_TOOLS = new Set(["blur-brush", "sharpen-brush", "smudge"]); + +export function PixelBrushOptions() { + const activeTool = useEditorStore((s) => s.activeTool); + const setTool = useEditorStore((s) => s.setTool); + const brushSize = useEditorStore((s) => s.brushSize); + const setBrushSize = useEditorStore((s) => s.setBrushSize); + const strength = useEditorStore((s) => s.pixelBrushStrength); + const setPixelBrushStrength = useEditorStore((s) => s.setPixelBrushStrength); + + if (!PIXEL_BRUSH_TOOLS.has(activeTool)) return null; + + return ( +
+ {/* Tool toggle */} + + + {/* Size */} + + + {/* Strength */} + +
+ ); +} diff --git a/apps/web/src/components/editor/options/selection-options.tsx b/apps/web/src/components/editor/options/selection-options.tsx new file mode 100644 index 00000000..5c34a40a --- /dev/null +++ b/apps/web/src/components/editor/options/selection-options.tsx @@ -0,0 +1,196 @@ +import { Circle, Minus, PenTool, Plus, Square, Wand2 } from "lucide-react"; +import { useCallback } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import type { SelectionMode, ToolType } from "@/types/editor"; + +type SelectionType = "rect" | "ellipse" | "lasso"; + +// --------------------------------------------------------------------------- +// SelectionOptions -- selection type toggle, mode buttons, feather input +// --------------------------------------------------------------------------- + +function ToggleButton({ + active, + onClick, + label, + children, +}: { + active: boolean; + onClick: () => void; + label: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +export function SelectionOptions() { + const activeTool = useEditorStore((s) => s.activeTool); + const setTool = useEditorStore((s) => s.setTool); + const selectionMode = useEditorStore((s) => s.selectionMode); + const setSelectionMode = useEditorStore((s) => s.setSelectionMode); + const magicWandTolerance = useEditorStore((s) => s.magicWandTolerance); + const setMagicWandTolerance = useEditorStore((s) => s.setMagicWandTolerance); + + const selectionType: SelectionType = + activeTool === "marquee-ellipse" + ? "ellipse" + : activeTool === "lasso-free" || activeTool === "lasso-poly" + ? "lasso" + : "rect"; + + const handleTypeChange = useCallback( + (type: SelectionType) => { + const toolMap: Record = { + rect: "marquee-rect", + ellipse: "marquee-ellipse", + lasso: "lasso-free", + }; + setTool(toolMap[type]); + }, + [setTool], + ); + + const handleModeChange = useCallback( + (mode: SelectionMode) => { + setSelectionMode(mode); + }, + [setSelectionMode], + ); + + const isMarquee = activeTool === "marquee-rect" || activeTool === "marquee-ellipse"; + const isLasso = activeTool === "lasso-free" || activeTool === "lasso-poly"; + const isMagicWand = activeTool === "magic-wand"; + + return ( +
+ {/* Selection type toggle */} + {!isMagicWand && ( +
+ Type: + handleTypeChange("rect")} + label="Rectangular" + > + + Rect + + handleTypeChange("ellipse")} + label="Elliptical" + > + + Ellipse + + handleTypeChange("lasso")} label="Lasso"> + + Lasso + +
+ )} + + {isMagicWand && ( +
+ + Magic Wand +
+ )} + +
+ + {/* Selection mode buttons */} +
+ Mode: + handleModeChange("new")} + label="New Selection" + > + New + + handleModeChange("add")} + label="Add to Selection" + > + + Add + + handleModeChange("subtract")} + label="Subtract from Selection" + > + + Sub + +
+ + {/* Lasso sub-type toggle */} + {isLasso && ( + <> +
+
+ setTool("lasso-free")} + label="Freehand Lasso" + > + Freehand + + setTool("lasso-poly")} + label="Polygonal Lasso" + > + Polygonal + +
+ + )} + + {/* Magic Wand tolerance */} + {isMagicWand && ( + <> +
+
+ + setMagicWandTolerance(Number(e.target.value))} + className={cn( + "h-6 w-14 rounded border border-border bg-card px-1.5 text-xs text-foreground", + "focus:border-primary focus:outline-none", + )} + /> +
+ + )} +
+ ); +} diff --git a/apps/web/src/components/editor/options/shape-options.tsx b/apps/web/src/components/editor/options/shape-options.tsx new file mode 100644 index 00000000..af61bbad --- /dev/null +++ b/apps/web/src/components/editor/options/shape-options.tsx @@ -0,0 +1,157 @@ +// apps/web/src/components/editor/options/shape-options.tsx + +import { useEditorStore } from "@/stores/editor-store"; +import type { ToolType } from "@/types/editor"; + +const SHAPE_TOOLS = new Set([ + "shape-rect", + "shape-ellipse", + "shape-line", + "shape-arrow", + "shape-polygon", + "shape-star", +]); + +const SHAPE_TYPE_OPTIONS: { value: ToolType; label: string }[] = [ + { value: "shape-rect", label: "Rectangle" }, + { value: "shape-ellipse", label: "Ellipse" }, + { value: "shape-line", label: "Line" }, + { value: "shape-arrow", label: "Arrow" }, + { value: "shape-polygon", label: "Polygon" }, + { value: "shape-star", label: "Star" }, +]; + +export function ShapeOptions() { + const activeTool = useEditorStore((s) => s.activeTool); + const setTool = useEditorStore((s) => s.setTool); + const shapeFill = useEditorStore((s) => s.shapeFill); + const shapeStroke = useEditorStore((s) => s.shapeStroke); + const shapeStrokeWidth = useEditorStore((s) => s.shapeStrokeWidth); + const shapeCornerRadius = useEditorStore((s) => s.shapeCornerRadius); + const shapePolygonSides = useEditorStore((s) => s.shapePolygonSides); + const shapeStarPoints = useEditorStore((s) => s.shapeStarPoints); + const setShapeFill = useEditorStore((s) => s.setShapeFill); + const setShapeStroke = useEditorStore((s) => s.setShapeStroke); + const setShapeStrokeWidth = useEditorStore((s) => s.setShapeStrokeWidth); + const setShapeCornerRadius = useEditorStore((s) => s.setShapeCornerRadius); + const setShapePolygonSides = useEditorStore((s) => s.setShapePolygonSides); + const setShapeStarPoints = useEditorStore((s) => s.setShapeStarPoints); + + if (!SHAPE_TOOLS.has(activeTool)) return null; + + return ( +
+ {/* Shape type selector */} + + + {/* Fill color */} + + + {/* Stroke color */} + + + {/* Stroke width */} + + + {/* Corner radius (only for rect) */} + {activeTool === "shape-rect" && ( + + )} + + {/* Polygon sides */} + {activeTool === "shape-polygon" && ( + + )} + + {/* Star points */} + {activeTool === "shape-star" && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/editor/options/text-options.tsx b/apps/web/src/components/editor/options/text-options.tsx new file mode 100644 index 00000000..ce7f5b98 --- /dev/null +++ b/apps/web/src/components/editor/options/text-options.tsx @@ -0,0 +1,436 @@ +// apps/web/src/components/editor/options/text-options.tsx + +import { + AlignCenter, + AlignLeft, + AlignRight, + Bold, + Italic, + Strikethrough, + Type, + Underline, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import type { TextAttrs } from "@/types/editor"; +import { getAllFonts, isSystemFont, loadGoogleFont } from "../common/font-loader"; + +// --------------------------------------------------------------------------- +// Default attrs used when no text object is selected (next-text settings) +// --------------------------------------------------------------------------- + +const DEFAULT_TEXT_ATTRS: TextAttrs = { + x: 0, + y: 0, + text: "", + fontFamily: "Arial", + fontSize: 24, + fontStyle: "normal", + fontVariant: "normal", + textDecoration: "", + align: "left", + fill: "#000000", + lineHeight: 1.2, + letterSpacing: 0, + rotation: 0, + opacity: 1, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function getSelectedTextAttrs(): TextAttrs | null { + const { selectedObjectIds, objects } = useEditorStore.getState(); + if (selectedObjectIds.length !== 1) return null; + const obj = objects.find((o) => o.id === selectedObjectIds[0] && o.type === "text"); + return obj ? (obj.attrs as TextAttrs) : null; +} + +function updateSelected(partial: Partial) { + const { selectedObjectIds } = useEditorStore.getState(); + for (const id of selectedObjectIds) { + useEditorStore.getState().updateObject(id, partial); + } +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +function ToggleButton({ + active, + onClick, + label, + children, +}: { + active: boolean; + onClick: () => void; + label: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +function NumberInput({ + value, + min, + max, + step, + label, + onChange, + width = "w-16", +}: { + value: number; + min: number; + max: number; + step: number; + label: string; + onChange: (v: number) => void; + width?: string; +}) { + return ( + { + const n = Number.parseFloat(e.target.value); + if (!Number.isNaN(n)) onChange(Math.max(min, Math.min(max, n))); + }} + className={cn( + "h-7 rounded border border-border bg-background px-1.5 text-xs text-center", + "focus:outline-none focus:ring-1 focus:ring-ring", + width, + )} + /> + ); +} + +// --------------------------------------------------------------------------- +// Font Dropdown +// --------------------------------------------------------------------------- + +function FontDropdown({ value, onChange }: { value: string; onChange: (name: string) => void }) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const fonts = useMemo(() => getAllFonts(), []); + + // Close on outside click + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const handleSelect = useCallback( + async (name: string) => { + if (!isSystemFont(name)) { + await loadGoogleFont(name); + } + onChange(name); + setOpen(false); + }, + [onChange], + ); + + return ( +
+ + + {open && ( +
+ {/* System fonts */} +
+ System Fonts +
+ {fonts.system.map((name) => ( + + ))} + +
+ + {/* Google fonts */} +
+ Google Fonts +
+ {fonts.google.map((name) => ( + + ))} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Main Component +// --------------------------------------------------------------------------- + +export function TextOptions() { + const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds); + const objects = useEditorStore((s) => s.objects); + + // Derive current attrs from the selected text object, fall back to defaults + const selectedAttrs = useMemo(() => { + if (selectedObjectIds.length !== 1) return null; + const obj = objects.find((o) => o.id === selectedObjectIds[0] && o.type === "text"); + return obj ? (obj.attrs as TextAttrs) : null; + }, [selectedObjectIds, objects]); + + const attrs = selectedAttrs ?? DEFAULT_TEXT_ATTRS; + + const isBold = attrs.fontStyle.includes("bold"); + const isItalic = attrs.fontStyle.includes("italic"); + const hasUnderline = attrs.textDecoration.includes("underline"); + const hasStrikethrough = attrs.textDecoration.includes("line-through"); + const isAreaText = attrs.wrap !== undefined; + + const toggleBold = () => { + const current = getSelectedTextAttrs(); + if (!current) return; + const wasBold = current.fontStyle.includes("bold"); + const parts = current.fontStyle.split(" ").filter((p) => p !== "bold" && p !== ""); + if (!wasBold) parts.push("bold"); + updateSelected({ fontStyle: parts.join(" ") || "normal" }); + }; + + const toggleItalic = () => { + const current = getSelectedTextAttrs(); + if (!current) return; + const wasItalic = current.fontStyle.includes("italic"); + const parts = current.fontStyle.split(" ").filter((p) => p !== "italic" && p !== ""); + if (!wasItalic) parts.push("italic"); + updateSelected({ fontStyle: parts.join(" ") || "normal" }); + }; + + const toggleUnderline = () => { + const current = getSelectedTextAttrs(); + if (!current) return; + const had = current.textDecoration.includes("underline"); + const parts = current.textDecoration.split(" ").filter((p) => p !== "underline" && p !== ""); + if (!had) parts.push("underline"); + updateSelected({ textDecoration: parts.join(" ") || "none" }); + }; + + const toggleStrikethrough = () => { + const current = getSelectedTextAttrs(); + if (!current) return; + const had = current.textDecoration.includes("line-through"); + const parts = current.textDecoration.split(" ").filter((p) => p !== "line-through" && p !== ""); + if (!had) parts.push("line-through"); + updateSelected({ textDecoration: parts.join(" ") || "none" }); + }; + + const toggleTextMode = () => { + const current = getSelectedTextAttrs(); + if (!current) return; + if (current.wrap !== undefined) { + // Switch to point text: remove width/height/wrap + updateSelected({ + width: undefined, + height: undefined, + wrap: undefined, + }); + } else { + // Switch to area text + updateSelected({ + width: 200, + height: 100, + wrap: "word", + }); + } + }; + + return ( +
+ {/* Font family */} + updateSelected({ fontFamily: name })} + /> + + {/* Font size */} + updateSelected({ fontSize: v })} + width="w-14" + /> + +
+ + {/* Bold / Italic / Underline / Strikethrough */} +
+ + + + + + + + + + + + +
+ +
+ + {/* Alignment */} +
+ updateSelected({ align: "left" })} + label="Align left" + > + + + updateSelected({ align: "center" })} + label="Align center" + > + + + updateSelected({ align: "right" })} + label="Align right" + > + + +
+ +
+ + {/* Line height */} +
+ LH + updateSelected({ lineHeight: v })} + width="w-14" + /> +
+ + {/* Letter spacing */} +
+ LS + updateSelected({ letterSpacing: v })} + width="w-14" + /> +
+ +
+ + {/* Color swatch */} +