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 {
`;
@@ -693,6 +715,13 @@ export class PFProjectBrowser extends BaseComponent {
return;
}
+ if (returnValue === 'guided-drawing') {
+ this.dispatchEvent(
+ new CustomEvent('show-paint-by-number-dialog', { bubbles: true, composed: true })
+ );
+ return;
+ }
+
if (!this.canDismissBrowser()) {
this.showNativeDialog(this.browserDialog);
return;
diff --git a/src/components/app/pixel-forge-app.ts b/src/components/app/pixel-forge-app.ts
index 42f1c2c..2d009e1 100644
--- a/src/components/app/pixel-forge-app.ts
+++ b/src/components/app/pixel-forge-app.ts
@@ -683,6 +683,7 @@ export class PixelForgeApp extends BaseComponent {
- ${session && zoom < NUMBER_ZOOM_THRESHOLD
+ ${session && numbersVisible && zoom < NUMBER_ZOOM_THRESHOLD
? html`
Zoom in to see guide numbers
@@ -155,23 +184,62 @@ export class PFGuidedNumberOverlay extends CanvasOverlay {
if (!snapshot) return;
const { viewport } = this.context;
- const cells = collectVisibleGuidedNumberCells(
+ const frame = {
+ zoom: viewport.zoom.value,
+ panX: viewport.panX.value,
+ panY: viewport.panY.value,
+ viewportWidth: this.clientWidth,
+ viewportHeight: this.clientHeight,
+ };
+
+ if (this.context.guidedDrawing.targetPreviewVisible.value) {
+ const targetCells = collectVisibleGuidedTargetCells(
+ snapshot.session.target,
+ snapshot.session.width,
+ frame,
+ );
+ this.drawTargetPreview(
+ drawingContext,
+ targetCells,
+ this.context.palette.mainColors.value,
+ );
+ }
+
+ if (!this.context.guidedDrawing.numbersVisible.value) return;
+
+ const numberCells = collectVisibleGuidedNumberCells(
snapshot.session.target,
snapshot.pixels,
snapshot.session.width,
- {
- zoom: viewport.zoom.value,
- panX: viewport.panX.value,
- panY: viewport.panY.value,
- viewportWidth: this.clientWidth,
- viewportHeight: this.clientHeight,
- },
+ frame,
);
- this.drawCells(drawingContext, cells);
+ this.drawNumbers(drawingContext, numberCells);
+ }
+
+ private drawTargetPreview(
+ context: CanvasRenderingContext2D,
+ cells: GuidedNumberCell[],
+ palette: string[],
+ ) {
+ context.save();
+ context.globalAlpha = 0.72;
+ for (const cell of cells) {
+ const color = palette[cell.guideNumber - 1];
+ if (!color) continue;
+
+ context.fillStyle = color;
+ context.fillRect(
+ Math.floor(cell.screenX),
+ Math.floor(cell.screenY),
+ Math.ceil(cell.size),
+ Math.ceil(cell.size),
+ );
+ }
+ context.restore();
}
- private drawCells(context: CanvasRenderingContext2D, cells: GuidedNumberCell[]) {
+ private drawNumbers(context: CanvasRenderingContext2D, cells: GuidedNumberCell[]) {
const fontFamily = getComputedStyle(this).fontFamily || '"Departure Mono", monospace';
context.textAlign = 'center';
context.textBaseline = 'middle';
diff --git a/src/components/color/palette-panel/pf-guided-palette.ts b/src/components/color/palette-panel/pf-guided-palette.ts
index 290cedc..a35abdb 100644
--- a/src/components/color/palette-panel/pf-guided-palette.ts
+++ b/src/components/color/palette-panel/pf-guided-palette.ts
@@ -1,12 +1,14 @@
import { css, html } from 'lit';
-import { customElement } from 'lit/decorators.js';
+import { customElement, state } from 'lit/decorators.js';
import { BaseComponent } from '../../../core/base-component';
+import { autoSaveService } from '../../../services/auto-save';
import {
analyzeGuidedDrawingProgress,
getGuidedDrawingSnapshot,
type GuidedDrawingProgress,
} from '../../../services/paint-by-number/guided-progress';
import { defaultProjectContext, type ProjectContext } from '../../../stores/project-context';
+import '../../ui/pf-dialog';
const EMPTY_PROGRESS: GuidedDrawingProgress = {
total: 0,
@@ -46,6 +48,84 @@ export class PFGuidedPalette extends BaseComponent {
line-height: 1.4;
}
+ .view-controls {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 5px;
+ }
+
+ .view-controls button {
+ min-height: 30px;
+ padding: 5px 7px;
+ border: 1px solid var(--pf-color-border);
+ border-radius: var(--pf-radius-sm);
+ background: var(--pf-color-bg-dark);
+ color: var(--pf-color-text-secondary);
+ cursor: pointer;
+ font-size: var(--pf-font-size-xs);
+ }
+
+ .view-controls button[aria-pressed='true'] {
+ border-color: var(--pf-color-accent);
+ background: var(--pf-color-primary-transparent);
+ color: var(--pf-color-text-main);
+ }
+
+ .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);
@@ -189,6 +269,9 @@ export class PFGuidedPalette extends BaseComponent {
`;
private context: ProjectContext = defaultProjectContext;
+ @state() private finishConfirmationOpen = false;
+ @state() private isFinishing = false;
+ @state() private finishError = '';
connectedCallback() {
super.connectedCallback();
@@ -226,6 +309,9 @@ export class PFGuidedPalette extends BaseComponent {
>
+ ${this.renderViewControls()}
+ ${this.renderFinishGuidance(progress)}
+
${guideColors.map((color, index) => {
const guideNumber = index + 1;
@@ -287,6 +373,109 @@ export class PFGuidedPalette extends BaseComponent {
: EMPTY_PROGRESS;
}
+ private renderViewControls() {
+ const guidance = this.context.guidedDrawing;
+ const numbersVisible = guidance.numbersVisible.value;
+ const targetPreviewVisible = guidance.targetPreviewVisible.value;
+
+ return html`
+
+ guidance.toggleNumbers()}
+ >
+ ${numbersVisible ? 'Hide numbers' : 'Show numbers'}
+
+ guidance.toggleTargetPreview()}
+ >
+ ${targetPreviewVisible ? 'Hide target' : 'Preview target'}
+
+
+ `;
+ }
+
+ private renderFinishGuidance(progress: GuidedDrawingProgress) {
+ return html`
+
+
+ Finish guidance
+
+ 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`${this.finishError}
`
+ : ''}
+
+
+ Keep guidance
+
+
+ ${this.isFinishing ? 'Finishing…' : 'Finish guidance'}
+
+
+
+ `;
+ }
+
+ private openFinishConfirmation = () => {
+ this.finishError = '';
+ this.finishConfirmationOpen = true;
+ };
+
+ private closeFinishConfirmation = () => {
+ if (this.isFinishing) return;
+ this.finishConfirmationOpen = false;
+ this.finishError = '';
+ };
+
+ private finishGuidance = async () => {
+ if (this.isFinishing || !this.context.guidedDrawing.active) return;
+
+ this.isFinishing = true;
+ this.finishError = '';
+ this.context.guidedDrawing.beginFinish();
+ try {
+ await autoSaveService.saveNow(this.context);
+ this.context.guidedDrawing.completeFinish();
+ this.context.viewport.resetView();
+ this.finishConfirmationOpen = false;
+ } catch {
+ this.context.guidedDrawing.cancelFinish();
+ this.finishError = 'Guidance could not be finished safely. Your guide is still active.';
+ } finally {
+ this.isFinishing = false;
+ }
+ };
+
private selectColor(color: string) {
this.context.colors.setPrimaryColor(color);
this.context.colors.updateLightnessVariations(color);
diff --git a/src/serialization/project-load.ts b/src/serialization/project-load.ts
index fb1003f..50acbe2 100644
--- a/src/serialization/project-load.ts
+++ b/src/serialization/project-load.ts
@@ -31,6 +31,7 @@ export type ProjectLoadStores = {
getCelKey: (layerId: string, frameId: string) => string;
goToFrame: (frameId: string) => void;
linkCels: (celKeys: string[], linkType: CelLinkType) => string | null;
+ rebuildAllCelCanvases: () => void;
rebuildAllIndexBuffers: () => void;
setFrameDuration: (frameId: string, duration: number) => void;
setTextCelData: (
@@ -133,6 +134,7 @@ export async function hydrateProjectFrames(
}
restoreLinkedCelGroups(stores, linkedCelGroups);
+ stores.animation.rebuildAllCelCanvases();
deletePlaceholderFrame(stores, placeholderFrameId);
}
@@ -304,6 +306,7 @@ function giveSharedTransparentCelOwnCanvas(
!cel ||
cel.linkedCelId !== EMPTY_CEL_LINK_ID ||
!hasProjectImageData(celFile.data)
+ && !(celFile.indexData && celFile.indexData.length > 0)
) {
return;
}
@@ -351,7 +354,7 @@ function restoreCelIndexBuffer(
): void {
const { animation } = stores;
const cel = animation.cels.value.get(celKey);
- if (!cel || !hasProjectImageData(celFile.data)) return;
+ if (!cel) return;
const cels = new Map(animation.cels.value);
@@ -360,7 +363,7 @@ function restoreCelIndexBuffer(
...cel,
indexBuffer: new Uint8Array(celFile.indexData),
});
- } else if (canvas && !file.palette) {
+ } else if (canvas && hasProjectImageData(celFile.data) && !file.palette) {
cels.set(celKey, {
...cel,
indexBuffer: buildIndexBufferFromCanvas(canvas, stores.palette, true),
diff --git a/src/stores/guided-drawing-store.ts b/src/stores/guided-drawing-store.ts
index d8b15ec..1079e1a 100644
--- a/src/stores/guided-drawing-store.ts
+++ b/src/stores/guided-drawing-store.ts
@@ -7,6 +7,9 @@ import {
class GuidedDrawingStore {
readonly session = signal
(null);
+ readonly numbersVisible = signal(true);
+ readonly targetPreviewVisible = signal(false);
+ readonly finishPending = signal(false);
get active(): boolean {
return this.session.value !== null;
@@ -14,9 +17,19 @@ class GuidedDrawingStore {
start(session: GuidedDrawingSession): void {
validateSession(session);
+ this.resetViewOptions();
+ this.finishPending.value = false;
this.session.value = cloneSession(session);
}
+ toggleNumbers(): void {
+ this.numbersVisible.value = !this.numbersVisible.value;
+ }
+
+ toggleTargetPreview(): void {
+ this.targetPreviewVisible.value = !this.targetPreviewVisible.value;
+ }
+
load(file: GuidedDrawingSessionFile | undefined): void {
if (!file) {
this.clear();
@@ -30,6 +43,8 @@ class GuidedDrawingStore {
}
toFile(): GuidedDrawingSessionFile | undefined {
+ if (this.finishPending.value) return undefined;
+
const session = this.session.value;
if (!session) return undefined;
@@ -42,6 +57,26 @@ class GuidedDrawingStore {
clear(): void {
this.session.value = null;
+ this.finishPending.value = false;
+ this.resetViewOptions();
+ }
+
+ beginFinish(): void {
+ if (this.session.value) this.finishPending.value = true;
+ }
+
+ cancelFinish(): void {
+ this.finishPending.value = false;
+ }
+
+ completeFinish(): void {
+ if (!this.finishPending.value) return;
+ this.clear();
+ }
+
+ private resetViewOptions(): void {
+ this.numbersVisible.value = true;
+ this.targetPreviewVisible.value = false;
}
}
diff --git a/tests/components/app/pf-project-browser.test.ts b/tests/components/app/pf-project-browser.test.ts
index 78b1473..9aa6e74 100644
--- a/tests/components/app/pf-project-browser.test.ts
+++ b/tests/components/app/pf-project-browser.test.ts
@@ -214,6 +214,7 @@ describe('pf-project-browser', () => {
expect(element.shadowRoot?.textContent).toContain('Open Project');
expect(element.shadowRoot?.textContent).toContain('Second Project');
expect(buttonWithText(element.shadowRoot!, 'New Project')).toBeTruthy();
+ expect(buttonWithText(element.shadowRoot!, 'Guided Drawing')).toBeTruthy();
});
it('emits show-new-project-dialog from the native dialog return value', async () => {
@@ -230,6 +231,19 @@ describe('pf-project-browser', () => {
expect(requested).toBe(true);
});
+ it('emits the shared guided-drawing setup request from the library', async () => {
+ const element = await createBrowser();
+ let requested = false;
+ element.addEventListener('show-paint-by-number-dialog', () => {
+ requested = true;
+ });
+
+ closeWithReturnValue(projectDialog(element.shadowRoot!), 'guided-drawing');
+ await settle(element);
+
+ expect(requested).toBe(true);
+ });
+
it('emits project-browser-close from the dialog close button when closing is allowed', async () => {
const element = await createBrowser();
element.canClose = true;
diff --git a/tests/components/app/pixel-forge-app.test.ts b/tests/components/app/pixel-forge-app.test.ts
index f9eba3d..2d3109b 100644
--- a/tests/components/app/pixel-forge-app.test.ts
+++ b/tests/components/app/pixel-forge-app.test.ts
@@ -184,4 +184,34 @@ describe('pixel-forge-app project dialogs', () => {
expect(element.shadowRoot?.querySelector('pf-project-browser')).toBeNull();
expect(element.shadowRoot?.querySelector('pf-new-project-dialog')?.open).toBe(true);
});
+
+ it('opens guided setup when the project browser requests it', async () => {
+ await import('../../../src/components/app/pixel-forge-app');
+
+ const element = document.createElement('pixel-forge-app') as HTMLElement & {
+ hasLibraryProject: boolean;
+ showPaintByNumberDialog: boolean;
+ showProjectBrowser: boolean;
+ updateComplete: Promise;
+ };
+
+ document.body.append(element);
+ await element.updateComplete;
+
+ element.hasLibraryProject = true;
+ element.showProjectBrowser = true;
+ element.showPaintByNumberDialog = false;
+ await element.updateComplete;
+
+ const browser = element.shadowRoot?.querySelector('pf-project-browser');
+ browser?.dispatchEvent(
+ new CustomEvent('show-paint-by-number-dialog', { bubbles: true, composed: true })
+ );
+ await element.updateComplete;
+
+ expect(element.showProjectBrowser).toBe(false);
+ expect(element.showPaintByNumberDialog).toBe(true);
+ expect(element.shadowRoot?.querySelector('pf-project-browser')).toBeNull();
+ expect(element.shadowRoot?.querySelector('pf-paint-by-number-dialog')?.open).toBe(true);
+ });
});
diff --git a/tests/components/canvas/pf-guided-number-overlay.test.ts b/tests/components/canvas/pf-guided-number-overlay.test.ts
index 3793b15..b218f3a 100644
--- a/tests/components/canvas/pf-guided-number-overlay.test.ts
+++ b/tests/components/canvas/pf-guided-number-overlay.test.ts
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
collectVisibleGuidedNumberCells,
+ collectVisibleGuidedTargetCells,
type PFGuidedNumberOverlay,
} from '../../../src/components/canvas/pf-guided-number-overlay';
import '../../../src/components/canvas/pf-canvas-viewport';
@@ -56,6 +57,22 @@ describe('guided number overlay', () => {
})).toEqual([]);
});
+ it('plans a target preview at any zoom without reading painted pixels', () => {
+ const cells = collectVisibleGuidedTargetCells(
+ Uint8Array.from([1, 0, 2]),
+ 3,
+ {
+ zoom: 4,
+ panX: 0,
+ panY: 0,
+ viewportWidth: 12,
+ viewportHeight: 4,
+ },
+ );
+
+ expect(cells.map((cell) => cell.guideNumber)).toEqual([1, 2]);
+ });
+
it('shows a DOM hint below the number threshold', async () => {
const context = createProjectContext();
context.guidedDrawing.start({
@@ -85,6 +102,11 @@ describe('guided number overlay', () => {
await element.updateComplete;
expect(element.shadowRoot?.textContent).not.toContain('Zoom in to see guide numbers');
+ context.viewport.setZoom(8);
+ context.guidedDrawing.toggleNumbers();
+ await element.updateComplete;
+ expect(element.shadowRoot?.textContent).not.toContain('Zoom in to see guide numbers');
+
context.dispose();
});
diff --git a/tests/components/color/pf-guided-palette.test.ts b/tests/components/color/pf-guided-palette.test.ts
index aa35cee..1ec9d09 100644
--- a/tests/components/color/pf-guided-palette.test.ts
+++ b/tests/components/color/pf-guided-palette.test.ts
@@ -1,4 +1,12 @@
-import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const autoSaveServiceMock = vi.hoisted(() => ({
+ saveNow: vi.fn(),
+}));
+
+vi.mock('../../../src/services/auto-save', () => ({
+ autoSaveService: autoSaveServiceMock,
+}));
import type { PFGuidedPalette } from '../../../src/components/color/palette-panel/pf-guided-palette';
import '../../../src/components/color/palette-panel/pf-guided-palette';
import '../../../src/components/color/pf-palette-panel';
@@ -12,6 +20,10 @@ describe('guided palette', () => {
HTMLElement.prototype.hidePopover = () => {};
});
+ beforeEach(() => {
+ autoSaveServiceMock.saveNow.mockResolvedValue(undefined);
+ });
+
afterAll(() => {
if (originalHidePopover) {
HTMLElement.prototype.hidePopover = originalHidePopover;
@@ -46,6 +58,19 @@ describe('guided palette', () => {
'Canvas, palette, layers, and frames stay fixed',
);
+ const viewButtons = [
+ ...element.shadowRoot!.querySelectorAll('.view-controls button'),
+ ];
+ expect(viewButtons.map((button) => button.textContent?.trim()))
+ .toEqual(['Hide numbers', 'Preview target']);
+ viewButtons[0].click();
+ viewButtons[1].click();
+ await element.updateComplete;
+ expect(context.guidedDrawing.numbersVisible.value).toBe(false);
+ expect(context.guidedDrawing.targetPreviewVisible.value).toBe(true);
+ expect(viewButtons.map((button) => button.textContent?.trim()))
+ .toEqual(['Show numbers', 'Hide target']);
+
buttons[1].click();
await element.updateComplete;
expect(context.colors.primaryColor.value).toBe('#eeeeee');
@@ -92,8 +117,72 @@ describe('guided palette', () => {
guidedContext.dispose();
normalContext.dispose();
});
+
+ it('finishes guidance at partial coverage without touching art or history', async () => {
+ const context = createGuidedContext();
+ const historyEntry = {
+ id: 'stroke',
+ name: 'Pencil',
+ execute: vi.fn(),
+ undo: vi.fn(),
+ };
+ context.history.undoStack.value = [historyEntry];
+ const resetView = vi.spyOn(context.viewport, 'resetView');
+ setActiveProjectContext(context);
+ const element = document.createElement('pf-guided-palette') as PFGuidedPalette;
+ document.body.append(element);
+ await element.updateComplete;
+
+ const pixelsBefore = getPaintingPixels(context);
+ element.shadowRoot?.querySelector('.finish-guidance')?.click();
+ await element.updateComplete;
+ expect(element.shadowRoot?.textContent).toContain('Your drawing is 50% covered');
+
+ element.shadowRoot?.querySelector('.finish-confirm')?.click();
+ await settle(element);
+
+ expect(autoSaveServiceMock.saveNow).toHaveBeenCalledWith(context);
+ expect(context.guidedDrawing.active).toBe(false);
+ expect(context.history.undoStack.value).toEqual([historyEntry]);
+ expect(getPaintingPixels(context)).toEqual(pixelsBefore);
+ expect(resetView).toHaveBeenCalledTimes(1);
+
+ context.dispose();
+ });
+
+ it('keeps guidance active when the finish save fails', async () => {
+ const context = createGuidedContext();
+ setActiveProjectContext(context);
+ autoSaveServiceMock.saveNow.mockRejectedValueOnce(new Error('storage failed'));
+ const element = document.createElement('pf-guided-palette') as PFGuidedPalette;
+ document.body.append(element);
+ await element.updateComplete;
+
+ element.shadowRoot?.querySelector('.finish-guidance')?.click();
+ await element.updateComplete;
+ element.shadowRoot?.querySelector('.finish-confirm')?.click();
+ await settle(element);
+
+ expect(context.guidedDrawing.active).toBe(true);
+ expect(context.guidedDrawing.finishPending.value).toBe(false);
+ expect(element.shadowRoot?.textContent).toContain('Your guide is still active');
+
+ context.dispose();
+ });
});
+async function settle(element: PFGuidedPalette) {
+ await Promise.resolve();
+ await element.updateComplete;
+ await Promise.resolve();
+ await element.updateComplete;
+}
+
+function getPaintingPixels(context: ReturnType) {
+ const layer = context.layers.layers.value.find((item) => item.type === 'image');
+ return [...(layer?.canvas?.getContext('2d')?.getImageData(0, 0, 2, 1).data ?? [])];
+}
+
function createGuidedContext() {
const context = createProjectContext();
const layer = context.layers.layers.value.find((item) => item.type === 'image');
diff --git a/tests/services/paint-by-number/guided-resume.test.ts b/tests/services/paint-by-number/guided-resume.test.ts
new file mode 100644
index 0000000..dcfd828
--- /dev/null
+++ b/tests/services/paint-by-number/guided-resume.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest';
+import { analyzeGuidedDrawingProgress } from '../../../src/services/paint-by-number/guided-progress';
+import { createGuidedProjectFile } from '../../../src/services/paint-by-number/guided-project';
+import { createProjectContext } from '../../../src/stores/project-context';
+
+describe('guided project resume', () => {
+ it('restores the durable guide and derives progress from saved painted pixels', async () => {
+ const file = createGuidedProjectFile({
+ guide: {
+ palette: ['#111111', '#eeeeee'],
+ target: Uint8Array.from([1, 2]),
+ width: 2,
+ height: 1,
+ complexity: {
+ paintableCells: 2,
+ paletteSize: 2,
+ isolatedCells: 2,
+ simplifiedCells: 0,
+ },
+ },
+ settings: {
+ longSide: 2,
+ paletteSource: 'generated',
+ maxColors: 2,
+ mapping: 'luminance',
+ simplifyIsolatedPixels: true,
+ },
+ sourceName: 'study.png',
+ createdAt: 123,
+ });
+ const editingContext = createProjectContext();
+ await editingContext.project.loadProject(file);
+
+ const layerId = editingContext.layers.layers.value[0].id;
+ const frameId = editingContext.animation.frames.value[0].id;
+ editingContext.animation.updateCelIndexBuffer(
+ layerId,
+ frameId,
+ Uint8Array.from([1, 0]),
+ );
+ const saved = await editingContext.project.saveProject();
+
+ const resumedContext = createProjectContext();
+ await resumedContext.project.loadProject(saved);
+ const resumedLayerId = resumedContext.layers.layers.value[0].id;
+ const resumedFrameId = resumedContext.animation.frames.value[0].id;
+ const resumedBuffer = resumedContext.animation.getCelIndexBuffer(
+ resumedLayerId,
+ resumedFrameId,
+ );
+ const progress = analyzeGuidedDrawingProgress(
+ resumedContext.guidedDrawing.session.value!.target,
+ pixelsFromIndexBuffer(resumedBuffer!),
+ );
+
+ expect(resumedContext.guidedDrawing.session.value).toMatchObject({
+ target: Uint8Array.from([1, 2]),
+ guideColorCount: 2,
+ sourceName: 'study.png',
+ createdAt: 123,
+ settings: {
+ mapping: 'luminance',
+ simplifyIsolatedPixels: true,
+ },
+ });
+ expect(resumedContext.guidedDrawing.numbersVisible.value).toBe(true);
+ expect(resumedContext.guidedDrawing.targetPreviewVisible.value).toBe(false);
+ expect(resumedBuffer).toEqual(Uint8Array.from([1, 0]));
+ expect(progress).toMatchObject({ total: 2, covered: 1, remaining: 1, percentage: 50 });
+
+ editingContext.dispose();
+ resumedContext.dispose();
+ });
+});
+
+function pixelsFromIndexBuffer(indexBuffer: Uint8Array): Uint8ClampedArray {
+ const pixels = new Uint8ClampedArray(indexBuffer.length * 4);
+ for (let index = 0; index < indexBuffer.length; index += 1) {
+ if (indexBuffer[index] > 0) pixels[index * 4 + 3] = 255;
+ }
+ return pixels;
+}
diff --git a/tests/stores/guided-drawing-store.test.ts b/tests/stores/guided-drawing-store.test.ts
index ad02740..3bc11b0 100644
--- a/tests/stores/guided-drawing-store.test.ts
+++ b/tests/stores/guided-drawing-store.test.ts
@@ -59,4 +59,42 @@ describe('GuidedDrawingStore', () => {
expect(() => store.start({ ...createSession(), target: new Uint8Array([1]) }))
.toThrow('target does not match');
});
+
+ it('keeps temporary view choices out of the durable session', () => {
+ const store = createGuidedDrawingStore();
+ store.start(createSession());
+
+ store.toggleNumbers();
+ store.toggleTargetPreview();
+ expect(store.numbersVisible.value).toBe(false);
+ expect(store.targetPreviewVisible.value).toBe(true);
+ expect(store.toFile()).not.toHaveProperty('numbersVisible');
+
+ store.load({ ...createSession(), target: [1, 2] });
+ expect(store.numbersVisible.value).toBe(true);
+ expect(store.targetPreviewVisible.value).toBe(false);
+
+ store.toggleNumbers();
+ store.toggleTargetPreview();
+ store.clear();
+ expect(store.numbersVisible.value).toBe(true);
+ expect(store.targetPreviewVisible.value).toBe(false);
+ });
+
+ it('omits guidance only while an explicit finish is being saved', () => {
+ const store = createGuidedDrawingStore();
+ store.start(createSession());
+
+ store.beginFinish();
+ expect(store.active).toBe(true);
+ expect(store.toFile()).toBeUndefined();
+
+ store.cancelFinish();
+ expect(store.toFile()?.target).toEqual([1, 2]);
+
+ store.beginFinish();
+ store.completeFinish();
+ expect(store.active).toBe(false);
+ expect(store.finishPending.value).toBe(false);
+ });
});