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/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..290cedc --- /dev/null +++ b/src/components/color/palette-panel/pf-guided-palette.ts @@ -0,0 +1,306 @@ +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, + .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; + 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` + + `; + })} +
+ +

+ 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 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-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")} > -