diff --git a/src/components/app/pixel-forge-app.ts b/src/components/app/pixel-forge-app.ts index 121abae..42f1c2c 100644 --- a/src/components/app/pixel-forge-app.ts +++ b/src/components/app/pixel-forge-app.ts @@ -15,6 +15,7 @@ import "./pf-project-browser"; import "../dialogs/pf-resize-dialog"; import "../dialogs/pf-export-dialog"; import "../dialogs/pf-new-project-dialog"; +import "../dialogs/pf-paint-by-number-dialog"; import "../dialogs/pf-grid-settings-dialog"; import "../dialogs/pf-checker-settings-dialog"; import "../dialogs/pf-accent-color-dialog"; @@ -235,6 +236,7 @@ export class PixelForgeApp extends BaseComponent { @state() showResizeDialog = false; @state() showExportDialog = false; @state() showNewProjectDialog = false; + @state() showPaintByNumberDialog = false; @state() showProjectBrowser = false; @state() showDeleteCurrentDialog = false; @state() showKeyboardShortcutsDialog = false; @@ -265,6 +267,10 @@ export class PixelForgeApp extends BaseComponent { "show-new-project-dialog", this.handleShowNewProjectDialog ); + window.addEventListener( + "show-paint-by-number-dialog", + this.handleShowPaintByNumberDialog + ); window.addEventListener( "show-keyboard-shortcuts-dialog", this.handleShowKeyboardShortcutsDialog @@ -333,6 +339,19 @@ export class PixelForgeApp extends BaseComponent { this.showNewProjectDialog = true; }; + private handleShowPaintByNumberDialog = () => { + this.showProjectBrowser = false; + this.showPaintByNumberDialog = true; + }; + + private handlePaintByNumberDialogClose = () => { + this.showPaintByNumberDialog = false; + + if (!this.hasLibraryProject) { + this.showProjectBrowser = true; + } + }; + private handleNewProjectDialogClose = () => { this.showNewProjectDialog = false; @@ -394,6 +413,7 @@ export class PixelForgeApp extends BaseComponent { private handleProjectCreated = () => { this.hasLibraryProject = true; this.showNewProjectDialog = false; + this.showPaintByNumberDialog = false; this.showProjectBrowser = false; }; @@ -483,6 +503,10 @@ export class PixelForgeApp extends BaseComponent { "show-new-project-dialog", this.handleShowNewProjectDialog ); + window.removeEventListener( + "show-paint-by-number-dialog", + this.handleShowPaintByNumberDialog + ); window.removeEventListener( "show-keyboard-shortcuts-dialog", this.handleShowKeyboardShortcutsDialog @@ -569,6 +593,7 @@ export class PixelForgeApp extends BaseComponent { @resize-canvas=${() => (this.showResizeDialog = true)} @show-export-dialog=${() => (this.showExportDialog = true)} @show-new-project-dialog=${this.handleShowNewProjectDialog} + @show-paint-by-number-dialog=${this.handleShowPaintByNumberDialog} @show-project-browser=${() => (this.showProjectBrowser = true)} > @@ -647,6 +672,12 @@ export class PixelForgeApp extends BaseComponent { @project-created=${this.handleProjectCreated} > + + ${this.showProjectBrowser ? html` + Guided drawing + + + + Source image + + Processed locally. The source image is not uploaded. + + + + Guide preset + + ${GUIDE_PRESETS.map((preset) => html` + this.selectPreset(preset)} + > + ${preset.label} + + ${preset.longSide}px · ${preset.maxColors} colors + + + `)} + + + + + Advanced settings + + + Long side (pixels) + + + + + Maximum colors + + + + + Palette + + Generate from image + Use current project palette + + + + + Color matching + + Perceptual color + Luminance only + + + + + + Simplify isolated single pixels + + + + + + ${this.renderPreviewFigure('Source', 'source-preview', Boolean(this.sourceImage))} + ${this.renderPreviewFigure('Guide', 'guide-preview', Boolean(this.guide))} + + + + ${this.errorMessage || this.statusMessage} + + + + + + Cancel + + ${this.isCreating ? 'Creating…' : 'Create guided project'} + + + + `; + } + + private renderPreviewFigure(label: string, id: string, ready: boolean) { + return html` + + ${label} + + ${ready + ? html`` + : html` + ${id === 'source-preview' + ? 'Your selected image appears here.' + : 'The reduced numbered guide appears here.'} + `} + + + `; + } + + private handleFileChange = async (event: Event) => { + const input = event.currentTarget as HTMLInputElement; + const file = input.files?.[0] ?? null; + const version = ++this.requestVersion; + this.cancelPreviewTimer(); + this.sourceFile = file; + this.sourceImage = null; + this.guide = null; + this.errorMessage = ''; + + if (!file) { + this.statusMessage = 'Choose an image to begin.'; + return; + } + + this.statusMessage = 'Reading image…'; + try { + const image = await decodeImageFile(file); + if (version !== this.requestVersion) return; + this.sourceImage = image; + this.schedulePreview(0); + } catch { + if (version !== this.requestVersion) return; + this.errorMessage = 'This image could not be read. Try a PNG, JPEG, or WebP file.'; + } + }; + + private selectPreset(preset: (typeof GUIDE_PRESETS)[number]) { + this.longSide = preset.longSide; + this.maxColors = preset.maxColors; + this.schedulePreview(); + } + + private handleLongSideChange = (event: Event) => { + this.longSide = clampNumber((event.currentTarget as HTMLInputElement).valueAsNumber, 8, 64, 24); + this.schedulePreview(); + }; + + private handleMaxColorsChange = (event: Event) => { + this.maxColors = clampNumber((event.currentTarget as HTMLInputElement).valueAsNumber, 2, 32, 8); + this.schedulePreview(); + }; + + private handlePaletteSourceChange = (event: Event) => { + this.paletteSource = (event.currentTarget as HTMLSelectElement).value as GuidedPaletteSource; + this.schedulePreview(); + }; + + private handleMappingChange = (event: Event) => { + this.mapping = (event.currentTarget as HTMLSelectElement).value as GuidedColorMapping; + this.schedulePreview(); + }; + + private handleSimplifyChange = (event: Event) => { + this.simplifyIsolatedPixels = (event.currentTarget as HTMLInputElement).checked; + this.schedulePreview(); + }; + + private schedulePreview(delay = PREVIEW_DEBOUNCE_MS) { + this.cancelPreviewTimer(); + if (!this.sourceImage) return; + + const version = ++this.requestVersion; + this.isGenerating = true; + this.errorMessage = ''; + this.statusMessage = 'Updating guide preview…'; + this.previewTimer = window.setTimeout(() => { + this.previewTimer = null; + this.generatePreview(version); + }, delay); + } + + private generatePreview(version: number) { + if (!this.sourceImage || version !== this.requestVersion) return; + + try { + const sampled = sampleImageToGrid(this.sourceImage, { longSide: this.longSide }); + const restrictedPalette = this.getRestrictedPalette(); + const guide = generateNumberedGuide(sampled, { + maxColors: this.maxColors, + palette: restrictedPalette, + mapping: this.mapping, + simplifyIsolatedPixels: this.simplifyIsolatedPixels, + }); + if (guide.palette.length === 0) { + throw new Error('The image has no visible paintable pixels.'); + } + if (version !== this.requestVersion) return; + + this.guide = guide; + this.statusMessage = this.describeGuide(guide); + } catch (error) { + if (version !== this.requestVersion) return; + this.guide = null; + this.errorMessage = error instanceof Error + ? error.message + : 'The guide preview could not be generated.'; + } finally { + if (version === this.requestVersion) this.isGenerating = false; + } + } + + private getRestrictedPalette(): string[] | undefined { + if (this.paletteSource !== 'restricted') return undefined; + return [...getActiveProjectContext().palette.mainColors.value]; + } + + private describeGuide(guide: NumberedGuide): string { + const { complexity } = guide; + const simplified = complexity.simplifiedCells > 0 + ? ` ${complexity.simplifiedCells} isolated cells simplified.` + : ''; + return `${guide.width} × ${guide.height}, ${guide.palette.length} colors, ${complexity.paintableCells} cells.${simplified}`; + } + + private createProject = async (event: Event) => { + event.preventDefault(); + if (!this.guide || !this.sourceFile || this.isCreating) return; + + this.isCreating = true; + this.errorMessage = ''; + try { + const settings = this.currentSettings(); + const result = await createGuidedProject({ + guide: this.guide, + settings, + sourceName: this.sourceFile.name, + }); + if (!result.ok) { + this.errorMessage = result.message; + return; + } + + const projectId = result.projectId; + this.close(); + this.dispatchEvent(new CustomEvent('project-created', { + bubbles: true, + composed: true, + detail: { id: projectId, guided: true }, + })); + } catch (error) { + this.errorMessage = error instanceof Error + ? error.message + : 'The guided project could not be created.'; + } finally { + this.isCreating = false; + } + }; + + private currentSettings(): GuidedDrawingSettings { + return { + longSide: this.longSide, + paletteSource: this.paletteSource, + maxColors: this.paletteSource === 'generated' ? this.maxColors : undefined, + restrictedPalette: this.getRestrictedPalette(), + mapping: this.mapping, + simplifyIsolatedPixels: this.simplifyIsolatedPixels, + }; + } + + private drawPreviews() { + if (this.sourceImage && this.sourceCanvas) { + drawImageData(this.sourceCanvas, this.sourceImage); + } + if (this.guide && this.guideCanvas) { + drawGuide(this.guideCanvas, this.guide); + } + } + + private cancelPreviewTimer() { + if (this.previewTimer === null) return; + clearTimeout(this.previewTimer); + this.previewTimer = null; + } + + private cancelPendingPreview() { + this.requestVersion += 1; + this.cancelPreviewTimer(); + } + + close = () => { + this.cancelPendingPreview(); + this.open = false; + this.dispatchEvent(new CustomEvent('close')); + }; +} + +function clampNumber(value: number, min: number, max: number, fallback: number) { + if (!Number.isFinite(value)) return fallback; + return Math.max(min, Math.min(max, Math.round(value))); +} + +function drawImageData(canvas: HTMLCanvasElement, image: ImageData) { + const context = canvas.getContext('2d'); + if (!context) return; + canvas.width = image.width; + canvas.height = image.height; + context.putImageData(image, 0, 0); +} + +function drawGuide(canvas: HTMLCanvasElement, guide: NumberedGuide) { + const context = canvas.getContext('2d'); + if (!context) return; + canvas.width = guide.width; + canvas.height = guide.height; + const image = context.createImageData(guide.width, guide.height); + + for (let index = 0; index < guide.target.length; index += 1) { + const paletteIndex = guide.target[index]; + if (paletteIndex === 0) continue; + const color = guide.palette[paletteIndex - 1]; + const rgb = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color); + if (!rgb) continue; + + const dataIndex = index * 4; + image.data[dataIndex] = parseInt(rgb[1], 16); + image.data[dataIndex + 1] = parseInt(rgb[2], 16); + image.data[dataIndex + 2] = parseInt(rgb[3], 16); + image.data[dataIndex + 3] = 255; + } + + context.putImageData(image, 0, 0); +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-paint-by-number-dialog': PFPaintByNumberDialog; + } +} diff --git a/src/components/menu/pf-menu-bar.ts b/src/components/menu/pf-menu-bar.ts index 2e01b6a..6deeede 100644 --- a/src/components/menu/pf-menu-bar.ts +++ b/src/components/menu/pf-menu-bar.ts @@ -580,6 +580,15 @@ export class PFMenuBar extends BaseComponent { ); } + showPaintByNumberDialog() { + this.dispatchEvent( + new CustomEvent("show-paint-by-number-dialog", { + bubbles: true, + composed: true, + }) + ); + } + showProjectBrowser() { this.dispatchEvent( new CustomEvent("show-project-browser", { @@ -710,6 +719,13 @@ export class PFMenuBar extends BaseComponent { New Project... ${formatShortcut(menuShortcuts.newProject)} + + New Guided Drawing... + Open Project... ${formatShortcut(menuShortcuts.open)} diff --git a/src/services/paint-by-number/guide-generator.ts b/src/services/paint-by-number/guide-generator.ts new file mode 100644 index 0000000..3661044 --- /dev/null +++ b/src/services/paint-by-number/guide-generator.ts @@ -0,0 +1,435 @@ +import { + hexToRgb, + normalizeHex, + rgbToHex, + type RGB, +} from '../../stores/palette/color-utils'; +import type { GuidedColorMapping } from '../../types/guided-drawing'; + +const MAX_GUIDE_COLORS = 255; +const DEFAULT_GUIDE_COLOR_COUNT = 12; + +export interface GuideGenerationOptions { + maxColors?: number; + palette?: string[]; + mapping?: GuidedColorMapping; + simplifyIsolatedPixels?: boolean; +} + +export interface GuideComplexity { + paintableCells: number; + paletteSize: number; + isolatedCells: number; + simplifiedCells: number; +} + +export interface NumberedGuide { + palette: string[]; + target: Uint8Array; + width: number; + height: number; + complexity: GuideComplexity; +} + +interface ColorBox { + pixels: RGB[]; + redRange: number; + greenRange: number; + blueRange: number; +} + +export function generateNumberedGuide( + sampledImage: ImageData, + options: number | GuideGenerationOptions = DEFAULT_GUIDE_COLOR_COUNT, +): NumberedGuide { + const settings = normalizeOptions(options); + const palette = settings.palette + ? parseRestrictedPalette(settings.palette) + : buildGeneratedPalette( + collectOpaquePixels(sampledImage), + clampColorCount(settings.maxColors ?? DEFAULT_GUIDE_COLOR_COUNT), + ); + const originalTarget = mapImageToPalette(sampledImage, palette, settings.mapping); + const target = settings.simplifyIsolatedPixels + ? simplifyIsolatedGuidePixels(originalTarget, sampledImage.width, sampledImage.height) + : originalTarget; + const simplifiedCells = countChangedCells(originalTarget, target); + + return { + palette: palette.map((color) => rgbToHex(color.r, color.g, color.b)), + target, + width: sampledImage.width, + height: sampledImage.height, + complexity: analyzeGuideComplexity( + target, + sampledImage.width, + sampledImage.height, + palette.length, + simplifiedCells, + ), + }; +} + +export function perceptualColorDistance(left: RGB, right: RGB): number { + const redMean = (left.r + right.r) / 2; + const red = left.r - right.r; + const green = left.g - right.g; + const blue = left.b - right.b; + + return Math.sqrt( + (2 + redMean / 256) * red * red + + 4 * green * green + + (2 + (255 - redMean) / 256) * blue * blue, + ); +} + +function luminanceDistance(left: RGB, right: RGB): number { + return Math.abs(relativeLuminance(left) - relativeLuminance(right)); +} + +export function simplifyIsolatedGuidePixels( + target: Uint8Array, + width: number, + height: number, +): Uint8Array { + if (target.length !== width * height) { + throw new RangeError('Guide dimensions do not match the target buffer'); + } + + const simplified = new Uint8Array(target); + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const index = y * width + x; + const current = target[index]; + if (current === 0) continue; + + const neighbors = getNeighborIndices(target, width, height, x, y); + if (neighbors.includes(current)) continue; + + const replacement = getMajorityIndex(neighbors); + if (replacement !== 0) simplified[index] = replacement; + } + } + + return simplified; +} + +export function analyzeGuideComplexity( + target: Uint8Array, + width: number, + height: number, + paletteSize: number, + simplifiedCells: number = 0, +): GuideComplexity { + if (target.length !== width * height) { + throw new RangeError('Guide dimensions do not match the target buffer'); + } + + let paintableCells = 0; + let isolatedCells = 0; + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const current = target[y * width + x]; + if (current === 0) continue; + + paintableCells += 1; + const neighbors = getNeighborIndices(target, width, height, x, y); + if (!neighbors.includes(current)) isolatedCells += 1; + } + } + + return { paintableCells, paletteSize, isolatedCells, simplifiedCells }; +} + +function collectOpaquePixels(image: ImageData): RGB[] { + const pixels: RGB[] = []; + + for (let index = 0; index < image.data.length; index += 4) { + if (image.data[index + 3] < 128) continue; + + pixels.push({ + r: image.data[index], + g: image.data[index + 1], + b: image.data[index + 2], + }); + } + + return pixels; +} + +function buildGeneratedPalette(pixels: RGB[], colorLimit: number): RGB[] { + if (pixels.length === 0) return []; + + const exactColors = getExactColors(pixels); + if (exactColors.length <= colorLimit) { + return sortPalette(exactColors); + } + + const boxes: ColorBox[] = [createColorBox(pixels)]; + + while (boxes.length < colorLimit) { + const boxIndex = findBoxToSplit(boxes); + if (boxIndex === -1) break; + + const [box] = boxes.splice(boxIndex, 1); + const split = splitColorBox(box); + if (!split) { + boxes.push(box); + break; + } + + boxes.push(...split); + } + + return sortPalette(dedupeColors(boxes.map(averageBoxColor))); +} + +function parseRestrictedPalette(palette: string[]): RGB[] { + if (palette.length === 0) { + throw new RangeError('A restricted palette must contain at least one color'); + } + if (palette.length > MAX_GUIDE_COLORS) { + throw new RangeError(`A restricted palette cannot exceed ${MAX_GUIDE_COLORS} colors`); + } + + return palette.map((hex) => { + const normalized = normalizeHex(hex); + const color = hexToRgb(normalized); + if (!color) throw new TypeError(`Invalid palette color: ${hex}`); + return color; + }); +} + +function getExactColors(pixels: RGB[]): RGB[] { + const colors = new Map(); + + for (const pixel of pixels) { + const key = `${pixel.r},${pixel.g},${pixel.b}`; + if (!colors.has(key)) colors.set(key, pixel); + } + + return [...colors.values()]; +} + +function createColorBox(pixels: RGB[]): ColorBox { + let minRed = 255; + let minGreen = 255; + let minBlue = 255; + let maxRed = 0; + let maxGreen = 0; + let maxBlue = 0; + + for (const pixel of pixels) { + minRed = Math.min(minRed, pixel.r); + minGreen = Math.min(minGreen, pixel.g); + minBlue = Math.min(minBlue, pixel.b); + maxRed = Math.max(maxRed, pixel.r); + maxGreen = Math.max(maxGreen, pixel.g); + maxBlue = Math.max(maxBlue, pixel.b); + } + + return { + pixels, + redRange: maxRed - minRed, + greenRange: maxGreen - minGreen, + blueRange: maxBlue - minBlue, + }; +} + +function findBoxToSplit(boxes: ColorBox[]): number { + let bestIndex = -1; + let bestScore = -1; + + for (let index = 0; index < boxes.length; index += 1) { + const box = boxes[index]; + if (box.pixels.length < 2) continue; + + const largestRange = Math.max(box.redRange, box.greenRange, box.blueRange); + const score = largestRange * box.pixels.length; + if (score > bestScore) { + bestIndex = index; + bestScore = score; + } + } + + return bestIndex; +} + +function splitColorBox(box: ColorBox): [ColorBox, ColorBox] | null { + const channel = getSplitChannel(box); + const sortedPixels = [...box.pixels].sort((left, right) => { + const difference = left[channel] - right[channel]; + if (difference !== 0) return difference; + if (left.r !== right.r) return left.r - right.r; + if (left.g !== right.g) return left.g - right.g; + return left.b - right.b; + }); + const splitIndex = Math.floor(sortedPixels.length / 2); + if (splitIndex === 0 || splitIndex === sortedPixels.length) return null; + + return [ + createColorBox(sortedPixels.slice(0, splitIndex)), + createColorBox(sortedPixels.slice(splitIndex)), + ]; +} + +function getSplitChannel(box: ColorBox): keyof RGB { + if (box.redRange >= box.greenRange && box.redRange >= box.blueRange) { + return 'r'; + } + if (box.greenRange >= box.blueRange) return 'g'; + return 'b'; +} + +function averageBoxColor(box: ColorBox): RGB { + let red = 0; + let green = 0; + let blue = 0; + + for (const pixel of box.pixels) { + red += pixel.r; + green += pixel.g; + blue += pixel.b; + } + + return { + r: Math.round(red / box.pixels.length), + g: Math.round(green / box.pixels.length), + b: Math.round(blue / box.pixels.length), + }; +} + +function dedupeColors(colors: RGB[]): RGB[] { + const unique = new Map(); + + for (const color of colors) { + unique.set(`${color.r},${color.g},${color.b}`, color); + } + + return [...unique.values()]; +} + +function sortPalette(colors: RGB[]): RGB[] { + return [...colors].sort((left, right) => { + const luminanceDifference = relativeLuminance(left) - relativeLuminance(right); + if (luminanceDifference !== 0) return luminanceDifference; + if (left.r !== right.r) return left.r - right.r; + if (left.g !== right.g) return left.g - right.g; + return left.b - right.b; + }); +} + +function relativeLuminance(color: RGB): number { + return 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b; +} + +function mapImageToPalette( + image: ImageData, + palette: RGB[], + mapping: GuidedColorMapping, +): Uint8Array { + const target = new Uint8Array(image.width * image.height); + if (palette.length === 0) return target; + + for (let pixelIndex = 0; pixelIndex < target.length; pixelIndex += 1) { + const dataIndex = pixelIndex * 4; + if (image.data[dataIndex + 3] < 128) continue; + + const color = { + r: image.data[dataIndex], + g: image.data[dataIndex + 1], + b: image.data[dataIndex + 2], + }; + target[pixelIndex] = findClosestPaletteIndex(color, palette, mapping); + } + + return target; +} + +function findClosestPaletteIndex( + color: RGB, + palette: RGB[], + mapping: GuidedColorMapping, +): number { + let closestIndex = 0; + let closestDistance = Number.POSITIVE_INFINITY; + + for (let index = 0; index < palette.length; index += 1) { + const distance = mapping === 'luminance' + ? luminanceDistance(color, palette[index]) + : perceptualColorDistance(color, palette[index]); + if (distance < closestDistance) { + closestIndex = index; + closestDistance = distance; + } + } + + return closestIndex + 1; +} + +function normalizeOptions( + options: number | GuideGenerationOptions, +): Required> + & Omit { + if (typeof options === 'number') { + return { + maxColors: options, + mapping: 'color', + simplifyIsolatedPixels: true, + }; + } + + return { + ...options, + mapping: options.mapping ?? 'color', + simplifyIsolatedPixels: options.simplifyIsolatedPixels ?? true, + }; +} + +function getNeighborIndices( + target: Uint8Array, + width: number, + height: number, + x: number, + y: number, +): number[] { + const neighbors: number[] = []; + if (x > 0) neighbors.push(target[y * width + x - 1]); + if (x + 1 < width) neighbors.push(target[y * width + x + 1]); + if (y > 0) neighbors.push(target[(y - 1) * width + x]); + if (y + 1 < height) neighbors.push(target[(y + 1) * width + x]); + return neighbors; +} + +function getMajorityIndex(indices: number[]): number { + const counts = new Map(); + for (const index of indices) { + if (index === 0) continue; + counts.set(index, (counts.get(index) ?? 0) + 1); + } + + let majorityIndex = 0; + let majorityCount = 1; + for (const [index, count] of counts) { + if (count > majorityCount || (count === majorityCount && index < majorityIndex)) { + majorityIndex = index; + majorityCount = count; + } + } + return majorityIndex; +} + +function countChangedCells(before: Uint8Array, after: Uint8Array): number { + let changed = 0; + for (let index = 0; index < before.length; index += 1) { + if (before[index] !== after[index]) changed += 1; + } + return changed; +} + +function clampColorCount(value: number): number { + if (!Number.isFinite(value)) return DEFAULT_GUIDE_COLOR_COUNT; + return Math.max(1, Math.min(MAX_GUIDE_COLORS, Math.round(value))); +} diff --git a/src/services/paint-by-number/guided-project.ts b/src/services/paint-by-number/guided-project.ts new file mode 100644 index 0000000..2a9cc05 --- /dev/null +++ b/src/services/paint-by-number/guided-project.ts @@ -0,0 +1,122 @@ +import { v4 as uuidv4 } from 'uuid'; +import { normalizeProjectName } from '../project-defaults'; +import { workspaceStore, type WorkspaceProjectOptions } from '../../stores/workspace'; +import { + GUIDED_DRAWING_VERSION, + type GuidedDrawingSettings, +} from '../../types/guided-drawing'; +import { PROJECT_VERSION, type ProjectFile } from '../../types/project'; +import type { NumberedGuide } from './guide-generator'; + +export interface GuidedProjectInput { + guide: NumberedGuide; + settings: GuidedDrawingSettings; + name?: string; + sourceName?: string; + createdAt?: number; +} + +interface GuidedProjectWorkspace { + createProjectFromFile( + project: ProjectFile, + options?: WorkspaceProjectOptions, + ): ReturnType; +} + +export function createGuidedProjectFile(input: GuidedProjectInput): ProjectFile { + validateGuide(input.guide); + + const layerId = uuidv4(); + const frameId = uuidv4(); + const { guide } = input; + + return { + version: PROJECT_VERSION, + name: normalizeProjectName(input.name ?? defaultGuidedProjectName(input.sourceName)), + width: guide.width, + height: guide.height, + palette: [...guide.palette], + layers: [ + { + id: layerId, + name: 'Painting', + type: 'image', + visible: true, + opacity: 255, + blendMode: 'normal', + continuous: false, + data: new Uint8Array(0), + }, + ], + frames: [ + { + id: frameId, + duration: 100, + cels: [ + { + layerId, + data: new Uint8Array(0), + indexData: Array.from(new Uint8Array(guide.width * guide.height)), + }, + ], + }, + ], + animation: { + fps: 12, + currentFrameIndex: 0, + }, + tags: [], + guidedDrawing: { + version: GUIDED_DRAWING_VERSION, + width: guide.width, + height: guide.height, + target: Array.from(guide.target), + settings: cloneSettings(input.settings), + sourceName: input.sourceName, + createdAt: input.createdAt ?? Date.now(), + }, + }; +} + +export async function createGuidedProject( + input: GuidedProjectInput, + options: WorkspaceProjectOptions = {}, + workspace: GuidedProjectWorkspace = workspaceStore, +) { + return workspace.createProjectFromFile(createGuidedProjectFile(input), { + activate: options.activate ?? true, + saveActiveContext: options.saveActiveContext ?? true, + }); +} + +function validateGuide(guide: NumberedGuide): void { + if (guide.width < 1 || guide.height < 1) { + throw new RangeError('A guided project needs positive dimensions'); + } + if (guide.target.length !== guide.width * guide.height) { + throw new RangeError('Guide target does not match its dimensions'); + } + if (guide.palette.length < 1) { + throw new RangeError('A guided project needs at least one paint color'); + } + + for (const index of guide.target) { + if (index > guide.palette.length) { + throw new RangeError('Guide target contains an unknown palette index'); + } + } +} + +function cloneSettings(settings: GuidedDrawingSettings): GuidedDrawingSettings { + return { + ...settings, + restrictedPalette: settings.restrictedPalette + ? [...settings.restrictedPalette] + : undefined, + }; +} + +function defaultGuidedProjectName(sourceName?: string): string { + const baseName = sourceName?.replace(/\.[^.]+$/, '').trim(); + return baseName ? `${baseName} guide` : 'Guided drawing'; +} diff --git a/src/services/paint-by-number/image-file.ts b/src/services/paint-by-number/image-file.ts new file mode 100644 index 0000000..33bab16 --- /dev/null +++ b/src/services/paint-by-number/image-file.ts @@ -0,0 +1,18 @@ +export async function decodeImageFile(file: File): Promise { + const bitmap = await createImageBitmap(file); + + try { + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context) { + throw new Error('Could not create an image decoding canvas'); + } + + context.drawImage(bitmap, 0, 0); + return context.getImageData(0, 0, bitmap.width, bitmap.height); + } finally { + bitmap.close(); + } +} diff --git a/src/services/paint-by-number/image-sampling.ts b/src/services/paint-by-number/image-sampling.ts new file mode 100644 index 0000000..7495feb --- /dev/null +++ b/src/services/paint-by-number/image-sampling.ts @@ -0,0 +1,145 @@ +const DEFAULT_GUIDE_ALPHA_THRESHOLD = 128; + +export interface TargetGridSize { + width: number; + height: number; +} + +export interface SampleImageOptions { + longSide: number; + alphaThreshold?: number; +} + +export function getTargetGridSize( + sourceWidth: number, + sourceHeight: number, + longSide: number, +): TargetGridSize { + const safeWidth = requirePositiveInteger(sourceWidth, 'sourceWidth'); + const safeHeight = requirePositiveInteger(sourceHeight, 'sourceHeight'); + const safeLongSide = requirePositiveInteger(longSide, 'longSide'); + + if (safeWidth >= safeHeight) { + return { + width: safeLongSide, + height: Math.max(1, Math.round((safeHeight / safeWidth) * safeLongSide)), + }; + } + + return { + width: Math.max(1, Math.round((safeWidth / safeHeight) * safeLongSide)), + height: safeLongSide, + }; +} + +export function sampleImageToGrid( + source: ImageData, + options: SampleImageOptions, +): ImageData { + const target = getTargetGridSize(source.width, source.height, options.longSide); + const alphaThreshold = clampByte( + options.alphaThreshold ?? DEFAULT_GUIDE_ALPHA_THRESHOLD, + ); + const result = new ImageData(target.width, target.height); + const scaleX = source.width / target.width; + const scaleY = source.height / target.height; + + for (let targetY = 0; targetY < target.height; targetY += 1) { + const sourceTop = targetY * scaleY; + const sourceBottom = (targetY + 1) * scaleY; + + for (let targetX = 0; targetX < target.width; targetX += 1) { + const sourceLeft = targetX * scaleX; + const sourceRight = (targetX + 1) * scaleX; + const sample = sampleSourceRegion( + source, + sourceLeft, + sourceTop, + sourceRight, + sourceBottom, + ); + const targetIndex = (targetY * target.width + targetX) * 4; + + if (sample.alpha < alphaThreshold) { + continue; + } + + result.data[targetIndex] = sample.red; + result.data[targetIndex + 1] = sample.green; + result.data[targetIndex + 2] = sample.blue; + result.data[targetIndex + 3] = 255; + } + } + + return result; +} + +interface SampledColor { + red: number; + green: number; + blue: number; + alpha: number; +} + +function sampleSourceRegion( + source: ImageData, + left: number, + top: number, + right: number, + bottom: number, +): SampledColor { + let red = 0; + let green = 0; + let blue = 0; + let alpha = 0; + let opaqueWeight = 0; + let regionWeight = 0; + + const startX = Math.floor(left); + const endX = Math.min(Math.ceil(right), source.width); + const startY = Math.floor(top); + const endY = Math.min(Math.ceil(bottom), source.height); + + for (let sourceY = startY; sourceY < endY; sourceY += 1) { + const overlapY = Math.min(sourceY + 1, bottom) - Math.max(sourceY, top); + + for (let sourceX = startX; sourceX < endX; sourceX += 1) { + const overlapX = Math.min(sourceX + 1, right) - Math.max(sourceX, left); + const pixelWeight = overlapX * overlapY; + const sourceIndex = (sourceY * source.width + sourceX) * 4; + const sourceAlpha = source.data[sourceIndex + 3] / 255; + const colorWeight = pixelWeight * sourceAlpha; + + red += source.data[sourceIndex] * colorWeight; + green += source.data[sourceIndex + 1] * colorWeight; + blue += source.data[sourceIndex + 2] * colorWeight; + alpha += source.data[sourceIndex + 3] * pixelWeight; + opaqueWeight += colorWeight; + regionWeight += pixelWeight; + } + } + + if (regionWeight === 0 || opaqueWeight === 0) { + return { red: 0, green: 0, blue: 0, alpha: 0 }; + } + + return { + red: Math.round(red / opaqueWeight), + green: Math.round(green / opaqueWeight), + blue: Math.round(blue / opaqueWeight), + alpha: Math.round(alpha / regionWeight), + }; +} + +function requirePositiveInteger(value: number, name: string): number { + if (!Number.isFinite(value) || value < 1) { + throw new RangeError(`${name} must be at least 1`); + } + + return Math.round(value); +} + +function clampByte(value: number): number { + if (!Number.isFinite(value)) return DEFAULT_GUIDE_ALPHA_THRESHOLD; + return Math.max(0, Math.min(255, Math.round(value))); +} diff --git a/src/services/project-library.ts b/src/services/project-library.ts index da90fda..c82df6f 100644 --- a/src/services/project-library.ts +++ b/src/services/project-library.ts @@ -70,6 +70,21 @@ export class ProjectLibraryService { return id; } + async createProjectFromFile( + project: ProjectFile, + settings: CreateProjectSettings = {} + ): Promise { + const context = settings.context ?? defaultProjectContext; + if (settings.saveCurrent ?? true) { + await autoSaveService.saveNow(context); + } + + const id = uuidv4(); + await this.repository.save(id, structuredClone(project)); + await this.loadStoredProject(id, context); + return id; + } + async duplicateProject(id: string): Promise { const project = await this.getProjectOrThrow(id); const sourceMeta = await this.findProjectMeta(id); diff --git a/src/stores/guided-drawing-store.ts b/src/stores/guided-drawing-store.ts new file mode 100644 index 0000000..03f368a --- /dev/null +++ b/src/stores/guided-drawing-store.ts @@ -0,0 +1,84 @@ +import { signal } from '../core/signal'; +import { + GUIDED_DRAWING_VERSION, + type GuidedDrawingSession, + type GuidedDrawingSessionFile, +} from '../types/guided-drawing'; + +class GuidedDrawingStore { + readonly session = signal(null); + + get active(): boolean { + return this.session.value !== null; + } + + start(session: GuidedDrawingSession): void { + validateSession(session); + this.session.value = cloneSession(session); + } + + load(file: GuidedDrawingSessionFile | undefined): void { + if (!file) { + this.clear(); + return; + } + + this.start({ + ...file, + target: Uint8Array.from(file.target), + }); + } + + toFile(): GuidedDrawingSessionFile | undefined { + const session = this.session.value; + if (!session) return undefined; + + return { + ...session, + target: Array.from(session.target), + settings: cloneSettings(session.settings), + }; + } + + clear(): void { + this.session.value = null; + } +} + +export function createGuidedDrawingStore(): GuidedDrawingStore { + return new GuidedDrawingStore(); +} + +function validateSession(session: GuidedDrawingSession): void { + if (session.version !== GUIDED_DRAWING_VERSION) { + throw new RangeError(`Unsupported guided drawing version: ${session.version}`); + } + if (!Number.isInteger(session.width) || session.width < 1) { + throw new RangeError('Guided drawing width must be at least 1'); + } + if (!Number.isInteger(session.height) || session.height < 1) { + throw new RangeError('Guided drawing height must be at least 1'); + } + if (session.target.length !== session.width * session.height) { + throw new RangeError('Guided drawing target does not match its dimensions'); + } +} + +function cloneSession(session: GuidedDrawingSession): GuidedDrawingSession { + return { + ...session, + target: new Uint8Array(session.target), + settings: cloneSettings(session.settings), + }; +} + +function cloneSettings( + settings: GuidedDrawingSession['settings'], +): GuidedDrawingSession['settings'] { + return { + ...settings, + restrictedPalette: settings.restrictedPalette + ? [...settings.restrictedPalette] + : undefined, + }; +} diff --git a/src/stores/guided-drawing.ts b/src/stores/guided-drawing.ts new file mode 100644 index 0000000..37b4a30 --- /dev/null +++ b/src/stores/guided-drawing.ts @@ -0,0 +1,3 @@ +import { defaultProjectContext } from './project-context'; + +export const guidedDrawingStore = defaultProjectContext.guidedDrawing; diff --git a/src/stores/project-context.ts b/src/stores/project-context.ts index bb24e32..533f668 100644 --- a/src/stores/project-context.ts +++ b/src/stores/project-context.ts @@ -4,6 +4,7 @@ import { createColorStore } from "./colors-store"; import { createDirtyRectStore } from "./dirty-rect-store"; import { createGridStore } from "./grid-store"; import { createGuidesStore } from "./guides-store"; +import { createGuidedDrawingStore } from "./guided-drawing-store"; import { createHistoryHighlightStore } from "./history-highlight-store"; import { createHistoryStore } from "./history-store"; import { createLayerStore } from "./layers-store"; @@ -18,6 +19,7 @@ export type ProjectColorStore = ReturnType; export type ProjectDirtyRectStore = ReturnType; export type ProjectGridStore = ReturnType; export type ProjectGuidesStore = ReturnType; +export type ProjectGuidedDrawingStore = ReturnType; export type ProjectHistoryHighlightStore = ReturnType< typeof createHistoryHighlightStore >; @@ -34,6 +36,7 @@ export interface ProjectContextStores { dirtyRect: ProjectDirtyRectStore; grid: ProjectGridStore; guides: ProjectGuidesStore; + guidedDrawing: ProjectGuidedDrawingStore; history: ProjectHistoryStore; historyHighlight: ProjectHistoryHighlightStore; layers: ProjectLayerStore; @@ -57,6 +60,7 @@ function createProjectContextStores( const dirtyRect = createDirtyRectStore(); const grid = createGridStore(); const guides = createGuidesStore(); + const guidedDrawing = createGuidedDrawingStore(); const historyHighlight = createHistoryHighlightStore(); const selection = createSelectionStore(); const palette = createPaletteStore({ layers, refs }); @@ -66,6 +70,7 @@ function createProjectContextStores( const project = createProjectStore({ animation, dirtyRect, + guidedDrawing, history, layers, palette, @@ -80,6 +85,7 @@ function createProjectContextStores( dirtyRect, grid, guides, + guidedDrawing, history, historyHighlight, layers, @@ -97,6 +103,7 @@ export class ProjectContext { readonly dirtyRect: ProjectDirtyRectStore; readonly grid: ProjectGridStore; readonly guides: ProjectGuidesStore; + readonly guidedDrawing: ProjectGuidedDrawingStore; readonly history: ProjectHistoryStore; readonly historyHighlight: ProjectHistoryHighlightStore; readonly layers: ProjectLayerStore; @@ -114,6 +121,7 @@ export class ProjectContext { this.dirtyRect = stores.dirtyRect; this.grid = stores.grid; this.guides = stores.guides; + this.guidedDrawing = stores.guidedDrawing; this.history = stores.history; this.historyHighlight = stores.historyHighlight; this.layers = stores.layers; diff --git a/src/stores/project-store.ts b/src/stores/project-store.ts index 2a48f3c..aa5f242 100644 --- a/src/stores/project-store.ts +++ b/src/stores/project-store.ts @@ -33,6 +33,7 @@ import type { Layer } from "../types/layer"; import type { createAnimationStore } from "./animation/store"; import type { createDirtyRectStore } from "./dirty-rect-store"; import type { createHistoryStore } from "./history-store"; +import type { createGuidedDrawingStore } from "./guided-drawing-store"; import type { createLayerStore } from "./layers-store"; import type { createPaletteStore } from "./palette/store"; import type { createSelectionStore } from "./selection/store"; @@ -40,6 +41,7 @@ import type { createSelectionStore } from "./selection/store"; type ProjectAnimationStore = ReturnType; type ProjectDirtyRectStore = ReturnType; type ProjectHistoryStore = ReturnType; +type ProjectGuidedDrawingStore = ReturnType; type ProjectLayerStore = ReturnType; type ProjectPaletteStore = ReturnType; type ProjectSelectionStore = ReturnType; @@ -48,6 +50,7 @@ export interface ProjectStoreDependencies { animation: ProjectAnimationStore; dirtyRect: ProjectDirtyRectStore; history: ProjectHistoryStore; + guidedDrawing: ProjectGuidedDrawingStore; layers: ProjectLayerStore; palette: ProjectPaletteStore; refs: StoreRefs; @@ -102,6 +105,7 @@ class ProjectStore { private readonly animation: ProjectAnimationStore; private readonly dirtyRect: ProjectDirtyRectStore; private readonly history: ProjectHistoryStore; + private readonly guidedDrawing: ProjectGuidedDrawingStore; private readonly layers: ProjectLayerStore; private readonly loadStores: ProjectLoadStores; private readonly palette: ProjectPaletteStore; @@ -112,6 +116,7 @@ class ProjectStore { this.animation = dependencies.animation; this.dirtyRect = dependencies.dirtyRect; this.history = dependencies.history; + this.guidedDrawing = dependencies.guidedDrawing; this.layers = dependencies.layers; this.palette = dependencies.palette; this.refs = dependencies.refs; @@ -177,6 +182,8 @@ class ProjectStore { (f) => f.id === this.animation.currentFrameId.value ); + const guidedDrawing = this.guidedDrawing.toFile(); + return { version: PROJECT_VERSION, name: this.name.value, @@ -190,6 +197,7 @@ class ProjectStore { currentFrameIndex: currentFrameIndex === -1 ? 0 : currentFrameIndex, }, tags: this.animation.tags.value, + ...(guidedDrawing && { guidedDrawing }), }; } @@ -207,6 +215,7 @@ class ProjectStore { this.setSize(file.width, file.height); this.name.value = file.name || DEFAULT_PROJECT_NAME; + this.guidedDrawing.load(file.guidedDrawing); restoreProjectPaletteForLoad(this.loadStores, file); await hydrateProjectLayers(this.loadStores, file); @@ -236,6 +245,7 @@ class ProjectStore { this.setSize(width, height); this.name.value = DEFAULT_PROJECT_NAME; this.lastSaved.value = null; + this.guidedDrawing.clear(); // 2. Clear all layers while (this.layers.layers.value.length > 0) { diff --git a/src/stores/workspace.ts b/src/stores/workspace.ts index 696ab21..9fd5f47 100644 --- a/src/stores/workspace.ts +++ b/src/stores/workspace.ts @@ -16,6 +16,7 @@ import { setActiveProjectContext, type ProjectContext, } from "./project-context"; +import type { ProjectFile } from "../types/project"; import { log } from "../utils/log"; export const WORKSPACE_OPEN_ITEM_LIMIT = 8; @@ -42,7 +43,7 @@ type WorkspaceLimitFailure = { type WorkspaceProjectLibrary = Pick< ProjectLibraryService, - "openProject" | "createProject" + "openProject" | "createProject" | "createProjectFromFile" >; type WorkspaceAutoSave = Pick< typeof autoSaveService, @@ -194,6 +195,30 @@ export class WorkspaceStore { async createProject( projectOptions: CreateProjectOptions, options: WorkspaceProjectOptions = {}, + ): Promise { + return this.createProjectInNewContext(options, (context) => + this.projectLibrary.createProject(projectOptions, { + context, + saveCurrent: false, + }), + ); + } + + async createProjectFromFile( + project: ProjectFile, + options: WorkspaceProjectOptions = {}, + ): Promise { + return this.createProjectInNewContext(options, (context) => + this.projectLibrary.createProjectFromFile(project, { + context, + saveCurrent: false, + }), + ); + } + + private async createProjectInNewContext( + options: WorkspaceProjectOptions, + createProject: (context: ProjectContext) => Promise, ): Promise { if (this.items.value.length >= this.itemLimit) { return this.createLimitFailure(); @@ -204,10 +229,7 @@ export class WorkspaceStore { const context = createProjectContext(); let projectId: string; try { - projectId = await this.projectLibrary.createProject(projectOptions, { - context, - saveCurrent: false, - }); + projectId = await createProject(context); } catch (error) { context.dispose(); throw error; diff --git a/src/types/guided-drawing.ts b/src/types/guided-drawing.ts new file mode 100644 index 0000000..c35c3b2 --- /dev/null +++ b/src/types/guided-drawing.ts @@ -0,0 +1,28 @@ +export const GUIDED_DRAWING_VERSION = 1; + +export type GuidedPaletteSource = 'generated' | 'restricted'; +export type GuidedColorMapping = 'color' | 'luminance'; + +export interface GuidedDrawingSettings { + longSide: number; + paletteSource: GuidedPaletteSource; + maxColors?: number; + restrictedPalette?: string[]; + mapping: GuidedColorMapping; + simplifyIsolatedPixels: boolean; +} + +export interface GuidedDrawingSessionFile { + version: typeof GUIDED_DRAWING_VERSION; + width: number; + height: number; + target: number[]; + settings: GuidedDrawingSettings; + sourceName?: string; + createdAt: number; +} + +export interface GuidedDrawingSession + extends Omit { + target: Uint8Array; +} diff --git a/src/types/project.ts b/src/types/project.ts index d853a7e..8b978d2 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -2,6 +2,7 @@ import type { FrameTag } from './animation'; import type { LayerType, BlendMode } from './layer'; import type { ReferenceLayerData } from './reference'; import type { TextLayerData, TextCelData } from './text'; +import type { GuidedDrawingSessionFile } from './guided-drawing'; /** * Current project file format version. @@ -12,8 +13,9 @@ import type { TextLayerData, TextCelData } from './text'; * * History: 3.1.0 added `ephemeralPalette`, 3.2.0 added `layers[].continuous`, * 4.0.0 removed `ephemeralPalette` and added reference layer data. + * 4.1.0 added optional durable guided drawing sessions. */ -export const PROJECT_VERSION = '4.0.0'; +export const PROJECT_VERSION = '4.1.0'; export type ProjectImageData = Uint8Array; export type LegacyProjectImageData = @@ -62,6 +64,7 @@ export interface ProjectFile { currentFrameIndex: number; }; tags?: FrameTag[]; // Frame tags (v2.0+, optional for backward compat) + guidedDrawing?: GuidedDrawingSessionFile; // v4.1+: optional numbered drawing guide } export type ProjectLayerFileInput = Omit & { diff --git a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts new file mode 100644 index 0000000..a67af07 --- /dev/null +++ b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const decodeImageFileMock = vi.hoisted(() => vi.fn()); +const sampleImageToGridMock = vi.hoisted(() => vi.fn()); +const generateNumberedGuideMock = vi.hoisted(() => vi.fn()); +const createGuidedProjectMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../../src/services/paint-by-number/image-file', () => ({ + decodeImageFile: decodeImageFileMock, +})); + +vi.mock('../../../src/services/paint-by-number/image-sampling', () => ({ + sampleImageToGrid: sampleImageToGridMock, +})); + +vi.mock('../../../src/services/paint-by-number/guide-generator', () => ({ + generateNumberedGuide: generateNumberedGuideMock, +})); + +vi.mock('../../../src/services/paint-by-number/guided-project', () => ({ + createGuidedProject: createGuidedProjectMock, +})); + +import '../../../src/components/dialogs/pf-paint-by-number-dialog'; +import type { PFPaintByNumberDialog } from '../../../src/components/dialogs/pf-paint-by-number-dialog'; + +const sourceImage = { + width: 4, + height: 2, + data: new Uint8ClampedArray(32), +} as ImageData; + +const sampledImage = { + width: 2, + height: 1, + data: new Uint8ClampedArray(8), +} as ImageData; + +const guide = { + palette: ['#111111', '#eeeeee'], + target: new Uint8Array([1, 2]), + width: 2, + height: 1, + complexity: { + paintableCells: 2, + paletteSize: 2, + isolatedCells: 2, + simplifiedCells: 1, + }, +}; + +async function settle(element: PFPaintByNumberDialog) { + await Promise.resolve(); + await element.updateComplete; + await Promise.resolve(); + await element.updateComplete; +} + +async function createDialog() { + const element = document.createElement( + 'pf-paint-by-number-dialog', + ) as PFPaintByNumberDialog; + element.open = true; + document.body.append(element); + await settle(element); + return element; +} + +function chooseFile(element: PFPaintByNumberDialog, file: File) { + const input = element.shadowRoot?.querySelector( + '#guided-source-file', + ); + Object.defineProperty(input, 'files', { + configurable: true, + value: [file], + }); + input?.dispatchEvent(new Event('change')); +} + +describe('pf-paint-by-number-dialog', () => { + beforeEach(() => { + vi.useFakeTimers(); + document.body.replaceChildren(); + vi.clearAllMocks(); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); + decodeImageFileMock.mockResolvedValue(sourceImage); + sampleImageToGridMock.mockReturnValue(sampledImage); + generateNumberedGuideMock.mockReturnValue(guide); + createGuidedProjectMock.mockResolvedValue({ + ok: true, + item: {}, + projectId: 'guided-project', + }); + }); + + afterEach(() => { + document.body.replaceChildren(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('generates a balanced preview and creates a separate guided project', async () => { + const element = await createDialog(); + const file = new File(['pixels'], 'portrait.png', { type: 'image/png' }); + let createdProjectId = ''; + element.addEventListener('project-created', (event) => { + createdProjectId = (event as CustomEvent<{ id: string }>).detail.id; + }); + + chooseFile(element, file); + await Promise.resolve(); + await vi.runAllTimersAsync(); + await settle(element); + + expect(sampleImageToGridMock).toHaveBeenCalledWith(sourceImage, { + longSide: 24, + }); + expect(generateNumberedGuideMock).toHaveBeenCalledWith(sampledImage, { + maxColors: 8, + palette: undefined, + mapping: 'color', + simplifyIsolatedPixels: true, + }); + + element.shadowRoot?.querySelector('button.primary')?.click(); + await settle(element); + + expect(createGuidedProjectMock).toHaveBeenCalledWith({ + guide, + settings: { + longSide: 24, + paletteSource: 'generated', + maxColors: 8, + restrictedPalette: undefined, + mapping: 'color', + simplifyIsolatedPixels: true, + }, + sourceName: 'portrait.png', + }); + expect(createdProjectId).toBe('guided-project'); + expect(element.open).toBe(false); + }); + + it('ignores a stale image decode after a newer file is selected', async () => { + const element = await createDialog(); + const firstImage = { ...sourceImage, width: 10 } as ImageData; + const secondImage = { ...sourceImage, width: 20 } as ImageData; + let resolveFirst: ((image: ImageData) => void) | undefined; + decodeImageFileMock + .mockImplementationOnce(() => new Promise((resolve) => { + resolveFirst = resolve; + })) + .mockResolvedValueOnce(secondImage); + + chooseFile(element, new File(['first'], 'first.png', { type: 'image/png' })); + chooseFile(element, new File(['second'], 'second.png', { type: 'image/png' })); + await Promise.resolve(); + resolveFirst?.(firstImage); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + expect(sampleImageToGridMock).toHaveBeenCalledTimes(1); + expect(sampleImageToGridMock).toHaveBeenCalledWith(secondImage, { + longSide: 24, + }); + }); + + it('keeps the dialog open when project creation reaches the workspace limit', async () => { + const element = await createDialog(); + createGuidedProjectMock.mockResolvedValue({ + ok: false, + reason: 'tab-limit-reached', + message: 'Workspace limit reached.', + }); + + chooseFile(element, new File(['pixels'], 'portrait.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + await settle(element); + element.shadowRoot?.querySelector('button.primary')?.click(); + await settle(element); + + expect(element.open).toBe(true); + expect(element.shadowRoot?.textContent).toContain('Workspace limit reached.'); + }); +}); diff --git a/tests/components/menu/pf-menu-bar.test.ts b/tests/components/menu/pf-menu-bar.test.ts index 4e71e82..7aa5ebe 100644 --- a/tests/components/menu/pf-menu-bar.test.ts +++ b/tests/components/menu/pf-menu-bar.test.ts @@ -316,6 +316,25 @@ describe("pf-menu-bar popovers", () => { expect(fileButton?.getAttribute("aria-expanded")).toBe("false"); }); + it("opens guided drawing setup from the File menu", async () => { + const element = await createMenuBar(); + const fileButton = button(element, "file"); + const fileMenu = menu(element, "file"); + let guidedDrawingRequested = false; + + element.addEventListener("show-paint-by-number-dialog", () => { + guidedDrawingRequested = true; + }); + + fileButton?.click(); + await element.updateComplete; + menuItem(fileMenu!, "New Guided Drawing")?.click(); + await element.updateComplete; + + expect(guidedDrawingRequested).toBe(true); + expect(fileButton?.getAttribute("aria-expanded")).toBe("false"); + }); + it("imports a reference image from the File menu into the project active when the picker opened", async () => { const contextA = createContext("Context A"); const contextB = createContext("Context B"); diff --git a/tests/services/paint-by-number/guide-generator.test.ts b/tests/services/paint-by-number/guide-generator.test.ts new file mode 100644 index 0000000..ff425c2 --- /dev/null +++ b/tests/services/paint-by-number/guide-generator.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + analyzeGuideComplexity, + generateNumberedGuide, + perceptualColorDistance, + simplifyIsolatedGuidePixels, +} from '../../../src/services/paint-by-number/guide-generator'; + +class FakeImageData { + readonly data: Uint8ClampedArray; + readonly width: number; + readonly height: number; + + constructor(data: Uint8ClampedArray, width: number, height: number) { + this.data = data; + this.width = width; + this.height = height; + } +} + +vi.stubGlobal('ImageData', FakeImageData); + +function imageData(width: number, height: number, pixels: number[][]): ImageData { + return new ImageData(new Uint8ClampedArray(pixels.flat()), width, height); +} + +describe('generateNumberedGuide', () => { + it('keeps exact colors when they fit the requested limit', () => { + const image = imageData(3, 1, [ + [255, 0, 0, 255], + [0, 0, 255, 255], + [255, 0, 0, 255], + ]); + + const guide = generateNumberedGuide(image, { + maxColors: 2, + simplifyIsolatedPixels: false, + }); + + expect(guide.palette).toEqual(['#0000ff', '#ff0000']); + expect([...guide.target]).toEqual([2, 1, 2]); + expect([guide.width, guide.height]).toEqual([3, 1]); + expect(guide.complexity).toEqual({ + paintableCells: 3, + paletteSize: 2, + isolatedCells: 3, + simplifiedCells: 0, + }); + }); + + it('maps transparent cells to zero', () => { + const image = imageData(2, 1, [ + [12, 34, 56, 0], + [12, 34, 56, 255], + ]); + + const guide = generateNumberedGuide(image, 4); + + expect(guide.palette).toEqual(['#0c2238']); + expect([...guide.target]).toEqual([0, 1]); + }); + + it('never exceeds the requested palette size', () => { + const image = imageData(6, 1, [ + [255, 0, 0, 255], + [0, 255, 0, 255], + [0, 0, 255, 255], + [255, 255, 0, 255], + [255, 0, 255, 255], + [0, 255, 255, 255], + ]); + + const guide = generateNumberedGuide(image, 3); + + expect(guide.palette).toHaveLength(3); + expect([...guide.target].every((index) => index >= 1 && index <= 3)).toBe(true); + }); + + it('produces stable palette and target fixtures', () => { + const image = imageData(4, 2, [ + [5, 5, 8, 255], + [20, 15, 25, 255], + [220, 40, 30, 255], + [250, 80, 35, 255], + [10, 120, 200, 255], + [20, 150, 240, 255], + [230, 210, 40, 255], + [250, 240, 90, 255], + ]); + + const first = generateNumberedGuide(image, 4); + const second = generateNumberedGuide(image, 4); + + expect(first.palette).toEqual(['#0d0a11', '#eb3c21', '#0f87dc', '#f0e141']); + expect([...first.target]).toEqual([1, 1, 2, 2, 3, 3, 4, 4]); + expect(second.palette).toEqual(first.palette); + expect([...second.target]).toEqual([...first.target]); + }); + + it('returns an empty palette for a fully transparent image', () => { + const image = imageData(1, 1, [[0, 0, 0, 0]]); + + expect(generateNumberedGuide(image, 4)).toEqual({ + palette: [], + target: new Uint8Array([0]), + width: 1, + height: 1, + complexity: { + paintableCells: 0, + paletteSize: 0, + isolatedCells: 0, + simplifiedCells: 0, + }, + }); + }); + + it('preserves a supplied palette and supports luminance matching', () => { + const image = imageData(1, 1, [[0, 255, 255, 255]]); + const palette = ['#0000ff', '#ff0000']; + + const colorGuide = generateNumberedGuide(image, { + palette, + mapping: 'color', + }); + const luminanceGuide = generateNumberedGuide(image, { + palette, + mapping: 'luminance', + }); + + expect(colorGuide.palette).toEqual(palette); + expect(luminanceGuide.palette).toEqual(palette); + expect([...colorGuide.target]).toEqual([1]); + expect([...luminanceGuide.target]).toEqual([2]); + }); +}); + +describe('perceptualColorDistance', () => { + it('is zero for equal colors and symmetric for different colors', () => { + const red = { r: 255, g: 0, b: 0 }; + const blue = { r: 0, g: 0, b: 255 }; + + expect(perceptualColorDistance(red, red)).toBe(0); + expect(perceptualColorDistance(red, blue)).toBe( + perceptualColorDistance(blue, red), + ); + }); +}); + +describe('guide playability', () => { + it('simplifies a truly isolated cell to the neighboring majority', () => { + expect([ + ...simplifyIsolatedGuidePixels(new Uint8Array([1, 2, 1]), 3, 1), + ]).toEqual([1, 1, 1]); + }); + + it('keeps connected regions and reports complexity', () => { + const target = new Uint8Array([ + 1, 2, + 1, 2, + ]); + + expect([...simplifyIsolatedGuidePixels(target, 2, 2)]).toEqual([...target]); + expect(analyzeGuideComplexity(target, 2, 2, 2)).toEqual({ + paintableCells: 4, + paletteSize: 2, + isolatedCells: 0, + simplifiedCells: 0, + }); + }); +}); diff --git a/tests/services/paint-by-number/guided-project.test.ts b/tests/services/paint-by-number/guided-project.test.ts new file mode 100644 index 0000000..eb77edd --- /dev/null +++ b/tests/services/paint-by-number/guided-project.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createGuidedProject, + createGuidedProjectFile, +} from '../../../src/services/paint-by-number/guided-project'; +import type { NumberedGuide } from '../../../src/services/paint-by-number/guide-generator'; + +function guide(): NumberedGuide { + return { + palette: ['#111111', '#eeeeee'], + target: new Uint8Array([1, 2, 0, 1]), + width: 2, + height: 2, + complexity: { + paintableCells: 3, + paletteSize: 2, + isolatedCells: 3, + simplifiedCells: 0, + }, + }; +} + +const settings = { + longSide: 2, + paletteSource: 'generated' as const, + maxColors: 2, + mapping: 'color' as const, + simplifyIsolatedPixels: true, +}; + +describe('createGuidedProjectFile', () => { + it('creates a blank normal project plus its durable guide', () => { + const file = createGuidedProjectFile({ + guide: guide(), + settings, + sourceName: 'portrait.png', + createdAt: 123, + }); + + expect(file.name).toBe('portrait guide'); + expect([file.width, file.height]).toEqual([2, 2]); + expect(file.palette).toEqual(['#111111', '#eeeeee']); + expect(file.layers).toHaveLength(1); + expect(file.layers[0].name).toBe('Painting'); + expect(file.frames).toHaveLength(1); + expect(file.frames[0].cels[0].indexData).toEqual([0, 0, 0, 0]); + expect(file.guidedDrawing).toMatchObject({ + version: 1, + target: [1, 2, 0, 1], + settings, + sourceName: 'portrait.png', + createdAt: 123, + }); + }); + + it('rejects invalid guide data', () => { + expect(() => createGuidedProjectFile({ + guide: { ...guide(), target: new Uint8Array([1]) }, + settings, + })).toThrow('target does not match'); + + expect(() => createGuidedProjectFile({ + guide: { ...guide(), palette: [] }, + settings, + })).toThrow('at least one paint color'); + }); +}); + +describe('createGuidedProject', () => { + it('delegates a separate activated project to the workspace', async () => { + const createProjectFromFile = vi.fn(async () => ({ + ok: true as const, + item: {} as never, + projectId: 'guided-id', + })); + + const result = await createGuidedProject( + { guide: guide(), settings, createdAt: 123 }, + {}, + { createProjectFromFile }, + ); + + expect(result.projectId).toBe('guided-id'); + expect(createProjectFromFile).toHaveBeenCalledWith( + expect.objectContaining({ guidedDrawing: expect.any(Object) }), + { activate: true, saveActiveContext: true }, + ); + }); +}); diff --git a/tests/services/paint-by-number/image-sampling.test.ts b/tests/services/paint-by-number/image-sampling.test.ts new file mode 100644 index 0000000..a218a63 --- /dev/null +++ b/tests/services/paint-by-number/image-sampling.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + getTargetGridSize, + sampleImageToGrid, +} from '../../../src/services/paint-by-number/image-sampling'; + +class FakeImageData { + readonly data: Uint8ClampedArray; + readonly width: number; + readonly height: number; + + constructor(width: number, height: number); + constructor(data: Uint8ClampedArray, width: number, height: number); + constructor( + dataOrWidth: Uint8ClampedArray | number, + widthOrHeight: number, + maybeHeight?: number, + ) { + if (typeof dataOrWidth === 'number') { + this.width = dataOrWidth; + this.height = widthOrHeight; + this.data = new Uint8ClampedArray(this.width * this.height * 4); + return; + } + + this.data = dataOrWidth; + this.width = widthOrHeight; + this.height = maybeHeight ?? 0; + } +} + +vi.stubGlobal('ImageData', FakeImageData); + +function imageData(width: number, height: number, pixels: number[][]): ImageData { + return new ImageData( + new Uint8ClampedArray(pixels.flat()), + width, + height, + ); +} + +describe('getTargetGridSize', () => { + it('preserves landscape, portrait, and square aspect ratios', () => { + expect(getTargetGridSize(8, 4, 4)).toEqual({ width: 4, height: 2 }); + expect(getTargetGridSize(4, 8, 4)).toEqual({ width: 2, height: 4 }); + expect(getTargetGridSize(5, 5, 3)).toEqual({ width: 3, height: 3 }); + }); + + it('keeps the shorter side at least one cell', () => { + expect(getTargetGridSize(100, 1, 8)).toEqual({ width: 8, height: 1 }); + }); + + it('rejects invalid dimensions', () => { + expect(() => getTargetGridSize(0, 1, 1)).toThrow(RangeError); + expect(() => getTargetGridSize(1, 1, 0)).toThrow(RangeError); + }); +}); + +describe('sampleImageToGrid', () => { + it('averages source regions into target cells', () => { + const source = imageData(4, 2, [ + [255, 0, 0, 255], + [0, 0, 255, 255], + [0, 255, 0, 255], + [0, 255, 0, 255], + [255, 0, 0, 255], + [0, 0, 255, 255], + [0, 255, 0, 255], + [0, 255, 0, 255], + ]); + + const result = sampleImageToGrid(source, { longSide: 2 }); + + expect([result.width, result.height]).toEqual([2, 1]); + expect([...result.data]).toEqual([ + 128, 0, 128, 255, + 0, 255, 0, 255, + ]); + }); + + it('supports non-integer downscale regions deterministically', () => { + const source = imageData(3, 1, [ + [0, 0, 0, 255], + [120, 120, 120, 255], + [240, 240, 240, 255], + ]); + + const first = sampleImageToGrid(source, { longSide: 2 }); + const second = sampleImageToGrid(source, { longSide: 2 }); + + expect([...first.data]).toEqual([40, 40, 40, 255, 200, 200, 200, 255]); + expect([...second.data]).toEqual([...first.data]); + }); + + it('uses premultiplied color averaging and hard transparency', () => { + const source = imageData(2, 1, [ + [255, 0, 0, 255], + [0, 0, 255, 0], + ]); + + const visible = sampleImageToGrid(source, { + longSide: 1, + alphaThreshold: 127, + }); + const transparent = sampleImageToGrid(source, { + longSide: 1, + alphaThreshold: 129, + }); + + expect([...visible.data]).toEqual([255, 0, 0, 255]); + expect([...transparent.data]).toEqual([0, 0, 0, 0]); + }); + + it('copies a one-pixel input', () => { + const source = imageData(1, 1, [[12, 34, 56, 255]]); + const result = sampleImageToGrid(source, { longSide: 1 }); + + expect([...result.data]).toEqual([12, 34, 56, 255]); + }); +}); diff --git a/tests/services/project-library.test.ts b/tests/services/project-library.test.ts index 895d00c..9930bc6 100644 --- a/tests/services/project-library.test.ts +++ b/tests/services/project-library.test.ts @@ -214,6 +214,33 @@ describe('ProjectLibraryService', () => { expect(projects.has('current')).toBe(false); }); + it('creates and opens an exact project file without mutating its input', async () => { + const project = makeProject('Guided project', 5, 3); + project.guidedDrawing = { + version: 1, + width: 5, + height: 3, + target: new Array(15).fill(1), + settings: { + longSide: 5, + paletteSource: 'generated', + maxColors: 2, + mapping: 'color', + simplifyIsolatedPixels: true, + }, + createdAt: 123, + }; + const input = structuredClone(project); + + const id = await service.createProjectFromFile(project, { saveCurrent: false }); + + expect(project).toEqual(input); + expect(projectStore.id.value).toBe(id); + expect(projectStore.name.value).toBe('Guided project'); + expect(projectStore.width.value).toBe(5); + expect(projects.get(id)?.guidedDrawing).toEqual(project.guidedDrawing); + }); + it('saves edits to the open project before loading another project', async () => { projects.set('a', makeProject('Project A')); projects.set('b', makeProject('Project B')); diff --git a/tests/stores/guided-drawing-store.test.ts b/tests/stores/guided-drawing-store.test.ts new file mode 100644 index 0000000..ad02740 --- /dev/null +++ b/tests/stores/guided-drawing-store.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { createGuidedDrawingStore } from '../../src/stores/guided-drawing-store'; + +function createSession() { + return { + version: 1 as const, + width: 2, + height: 1, + target: new Uint8Array([1, 2]), + settings: { + longSide: 2, + paletteSource: 'restricted' as const, + restrictedPalette: ['#000000', '#ffffff'], + mapping: 'luminance' as const, + simplifyIsolatedPixels: true, + }, + sourceName: 'source.png', + createdAt: 123, + }; +} + +describe('GuidedDrawingStore', () => { + it('owns and clones runtime session data', () => { + const store = createGuidedDrawingStore(); + const session = createSession(); + + store.start(session); + session.target[0] = 9; + session.settings.restrictedPalette[0] = '#ff00ff'; + + expect(store.session.value?.target).toEqual(new Uint8Array([1, 2])); + expect(store.session.value?.settings.restrictedPalette).toEqual([ + '#000000', + '#ffffff', + ]); + }); + + it('converts typed targets to serializable file data', () => { + const store = createGuidedDrawingStore(); + store.start(createSession()); + + expect(store.toFile()).toEqual({ + ...createSession(), + target: [1, 2], + }); + }); + + it('loads, clears, and rejects mismatched targets', () => { + const store = createGuidedDrawingStore(); + const file = { ...createSession(), target: [1, 2] }; + + store.load(file); + expect(store.active).toBe(true); + store.clear(); + expect(store.active).toBe(false); + store.load(undefined); + expect(store.active).toBe(false); + + expect(() => store.start({ ...createSession(), target: new Uint8Array([1]) })) + .toThrow('target does not match'); + }); +}); diff --git a/tests/stores/project-context.test.ts b/tests/stores/project-context.test.ts index 85071fe..8ba08db 100644 --- a/tests/stores/project-context.test.ts +++ b/tests/stores/project-context.test.ts @@ -5,6 +5,7 @@ import { colorStore } from "../../src/stores/colors"; import { dirtyRectStore } from "../../src/stores/dirty-rect"; import { gridStore } from "../../src/stores/grid"; import { guidesStore } from "../../src/stores/guides"; +import { guidedDrawingStore } from "../../src/stores/guided-drawing"; import { historyStore } from "../../src/stores/history"; import { historyHighlightStore } from "../../src/stores/history-highlight"; import { layerStore } from "../../src/stores/layers"; @@ -112,6 +113,20 @@ describe("ProjectContext", () => { contextA.project.setSize(32, 24); contextA.animation.fps.value = 24; contextA.palette.setPalette(["#111111", "#222222"]); + contextA.guidedDrawing.start({ + version: 1, + width: 1, + height: 1, + target: new Uint8Array([1]), + settings: { + longSide: 1, + paletteSource: "generated", + maxColors: 1, + mapping: "color", + simplifyIsolatedPixels: true, + }, + createdAt: 1, + }); await contextA.history.execute({ id: "command-a", name: "Command A", @@ -125,6 +140,8 @@ describe("ProjectContext", () => { expect(contextB.animation.fps.value).toBe(12); expect(contextA.palette.mainColors.value).toEqual(["#111111", "#222222"]); expect(contextB.palette.mainColors.value).not.toEqual(["#111111", "#222222"]); + expect(contextA.guidedDrawing.active).toBe(true); + expect(contextB.guidedDrawing.active).toBe(false); expect(contextA.history.canUndo.value).toBe(true); expect(contextB.history.canUndo.value).toBe(false); }); @@ -177,6 +194,7 @@ describe("ProjectContext", () => { expect(defaultProjectContext.dirtyRect).toBe(dirtyRectStore); expect(defaultProjectContext.grid).toBe(gridStore); expect(defaultProjectContext.guides).toBe(guidesStore); + expect(defaultProjectContext.guidedDrawing).toBe(guidedDrawingStore); expect(defaultProjectContext.history).toBe(historyStore); expect(defaultProjectContext.historyHighlight).toBe(historyHighlightStore); expect(defaultProjectContext.layers).toBe(layerStore); diff --git a/tests/stores/project-serialization.test.ts b/tests/stores/project-serialization.test.ts index bc7e8b4..811ee62 100644 --- a/tests/stores/project-serialization.test.ts +++ b/tests/stores/project-serialization.test.ts @@ -34,6 +34,7 @@ import { projectStore } from '../../src/stores/project'; import { layerStore } from '../../src/stores/layers'; import { animationStore } from '../../src/stores/animation'; import { paletteStore } from '../../src/stores/palette'; +import { guidedDrawingStore } from '../../src/stores/guided-drawing'; import { PROJECT_VERSION } from '../../src/types/project'; import type { ProjectFile, ProjectFileInput } from '../../src/types/project'; import { loadImageDataToCanvas } from '../../src/utils/canvas-binary'; @@ -264,6 +265,44 @@ describe('project save -> load -> save round-trip (structural)', () => { expect(paletteStore.mainColors.value).toEqual(PALETTE); }); + it('round-trips optional guided drawing state', async () => { + const saved = await buildSampleProject(); + guidedDrawingStore.start({ + version: 1, + width: 2, + height: 2, + target: new Uint8Array([1, 2, 0, 1]), + settings: { + longSide: 2, + paletteSource: 'restricted', + restrictedPalette: ['#000000', '#ffffff'], + mapping: 'luminance', + simplifyIsolatedPixels: true, + }, + sourceName: 'source.png', + createdAt: 123, + }); + + const withGuide = await projectStore.saveProject(); + expect(withGuide.guidedDrawing?.target).toEqual([1, 2, 0, 1]); + + guidedDrawingStore.clear(); + vi.useFakeTimers(); + await projectStore.loadProject(structuredClone(withGuide), false); + await vi.runAllTimersAsync(); + vi.useRealTimers(); + + expect(guidedDrawingStore.session.value?.target).toEqual( + new Uint8Array([1, 2, 0, 1]), + ); + expect((await projectStore.saveProject()).guidedDrawing).toEqual( + withGuide.guidedDrawing, + ); + + await projectStore.loadProject(saved, false); + expect(guidedDrawingStore.active).toBe(false); + }); + it('round-trips reference layer metadata without animation cels', async () => { await buildSampleProject(); const referenceBytes = new Uint8Array([1, 2, 3, 4]); diff --git a/tests/stores/project-version.test.ts b/tests/stores/project-version.test.ts index e669389..4ea9945 100644 --- a/tests/stores/project-version.test.ts +++ b/tests/stores/project-version.test.ts @@ -33,10 +33,10 @@ import { projectStore } from '../../src/stores/project'; import { layerStore } from '../../src/stores/layers'; describe('project file format version', () => { - it('is in sync with the newest schema fields (4.0.0 = reference layer data)', () => { + it('is in sync with the newest schema fields (4.1.0 = guided drawing)', () => { // If this fails, the ProjectFile schema changed without bumping // PROJECT_VERSION — bump it in the same PR (see src/types/project.ts). - expect(PROJECT_VERSION).toBe('4.0.0'); + expect(PROJECT_VERSION).toBe('4.1.0'); }); it('stamps saved projects with PROJECT_VERSION and serializes current-format fields', async () => { diff --git a/tests/stores/workspace.test.ts b/tests/stores/workspace.test.ts index bd95721..52c0910 100644 --- a/tests/stores/workspace.test.ts +++ b/tests/stores/workspace.test.ts @@ -16,7 +16,7 @@ import { PROJECT_VERSION, type ProjectFile } from "../../src/types/project"; type WorkspaceProjectLibrary = Pick< ProjectLibraryService, - "openProject" | "createProject" + "openProject" | "createProject" | "createProjectFromFile" >; function makeProjectFile(name: string): ProjectFile { @@ -97,6 +97,17 @@ function createProjectLibraryMock() { } return projectId; }), + createProjectFromFile: vi.fn(async (project, settings = {}) => { + const projectId = `created-project-${nextProjectNumber}`; + nextProjectNumber += 1; + rememberContext(settings.context); + if (settings.context) { + settings.context.project.id.value = projectId; + settings.context.project.name.value = project.name ?? "Untitled"; + settings.context.guidedDrawing.load(project.guidedDrawing); + } + return projectId; + }), }; return projectLibrary; @@ -502,6 +513,51 @@ describe("WorkspaceStore", () => { expect(autoSave.start).toHaveBeenCalledWith(result.item.context); }); + it("creates an exact project file in a separate context", async () => { + const initialContext = createTestContext("Current Project"); + initialContext.project.id.value = "current-project"; + const projectLibrary = createProjectLibraryMock(); + const autoSave = createAutoSaveMock(); + const workspace = new WorkspaceStore({ + initialContext, + initialItemId: "current-project", + projectLibrary, + autoSave, + }); + const project = makeProjectFile("Guided Project"); + project.guidedDrawing = { + version: 1, + width: 8, + height: 8, + target: new Array(64).fill(1), + settings: { + longSide: 8, + paletteSource: "generated", + maxColors: 2, + mapping: "color", + simplifyIsolatedPixels: true, + }, + createdAt: 123, + }; + + const result = await workspace.createProjectFromFile(project, { + activate: true, + saveActiveContext: true, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.item.context).not.toBe(initialContext); + expect(result.item.context.project.name.value).toBe("Guided Project"); + expect(result.item.context.guidedDrawing.active).toBe(true); + expect(initialContext.project.name.value).toBe("Current Project"); + expect(autoSave.saveNow).toHaveBeenCalledWith(initialContext); + expect(projectLibrary.createProjectFromFile).toHaveBeenCalledWith(project, { + context: result.item.context, + saveCurrent: false, + }); + }); + it("saves the active context before opening another project when requested", async () => { const initialContext = createTestContext("Project A"); initialContext.project.id.value = "project-a";
+ ${this.errorMessage || this.statusMessage} +
+ ${id === 'source-preview' + ? 'Your selected image appears here.' + : 'The reduced numbered guide appears here.'} +