From 8450b64ba7de76e6e052956b75521d1ba138f62b Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 12:07:06 +0200 Subject: [PATCH 1/4] Derive guided drawing progress (#311) --- .../paint-by-number/guided-progress.ts | 82 ++++++++++++++ .../paint-by-number/guided-progress.test.ts | 106 ++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 src/services/paint-by-number/guided-progress.ts create mode 100644 tests/services/paint-by-number/guided-progress.test.ts diff --git a/src/services/paint-by-number/guided-progress.ts b/src/services/paint-by-number/guided-progress.ts new file mode 100644 index 0000000..53434a3 --- /dev/null +++ b/src/services/paint-by-number/guided-progress.ts @@ -0,0 +1,82 @@ +import type { ProjectContext } from '../../stores/project-context'; +import type { GuidedDrawingSession } from '../../types/guided-drawing'; + +export interface GuidedDrawingSnapshot { + session: GuidedDrawingSession; + pixels: Uint8ClampedArray; +} + +export interface GuidedDrawingProgress { + total: number; + covered: number; + remaining: number; + percentage: number; + remainingByNumber: Uint32Array; +} + +export function analyzeGuidedDrawingProgress( + target: Uint8Array, + pixels: Uint8ClampedArray, +): GuidedDrawingProgress { + if (pixels.length !== target.length * 4) { + throw new RangeError('Guided drawing pixels do not match the target buffer'); + } + + const remainingByNumber = new Uint32Array(findHighestGuideNumber(target) + 1); + let total = 0; + let covered = 0; + + for (let index = 0; index < target.length; index += 1) { + const guideNumber = target[index]; + if (guideNumber === 0) continue; + + total += 1; + if (pixels[index * 4 + 3] > 0) { + covered += 1; + } else { + remainingByNumber[guideNumber] += 1; + } + } + + const remaining = total - covered; + return { + total, + covered, + remaining, + percentage: total === 0 ? 0 : Math.round((covered / total) * 100), + remainingByNumber, + }; +} + +export function getGuidedDrawingSnapshot( + context: ProjectContext, +): GuidedDrawingSnapshot | null { + const session = context.guidedDrawing.session.value; + if (!session) return null; + + const paintingLayer = context.layers.layers.value.find((layer) => layer.type === 'image'); + const canvas = paintingLayer?.canvas; + if (!canvas || canvas.width !== session.width || canvas.height !== session.height) { + return null; + } + + const drawingContext = canvas.getContext('2d', { willReadFrequently: true }); + if (!drawingContext) return null; + + try { + return { + session, + pixels: drawingContext.getImageData(0, 0, session.width, session.height).data, + }; + } catch { + return null; + } +} + +function findHighestGuideNumber(target: Uint8Array): number { + let highest = 0; + for (const guideNumber of target) { + highest = Math.max(highest, guideNumber); + } + return highest; +} diff --git a/tests/services/paint-by-number/guided-progress.test.ts b/tests/services/paint-by-number/guided-progress.test.ts new file mode 100644 index 0000000..9497bc9 --- /dev/null +++ b/tests/services/paint-by-number/guided-progress.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { + analyzeGuidedDrawingProgress, + getGuidedDrawingSnapshot, +} from '../../../src/services/paint-by-number/guided-progress'; +import { createProjectContext } from '../../../src/stores/project-context'; +import { GUIDED_DRAWING_VERSION } from '../../../src/types/guided-drawing'; + +describe('guided drawing progress', () => { + it('counts any visible painted color as creative coverage', () => { + const target = Uint8Array.from([1, 2, 0, 2]); + const pixels = new Uint8ClampedArray(target.length * 4); + pixels.set([18, 52, 86, 255], 0); + pixels.set([250, 10, 190, 128], 12); + + const progress = analyzeGuidedDrawingProgress(target, pixels); + + expect(progress).toMatchObject({ + total: 3, + covered: 2, + remaining: 1, + percentage: 67, + }); + expect([...progress.remainingByNumber]).toEqual([0, 0, 1]); + }); + + it('treats an erased cell as remaining again', () => { + const target = Uint8Array.from([1, 1]); + const painted = new Uint8ClampedArray([ + 255, 0, 0, 255, + 0, 0, 0, 0, + ]); + + expect(analyzeGuidedDrawingProgress(target, painted)).toMatchObject({ + covered: 1, + remaining: 1, + percentage: 50, + }); + + painted[3] = 0; + expect(analyzeGuidedDrawingProgress(target, painted)).toMatchObject({ + covered: 0, + remaining: 2, + percentage: 0, + }); + }); + + it('handles empty and fully covered targets without grading colors', () => { + const empty = analyzeGuidedDrawingProgress( + new Uint8Array(2), + new Uint8ClampedArray(8), + ); + expect(empty).toMatchObject({ total: 0, covered: 0, remaining: 0, percentage: 0 }); + + const full = analyzeGuidedDrawingProgress( + Uint8Array.from([1, 2]), + new Uint8ClampedArray([ + 1, 1, 1, 1, + 2, 2, 2, 255, + ]), + ); + expect(full).toMatchObject({ total: 2, covered: 2, remaining: 0, percentage: 100 }); + }); + + it('rejects pixel buffers that cannot align with the guide', () => { + expect(() => + analyzeGuidedDrawingProgress(Uint8Array.from([1]), new Uint8ClampedArray(3)), + ).toThrow('Guided drawing pixels do not match the target buffer'); + }); + + it('reads the fixed painting layer from a guided project context', () => { + const context = createProjectContext(); + context.guidedDrawing.start({ + version: GUIDED_DRAWING_VERSION, + width: 2, + height: 1, + target: Uint8Array.from([1, 2]), + settings: { + longSide: 2, + paletteSource: 'generated', + maxColors: 2, + mapping: 'color', + simplifyIsolatedPixels: false, + }, + createdAt: 1, + }); + + const layer = context.layers.layers.value.find((item) => item.type === 'image'); + const pixels = new Uint8ClampedArray([ + 0, 0, 0, 255, + 0, 0, 0, 0, + ]); + const canvas = { + width: 2, + height: 1, + getContext: () => ({ getImageData: () => ({ data: pixels }) }), + } as unknown as HTMLCanvasElement; + if (layer) context.layers.updateLayer(layer.id, { canvas }); + + const snapshot = getGuidedDrawingSnapshot(context); + expect(snapshot?.session.target).toEqual(Uint8Array.from([1, 2])); + expect(snapshot?.pixels[3]).toBe(255); + + context.dispose(); + }); +}); From 4437e3c04558f8f00558c57d37a09634f51a1b98 Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 12:09:06 +0200 Subject: [PATCH 2/4] Render readable guided cell numbers (#312) --- src/components/canvas/pf-canvas-viewport.ts | 2 + .../canvas/pf-guided-number-overlay.ts | 217 ++++++++++++++++++ .../canvas/pf-guided-number-overlay.test.ts | 98 ++++++++ 3 files changed, 317 insertions(+) create mode 100644 src/components/canvas/pf-guided-number-overlay.ts create mode 100644 tests/components/canvas/pf-guided-number-overlay.test.ts diff --git a/src/components/canvas/pf-canvas-viewport.ts b/src/components/canvas/pf-canvas-viewport.ts index ef9e0eb..122c97a 100644 --- a/src/components/canvas/pf-canvas-viewport.ts +++ b/src/components/canvas/pf-canvas-viewport.ts @@ -5,6 +5,7 @@ import { defaultProjectContext, type ProjectContext } from '../../stores/project import './pf-selection-overlay'; import './pf-marching-ants-overlay'; import './pf-brush-cursor-overlay'; +import './pf-guided-number-overlay'; import './pf-transform-handles'; import './pf-reference-transform-handles'; import './pf-text-input'; @@ -181,6 +182,7 @@ export class PFCanvasViewport extends BaseComponent { + diff --git a/src/components/canvas/pf-guided-number-overlay.ts b/src/components/canvas/pf-guided-number-overlay.ts new file mode 100644 index 0000000..32b3282 --- /dev/null +++ b/src/components/canvas/pf-guided-number-overlay.ts @@ -0,0 +1,217 @@ +import { css, html } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { getGuidedDrawingSnapshot } from '../../services/paint-by-number/guided-progress'; +import { defaultProjectContext, type ProjectContext } from '../../stores/project-context'; +import { CanvasOverlay } from './canvas-overlay'; + +const NUMBER_ZOOM_THRESHOLD = 16; + +export interface GuidedNumberCell { + guideNumber: number; + screenX: number; + screenY: number; + size: number; +} + +export interface GuidedNumberViewport { + zoom: number; + panX: number; + panY: number; + viewportWidth: number; + viewportHeight: number; +} + +export function collectVisibleGuidedNumberCells( + target: Uint8Array, + pixels: Uint8ClampedArray, + width: number, + viewport: GuidedNumberViewport, +): GuidedNumberCell[] { + if (pixels.length !== target.length * 4) { + throw new RangeError('Guided drawing pixels do not match the target buffer'); + } + if (viewport.zoom < NUMBER_ZOOM_THRESHOLD) return []; + + const cells: GuidedNumberCell[] = []; + for (let index = 0; index < target.length; index += 1) { + const guideNumber = target[index]; + if (guideNumber === 0 || pixels[index * 4 + 3] > 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; +} + +@customElement('pf-guided-number-overlay') +export class PFGuidedNumberOverlay extends CanvasOverlay { + static styles = css` + :host { + position: absolute; + inset: 0; + z-index: 49; + pointer-events: none; + } + + canvas { + width: 100%; + height: 100%; + } + + .zoom-hint { + position: absolute; + inset-block-start: 34px; + inset-inline-start: 50%; + z-index: 3; + margin: 0; + padding: 6px 9px; + transform: translateX(-50%); + border: 1px solid var(--pf-color-border); + border-radius: var(--pf-radius-sm); + background: rgba(13, 16, 21, 0.9); + color: var(--pf-color-text-secondary); + box-shadow: var(--pf-shadow-md); + font-size: var(--pf-font-size-xs); + white-space: nowrap; + } + `; + + private context: ProjectContext = defaultProjectContext; + private animationFrameId = 0; + + connectedCallback() { + super.connectedCallback(); + this.subscribeToActiveProjectContext((context) => { + this.context = context; + this.requestUpdate(); + this.scheduleDraw(); + }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this.animationFrameId) cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = 0; + } + + firstUpdated() { + super.firstUpdated(); + this.scheduleDraw(); + } + + updated() { + this.scheduleDraw(); + } + + protected resizeCanvas() { + super.resizeCanvas(); + this.scheduleDraw(); + } + + render() { + const session = this.context.guidedDrawing.session.value; + const zoom = this.context.viewport.zoom.value; + void this.context.viewport.panX.value; + void this.context.viewport.panY.value; + void this.context.history.version.value; + void this.context.layers.layers.value; + + return html` + + ${session && zoom < NUMBER_ZOOM_THRESHOLD + ? html` +

+ Zoom in to see guide numbers +

+ ` + : ''} + `; + } + + private scheduleDraw() { + if (this.animationFrameId) return; + this.animationFrameId = requestAnimationFrame(() => { + this.animationFrameId = 0; + this.draw(); + }); + } + + private draw() { + const drawingContext = this.prepareFrame(); + if (!drawingContext) return; + + const snapshot = getGuidedDrawingSnapshot(this.context); + if (!snapshot) return; + + const { viewport } = this.context; + const cells = 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, + }, + ); + + this.drawCells(drawingContext, cells); + } + + private drawCells(context: CanvasRenderingContext2D, cells: GuidedNumberCell[]) { + const fontFamily = getComputedStyle(this).fontFamily || '"Departure Mono", monospace'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + + for (const cell of cells) { + const label = String(cell.guideNumber); + const fontSize = Math.min(14, cell.size * (label.length > 1 ? 0.5 : 0.58)); + const inset = Math.max(1, Math.round(cell.size * 0.1)); + const badgeWidth = cell.size - inset * 2; + const badgeHeight = cell.size - inset * 2; + const centerX = cell.screenX + cell.size / 2; + const centerY = cell.screenY + cell.size / 2; + + context.fillStyle = 'rgba(7, 9, 13, 0.78)'; + context.fillRect( + Math.round(cell.screenX + inset), + Math.round(cell.screenY + inset), + Math.max(1, Math.round(badgeWidth)), + Math.max(1, Math.round(badgeHeight)), + ); + context.font = `700 ${fontSize}px ${fontFamily}`; + context.fillStyle = '#f5f3ee'; + context.fillText(label, centerX, centerY + 0.5, badgeWidth); + } + } +} + +function isCellVisible( + screenX: number, + screenY: number, + viewport: GuidedNumberViewport, +): boolean { + return screenX + viewport.zoom >= 0 + && screenY + viewport.zoom >= 0 + && screenX <= viewport.viewportWidth + && screenY <= viewport.viewportHeight; +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-guided-number-overlay': PFGuidedNumberOverlay; + } +} diff --git a/tests/components/canvas/pf-guided-number-overlay.test.ts b/tests/components/canvas/pf-guided-number-overlay.test.ts new file mode 100644 index 0000000..3793b15 --- /dev/null +++ b/tests/components/canvas/pf-guided-number-overlay.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + collectVisibleGuidedNumberCells, + type PFGuidedNumberOverlay, +} from '../../../src/components/canvas/pf-guided-number-overlay'; +import '../../../src/components/canvas/pf-canvas-viewport'; +import { createProjectContext, restoreDefaultProjectContext, setActiveProjectContext } from '../../../src/stores/project-context'; +import { GUIDED_DRAWING_VERSION } from '../../../src/types/guided-drawing'; + +describe('guided number overlay', () => { + afterEach(() => { + document.body.innerHTML = ''; + restoreDefaultProjectContext(); + }); + + it('plans every visible unpainted numbered cell at readable zoom', () => { + const cells = collectVisibleGuidedNumberCells( + Uint8Array.from([1, 2, 3, 4]), + new Uint8ClampedArray([ + 0, 0, 0, 0, + 20, 30, 40, 255, + 0, 0, 0, 0, + 0, 0, 0, 0, + ]), + 2, + { + zoom: 16, + panX: 0, + panY: 0, + viewportWidth: 32, + viewportHeight: 16, + }, + ); + + expect(cells.map((cell) => cell.guideNumber)).toEqual([1, 3, 4]); + }); + + it('does not plan illegible or off-screen numbers', () => { + const target = Uint8Array.from([1, 2]); + const pixels = new Uint8ClampedArray(8); + + expect(collectVisibleGuidedNumberCells(target, pixels, 2, { + zoom: 8, + panX: 0, + panY: 0, + viewportWidth: 100, + viewportHeight: 100, + })).toEqual([]); + + expect(collectVisibleGuidedNumberCells(target, pixels, 2, { + zoom: 16, + panX: -40, + panY: 0, + viewportWidth: 16, + viewportHeight: 16, + })).toEqual([]); + }); + + it('shows a DOM hint below the number threshold', async () => { + const context = createProjectContext(); + context.guidedDrawing.start({ + version: GUIDED_DRAWING_VERSION, + width: 1, + height: 1, + target: Uint8Array.from([1]), + settings: { + longSide: 1, + paletteSource: 'generated', + maxColors: 1, + mapping: 'color', + simplifyIsolatedPixels: false, + }, + createdAt: 1, + }); + context.viewport.setZoom(8); + setActiveProjectContext(context); + + const element = document.createElement('pf-guided-number-overlay') as PFGuidedNumberOverlay; + document.body.append(element); + await element.updateComplete; + + expect(element.shadowRoot?.textContent).toContain('Zoom in to see guide numbers'); + + context.viewport.setZoom(16); + await element.updateComplete; + expect(element.shadowRoot?.textContent).not.toContain('Zoom in to see guide numbers'); + + context.dispose(); + }); + + it('mounts the guidance layer inside the shared canvas viewport', async () => { + const viewport = document.createElement('pf-canvas-viewport'); + document.body.append(viewport); + await viewport.updateComplete; + + expect(viewport.shadowRoot?.querySelector('pf-guided-number-overlay')).not.toBeNull(); + }); +}); From f1eef21cf27a4385e1d8a0fbae204c3e63127c0e Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 12:12:50 +0200 Subject: [PATCH 3/4] Add guided palette and creative progress (#313) --- .../color/palette-panel/pf-guided-palette.ts | 294 ++++++++++++++++++ src/components/color/pf-palette-panel.ts | 5 + .../paint-by-number/guided-project.ts | 1 + src/stores/guided-drawing-store.ts | 8 + src/types/guided-drawing.ts | 1 + .../color/pf-guided-palette.test.ts | 126 ++++++++ .../paint-by-number/guided-project.test.ts | 1 + 7 files changed, 436 insertions(+) create mode 100644 src/components/color/palette-panel/pf-guided-palette.ts create mode 100644 tests/components/color/pf-guided-palette.test.ts diff --git a/src/components/color/palette-panel/pf-guided-palette.ts b/src/components/color/palette-panel/pf-guided-palette.ts new file mode 100644 index 0000000..074a43d --- /dev/null +++ b/src/components/color/palette-panel/pf-guided-palette.ts @@ -0,0 +1,294 @@ +import { css, html } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { BaseComponent } from '../../../core/base-component'; +import { + analyzeGuidedDrawingProgress, + getGuidedDrawingSnapshot, + type GuidedDrawingProgress, +} from '../../../services/paint-by-number/guided-progress'; +import { defaultProjectContext, type ProjectContext } from '../../../stores/project-context'; + +const EMPTY_PROGRESS: GuidedDrawingProgress = { + total: 0, + covered: 0, + remaining: 0, + percentage: 0, + remainingByNumber: new Uint32Array(1), +}; + +@customElement('pf-guided-palette') +export class PFGuidedPalette extends BaseComponent { + static styles = css` + :host { + display: grid; + gap: 12px; + } + + h2, + h3, + p { + margin: 0; + } + + h2, + h3 { + color: var(--pf-color-text-secondary); + font-size: var(--pf-font-size-xs); + text-transform: uppercase; + } + + .progress-copy, + .remaining, + .artist-copy { + color: var(--pf-color-text-muted); + font-size: var(--pf-font-size-xs); + line-height: 1.4; + } + + progress { + width: 100%; + height: 6px; + overflow: hidden; + border: 0; + border-radius: 999px; + background: var(--pf-color-bg-input); + } + + progress::-webkit-progress-bar { + background: var(--pf-color-bg-input); + } + + progress::-webkit-progress-value { + background: var(--pf-color-accent); + } + + progress::-moz-progress-bar { + background: var(--pf-color-accent); + } + + .guide-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 5px; + } + + .guide-color { + display: grid; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + min-width: 0; + padding: 5px; + border: 1px solid var(--pf-color-border); + border-radius: var(--pf-radius-sm); + background: var(--pf-color-bg-dark); + color: var(--pf-color-text-main); + cursor: pointer; + text-align: start; + } + + .guide-color:hover { + border-color: var(--pf-color-border-strong); + background: var(--pf-color-bg-hover); + } + + .guide-color[aria-pressed='true'] { + border-color: var(--pf-color-accent); + box-shadow: 0 0 0 1px var(--pf-color-primary-transparent); + } + + button:focus-visible { + outline: 2px solid var(--pf-color-accent); + outline-offset: 2px; + } + + .chip-wrap { + position: relative; + width: 30px; + height: 30px; + } + + .chip { + display: block; + width: 100%; + height: 100%; + border: 1px solid rgba(255, 255, 255, 0.24); + border-radius: 2px; + background: var(--guide-color); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.38) inset; + } + + .number { + position: absolute; + inset-block-start: -3px; + inset-inline-start: -3px; + min-width: 16px; + padding: 1px 3px; + border: 1px solid rgba(255, 255, 255, 0.28); + border-radius: 2px; + background: rgba(7, 9, 13, 0.9); + color: #f5f3ee; + font-size: 9px; + font-weight: 700; + line-height: 12px; + text-align: center; + } + + .color-copy { + display: grid; + min-width: 0; + } + + .hex { + overflow: hidden; + color: var(--pf-color-text-secondary); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; + } + + .remaining { + font-size: 9px; + white-space: nowrap; + } + + .artist-section { + display: grid; + gap: 7px; + padding-block-start: 2px; + border-block-start: 1px solid var(--pf-color-border); + } + + .artist-grid { + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: 2px; + } + + .artist-color { + aspect-ratio: 1; + min-width: 0; + padding: 0; + border: 1px solid var(--pf-color-border); + border-radius: 2px; + background: var(--artist-color); + cursor: pointer; + } + + .artist-color[aria-pressed='true'] { + outline: 2px solid var(--pf-color-accent); + outline-offset: 1px; + } + `; + + private context: ProjectContext = defaultProjectContext; + + connectedCallback() { + super.connectedCallback(); + this.subscribeToActiveProjectContext((context) => { + this.context = context; + this.requestUpdate(); + }); + } + + render() { + const session = this.context.guidedDrawing.session.value; + const palette = this.context.palette.mainColors.value; + const primaryColor = this.context.colors.primaryColor.value.toLowerCase(); + void this.context.history.version.value; + void this.context.layers.layers.value; + + if (!session) return html``; + + const progress = this.getProgress(); + const guideColorCount = session.guideColorCount ?? findHighestGuideNumber(session.target); + const guideColors = palette.slice(0, guideColorCount); + const artistColors = palette.slice(guideColorCount); + + return html` +
+

Guided palette

+

+ ${progress.covered} of ${progress.total} cells covered · + ${progress.remaining} remaining · ${progress.percentage}% +

+ +
+ +
+ ${guideColors.map((color, index) => { + const guideNumber = index + 1; + const remaining = progress.remainingByNumber[guideNumber] ?? 0; + const selected = color.toLowerCase() === primaryColor; + return html` + + `; + })} +
+ + ${artistColors.length > 0 + ? html` +
+

Artist colors

+

Colors you introduced while drawing off-guide.

+
+ ${artistColors.map((color) => html` + + `)} +
+
+ ` + : ''} + `; + } + + private getProgress(): GuidedDrawingProgress { + const snapshot = getGuidedDrawingSnapshot(this.context); + return snapshot + ? analyzeGuidedDrawingProgress(snapshot.session.target, snapshot.pixels) + : EMPTY_PROGRESS; + } + + private selectColor(color: string) { + this.context.colors.setPrimaryColor(color); + this.context.colors.updateLightnessVariations(color); + } +} + +function findHighestGuideNumber(target: Uint8Array): number { + let highest = 0; + for (const guideNumber of target) highest = Math.max(highest, guideNumber); + return highest; +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-guided-palette': PFGuidedPalette; + } +} diff --git a/src/components/color/pf-palette-panel.ts b/src/components/color/pf-palette-panel.ts index 4edc67c..f93bfd0 100644 --- a/src/components/color/pf-palette-panel.ts +++ b/src/components/color/pf-palette-panel.ts @@ -10,6 +10,7 @@ import './pf-save-palette-dialog'; import './pf-unsaved-changes-dialog'; import './palette-panel/pf-palette-toolbar'; import './palette-panel/pf-palette-grid'; +import './palette-panel/pf-guided-palette'; import './palette-panel/pf-extraction-section'; @customElement('pf-palette-panel') @@ -247,6 +248,10 @@ export class PFPalettePanel extends BaseComponent { } render() { + if (this.context.guidedDrawing.active) { + return html``; + } + const hasNewMarks = this.palette.newColorFlags.value.size > 0; return html` diff --git a/src/services/paint-by-number/guided-project.ts b/src/services/paint-by-number/guided-project.ts index 2a9cc05..f675e3d 100644 --- a/src/services/paint-by-number/guided-project.ts +++ b/src/services/paint-by-number/guided-project.ts @@ -71,6 +71,7 @@ export function createGuidedProjectFile(input: GuidedProjectInput): ProjectFile width: guide.width, height: guide.height, target: Array.from(guide.target), + guideColorCount: guide.palette.length, settings: cloneSettings(input.settings), sourceName: input.sourceName, createdAt: input.createdAt ?? Date.now(), diff --git a/src/stores/guided-drawing-store.ts b/src/stores/guided-drawing-store.ts index 03f368a..d8b15ec 100644 --- a/src/stores/guided-drawing-store.ts +++ b/src/stores/guided-drawing-store.ts @@ -62,6 +62,14 @@ function validateSession(session: GuidedDrawingSession): void { if (session.target.length !== session.width * session.height) { throw new RangeError('Guided drawing target does not match its dimensions'); } + validateGuideColorCount(session.guideColorCount); +} + +function validateGuideColorCount(guideColorCount: number | undefined): void { + if (guideColorCount === undefined) return; + if (Number.isInteger(guideColorCount) && guideColorCount > 0) return; + + throw new RangeError('Guided drawing color count must be a positive integer'); } function cloneSession(session: GuidedDrawingSession): GuidedDrawingSession { diff --git a/src/types/guided-drawing.ts b/src/types/guided-drawing.ts index c35c3b2..4a75196 100644 --- a/src/types/guided-drawing.ts +++ b/src/types/guided-drawing.ts @@ -17,6 +17,7 @@ export interface GuidedDrawingSessionFile { width: number; height: number; target: number[]; + guideColorCount?: number; settings: GuidedDrawingSettings; sourceName?: string; createdAt: number; diff --git a/tests/components/color/pf-guided-palette.test.ts b/tests/components/color/pf-guided-palette.test.ts new file mode 100644 index 0000000..55db843 --- /dev/null +++ b/tests/components/color/pf-guided-palette.test.ts @@ -0,0 +1,126 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +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'; +import { createProjectContext, restoreDefaultProjectContext, setActiveProjectContext } from '../../../src/stores/project-context'; +import { GUIDED_DRAWING_VERSION } from '../../../src/types/guided-drawing'; + +describe('guided palette', () => { + const originalHidePopover = HTMLElement.prototype.hidePopover; + + beforeAll(() => { + HTMLElement.prototype.hidePopover = () => {}; + }); + + afterAll(() => { + if (originalHidePopover) { + HTMLElement.prototype.hidePopover = originalHidePopover; + } else { + delete (HTMLElement.prototype as Partial).hidePopover; + } + }); + + afterEach(() => { + document.body.innerHTML = ''; + restoreDefaultProjectContext(); + }); + + it('shows numbered semantic colors and creative coverage', async () => { + const context = createGuidedContext(); + setActiveProjectContext(context); + const element = document.createElement('pf-guided-palette') as PFGuidedPalette; + document.body.append(element); + await element.updateComplete; + + const buttons = [...element.shadowRoot!.querySelectorAll('.guide-color')]; + expect(buttons).toHaveLength(2); + expect(buttons[0].getAttribute('aria-label')).toContain( + 'Guide color 1, #111111, 0 cells remaining', + ); + expect(buttons[1].getAttribute('aria-label')).toContain( + 'Guide color 2, #eeeeee, 1 cells remaining', + ); + expect(element.shadowRoot?.textContent).toContain('1 of 2 cells covered'); + expect(element.shadowRoot?.textContent).toContain('1 remaining · 50%'); + + buttons[1].click(); + await element.updateComplete; + expect(context.colors.primaryColor.value).toBe('#eeeeee'); + expect(buttons[1].getAttribute('aria-pressed')).toBe('true'); + + context.dispose(); + }); + + it('keeps off-guide artist colors selectable without numbering them', async () => { + const context = createGuidedContext(); + context.palette.mainColors.value = ['#111111', '#eeeeee', '#ff00ff']; + context.palette.rebuildColorMap(); + setActiveProjectContext(context); + + const element = document.createElement('pf-guided-palette') as PFGuidedPalette; + document.body.append(element); + await element.updateComplete; + + const artistColor = element.shadowRoot?.querySelector('.artist-color'); + expect(artistColor?.getAttribute('aria-label')).toBe('Artist color #ff00ff'); + artistColor?.click(); + await element.updateComplete; + expect(context.colors.primaryColor.value).toBe('#ff00ff'); + + context.dispose(); + }); + + it('replaces normal palette editing only for guided projects', async () => { + const guidedContext = createGuidedContext(); + setActiveProjectContext(guidedContext); + const panel = document.createElement('pf-palette-panel'); + document.body.append(panel); + await panel.updateComplete; + + expect(panel.shadowRoot?.querySelector('pf-guided-palette')).not.toBeNull(); + expect(panel.shadowRoot?.querySelector('pf-palette-toolbar')).toBeNull(); + + const normalContext = createProjectContext(); + setActiveProjectContext(normalContext); + await panel.updateComplete; + expect(panel.shadowRoot?.querySelector('pf-guided-palette')).toBeNull(); + expect(panel.shadowRoot?.querySelector('pf-palette-toolbar')).not.toBeNull(); + + guidedContext.dispose(); + normalContext.dispose(); + }); +}); + +function createGuidedContext() { + const context = createProjectContext(); + const layer = context.layers.layers.value.find((item) => item.type === 'image'); + const pixels = new Uint8ClampedArray([ + 12, 12, 12, 255, + 0, 0, 0, 0, + ]); + const canvas = { + width: 2, + height: 1, + getContext: () => ({ getImageData: () => ({ data: pixels }) }), + } as unknown as HTMLCanvasElement; + if (layer) context.layers.updateLayer(layer.id, { canvas }); + + context.palette.mainColors.value = ['#111111', '#eeeeee']; + context.palette.rebuildColorMap(); + context.guidedDrawing.start({ + version: GUIDED_DRAWING_VERSION, + width: 2, + height: 1, + target: Uint8Array.from([1, 2]), + guideColorCount: 2, + settings: { + longSide: 2, + paletteSource: 'generated', + maxColors: 2, + mapping: 'color', + simplifyIsolatedPixels: false, + }, + createdAt: 1, + }); + return context; +} diff --git a/tests/services/paint-by-number/guided-project.test.ts b/tests/services/paint-by-number/guided-project.test.ts index eb77edd..a5587a4 100644 --- a/tests/services/paint-by-number/guided-project.test.ts +++ b/tests/services/paint-by-number/guided-project.test.ts @@ -47,6 +47,7 @@ describe('createGuidedProjectFile', () => { expect(file.guidedDrawing).toMatchObject({ version: 1, target: [1, 2, 0, 1], + guideColorCount: 2, settings, sourceName: 'portrait.png', createdAt: 123, From adc43c470d03a6f7d68da7d1cae6ba3c81a5b1e0 Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 10 Jul 2026 12:18:12 +0200 Subject: [PATCH 4/4] Keep guided project structure aligned (#314) --- .../color/palette-panel/pf-guided-palette.ts | 14 +- src/components/dialogs/pf-resize-dialog.ts | 45 ++- src/components/menu/pf-menu-bar.ts | 169 ++++++-- .../timeline/pf-playback-controls.ts | 24 +- src/components/timeline/pf-timeline-layers.ts | 381 ++++++++---------- src/components/timeline/pf-timeline.ts | 30 +- .../color/pf-guided-palette.test.ts | 3 + .../dialogs/pf-resize-dialog.test.ts | 70 ++++ tests/components/menu/pf-menu-bar.test.ts | 35 ++ ...f-timeline-layers-reference-import.test.ts | 116 ------ .../timeline/timeline-active-context.test.ts | 61 ++- 11 files changed, 567 insertions(+), 381 deletions(-) create mode 100644 tests/components/dialogs/pf-resize-dialog.test.ts delete mode 100644 tests/components/timeline/pf-timeline-layers-reference-import.test.ts diff --git a/src/components/color/palette-panel/pf-guided-palette.ts b/src/components/color/palette-panel/pf-guided-palette.ts index 074a43d..290cedc 100644 --- a/src/components/color/palette-panel/pf-guided-palette.ts +++ b/src/components/color/palette-panel/pf-guided-palette.ts @@ -39,12 +39,19 @@ export class PFGuidedPalette extends BaseComponent { .progress-copy, .remaining, - .artist-copy { + .artist-copy, + .structure-note { color: var(--pf-color-text-muted); font-size: var(--pf-font-size-xs); line-height: 1.4; } + .structure-note { + padding: 7px 8px; + border-inline-start: 2px solid var(--pf-color-accent); + background: var(--pf-color-primary-transparent); + } + progress { width: 100%; height: 6px; @@ -245,6 +252,11 @@ export class PFGuidedPalette extends BaseComponent { })} +

+ Canvas, palette, layers, and frames stay fixed so the numbered guide remains aligned. + Drawing colors and tools are still yours. +

+ ${artistColors.length > 0 ? html`
diff --git a/src/components/dialogs/pf-resize-dialog.ts b/src/components/dialogs/pf-resize-dialog.ts index a20a502..49225f1 100644 --- a/src/components/dialogs/pf-resize-dialog.ts +++ b/src/components/dialogs/pf-resize-dialog.ts @@ -1,7 +1,7 @@ import { html, css } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { BaseComponent } from '../../core/base-component'; -import { projectStore } from '../../stores/project'; +import { defaultProjectContext, type ProjectContext } from '../../stores/project-context'; import '../ui/pf-dialog'; @customElement('pf-resize-dialog') @@ -13,6 +13,13 @@ export class PFResizeDialog extends BaseComponent { gap: 4px; } + .restriction { + margin: 0; + color: var(--pf-color-text-muted); + font-size: var(--pf-font-size-xs); + line-height: 1.45; + } + label { font-size: 12px; color: var(--pf-color-text-muted); @@ -49,16 +56,27 @@ export class PFResizeDialog extends BaseComponent { @property({ type: Boolean }) open = false; @state() width = 64; @state() height = 64; + private context: ProjectContext = defaultProjectContext; + + connectedCallback() { + super.connectedCallback(); + this.subscribeToActiveProjectContext((context) => { + this.context = context; + if (this.open) this.readProjectSize(); + this.requestUpdate(); + }); + } willUpdate(changedProperties: Map) { // Update dimensions when dialog opens if (changedProperties.has('open') && this.open) { - this.width = projectStore.width.value; - this.height = projectStore.height.value; + this.readProjectSize(); } } render() { + const guidedProject = this.context.guidedDrawing.active; + return html` Resize Canvas + ${guidedProject + ? html` +

+ Guided projects keep their canvas size fixed so every number stays aligned. +

+ ` + : ''} +
this.width = parseInt((e.target as HTMLInputElement).value)} > @@ -83,6 +110,7 @@ export class PFResizeDialog extends BaseComponent { type="number" min="1" max="2048" + ?disabled=${guidedProject} .value=${this.height} @input=${(e: Event) => this.height = parseInt((e.target as HTMLInputElement).value)} > @@ -90,7 +118,7 @@ export class PFResizeDialog extends BaseComponent {
- +
`; @@ -102,9 +130,16 @@ export class PFResizeDialog extends BaseComponent { } apply() { + if (this.context.guidedDrawing.active) return; + const width = Math.max(1, Math.min(2048, this.width)); const height = Math.max(1, Math.min(2048, this.height)); - projectStore.resizeCanvas(width, height); + this.context.project.resizeCanvas(width, height); this.close(); } + + private readProjectSize() { + this.width = this.context.project.width.value; + this.height = this.context.project.height.value; + } } diff --git a/src/components/menu/pf-menu-bar.ts b/src/components/menu/pf-menu-bar.ts index 6deeede..c1a0bf7 100644 --- a/src/components/menu/pf-menu-bar.ts +++ b/src/components/menu/pf-menu-bar.ts @@ -1,12 +1,7 @@ import { html, css } from "lit"; import { customElement, state } from "lit/decorators.js"; import { BaseComponent } from "../../core/base-component"; -import { historyStore } from "../../stores/history"; -import { layerStore } from "../../stores/layers"; -import { projectStore } from "../../stores/project"; import { getActiveProjectContext } from "../../stores/project-context"; -import { gridStore } from "../../stores/grid"; -import { viewportStore } from "../../stores/viewport"; import { FlipLayerCommand, RotateLayerCommand, @@ -244,11 +239,16 @@ export class PFMenuBar extends BaseComponent { width: 100%; } - .menu-item:hover { + .menu-item:hover:not(:disabled) { background-color: var(--pf-color-primary-transparent); color: var(--pf-color-text-main); } + button.menu-item:disabled { + cursor: not-allowed; + opacity: 0.45; + } + .menu-item.danger { color: #f0aaa2; } @@ -478,6 +478,14 @@ export class PFMenuBar extends BaseComponent { return Math.max(MENU_MARGIN, Math.min(value, maxValue)); } + private guidedActionTitle( + guidedProject: boolean, + regularTitle: string, + guidedTitle: string, + ): string { + return guidedProject ? guidedTitle : regularTitle; + } + private getMenuButton(menuId: MenuId) { return this.shadowRoot?.querySelector(`#btn-${menuId}`) ?? null; } @@ -487,16 +495,22 @@ export class PFMenuBar extends BaseComponent { } flipLayer(direction: "horizontal" | "vertical") { - const activeLayerId = layerStore.activeLayerId.value; + const context = getActiveProjectContext(); + if (context.guidedDrawing.active) return; + + const activeLayerId = context.layers.activeLayerId.value; if (activeLayerId) { - historyStore.execute(new FlipLayerCommand(activeLayerId, direction)); + void context.history.execute(new FlipLayerCommand(activeLayerId, direction, context)); } } rotateLayer(angle: number) { - const activeLayerId = layerStore.activeLayerId.value; + const context = getActiveProjectContext(); + if (context.guidedDrawing.active) return; + + const activeLayerId = context.layers.activeLayerId.value; if (activeLayerId) { - historyStore.execute(new RotateLayerCommand(activeLayerId, angle)); + void context.history.execute(new RotateLayerCommand(activeLayerId, angle, context)); } } @@ -544,12 +558,12 @@ export class PFMenuBar extends BaseComponent { const pako = await import("pako"); const decompressed = pako.default.inflate(new Uint8Array(buffer), { to: "string" }); const project = JSON.parse(decompressed) as ProjectFileInput; - await projectStore.loadProject(project); + await getActiveProjectContext().project.loadProject(project); } else { // JSON format (uncompressed) const text = await file.text(); const project = JSON.parse(text) as ProjectFileInput; - await projectStore.loadProject(project); + await getActiveProjectContext().project.loadProject(project); } } catch (error) { log.error("Failed to open file:", error); @@ -560,11 +574,25 @@ export class PFMenuBar extends BaseComponent { } importReferenceImage() { - openReferenceImagePicker(getActiveProjectContext(), (error) => { + const context = getActiveProjectContext(); + if (context.guidedDrawing.active) return; + + openReferenceImagePicker(context, (error) => { log.error("Failed to import reference image:", error); }); } + showResizeDialog() { + if (getActiveProjectContext().guidedDrawing.active) return; + + this.dispatchEvent( + new CustomEvent("resize-canvas", { + bubbles: true, + composed: true, + }) + ); + } + showExportDialog() { this.dispatchEvent( new CustomEvent("show-export-dialog", { bubbles: true, composed: true }) @@ -659,13 +687,15 @@ export class PFMenuBar extends BaseComponent { private async commitNameEdit(e: Event) { const input = e.target as HTMLInputElement; const newName = input.value.trim() || "Untitled"; - projectStore.name.value = newName; + getActiveProjectContext().project.name.value = newName; this.isEditingName = false; await autoSaveService.saveNow(); } render() { - const projectName = projectStore.name.value; + const context = getActiveProjectContext(); + const projectName = context.project.name.value; + const guidedProject = context.guidedDrawing.active; const activeCrtPreset = this.getActiveCrtPreset(); return html` @@ -742,9 +772,19 @@ export class PFMenuBar extends BaseComponent { - + @@ -767,10 +807,10 @@ export class PFMenuBar extends BaseComponent { @click=${this.handleMenuPanelClick} @toggle=${(event: Event) => this.handlePopoverToggle(event, "edit")} > -