diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02ef510..3225305 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: pull_request: push: - branches: [main, wip] + branches: [main, develop] permissions: contents: read diff --git a/package-lock.json b/package-lock.json index aacda6f..d0297fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3162,9 +3162,9 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { 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 121abae..3fea758 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; @@ -242,6 +244,7 @@ export class PixelForgeApp extends BaseComponent { @state() timelineHeight = 200; @state() private isResizingTimeline = false; @state() private hasLibraryProject = false; + @state() private projectSelectionRequired = false; @state() private warningMessage: string | null = null; private resizeStartY = 0; @@ -265,6 +268,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,10 +340,23 @@ export class PixelForgeApp extends BaseComponent { this.showNewProjectDialog = true; }; + private handleShowPaintByNumberDialog = () => { + this.showProjectBrowser = false; + this.showPaintByNumberDialog = true; + }; + + private handlePaintByNumberDialogClose = () => { + this.showPaintByNumberDialog = false; + + if (this.projectSelectionRequired) { + this.showProjectBrowser = true; + } + }; + private handleNewProjectDialogClose = () => { this.showNewProjectDialog = false; - if (!this.hasLibraryProject) { + if (this.projectSelectionRequired) { this.showProjectBrowser = true; } }; @@ -381,24 +401,28 @@ export class PixelForgeApp extends BaseComponent { }; private handleProjectBrowserClose = () => { - if (this.hasLibraryProject) { + if (!this.projectSelectionRequired) { this.showProjectBrowser = false; } }; private handleProjectOpened = () => { this.hasLibraryProject = true; + this.projectSelectionRequired = false; this.showProjectBrowser = false; }; private handleProjectCreated = () => { this.hasLibraryProject = true; + this.projectSelectionRequired = false; this.showNewProjectDialog = false; + this.showPaintByNumberDialog = false; this.showProjectBrowser = false; }; private handleCurrentProjectDeleted = () => { this.hasLibraryProject = false; + this.projectSelectionRequired = true; this.showProjectBrowser = true; }; @@ -442,6 +466,7 @@ export class PixelForgeApp extends BaseComponent { (await workspaceStore.restoreWorkspace(workspaceState)) ) { this.hasLibraryProject = true; + this.projectSelectionRequired = false; this.showProjectBrowser = false; return; } @@ -462,15 +487,18 @@ export class PixelForgeApp extends BaseComponent { ) { historyStore.clear(); this.hasLibraryProject = true; + this.projectSelectionRequired = false; this.showProjectBrowser = false; } else { this.hasLibraryProject = false; - this.showProjectBrowser = true; + this.projectSelectionRequired = false; + this.showProjectBrowser = false; } } catch (error) { log.warn("Failed to load saved project, starting fresh:", error); this.hasLibraryProject = false; - this.showProjectBrowser = true; + this.projectSelectionRequired = false; + this.showProjectBrowser = false; } } @@ -483,6 +511,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 +601,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,11 +680,18 @@ export class PixelForgeApp extends BaseComponent { @project-created=${this.handleProjectCreated} > + + ${this.showProjectBrowser ? html` + 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..bd3352d --- /dev/null +++ b/src/components/canvas/pf-guided-number-overlay.ts @@ -0,0 +1,285 @@ +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 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, + 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 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; + void this.context.history.version.value; + void this.context.layers.layers.value; + + return html` + + ${session && numbersVisible && 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 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, + frame, + ); + + 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 drawNumbers(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/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..a35abdb --- /dev/null +++ b/src/components/color/palette-panel/pf-guided-palette.ts @@ -0,0 +1,495 @@ +import { css, html } from 'lit'; +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, + 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, + .structure-note { + color: var(--pf-color-text-muted); + font-size: var(--pf-font-size-xs); + 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); + } + + .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); + background: var(--pf-color-primary-transparent); + } + + 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; + @state() private finishConfirmationOpen = false; + @state() private isFinishing = false; + @state() private finishError = ''; + + 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}% +

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

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

+ + ${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 renderViewControls() { + const guidance = this.context.guidedDrawing; + const numbersVisible = guidance.numbersVisible.value; + const targetPreviewVisible = guidance.targetPreviewVisible.value; + + return html` +
+ + +
+ `; + } + + 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); + } +} + +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/components/dialogs/pf-paint-by-number-dialog.ts b/src/components/dialogs/pf-paint-by-number-dialog.ts new file mode 100644 index 0000000..66c4dbd --- /dev/null +++ b/src/components/dialogs/pf-paint-by-number-dialog.ts @@ -0,0 +1,664 @@ +import { css, html } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; +import { BaseComponent } from '../../core/base-component'; +import { + generateNumberedGuide, + type NumberedGuide, +} from '../../services/paint-by-number/guide-generator'; +import { createGuidedProject } from '../../services/paint-by-number/guided-project'; +import { decodeImageFile } from '../../services/paint-by-number/image-file'; +import { sampleImageToGrid } from '../../services/paint-by-number/image-sampling'; +import { getActiveProjectContext } from '../../stores/project-context'; +import type { + GuidedColorMapping, + GuidedDrawingSettings, + GuidedPaletteSource, +} from '../../types/guided-drawing'; +import '../ui/pf-dialog'; + +const PREVIEW_DEBOUNCE_MS = 180; + +const GUIDE_PRESETS = [ + { label: 'Compact', longSide: 16, maxColors: 6 }, + { label: 'Balanced', longSide: 24, maxColors: 8 }, + { label: 'Detailed', longSide: 32, maxColors: 12 }, + { label: 'Complex', longSide: 48, maxColors: 16 }, +] as const; + +@customElement('pf-paint-by-number-dialog') +export class PFPaintByNumberDialog extends BaseComponent { + static styles = css` + :host { + color: var(--pf-color-text-main); + } + + form { + display: grid; + gap: 16px; + } + + fieldset { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + border: 0; + } + + legend, + .field-label { + margin-bottom: 6px; + color: var(--pf-color-text-muted); + font-size: var(--pf-font-size-xs); + font-weight: 700; + text-transform: uppercase; + } + + .file-field, + .field { + display: grid; + gap: 6px; + } + + input, + select { + box-sizing: border-box; + width: 100%; + min-height: 34px; + border: 1px solid var(--pf-color-border); + border-radius: var(--pf-radius-sm); + background: var(--pf-color-bg-input); + color: var(--pf-color-text-main); + font: inherit; + padding: 7px 9px; + } + + input:focus-visible, + select:focus-visible, + button:focus-visible, + summary:focus-visible { + outline: 2px solid var(--pf-color-accent); + outline-offset: 2px; + } + + .local-note, + .hint, + .status { + margin: 0; + color: var(--pf-color-text-muted); + font-size: var(--pf-font-size-xs); + line-height: 1.45; + } + + .presets { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + } + + .preset { + display: grid; + gap: 3px; + min-height: 56px; + padding: 8px; + 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; + } + + .preset:hover { + background: var(--pf-color-bg-hover); + } + + .preset[aria-pressed='true'] { + border-color: var(--pf-color-accent); + background: var(--pf-color-primary-transparent); + } + + .preset-name { + font-size: var(--pf-font-size-sm); + font-weight: 700; + } + + .preset-meta { + color: var(--pf-color-text-muted); + font-size: var(--pf-font-size-xs); + } + + details { + border: 1px solid var(--pf-color-border); + border-radius: var(--pf-radius-sm); + background: var(--pf-color-bg-dark); + } + + summary { + cursor: pointer; + padding: 10px; + color: var(--pf-color-text-main); + font-size: var(--pf-font-size-sm); + font-weight: 700; + } + + .advanced-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + padding: 4px 10px 12px; + } + + .checkbox-field { + display: flex; + align-items: center; + gap: 8px; + grid-column: 1 / -1; + color: var(--pf-color-text-main); + font-size: var(--pf-font-size-sm); + } + + .checkbox-field input { + width: auto; + min-height: auto; + margin: 0; + } + + .previews { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + } + + figure { + display: grid; + gap: 6px; + margin: 0; + } + + figcaption { + color: var(--pf-color-text-muted); + font-size: var(--pf-font-size-xs); + font-weight: 700; + text-transform: uppercase; + } + + .preview-frame { + display: grid; + place-items: center; + min-height: 180px; + overflow: hidden; + border: 1px solid var(--pf-color-border); + border-radius: var(--pf-radius-sm); + background: + linear-gradient(45deg, rgba(255, 255, 255, 0.06) 25%, transparent 25% 75%, rgba(255, 255, 255, 0.06) 75%) 0 0 / 16px 16px, + linear-gradient(45deg, transparent 25%, rgba(255, 255, 255, 0.035) 25% 75%, transparent 75%) 8px 8px / 16px 16px, + var(--pf-color-bg-dark); + } + + canvas { + display: block; + max-width: 100%; + max-height: 240px; + image-rendering: pixelated; + } + + .placeholder { + max-width: 24ch; + padding: 20px; + color: var(--pf-color-text-muted); + font-size: var(--pf-font-size-xs); + line-height: 1.5; + text-align: center; + } + + .status[data-error='true'] { + color: var(--pf-color-danger, #f0aaa2); + } + + button { + padding: 7px 12px; + border-radius: var(--pf-radius-sm); + cursor: pointer; + font: inherit; + } + + button.primary { + border: 1px solid var(--pf-color-accent); + background: var(--pf-color-primary-transparent); + color: var(--pf-color-accent-hover); + } + + button.secondary { + border: 1px solid var(--pf-color-border); + background: transparent; + color: var(--pf-color-text-main); + } + + button:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + @media (max-width: 720px) { + .presets, + .previews, + .advanced-fields { + grid-template-columns: 1fr 1fr; + } + } + `; + + @property({ type: Boolean, reflect: true }) open = false; + @state() private sourceFile: File | null = null; + @state() private sourceImage: ImageData | null = null; + @state() private guide: NumberedGuide | null = null; + @state() private longSide = 24; + @state() private maxColors = 8; + @state() private paletteSource: GuidedPaletteSource = 'generated'; + @state() private mapping: GuidedColorMapping = 'color'; + @state() private simplifyIsolatedPixels = true; + @state() private isGenerating = false; + @state() private isCreating = false; + @state() private statusMessage = 'Choose an image to begin.'; + @state() private errorMessage = ''; + @query('#source-preview') private sourceCanvas?: HTMLCanvasElement; + @query('#guide-preview') private guideCanvas?: HTMLCanvasElement; + + private previewTimer: number | null = null; + private requestVersion = 0; + + disconnectedCallback() { + super.disconnectedCallback(); + this.cancelPendingPreview(); + } + + protected updated(): void { + this.drawPreviews(); + } + + render() { + return html` + + Guided drawing + +
+ + +
+ Guide preset +
+ ${GUIDE_PRESETS.map((preset) => html` + + `)} +
+
+ +
+ Advanced settings +
+ + + + + + + + + +
+
+ +
+ ${this.renderPreviewFigure('Source', 'source-preview', Boolean(this.sourceImage))} + ${this.renderPreviewFigure('Guide', 'guide-preview', Boolean(this.guide))} +
+ +

+ ${this.errorMessage || this.statusMessage} +

+ +
+ +
+ + +
+
+ `; + } + + 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/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 2e01b6a..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 }) @@ -580,6 +608,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", { @@ -650,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` @@ -710,6 +749,13 @@ export class PFMenuBar extends BaseComponent { + @@ -726,9 +772,19 @@ export class PFMenuBar extends BaseComponent { - + @@ -751,10 +807,10 @@ export class PFMenuBar extends BaseComponent { @click=${this.handleMenuPanelClick} @toggle=${(event: Event) => this.handlePopoverToggle(event, "edit")} > -