From 196e7fe7e3390feedc327acd1638743a444f63af Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 12:43:25 +0200 Subject: [PATCH 1/3] Add temporary guidance view controls (#316) --- .../canvas/pf-guided-number-overlay.ts | 90 ++++++++++++++++--- .../color/palette-panel/pf-guided-palette.ts | 50 +++++++++++ src/stores/guided-drawing-store.ts | 17 ++++ .../canvas/pf-guided-number-overlay.test.ts | 22 +++++ .../color/pf-guided-palette.test.ts | 13 +++ tests/stores/guided-drawing-store.test.ts | 21 +++++ 6 files changed, 202 insertions(+), 11 deletions(-) diff --git a/src/components/canvas/pf-guided-number-overlay.ts b/src/components/canvas/pf-guided-number-overlay.ts index 32b3282..bd3352d 100644 --- a/src/components/canvas/pf-guided-number-overlay.ts +++ b/src/components/canvas/pf-guided-number-overlay.ts @@ -21,6 +21,33 @@ export interface GuidedNumberViewport { viewportHeight: number; } +export function collectVisibleGuidedTargetCells( + target: Uint8Array, + width: number, + viewport: GuidedNumberViewport, +): GuidedNumberCell[] { + const cells: GuidedNumberCell[] = []; + for (let index = 0; index < target.length; index += 1) { + const guideNumber = target[index]; + if (guideNumber === 0) continue; + + const x = index % width; + const y = Math.floor(index / width); + const screenX = x * viewport.zoom + viewport.panX; + const screenY = y * viewport.zoom + viewport.panY; + if (!isCellVisible(screenX, screenY, viewport)) continue; + + cells.push({ + guideNumber, + screenX, + screenY, + size: viewport.zoom, + }); + } + + return cells; +} + export function collectVisibleGuidedNumberCells( target: Uint8Array, pixels: Uint8ClampedArray, @@ -121,6 +148,8 @@ export class PFGuidedNumberOverlay extends CanvasOverlay { render() { const session = this.context.guidedDrawing.session.value; + const numbersVisible = this.context.guidedDrawing.numbersVisible.value; + void this.context.guidedDrawing.targetPreviewVisible.value; const zoom = this.context.viewport.zoom.value; void this.context.viewport.panX.value; void this.context.viewport.panY.value; @@ -129,7 +158,7 @@ export class PFGuidedNumberOverlay extends CanvasOverlay { return html` - ${session && zoom < NUMBER_ZOOM_THRESHOLD + ${session && numbersVisible && zoom < NUMBER_ZOOM_THRESHOLD ? html`

Zoom in to see guide numbers @@ -155,23 +184,62 @@ export class PFGuidedNumberOverlay extends CanvasOverlay { if (!snapshot) return; const { viewport } = this.context; - const cells = collectVisibleGuidedNumberCells( + const frame = { + zoom: viewport.zoom.value, + panX: viewport.panX.value, + panY: viewport.panY.value, + viewportWidth: this.clientWidth, + viewportHeight: this.clientHeight, + }; + + if (this.context.guidedDrawing.targetPreviewVisible.value) { + const targetCells = collectVisibleGuidedTargetCells( + snapshot.session.target, + snapshot.session.width, + frame, + ); + this.drawTargetPreview( + drawingContext, + targetCells, + this.context.palette.mainColors.value, + ); + } + + if (!this.context.guidedDrawing.numbersVisible.value) return; + + const numberCells = collectVisibleGuidedNumberCells( snapshot.session.target, snapshot.pixels, snapshot.session.width, - { - zoom: viewport.zoom.value, - panX: viewport.panX.value, - panY: viewport.panY.value, - viewportWidth: this.clientWidth, - viewportHeight: this.clientHeight, - }, + frame, ); - this.drawCells(drawingContext, cells); + this.drawNumbers(drawingContext, numberCells); + } + + private drawTargetPreview( + context: CanvasRenderingContext2D, + cells: GuidedNumberCell[], + palette: string[], + ) { + context.save(); + context.globalAlpha = 0.72; + for (const cell of cells) { + const color = palette[cell.guideNumber - 1]; + if (!color) continue; + + context.fillStyle = color; + context.fillRect( + Math.floor(cell.screenX), + Math.floor(cell.screenY), + Math.ceil(cell.size), + Math.ceil(cell.size), + ); + } + context.restore(); } - private drawCells(context: CanvasRenderingContext2D, cells: GuidedNumberCell[]) { + private drawNumbers(context: CanvasRenderingContext2D, cells: GuidedNumberCell[]) { const fontFamily = getComputedStyle(this).fontFamily || '"Departure Mono", monospace'; context.textAlign = 'center'; context.textBaseline = 'middle'; diff --git a/src/components/color/palette-panel/pf-guided-palette.ts b/src/components/color/palette-panel/pf-guided-palette.ts index 290cedc..8477201 100644 --- a/src/components/color/palette-panel/pf-guided-palette.ts +++ b/src/components/color/palette-panel/pf-guided-palette.ts @@ -46,6 +46,29 @@ export class PFGuidedPalette extends BaseComponent { line-height: 1.4; } + .view-controls { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 5px; + } + + .view-controls button { + min-height: 30px; + padding: 5px 7px; + border: 1px solid var(--pf-color-border); + border-radius: var(--pf-radius-sm); + background: var(--pf-color-bg-dark); + color: var(--pf-color-text-secondary); + cursor: pointer; + font-size: var(--pf-font-size-xs); + } + + .view-controls button[aria-pressed='true'] { + border-color: var(--pf-color-accent); + background: var(--pf-color-primary-transparent); + color: var(--pf-color-text-main); + } + .structure-note { padding: 7px 8px; border-inline-start: 2px solid var(--pf-color-accent); @@ -226,6 +249,8 @@ export class PFGuidedPalette extends BaseComponent { > + ${this.renderViewControls()} +

${guideColors.map((color, index) => { const guideNumber = index + 1; @@ -287,6 +312,31 @@ export class PFGuidedPalette extends BaseComponent { : EMPTY_PROGRESS; } + private renderViewControls() { + const guidance = this.context.guidedDrawing; + const numbersVisible = guidance.numbersVisible.value; + const targetPreviewVisible = guidance.targetPreviewVisible.value; + + return html` +
+ + +
+ `; + } + private selectColor(color: string) { this.context.colors.setPrimaryColor(color); this.context.colors.updateLightnessVariations(color); diff --git a/src/stores/guided-drawing-store.ts b/src/stores/guided-drawing-store.ts index d8b15ec..e4153c8 100644 --- a/src/stores/guided-drawing-store.ts +++ b/src/stores/guided-drawing-store.ts @@ -7,6 +7,8 @@ import { class GuidedDrawingStore { readonly session = signal(null); + readonly numbersVisible = signal(true); + readonly targetPreviewVisible = signal(false); get active(): boolean { return this.session.value !== null; @@ -14,9 +16,18 @@ class GuidedDrawingStore { start(session: GuidedDrawingSession): void { validateSession(session); + this.resetViewOptions(); this.session.value = cloneSession(session); } + toggleNumbers(): void { + this.numbersVisible.value = !this.numbersVisible.value; + } + + toggleTargetPreview(): void { + this.targetPreviewVisible.value = !this.targetPreviewVisible.value; + } + load(file: GuidedDrawingSessionFile | undefined): void { if (!file) { this.clear(); @@ -42,6 +53,12 @@ class GuidedDrawingStore { clear(): void { this.session.value = null; + this.resetViewOptions(); + } + + private resetViewOptions(): void { + this.numbersVisible.value = true; + this.targetPreviewVisible.value = false; } } diff --git a/tests/components/canvas/pf-guided-number-overlay.test.ts b/tests/components/canvas/pf-guided-number-overlay.test.ts index 3793b15..b218f3a 100644 --- a/tests/components/canvas/pf-guided-number-overlay.test.ts +++ b/tests/components/canvas/pf-guided-number-overlay.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { collectVisibleGuidedNumberCells, + collectVisibleGuidedTargetCells, type PFGuidedNumberOverlay, } from '../../../src/components/canvas/pf-guided-number-overlay'; import '../../../src/components/canvas/pf-canvas-viewport'; @@ -56,6 +57,22 @@ describe('guided number overlay', () => { })).toEqual([]); }); + it('plans a target preview at any zoom without reading painted pixels', () => { + const cells = collectVisibleGuidedTargetCells( + Uint8Array.from([1, 0, 2]), + 3, + { + zoom: 4, + panX: 0, + panY: 0, + viewportWidth: 12, + viewportHeight: 4, + }, + ); + + expect(cells.map((cell) => cell.guideNumber)).toEqual([1, 2]); + }); + it('shows a DOM hint below the number threshold', async () => { const context = createProjectContext(); context.guidedDrawing.start({ @@ -85,6 +102,11 @@ describe('guided number overlay', () => { await element.updateComplete; expect(element.shadowRoot?.textContent).not.toContain('Zoom in to see guide numbers'); + context.viewport.setZoom(8); + context.guidedDrawing.toggleNumbers(); + await element.updateComplete; + expect(element.shadowRoot?.textContent).not.toContain('Zoom in to see guide numbers'); + context.dispose(); }); diff --git a/tests/components/color/pf-guided-palette.test.ts b/tests/components/color/pf-guided-palette.test.ts index aa35cee..bb4cd5e 100644 --- a/tests/components/color/pf-guided-palette.test.ts +++ b/tests/components/color/pf-guided-palette.test.ts @@ -46,6 +46,19 @@ describe('guided palette', () => { 'Canvas, palette, layers, and frames stay fixed', ); + const viewButtons = [ + ...element.shadowRoot!.querySelectorAll('.view-controls button'), + ]; + expect(viewButtons.map((button) => button.textContent?.trim())) + .toEqual(['Hide numbers', 'Preview target']); + viewButtons[0].click(); + viewButtons[1].click(); + await element.updateComplete; + expect(context.guidedDrawing.numbersVisible.value).toBe(false); + expect(context.guidedDrawing.targetPreviewVisible.value).toBe(true); + expect(viewButtons.map((button) => button.textContent?.trim())) + .toEqual(['Show numbers', 'Hide target']); + buttons[1].click(); await element.updateComplete; expect(context.colors.primaryColor.value).toBe('#eeeeee'); diff --git a/tests/stores/guided-drawing-store.test.ts b/tests/stores/guided-drawing-store.test.ts index ad02740..d044ab7 100644 --- a/tests/stores/guided-drawing-store.test.ts +++ b/tests/stores/guided-drawing-store.test.ts @@ -59,4 +59,25 @@ describe('GuidedDrawingStore', () => { expect(() => store.start({ ...createSession(), target: new Uint8Array([1]) })) .toThrow('target does not match'); }); + + it('keeps temporary view choices out of the durable session', () => { + const store = createGuidedDrawingStore(); + store.start(createSession()); + + store.toggleNumbers(); + store.toggleTargetPreview(); + expect(store.numbersVisible.value).toBe(false); + expect(store.targetPreviewVisible.value).toBe(true); + expect(store.toFile()).not.toHaveProperty('numbersVisible'); + + store.load({ ...createSession(), target: [1, 2] }); + expect(store.numbersVisible.value).toBe(true); + expect(store.targetPreviewVisible.value).toBe(false); + + store.toggleNumbers(); + store.toggleTargetPreview(); + store.clear(); + expect(store.numbersVisible.value).toBe(true); + expect(store.targetPreviewVisible.value).toBe(false); + }); }); From bddabb44e9e731d46b4483ac34a49adc300bda5a Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 12:46:31 +0200 Subject: [PATCH 2/3] Finish guidance without changing artwork (#317) --- .../color/palette-panel/pf-guided-palette.ts | 141 +++++++++++++++++- src/stores/guided-drawing-store.ts | 18 +++ .../color/pf-guided-palette.test.ts | 78 +++++++++- tests/stores/guided-drawing-store.test.ts | 17 +++ 4 files changed, 252 insertions(+), 2 deletions(-) diff --git a/src/components/color/palette-panel/pf-guided-palette.ts b/src/components/color/palette-panel/pf-guided-palette.ts index 8477201..a35abdb 100644 --- a/src/components/color/palette-panel/pf-guided-palette.ts +++ b/src/components/color/palette-panel/pf-guided-palette.ts @@ -1,12 +1,14 @@ import { css, html } from 'lit'; -import { customElement } from 'lit/decorators.js'; +import { customElement, state } from 'lit/decorators.js'; import { BaseComponent } from '../../../core/base-component'; +import { autoSaveService } from '../../../services/auto-save'; import { analyzeGuidedDrawingProgress, getGuidedDrawingSnapshot, type GuidedDrawingProgress, } from '../../../services/paint-by-number/guided-progress'; import { defaultProjectContext, type ProjectContext } from '../../../stores/project-context'; +import '../../ui/pf-dialog'; const EMPTY_PROGRESS: GuidedDrawingProgress = { total: 0, @@ -69,6 +71,61 @@ export class PFGuidedPalette extends BaseComponent { color: var(--pf-color-text-main); } + .finish-section { + display: grid; + gap: 6px; + padding-block-start: 2px; + border-block-start: 1px solid var(--pf-color-border); + } + + .finish-guidance { + min-height: 32px; + padding: 6px 8px; + border: 1px solid var(--pf-color-border-strong); + border-radius: var(--pf-radius-sm); + background: transparent; + color: var(--pf-color-text-main); + cursor: pointer; + font-size: var(--pf-font-size-xs); + } + + .finish-guidance:hover { + border-color: var(--pf-color-accent); + background: var(--pf-color-bg-hover); + } + + .finish-copy, + .finish-error { + margin: 0; + color: var(--pf-color-text-muted); + font-size: var(--pf-font-size-xs); + line-height: 1.45; + } + + .finish-error { + color: var(--pf-color-danger, #f0aaa2); + } + + .dialog-action { + padding: 7px 10px; + border: 1px solid var(--pf-color-border); + border-radius: var(--pf-radius-sm); + background: transparent; + color: var(--pf-color-text-main); + cursor: pointer; + } + + .dialog-action.primary { + border-color: var(--pf-color-accent); + background: var(--pf-color-primary-transparent); + color: var(--pf-color-accent-hover); + } + + .dialog-action:disabled { + cursor: wait; + opacity: 0.55; + } + .structure-note { padding: 7px 8px; border-inline-start: 2px solid var(--pf-color-accent); @@ -212,6 +269,9 @@ export class PFGuidedPalette extends BaseComponent { `; private context: ProjectContext = defaultProjectContext; + @state() private finishConfirmationOpen = false; + @state() private isFinishing = false; + @state() private finishError = ''; connectedCallback() { super.connectedCallback(); @@ -250,6 +310,7 @@ export class PFGuidedPalette extends BaseComponent { ${this.renderViewControls()} + ${this.renderFinishGuidance(progress)}
${guideColors.map((color, index) => { @@ -337,6 +398,84 @@ export class PFGuidedPalette extends BaseComponent { `; } + private renderFinishGuidance(progress: GuidedDrawingProgress) { + return html` +
+ +

You decide when the scaffold has done its job.

+
+ + + Finish guidance? +

+ Your drawing is ${progress.percentage}% covered. This removes the numbers and fixed + guide structure. Your painted pixels and undo history stay exactly as they are. +

+ ${this.finishError + ? html`` + : ''} +
+ + +
+
+ `; + } + + private openFinishConfirmation = () => { + this.finishError = ''; + this.finishConfirmationOpen = true; + }; + + private closeFinishConfirmation = () => { + if (this.isFinishing) return; + this.finishConfirmationOpen = false; + this.finishError = ''; + }; + + private finishGuidance = async () => { + if (this.isFinishing || !this.context.guidedDrawing.active) return; + + this.isFinishing = true; + this.finishError = ''; + this.context.guidedDrawing.beginFinish(); + try { + await autoSaveService.saveNow(this.context); + this.context.guidedDrawing.completeFinish(); + this.context.viewport.resetView(); + this.finishConfirmationOpen = false; + } catch { + this.context.guidedDrawing.cancelFinish(); + this.finishError = 'Guidance could not be finished safely. Your guide is still active.'; + } finally { + this.isFinishing = false; + } + }; + private selectColor(color: string) { this.context.colors.setPrimaryColor(color); this.context.colors.updateLightnessVariations(color); diff --git a/src/stores/guided-drawing-store.ts b/src/stores/guided-drawing-store.ts index e4153c8..1079e1a 100644 --- a/src/stores/guided-drawing-store.ts +++ b/src/stores/guided-drawing-store.ts @@ -9,6 +9,7 @@ class GuidedDrawingStore { readonly session = signal(null); readonly numbersVisible = signal(true); readonly targetPreviewVisible = signal(false); + readonly finishPending = signal(false); get active(): boolean { return this.session.value !== null; @@ -17,6 +18,7 @@ class GuidedDrawingStore { start(session: GuidedDrawingSession): void { validateSession(session); this.resetViewOptions(); + this.finishPending.value = false; this.session.value = cloneSession(session); } @@ -41,6 +43,8 @@ class GuidedDrawingStore { } toFile(): GuidedDrawingSessionFile | undefined { + if (this.finishPending.value) return undefined; + const session = this.session.value; if (!session) return undefined; @@ -53,9 +57,23 @@ class GuidedDrawingStore { clear(): void { this.session.value = null; + this.finishPending.value = false; this.resetViewOptions(); } + beginFinish(): void { + if (this.session.value) this.finishPending.value = true; + } + + cancelFinish(): void { + this.finishPending.value = false; + } + + completeFinish(): void { + if (!this.finishPending.value) return; + this.clear(); + } + private resetViewOptions(): void { this.numbersVisible.value = true; this.targetPreviewVisible.value = false; diff --git a/tests/components/color/pf-guided-palette.test.ts b/tests/components/color/pf-guided-palette.test.ts index bb4cd5e..1ec9d09 100644 --- a/tests/components/color/pf-guided-palette.test.ts +++ b/tests/components/color/pf-guided-palette.test.ts @@ -1,4 +1,12 @@ -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const autoSaveServiceMock = vi.hoisted(() => ({ + saveNow: vi.fn(), +})); + +vi.mock('../../../src/services/auto-save', () => ({ + autoSaveService: autoSaveServiceMock, +})); import type { PFGuidedPalette } from '../../../src/components/color/palette-panel/pf-guided-palette'; import '../../../src/components/color/palette-panel/pf-guided-palette'; import '../../../src/components/color/pf-palette-panel'; @@ -12,6 +20,10 @@ describe('guided palette', () => { HTMLElement.prototype.hidePopover = () => {}; }); + beforeEach(() => { + autoSaveServiceMock.saveNow.mockResolvedValue(undefined); + }); + afterAll(() => { if (originalHidePopover) { HTMLElement.prototype.hidePopover = originalHidePopover; @@ -105,8 +117,72 @@ describe('guided palette', () => { guidedContext.dispose(); normalContext.dispose(); }); + + it('finishes guidance at partial coverage without touching art or history', async () => { + const context = createGuidedContext(); + const historyEntry = { + id: 'stroke', + name: 'Pencil', + execute: vi.fn(), + undo: vi.fn(), + }; + context.history.undoStack.value = [historyEntry]; + const resetView = vi.spyOn(context.viewport, 'resetView'); + setActiveProjectContext(context); + const element = document.createElement('pf-guided-palette') as PFGuidedPalette; + document.body.append(element); + await element.updateComplete; + + const pixelsBefore = getPaintingPixels(context); + element.shadowRoot?.querySelector('.finish-guidance')?.click(); + await element.updateComplete; + expect(element.shadowRoot?.textContent).toContain('Your drawing is 50% covered'); + + element.shadowRoot?.querySelector('.finish-confirm')?.click(); + await settle(element); + + expect(autoSaveServiceMock.saveNow).toHaveBeenCalledWith(context); + expect(context.guidedDrawing.active).toBe(false); + expect(context.history.undoStack.value).toEqual([historyEntry]); + expect(getPaintingPixels(context)).toEqual(pixelsBefore); + expect(resetView).toHaveBeenCalledTimes(1); + + context.dispose(); + }); + + it('keeps guidance active when the finish save fails', async () => { + const context = createGuidedContext(); + setActiveProjectContext(context); + autoSaveServiceMock.saveNow.mockRejectedValueOnce(new Error('storage failed')); + const element = document.createElement('pf-guided-palette') as PFGuidedPalette; + document.body.append(element); + await element.updateComplete; + + element.shadowRoot?.querySelector('.finish-guidance')?.click(); + await element.updateComplete; + element.shadowRoot?.querySelector('.finish-confirm')?.click(); + await settle(element); + + expect(context.guidedDrawing.active).toBe(true); + expect(context.guidedDrawing.finishPending.value).toBe(false); + expect(element.shadowRoot?.textContent).toContain('Your guide is still active'); + + context.dispose(); + }); }); +async function settle(element: PFGuidedPalette) { + await Promise.resolve(); + await element.updateComplete; + await Promise.resolve(); + await element.updateComplete; +} + +function getPaintingPixels(context: ReturnType) { + const layer = context.layers.layers.value.find((item) => item.type === 'image'); + return [...(layer?.canvas?.getContext('2d')?.getImageData(0, 0, 2, 1).data ?? [])]; +} + function createGuidedContext() { const context = createProjectContext(); const layer = context.layers.layers.value.find((item) => item.type === 'image'); diff --git a/tests/stores/guided-drawing-store.test.ts b/tests/stores/guided-drawing-store.test.ts index d044ab7..3bc11b0 100644 --- a/tests/stores/guided-drawing-store.test.ts +++ b/tests/stores/guided-drawing-store.test.ts @@ -80,4 +80,21 @@ describe('GuidedDrawingStore', () => { expect(store.numbersVisible.value).toBe(true); expect(store.targetPreviewVisible.value).toBe(false); }); + + it('omits guidance only while an explicit finish is being saved', () => { + const store = createGuidedDrawingStore(); + store.start(createSession()); + + store.beginFinish(); + expect(store.active).toBe(true); + expect(store.toFile()).toBeUndefined(); + + store.cancelFinish(); + expect(store.toFile()?.target).toEqual([1, 2]); + + store.beginFinish(); + store.completeFinish(); + expect(store.active).toBe(false); + expect(store.finishPending.value).toBe(false); + }); }); From 6b88cdcdaad3fafbef49e35cee1bcb4b23b4bfc9 Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 12:51:54 +0200 Subject: [PATCH 3/3] Add guided library entry and exact resume (#318) --- src/components/app/pf-project-browser.ts | 43 ++++++++-- src/components/app/pixel-forge-app.ts | 1 + src/serialization/project-load.ts | 7 +- .../components/app/pf-project-browser.test.ts | 14 ++++ tests/components/app/pixel-forge-app.test.ts | 30 +++++++ .../paint-by-number/guided-resume.test.ts | 82 +++++++++++++++++++ 6 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 tests/services/paint-by-number/guided-resume.test.ts diff --git a/src/components/app/pf-project-browser.ts b/src/components/app/pf-project-browser.ts index c82ec8b..26f3de9 100644 --- a/src/components/app/pf-project-browser.ts +++ b/src/components/app/pf-project-browser.ts @@ -90,7 +90,8 @@ export class PFProjectBrowser extends BaseComponent { .header-actions, .card-actions, - .rename-actions { + .rename-actions, + .empty-actions { display: flex; align-items: center; gap: 8px; @@ -182,6 +183,11 @@ export class PFProjectBrowser extends BaseComponent { min-height: 226px; } + .new-card.guided { + border-color: color-mix(in srgb, var(--pf-color-accent) 62%, var(--pf-color-border)); + background: var(--pf-color-primary-transparent); + } + .thumbnail { display: grid; place-items: center; @@ -344,11 +350,14 @@ export class PFProjectBrowser extends BaseComponent {
-

Open a saved sprite or start a fresh canvas.

+

Open a saved sprite, start fresh, or use an image as guidance.

+ ${this.canClose ? html` -
+
+
+ +
+
+ +
+
`; } @@ -396,6 +412,12 @@ export class PFProjectBrowser extends BaseComponent { Choose a size and start drawing. +
+ +
${this.projects.map((project) => this.renderProject(project))}
`; @@ -693,6 +715,13 @@ export class PFProjectBrowser extends BaseComponent { return; } + if (returnValue === 'guided-drawing') { + this.dispatchEvent( + new CustomEvent('show-paint-by-number-dialog', { bubbles: true, composed: true }) + ); + return; + } + if (!this.canDismissBrowser()) { this.showNativeDialog(this.browserDialog); return; diff --git a/src/components/app/pixel-forge-app.ts b/src/components/app/pixel-forge-app.ts index 42f1c2c..2d009e1 100644 --- a/src/components/app/pixel-forge-app.ts +++ b/src/components/app/pixel-forge-app.ts @@ -683,6 +683,7 @@ export class PixelForgeApp extends BaseComponent { string; goToFrame: (frameId: string) => void; linkCels: (celKeys: string[], linkType: CelLinkType) => string | null; + rebuildAllCelCanvases: () => void; rebuildAllIndexBuffers: () => void; setFrameDuration: (frameId: string, duration: number) => void; setTextCelData: ( @@ -133,6 +134,7 @@ export async function hydrateProjectFrames( } restoreLinkedCelGroups(stores, linkedCelGroups); + stores.animation.rebuildAllCelCanvases(); deletePlaceholderFrame(stores, placeholderFrameId); } @@ -304,6 +306,7 @@ function giveSharedTransparentCelOwnCanvas( !cel || cel.linkedCelId !== EMPTY_CEL_LINK_ID || !hasProjectImageData(celFile.data) + && !(celFile.indexData && celFile.indexData.length > 0) ) { return; } @@ -351,7 +354,7 @@ function restoreCelIndexBuffer( ): void { const { animation } = stores; const cel = animation.cels.value.get(celKey); - if (!cel || !hasProjectImageData(celFile.data)) return; + if (!cel) return; const cels = new Map(animation.cels.value); @@ -360,7 +363,7 @@ function restoreCelIndexBuffer( ...cel, indexBuffer: new Uint8Array(celFile.indexData), }); - } else if (canvas && !file.palette) { + } else if (canvas && hasProjectImageData(celFile.data) && !file.palette) { cels.set(celKey, { ...cel, indexBuffer: buildIndexBufferFromCanvas(canvas, stores.palette, true), diff --git a/tests/components/app/pf-project-browser.test.ts b/tests/components/app/pf-project-browser.test.ts index 78b1473..9aa6e74 100644 --- a/tests/components/app/pf-project-browser.test.ts +++ b/tests/components/app/pf-project-browser.test.ts @@ -214,6 +214,7 @@ describe('pf-project-browser', () => { expect(element.shadowRoot?.textContent).toContain('Open Project'); expect(element.shadowRoot?.textContent).toContain('Second Project'); expect(buttonWithText(element.shadowRoot!, 'New Project')).toBeTruthy(); + expect(buttonWithText(element.shadowRoot!, 'Guided Drawing')).toBeTruthy(); }); it('emits show-new-project-dialog from the native dialog return value', async () => { @@ -230,6 +231,19 @@ describe('pf-project-browser', () => { expect(requested).toBe(true); }); + it('emits the shared guided-drawing setup request from the library', async () => { + const element = await createBrowser(); + let requested = false; + element.addEventListener('show-paint-by-number-dialog', () => { + requested = true; + }); + + closeWithReturnValue(projectDialog(element.shadowRoot!), 'guided-drawing'); + await settle(element); + + expect(requested).toBe(true); + }); + it('emits project-browser-close from the dialog close button when closing is allowed', async () => { const element = await createBrowser(); element.canClose = true; diff --git a/tests/components/app/pixel-forge-app.test.ts b/tests/components/app/pixel-forge-app.test.ts index f9eba3d..2d3109b 100644 --- a/tests/components/app/pixel-forge-app.test.ts +++ b/tests/components/app/pixel-forge-app.test.ts @@ -184,4 +184,34 @@ describe('pixel-forge-app project dialogs', () => { expect(element.shadowRoot?.querySelector('pf-project-browser')).toBeNull(); expect(element.shadowRoot?.querySelector('pf-new-project-dialog')?.open).toBe(true); }); + + it('opens guided setup when the project browser requests it', async () => { + await import('../../../src/components/app/pixel-forge-app'); + + const element = document.createElement('pixel-forge-app') as HTMLElement & { + hasLibraryProject: boolean; + showPaintByNumberDialog: boolean; + showProjectBrowser: boolean; + updateComplete: Promise; + }; + + document.body.append(element); + await element.updateComplete; + + element.hasLibraryProject = true; + element.showProjectBrowser = true; + element.showPaintByNumberDialog = false; + await element.updateComplete; + + const browser = element.shadowRoot?.querySelector('pf-project-browser'); + browser?.dispatchEvent( + new CustomEvent('show-paint-by-number-dialog', { bubbles: true, composed: true }) + ); + await element.updateComplete; + + expect(element.showProjectBrowser).toBe(false); + expect(element.showPaintByNumberDialog).toBe(true); + expect(element.shadowRoot?.querySelector('pf-project-browser')).toBeNull(); + expect(element.shadowRoot?.querySelector('pf-paint-by-number-dialog')?.open).toBe(true); + }); }); diff --git a/tests/services/paint-by-number/guided-resume.test.ts b/tests/services/paint-by-number/guided-resume.test.ts new file mode 100644 index 0000000..dcfd828 --- /dev/null +++ b/tests/services/paint-by-number/guided-resume.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { analyzeGuidedDrawingProgress } from '../../../src/services/paint-by-number/guided-progress'; +import { createGuidedProjectFile } from '../../../src/services/paint-by-number/guided-project'; +import { createProjectContext } from '../../../src/stores/project-context'; + +describe('guided project resume', () => { + it('restores the durable guide and derives progress from saved painted pixels', async () => { + const file = createGuidedProjectFile({ + guide: { + palette: ['#111111', '#eeeeee'], + target: Uint8Array.from([1, 2]), + width: 2, + height: 1, + complexity: { + paintableCells: 2, + paletteSize: 2, + isolatedCells: 2, + simplifiedCells: 0, + }, + }, + settings: { + longSide: 2, + paletteSource: 'generated', + maxColors: 2, + mapping: 'luminance', + simplifyIsolatedPixels: true, + }, + sourceName: 'study.png', + createdAt: 123, + }); + const editingContext = createProjectContext(); + await editingContext.project.loadProject(file); + + const layerId = editingContext.layers.layers.value[0].id; + const frameId = editingContext.animation.frames.value[0].id; + editingContext.animation.updateCelIndexBuffer( + layerId, + frameId, + Uint8Array.from([1, 0]), + ); + const saved = await editingContext.project.saveProject(); + + const resumedContext = createProjectContext(); + await resumedContext.project.loadProject(saved); + const resumedLayerId = resumedContext.layers.layers.value[0].id; + const resumedFrameId = resumedContext.animation.frames.value[0].id; + const resumedBuffer = resumedContext.animation.getCelIndexBuffer( + resumedLayerId, + resumedFrameId, + ); + const progress = analyzeGuidedDrawingProgress( + resumedContext.guidedDrawing.session.value!.target, + pixelsFromIndexBuffer(resumedBuffer!), + ); + + expect(resumedContext.guidedDrawing.session.value).toMatchObject({ + target: Uint8Array.from([1, 2]), + guideColorCount: 2, + sourceName: 'study.png', + createdAt: 123, + settings: { + mapping: 'luminance', + simplifyIsolatedPixels: true, + }, + }); + expect(resumedContext.guidedDrawing.numbersVisible.value).toBe(true); + expect(resumedContext.guidedDrawing.targetPreviewVisible.value).toBe(false); + expect(resumedBuffer).toEqual(Uint8Array.from([1, 0])); + expect(progress).toMatchObject({ total: 2, covered: 1, remaining: 1, percentage: 50 }); + + editingContext.dispose(); + resumedContext.dispose(); + }); +}); + +function pixelsFromIndexBuffer(indexBuffer: Uint8Array): Uint8ClampedArray { + const pixels = new Uint8ClampedArray(indexBuffer.length * 4); + for (let index = 0; index < indexBuffer.length; index += 1) { + if (indexBuffer[index] > 0) pixels[index * 4 + 3] = 255; + } + return pixels; +}