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 {
+ 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` ++ ${progress.covered} of ${progress.total} cells covered · + ${progress.remaining} remaining · ${progress.percentage}% +
+ ++ Canvas, palette, layers, and frames stay fixed so the numbered guide remains aligned. + Drawing colors and tools are still yours. +
+ + ${artistColors.length > 0 + ? html` +Colors you introduced while drawing off-guide.
++ Guided projects keep their canvas size fixed so every number stays aligned. +
+ ` + : ''} +