From e842dedde0250f9d66e704767c8df741bb6bea39 Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 11 Jul 2026 06:02:44 +0200 Subject: [PATCH 1/8] feat(files): import projects without replacing active work (#330) --- src/services/project-file-import.ts | 71 ++++++++++++++ src/services/project-library.ts | 22 ++++- tests/services/project-file-import.test.ts | 107 +++++++++++++++++++++ tests/services/project-library.test.ts | 29 +++++- 4 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 src/services/project-file-import.ts create mode 100644 tests/services/project-file-import.test.ts diff --git a/src/services/project-file-import.ts b/src/services/project-file-import.ts new file mode 100644 index 0000000..4ef69bb --- /dev/null +++ b/src/services/project-file-import.ts @@ -0,0 +1,71 @@ +import type { ProjectFileInput } from '../types/project'; +import { projectLibrary, type ProjectLibraryService } from './project-library'; +import { workspaceStore, type WorkspaceStore } from '../stores/workspace'; + +type ImportProjectLibrary = Pick; +type ImportWorkspace = Pick; + +export interface ProjectFileImportResult { + projectId: string; + opened: boolean; + message?: string; +} + +export interface ProjectFileImportDependencies { + projectLibrary: ImportProjectLibrary; + workspace: ImportWorkspace; +} + +export class ProjectFileImportService { + private readonly projectLibrary: ImportProjectLibrary; + private readonly workspace: ImportWorkspace; + + constructor(dependencies: ProjectFileImportDependencies) { + this.projectLibrary = dependencies.projectLibrary; + this.workspace = dependencies.workspace; + } + + async importFile(file: File): Promise { + const project = await decodeProjectFile(file); + const projectId = await this.projectLibrary.importProjectFile(project); + const openResult = await this.workspace.openProject(projectId, { + activate: true, + saveActiveContext: true, + }); + + if (openResult.ok) { + return { projectId, opened: true }; + } + + return { + projectId, + opened: false, + message: `Imported ${project.name || file.name} into Project Library. ${openResult.message}`, + }; + } +} + +export const projectFileImportService = new ProjectFileImportService({ + projectLibrary, + workspace: workspaceStore, +}); + +async function decodeProjectFile(file: File): Promise { + const extension = getFileExtension(file.name); + + if (extension === 'pf') { + const { default: pako } = await import('pako'); + const compressed = new Uint8Array(await file.arrayBuffer()); + return JSON.parse(pako.inflate(compressed, { to: 'string' })) as ProjectFileInput; + } + + if (extension === 'json') { + return JSON.parse(await file.text()) as ProjectFileInput; + } + + throw new Error(`Unsupported project file: ${file.name}`); +} + +function getFileExtension(filename: string): string { + return filename.split('.').pop()?.toLowerCase() ?? ''; +} diff --git a/src/services/project-library.ts b/src/services/project-library.ts index c82df6f..b22c29f 100644 --- a/src/services/project-library.ts +++ b/src/services/project-library.ts @@ -12,7 +12,13 @@ import { } from './project-defaults'; import { defaultProjectContext, type ProjectContext } from '../stores/project-context'; import { DB32_COLORS, PALETTE_BY_ID } from '../stores/palette/types'; -import { PROJECT_VERSION, type ProjectFile } from '../types/project'; +import { + PROJECT_VERSION, + type ProjectFile, + type ProjectFileInput, +} from '../types/project'; +import { normalizeProjectFileImageData } from '../serialization/project-data'; +import { migrateProjectFileForLoad } from '../serialization/project-load'; export type CreateProjectOptions = { name?: string; @@ -85,6 +91,20 @@ export class ProjectLibraryService { return id; } + /** Persist an imported project under a fresh identity without opening it. */ + async importProjectFile(project: ProjectFileInput): Promise { + const id = uuidv4(); + const canonicalProject = migrateProjectFileForLoad( + normalizeProjectFileImageData(structuredClone(project)) + ); + + await this.repository.save(id, { + ...canonicalProject, + version: PROJECT_VERSION, + }); + return id; + } + async duplicateProject(id: string): Promise { const project = await this.getProjectOrThrow(id); const sourceMeta = await this.findProjectMeta(id); diff --git a/tests/services/project-file-import.test.ts b/tests/services/project-file-import.test.ts new file mode 100644 index 0000000..c1280c8 --- /dev/null +++ b/tests/services/project-file-import.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest'; +import pako from 'pako'; +import { ProjectFileImportService } from '../../src/services/project-file-import'; +import { PROJECT_VERSION, type ProjectFile } from '../../src/types/project'; + +function project(name = 'Imported project'): ProjectFile { + const layerId = 'layer-1'; + return { + version: PROJECT_VERSION, + name, + width: 2, + height: 2, + palette: ['#000000', '#ffffff'], + layers: [ + { + id: layerId, + name: 'Layer 1', + type: 'image', + visible: true, + opacity: 255, + blendMode: 'normal', + continuous: false, + data: new Uint8Array(0), + }, + ], + frames: [ + { + id: 'frame-1', + duration: 100, + cels: [{ layerId, data: new Uint8Array(0) }], + }, + ], + animation: { fps: 12, currentFrameIndex: 0 }, + tags: [], + }; +} + +function dependencies(opened = true) { + const projectLibrary = { + importProjectFile: vi.fn(async () => 'imported-id'), + }; + const workspace = { + openProject: vi.fn(async () => + opened + ? { + ok: true as const, + projectId: 'imported-id', + item: {} as never, + } + : { + ok: false as const, + reason: 'tab-limit-reached' as const, + message: 'The workspace can keep up to 8 projects open at once.', + } + ), + }; + + return { projectLibrary, workspace }; +} + +describe('ProjectFileImportService', () => { + it('stores a compressed Pixel Forge project and opens its new identity', async () => { + const source = project('Portrait'); + const file = new File([pako.deflate(JSON.stringify(source))], 'portrait.pf'); + const deps = dependencies(); + const service = new ProjectFileImportService(deps); + + const result = await service.importFile(file); + + expect(result).toEqual({ projectId: 'imported-id', opened: true }); + expect(deps.projectLibrary.importProjectFile).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Portrait' }) + ); + expect(deps.workspace.openProject).toHaveBeenCalledWith('imported-id', { + activate: true, + saveActiveContext: true, + }); + }); + + it('keeps a valid import in the library when the workspace is full', async () => { + const file = new File([JSON.stringify(project())], 'drawing.json'); + const deps = dependencies(false); + const service = new ProjectFileImportService(deps); + + const result = await service.importFile(file); + + expect(result).toMatchObject({ + projectId: 'imported-id', + opened: false, + message: expect.stringContaining('Imported project'), + }); + expect(deps.projectLibrary.importProjectFile).toHaveBeenCalledOnce(); + }); + + it('does not persist invalid or unsupported input', async () => { + const deps = dependencies(); + const service = new ProjectFileImportService(deps); + + await expect(service.importFile(new File(['not zlib'], 'broken.pf'))).rejects.toThrow(); + await expect(service.importFile(new File(['{}'], 'notes.txt'))).rejects.toThrow( + 'Unsupported project file' + ); + + expect(deps.projectLibrary.importProjectFile).not.toHaveBeenCalled(); + expect(deps.workspace.openProject).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/services/project-library.test.ts b/tests/services/project-library.test.ts index 9930bc6..a9023de 100644 --- a/tests/services/project-library.test.ts +++ b/tests/services/project-library.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ProjectFile } from '../../src/types/project'; +import type { ProjectFile, ProjectFileInput } from '../../src/types/project'; const repositoryMock = vi.hoisted(() => ({ list: vi.fn(), @@ -241,6 +241,33 @@ describe('ProjectLibraryService', () => { expect(projects.get(id)?.guidedDrawing).toEqual(project.guidedDrawing); }); + it('normalizes and stores an imported project without opening it', async () => { + const legacyProject = makeProject('Imported project'); + const input = { + ...legacyProject, + version: '3.2.0', + palette: ['#000000'], + ephemeralPalette: ['#ffffff'], + layers: legacyProject.layers.map((layer) => ({ + ...layer, + data: { 0: 1, 1: 2 }, + })), + } as ProjectFileInput; + + const id = await service.importProjectFile(input); + + expect(projectStore.id.value).toBe('current'); + expect(projectStore.name.value).toBe('Current'); + expect(projects.get(id)).toMatchObject({ + version: PROJECT_VERSION, + name: 'Imported project', + palette: ['#000000', '#ffffff'], + }); + expect(projects.get(id)?.layers[0].data).toEqual(Uint8Array.from([1, 2])); + expect(projects.get(id)).not.toHaveProperty('ephemeralPalette'); + expect(repository.setLastOpenedProjectId).not.toHaveBeenCalled(); + }); + it('saves edits to the open project before loading another project', async () => { projects.set('a', makeProject('Project A')); projects.set('b', makeProject('Project B')); From 35aae0c28b291fa27d29c9b1de5c00855caa12f9 Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 11 Jul 2026 09:08:24 +0200 Subject: [PATCH 2/8] refactor(files): isolate Aseprite project imports (#331) --- src/services/aseprite-service.ts | 103 ++++++++++------------ src/services/project-file-import.ts | 38 ++++++++- tests/services/aseprite-service.test.ts | 108 ++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 64 deletions(-) create mode 100644 tests/services/aseprite-service.test.ts diff --git a/src/services/aseprite-service.ts b/src/services/aseprite-service.ts index f6aabea..3b57090 100644 --- a/src/services/aseprite-service.ts +++ b/src/services/aseprite-service.ts @@ -1,9 +1,11 @@ -import { parseAseFile, celToImageData } from "./aseprite-parser"; -import { exportAseFile } from "./aseprite-writer"; -import { projectStore } from "../stores/project"; -import { layerStore } from "../stores/layers"; -import { animationStore } from "../stores/animation"; -import type { BlendMode } from "../types/layer"; +import { parseAseFile, celToImageData } from './aseprite-parser'; +import { exportAseFile } from './aseprite-writer'; +import { + getActiveProjectContext, + type ProjectAnimationStore, + type ProjectContext, +} from '../stores/project-context'; +import type { BlendMode } from '../types/layer'; // Track linked cels to establish links after all frames are created interface LinkedCelRecord { @@ -13,23 +15,27 @@ interface LinkedCelRecord { } /** - * Import an Aseprite file into the current project. - * This replaces the current project content. + * Import an Aseprite file into one project context. + * Existing callers keep targeting the active project by default. */ -export async function importAseFile(buffer: ArrayBuffer): Promise { +export async function importAseFile( + buffer: ArrayBuffer, + context: ProjectContext = getActiveProjectContext() +): Promise { const aseFile = parseAseFile(buffer); + const { animation, layers, project } = context; // Set project dimensions - projectStore.setSize(aseFile.header.width, aseFile.header.height); + project.setSize(aseFile.header.width, aseFile.header.height); // Clear existing frames (keep one, deleteFrame won't delete the last) - while (animationStore.frames.value.length > 1) { - animationStore.deleteFrame(animationStore.frames.value[0].id); + while (animation.frames.value.length > 1) { + animation.deleteFrame(animation.frames.value[0].id); } // Clear existing layers (this will leave us with no layers temporarily) - while (layerStore.layers.value.length > 0) { - layerStore.removeLayer(layerStore.layers.value[0].id); + while (layers.layers.value.length > 0) { + layers.removeLayer(layers.layers.value[0].id); } // Create layers (filter out groups, only use image layers) @@ -39,31 +45,20 @@ export async function importAseFile(buffer: ArrayBuffer): Promise { imageLayers.forEach((aseLayer) => { // Find original index in the full layers array const originalIndex = aseFile.layers.indexOf(aseLayer); - const layer = layerStore.addLayer( - aseLayer.name, - aseFile.header.width, - aseFile.header.height - ); + const layer = layers.addLayer(aseLayer.name, aseFile.header.width, aseFile.header.height); layer.visible = (aseLayer.flags & 1) !== 0; layer.opacity = aseLayer.opacity; // Map blend mode - const blendModes = [ - "normal", - "multiply", - "screen", - "overlay", - "darken", - "lighten", - ]; - layer.blendMode = (blendModes[aseLayer.blendMode] || "normal") as BlendMode; + const blendModes = ['normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten']; + layer.blendMode = (blendModes[aseLayer.blendMode] || 'normal') as BlendMode; layerIdMap.set(originalIndex, layer.id); }); // Track the old frame we need to delete later - const oldFrameId = animationStore.frames.value[0]?.id; + const oldFrameId = animation.frames.value[0]?.id; // Track linked cels to establish links after all frames exist const linkedCelRecords: LinkedCelRecord[] = []; @@ -74,8 +69,8 @@ export async function importAseFile(buffer: ArrayBuffer): Promise { // Create frames and populate cels aseFile.frames.forEach((aseFrame, frameIndex) => { // Add frame (don't duplicate) - animationStore.addFrame(false); - const frames = animationStore.frames.value; + animation.addFrame(false); + const frames = animation.frames.value; const frame = frames[frames.length - 1]; frame.duration = aseFrame.duration; @@ -100,15 +95,15 @@ export async function importAseFile(buffer: ArrayBuffer): Promise { // Regular or compressed cel - draw to canvas const imageData = celToImageData(cel, aseFile.header, aseFile.palette); if (imageData) { - drawCelToCanvas(frame.id, layerId, cel.x, cel.y, imageData); + drawCelToCanvas(animation, frame.id, layerId, cel.x, cel.y, imageData); } } }); }); // Now delete the old placeholder frame (we have new ones now) - if (oldFrameId && animationStore.frames.value.length > 1) { - animationStore.deleteFrame(oldFrameId); + if (oldFrameId && animation.frames.value.length > 1) { + animation.deleteFrame(oldFrameId); } // Establish hard links for linked cels @@ -121,14 +116,8 @@ export async function importAseFile(buffer: ArrayBuffer): Promise { if (!sourceFrameId || !targetFrameId) continue; const groupKey = `${record.sourceFrameIndex}:${record.layerId}`; - const sourceCelKey = animationStore.getCelKey( - record.layerId, - sourceFrameId - ); - const targetCelKey = animationStore.getCelKey( - record.layerId, - targetFrameId - ); + const sourceCelKey = animation.getCelKey(record.layerId, sourceFrameId); + const targetCelKey = animation.getCelKey(record.layerId, targetFrameId); if (!linkGroups.has(groupKey)) { // Start with source cel @@ -140,30 +129,23 @@ export async function importAseFile(buffer: ArrayBuffer): Promise { // Apply hard links for (const celKeys of linkGroups.values()) { if (celKeys.length >= 2) { - animationStore.linkCels(celKeys, "hard"); + animation.linkCels(celKeys, 'hard'); } } // Import tags for (const aseTag of aseFile.tags) { // Convert RGB to hex color - const color = `#${aseTag.color.r + const color = `#${aseTag.color.r.toString(16).padStart(2, '0')}${aseTag.color.g .toString(16) - .padStart(2, "0")}${aseTag.color.g - .toString(16) - .padStart(2, "0")}${aseTag.color.b.toString(16).padStart(2, "0")}`; - animationStore.addFrameTag( - aseTag.name, - color, - aseTag.fromFrame, - aseTag.toFrame - ); + .padStart(2, '0')}${aseTag.color.b.toString(16).padStart(2, '0')}`; + animation.addFrameTag(aseTag.name, color, aseTag.fromFrame, aseTag.toFrame); } // Go to first frame - const frames = animationStore.frames.value; + const frames = animation.frames.value; if (frames.length > 0) { - animationStore.goToFrame(frames[0].id); + animation.goToFrame(frames[0].id); } } @@ -172,6 +154,7 @@ export async function importAseFile(buffer: ArrayBuffer): Promise { * Ensures the cel has its own canvas (breaks soft links to shared empty canvas). */ function drawCelToCanvas( + animation: ProjectAnimationStore, frameId: string, layerId: string, x: number, @@ -179,18 +162,18 @@ function drawCelToCanvas( imageData: ImageData ): void { // Break any soft link to shared empty canvas - this cel needs its own canvas - animationStore.ensureUnlinkedForEdit(layerId, frameId); + animation.ensureUnlinkedForEdit(layerId, frameId); - const celCanvas = animationStore.getCelCanvas(frameId, layerId); + const celCanvas = animation.getCelCanvas(frameId, layerId); if (!celCanvas) return; - const ctx = celCanvas.getContext("2d")!; + const ctx = celCanvas.getContext('2d')!; // Create temporary canvas to hold the image data - const tempCanvas = document.createElement("canvas"); + const tempCanvas = document.createElement('canvas'); tempCanvas.width = imageData.width; tempCanvas.height = imageData.height; - const tempCtx = tempCanvas.getContext("2d")!; + const tempCtx = tempCanvas.getContext('2d')!; tempCtx.putImageData(imageData, 0, 0); // Draw at position diff --git a/src/services/project-file-import.ts b/src/services/project-file-import.ts index 4ef69bb..f63c6e5 100644 --- a/src/services/project-file-import.ts +++ b/src/services/project-file-import.ts @@ -1,6 +1,7 @@ import type { ProjectFileInput } from '../types/project'; import { projectLibrary, type ProjectLibraryService } from './project-library'; import { workspaceStore, type WorkspaceStore } from '../stores/workspace'; +import { createProjectContext } from '../stores/project-context'; type ImportProjectLibrary = Pick; type ImportWorkspace = Pick; @@ -26,7 +27,7 @@ export class ProjectFileImportService { } async importFile(file: File): Promise { - const project = await decodeProjectFile(file); + const project = await this.decodeProjectFile(file); const projectId = await this.projectLibrary.importProjectFile(project); const openResult = await this.workspace.openProject(projectId, { activate: true, @@ -43,6 +44,16 @@ export class ProjectFileImportService { message: `Imported ${project.name || file.name} into Project Library. ${openResult.message}`, }; } + + private async decodeProjectFile(file: File): Promise { + const extension = getFileExtension(file.name); + + if (extension === 'ase' || extension === 'aseprite') { + return await decodeAsepriteProjectFile(file); + } + + return await decodePixelForgeProjectFile(file, extension); + } } export const projectFileImportService = new ProjectFileImportService({ @@ -50,9 +61,10 @@ export const projectFileImportService = new ProjectFileImportService({ workspace: workspaceStore, }); -async function decodeProjectFile(file: File): Promise { - const extension = getFileExtension(file.name); - +async function decodePixelForgeProjectFile( + file: File, + extension: string +): Promise { if (extension === 'pf') { const { default: pako } = await import('pako'); const compressed = new Uint8Array(await file.arrayBuffer()); @@ -66,6 +78,24 @@ async function decodeProjectFile(file: File): Promise { throw new Error(`Unsupported project file: ${file.name}`); } +async function decodeAsepriteProjectFile(file: File): Promise { + const context = createProjectContext(); + + try { + const { importAseFile } = await import('./aseprite-service'); + await importAseFile(await file.arrayBuffer(), context); + context.project.name.value = getFileStem(file.name); + return await context.project.saveProject(); + } finally { + context.dispose(); + } +} + function getFileExtension(filename: string): string { return filename.split('.').pop()?.toLowerCase() ?? ''; } + +function getFileStem(filename: string): string { + const extensionStart = filename.lastIndexOf('.'); + return extensionStart > 0 ? filename.slice(0, extensionStart) : filename; +} diff --git a/tests/services/aseprite-service.test.ts b/tests/services/aseprite-service.test.ts new file mode 100644 index 0000000..747d6f7 --- /dev/null +++ b/tests/services/aseprite-service.test.ts @@ -0,0 +1,108 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import { importAseFile } from '../../src/services/aseprite-service'; +import { parseAseFile } from '../../src/services/aseprite-parser'; +import { ProjectFileImportService } from '../../src/services/project-file-import'; +import { + createProjectContext, + restoreDefaultProjectContext, + setActiveProjectContext, +} from '../../src/stores/project-context'; + +const canvasContext = new Proxy( + { imageSmoothingEnabled: false }, + { + get(target, key) { + if (key in target) return target[key as keyof typeof target]; + return vi.fn(); + }, + set(target, key, value) { + (target as Record)[key] = value; + return true; + }, + } +); + +HTMLCanvasElement.prototype.getContext = vi.fn(() => canvasContext); +HTMLCanvasElement.prototype.toBlob = vi.fn((callback) => { + callback(new Blob([new Uint8Array(0)], { type: 'image/png' })); +}); +vi.stubGlobal( + 'ImageData', + class { + readonly data: Uint8ClampedArray; + + constructor( + readonly width: number, + readonly height: number + ) { + this.data = new Uint8ClampedArray(width * height * 4); + } + } +); + +afterAll(() => { + vi.unstubAllGlobals(); +}); + +afterEach(() => { + restoreDefaultProjectContext(); + vi.clearAllMocks(); +}); + +describe('Aseprite import', () => { + it('imports a real file into only the supplied project context', async () => { + const sourceContext = createProjectContext(); + const targetContext = createProjectContext(); + const bytes = await readFile(resolve(process.cwd(), 'tests/Sprite-0001.ase')); + const buffer = bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength + ) as ArrayBuffer; + const parsed = parseAseFile(buffer); + + try { + sourceContext.project.name.value = 'Drawing in progress'; + sourceContext.project.setSize(17, 19); + setActiveProjectContext(sourceContext); + + await importAseFile(buffer, targetContext); + + expect(targetContext.project.width.value).toBe(parsed.header.width); + expect(targetContext.project.height.value).toBe(parsed.header.height); + expect(targetContext.layers.layers.value.map((layer) => layer.name)).toEqual( + parsed.layers.filter((layer) => layer.type === 0).map((layer) => layer.name) + ); + expect(sourceContext.project.name.value).toBe('Drawing in progress'); + expect(sourceContext.project.width.value).toBe(17); + expect(sourceContext.project.height.value).toBe(19); + } finally { + sourceContext.dispose(); + targetContext.dispose(); + } + }); + + it('converts a real Aseprite file into a named durable project', async () => { + const bytes = await readFile(resolve(process.cwd(), 'tests/Sprite-0001.ase')); + const file = new File([new Uint8Array(bytes)], 'tiny-fighter.aseprite'); + const projectLibrary = { + importProjectFile: vi.fn(async () => 'aseprite-project'), + }; + const workspace = { + openProject: vi.fn(async () => ({ + ok: true as const, + projectId: 'aseprite-project', + item: {} as never, + })), + }; + const service = new ProjectFileImportService({ projectLibrary, workspace }); + + const result = await service.importFile(file); + + expect(result).toEqual({ projectId: 'aseprite-project', opened: true }); + expect(projectLibrary.importProjectFile).toHaveBeenCalledWith( + expect.objectContaining({ name: 'tiny-fighter' }) + ); + }); +}); From 169b8e9f9ebd31db9d11c978c03b23d4189f53b8 Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 11 Jul 2026 09:14:17 +0200 Subject: [PATCH 3/8] feat(pwa): open project files from the operating system (#332) --- src/components/app/pixel-forge-app.ts | 5 +- src/services/project-file-import.ts | 37 +++++++++++ src/services/pwa-file-handling.ts | 55 +++++++++++++++++ src/types/file-handling.d.ts | 12 ++++ tests/components/app/pixel-forge-app.test.ts | 30 +++++++++ tests/pwa-manifest.test.ts | 41 +++++++++++++ tests/services/project-file-import.test.ts | 43 +++++++++++++ tests/services/pwa-file-handling.test.ts | 64 ++++++++++++++++++++ vite.config.ts | 13 ++++ 9 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 src/services/pwa-file-handling.ts create mode 100644 src/types/file-handling.d.ts create mode 100644 tests/pwa-manifest.test.ts create mode 100644 tests/services/pwa-file-handling.test.ts diff --git a/src/components/app/pixel-forge-app.ts b/src/components/app/pixel-forge-app.ts index 7d8cf47..19eea16 100644 --- a/src/components/app/pixel-forge-app.ts +++ b/src/components/app/pixel-forge-app.ts @@ -37,6 +37,7 @@ import { historyStore } from "../../stores/history"; import { projectRepository } from "../../services/persistence/indexed-db"; import { autoSaveService } from "../../services/auto-save"; import { projectLibrary } from "../../services/project-library"; +import { pwaFileHandling } from "../../services/pwa-file-handling"; import type { ToolType as _ToolType } from "../../stores/tools"; import { panelStore } from "../../stores/panels"; import { log } from "../../utils/log"; @@ -306,7 +307,9 @@ export class PixelForgeApp extends BaseComponent { ); // Load saved project from IndexedDB after listeners are ready. - this.loadSavedProject(); + void this.loadSavedProject().finally(() => { + pwaFileHandling.registerLaunchConsumer(); + }); } private handleShowWarningToast = (e: CustomEvent<{ message: string }>) => { diff --git a/src/services/project-file-import.ts b/src/services/project-file-import.ts index f63c6e5..66a95d6 100644 --- a/src/services/project-file-import.ts +++ b/src/services/project-file-import.ts @@ -12,6 +12,18 @@ export interface ProjectFileImportResult { message?: string; } +export type ProjectFileImportOutcome = + | { + file: File; + ok: true; + result: ProjectFileImportResult; + } + | { + file: File; + ok: false; + error: unknown; + }; + export interface ProjectFileImportDependencies { projectLibrary: ImportProjectLibrary; workspace: ImportWorkspace; @@ -20,6 +32,7 @@ export interface ProjectFileImportDependencies { export class ProjectFileImportService { private readonly projectLibrary: ImportProjectLibrary; private readonly workspace: ImportWorkspace; + private importQueue: Promise = Promise.resolve(); constructor(dependencies: ProjectFileImportDependencies) { this.projectLibrary = dependencies.projectLibrary; @@ -45,6 +58,16 @@ export class ProjectFileImportService { }; } + importFiles(files: Iterable): Promise { + const batch = Array.from(files); + const importBatch = this.importQueue.then(() => this.importBatch(batch)); + this.importQueue = importBatch.then( + () => undefined, + () => undefined + ); + return importBatch; + } + private async decodeProjectFile(file: File): Promise { const extension = getFileExtension(file.name); @@ -54,6 +77,20 @@ export class ProjectFileImportService { return await decodePixelForgeProjectFile(file, extension); } + + private async importBatch(files: File[]): Promise { + const outcomes: ProjectFileImportOutcome[] = []; + + for (const file of files) { + try { + outcomes.push({ file, ok: true, result: await this.importFile(file) }); + } catch (error) { + outcomes.push({ file, ok: false, error }); + } + } + + return outcomes; + } } export const projectFileImportService = new ProjectFileImportService({ diff --git a/src/services/pwa-file-handling.ts b/src/services/pwa-file-handling.ts new file mode 100644 index 0000000..130813a --- /dev/null +++ b/src/services/pwa-file-handling.ts @@ -0,0 +1,55 @@ +import { + projectFileImportService, + type ProjectFileImportOutcome, + type ProjectFileImportService, +} from './project-file-import'; +import { log } from '../utils/log'; + +type ProjectFileBatchImporter = Pick; + +export interface PwaFileLaunchResult { + outcomes: ProjectFileImportOutcome[]; + unreadableFiles: string[]; +} + +export class PwaFileHandlingService { + private readonly importer: ProjectFileBatchImporter; + private launchConsumerRegistered = false; + + constructor(importer: ProjectFileBatchImporter) { + this.importer = importer; + } + + registerLaunchConsumer(launchQueue = window.launchQueue): boolean { + if (!launchQueue || this.launchConsumerRegistered) return false; + + launchQueue.setConsumer((launchParams) => { + void this.importLaunch(launchParams).catch((error) => { + log.error('Failed to handle an external file launch:', error); + }); + }); + this.launchConsumerRegistered = true; + return true; + } + + async importLaunch(launchParams: LaunchParams): Promise { + const files: File[] = []; + const unreadableFiles: string[] = []; + + for (const handle of launchParams.files ?? []) { + try { + files.push(await handle.getFile()); + } catch (error) { + unreadableFiles.push(handle.name); + log.warn(`Could not read ${handle.name} from the operating system:`, error); + } + } + + return { + outcomes: await this.importer.importFiles(files), + unreadableFiles, + }; + } +} + +export const pwaFileHandling = new PwaFileHandlingService(projectFileImportService); diff --git a/src/types/file-handling.d.ts b/src/types/file-handling.d.ts new file mode 100644 index 0000000..75a216d --- /dev/null +++ b/src/types/file-handling.d.ts @@ -0,0 +1,12 @@ +interface LaunchParams { + files: FileSystemFileHandle[]; + targetURL?: string; +} + +interface LaunchQueue { + setConsumer(consumer: (launchParams: LaunchParams) => void): void; +} + +interface Window { + launchQueue?: LaunchQueue; +} diff --git a/tests/components/app/pixel-forge-app.test.ts b/tests/components/app/pixel-forge-app.test.ts index ef62575..468ce30 100644 --- a/tests/components/app/pixel-forge-app.test.ts +++ b/tests/components/app/pixel-forge-app.test.ts @@ -24,6 +24,10 @@ const workspaceStoreMock = vi.hoisted(() => ({ restoreWorkspace: vi.fn(), })); +const pwaFileHandlingMock = vi.hoisted(() => ({ + registerLaunchConsumer: vi.fn(), +})); + const canvasContext = new Proxy( { imageSmoothingEnabled: false }, { @@ -54,6 +58,10 @@ vi.mock('../../../src/stores/workspace', () => ({ workspaceStore: workspaceStoreMock, })); +vi.mock('../../../src/services/pwa-file-handling', () => ({ + pwaFileHandling: pwaFileHandlingMock, +})); + describe('pixel-forge-app project dialogs', () => { beforeEach(() => { document.body.replaceChildren(); @@ -130,6 +138,28 @@ describe('pixel-forge-app project dialogs', () => { expect(element.shadowRoot?.querySelector('pf-drawing-canvas')).toBeTruthy(); }); + it('waits for workspace restoration before accepting operating-system files', async () => { + await import('../../../src/components/app/pixel-forge-app'); + let finishWorkspaceRead: ((value: null) => void) | undefined; + projectRepositoryMock.getWorkspaceState.mockImplementationOnce( + () => + new Promise((resolve) => { + finishWorkspaceRead = resolve; + }) + ); + const element = document.createElement('pixel-forge-app') as HTMLElement; + + document.body.append(element); + await Promise.resolve(); + + expect(pwaFileHandlingMock.registerLaunchConsumer).not.toHaveBeenCalled(); + + finishWorkspaceRead?.(null); + await vi.waitFor(() => { + expect(pwaFileHandlingMock.registerLaunchConsumer).toHaveBeenCalledOnce(); + }); + }); + it('opens the project browser from the project tab strip', async () => { await import('../../../src/components/app/pixel-forge-app'); diff --git a/tests/pwa-manifest.test.ts b/tests/pwa-manifest.test.ts new file mode 100644 index 0000000..29c1eb5 --- /dev/null +++ b/tests/pwa-manifest.test.ts @@ -0,0 +1,41 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { build } from 'vite'; + +let outputDirectory: string | undefined; + +afterEach(async () => { + if (outputDirectory) await rm(outputDirectory, { recursive: true, force: true }); + outputDirectory = undefined; +}); + +describe('generated PWA manifest', () => { + it('registers project formats with one focused application window', async () => { + outputDirectory = await mkdtemp(join(tmpdir(), 'pixel-forge-pwa-')); + await build({ + configFile: resolve(process.cwd(), 'vite.config.ts'), + logLevel: 'silent', + build: { + outDir: outputDirectory, + emptyOutDir: true, + }, + }); + const manifest = JSON.parse( + await readFile(join(outputDirectory, 'manifest.webmanifest'), 'utf8') + ); + + expect(manifest.file_handlers).toEqual([ + { + action: './', + accept: { + 'application/x-pixelforge': ['.pf'], + 'application/x-aseprite': ['.ase', '.aseprite'], + }, + launch_type: 'single-client', + }, + ]); + expect(manifest.launch_handler).toEqual({ client_mode: 'focus-existing' }); + }, 20_000); +}); diff --git a/tests/services/project-file-import.test.ts b/tests/services/project-file-import.test.ts index c1280c8..ac32aa8 100644 --- a/tests/services/project-file-import.test.ts +++ b/tests/services/project-file-import.test.ts @@ -104,4 +104,47 @@ describe('ProjectFileImportService', () => { expect(deps.projectLibrary.importProjectFile).not.toHaveBeenCalled(); expect(deps.workspace.openProject).not.toHaveBeenCalled(); }); + + it('serializes batches and keeps importing after one file fails', async () => { + let releaseFirstImport: (() => void) | undefined; + const firstImportPaused = new Promise((resolve) => { + releaseFirstImport = resolve; + }); + const projectLibrary = { + importProjectFile: vi.fn(async (input: ProjectFile) => { + if (input.name === 'First') await firstImportPaused; + return input.name ?? 'imported'; + }), + }; + const workspace = { + openProject: vi.fn(async (projectId: string) => ({ + ok: true as const, + projectId, + item: {} as never, + })), + }; + const service = new ProjectFileImportService({ projectLibrary, workspace }); + const firstBatch = service.importFiles([ + new File([JSON.stringify(project('First'))], 'first.json'), + new File(['broken'], 'broken.pf'), + ]); + const secondBatch = service.importFiles([ + new File([JSON.stringify(project('Second'))], 'second.json'), + ]); + + await vi.waitFor(() => { + expect(projectLibrary.importProjectFile).toHaveBeenCalledOnce(); + }); + expect(projectLibrary.importProjectFile.mock.calls[0][0].name).toBe('First'); + + releaseFirstImport?.(); + const [firstOutcomes, secondOutcomes] = await Promise.all([firstBatch, secondBatch]); + + expect(firstOutcomes.map((outcome) => outcome.ok)).toEqual([true, false]); + expect(secondOutcomes.map((outcome) => outcome.ok)).toEqual([true]); + expect(projectLibrary.importProjectFile.mock.calls.map(([input]) => input.name)).toEqual([ + 'First', + 'Second', + ]); + }); }); diff --git a/tests/services/pwa-file-handling.test.ts b/tests/services/pwa-file-handling.test.ts new file mode 100644 index 0000000..e0ff044 --- /dev/null +++ b/tests/services/pwa-file-handling.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PwaFileHandlingService } from '../../src/services/pwa-file-handling'; + +function fileHandle(file: File): FileSystemFileHandle { + return { + kind: 'file', + name: file.name, + getFile: vi.fn(async () => file), + isSameEntry: vi.fn(async () => false), + createWritable: vi.fn(), + queryPermission: vi.fn(), + requestPermission: vi.fn(), + } as unknown as FileSystemFileHandle; +} + +describe('PwaFileHandlingService', () => { + it('is a quiet no-op when launchQueue is unavailable', () => { + const importer = { importFiles: vi.fn() }; + const service = new PwaFileHandlingService(importer); + + expect(service.registerLaunchConsumer(undefined)).toBe(false); + expect(importer.importFiles).not.toHaveBeenCalled(); + }); + + it('registers once and delivers multiple launched files in order', async () => { + const importer = { importFiles: vi.fn(async () => []) }; + const service = new PwaFileHandlingService(importer); + const setConsumer = vi.fn(); + const launchQueue = { setConsumer } as LaunchQueue; + const first = new File(['first'], 'first.pf'); + const second = new File(['second'], 'second.ase'); + + expect(service.registerLaunchConsumer(launchQueue)).toBe(true); + expect(service.registerLaunchConsumer(launchQueue)).toBe(false); + + const consumer = setConsumer.mock.calls[0][0] as (params: LaunchParams) => void; + consumer({ files: [fileHandle(first), fileHandle(second)] }); + + await vi.waitFor(() => { + expect(importer.importFiles).toHaveBeenCalledWith([first, second]); + }); + expect(setConsumer).toHaveBeenCalledOnce(); + }); + + it('continues when the operating system cannot expose one handle', async () => { + const readable = new File(['project'], 'project.pf'); + const importer = { importFiles: vi.fn(async () => []) }; + const service = new PwaFileHandlingService(importer); + const unreadable = { + kind: 'file', + name: 'private.ase', + getFile: vi.fn(async () => { + throw new DOMException('Permission denied', 'NotAllowedError'); + }), + } as unknown as FileSystemFileHandle; + + const result = await service.importLaunch({ + files: [unreadable, fileHandle(readable)], + }); + + expect(result.unreadableFiles).toEqual(['private.ase']); + expect(importer.importFiles).toHaveBeenCalledWith([readable]); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 0ee0358..2112a88 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -33,6 +33,19 @@ export default defineConfig({ display: 'standalone', background_color: '#0d1015', theme_color: '#0d1015', + file_handlers: [ + { + action: './', + accept: { + 'application/x-pixelforge': ['.pf'], + 'application/x-aseprite': ['.ase', '.aseprite'], + }, + launch_type: 'single-client', + }, + ], + launch_handler: { + client_mode: 'focus-existing', + }, icons: [ { src: 'icons/pwa-192.png', From c38ac83e68f2b3bc4367755d3be628fb2d46b9e3 Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 11 Jul 2026 09:28:44 +0200 Subject: [PATCH 4/8] feat(files): unify menu and drag-and-drop imports (#333) --- docs/verification/pwa.md | 19 ++- package.json | 1 + src/components/app/pixel-forge-app.ts | 137 +++++++++++++++++++ src/components/menu/pf-menu-bar.ts | 38 +---- src/services/project-file-handling.ts | 73 ++++++++++ src/services/pwa-file-handling.ts | 31 +++-- tests/components/app/pixel-forge-app.test.ts | 91 ++++++++++++ tests/components/menu/pf-menu-bar.test.ts | 20 +++ tests/services/project-file-handling.test.ts | 63 +++++++++ tests/services/pwa-file-handling.test.ts | 31 +++++ 10 files changed, 460 insertions(+), 44 deletions(-) create mode 100644 src/services/project-file-handling.ts create mode 100644 tests/services/project-file-handling.test.ts diff --git a/docs/verification/pwa.md b/docs/verification/pwa.md index 8328805..f509595 100644 --- a/docs/verification/pwa.md +++ b/docs/verification/pwa.md @@ -3,8 +3,7 @@ Use a Chromium browser for the install and service-worker checks. The development server does not register the production service worker, so build and preview the app first. ```sh -npm run build -npm run preview -- --host 127.0.0.1 +npm run pwa:preview ``` ## Install @@ -37,3 +36,19 @@ The menu action is intentionally absent when the browser does not expose an inst 7. Confirm the current project is saved before the waiting worker activates and version B loads. To exercise the save-failure path, temporarily make IndexedDB unavailable before choosing **Restart**. Pixel Forge should remain open and explain that the current session stayed open. + +## Project file handling + +File associations are refreshed when the PWA is installed. After changing the manifest, uninstall Pixel Forge, run `npm run pwa:preview`, and install it again before testing. + +1. In Finder, use **Open With → Pixel Forge** for a `.pf` project. +2. Repeat with `.ase` and `.aseprite` files. +3. Keep Pixel Forge open and use **Open With → Pixel Forge** again. Confirm the existing app window is focused and each project opens in a new internal tab. +4. Select multiple supported files in Finder and open them together. Confirm their tab order matches the selection order. +5. Fill all eight project tabs, then open one more valid project file. Confirm Pixel Forge explains that the import was saved to **Project Library** without replacing the active drawing. +6. Drag `.pf`, `.json`, `.ase`, and `.aseprite` files onto the app. Confirm they use the same new-project behavior and feedback. +7. Drop an unsupported file by itself. Confirm Pixel Forge leaves the browser's normal behavior alone. +8. Deny the browser-owned file-handling permission when prompted, then confirm the open project remains unchanged. +9. Open Pixel Forge in Firefox or Safari and confirm the editor starts normally. File-menu import and drag-and-drop should still work; operating-system association is unavailable there. + +Pixel Forge reads only the files supplied for the current launch. It does not retain file handles, request write permission, or save changes back to the source file. diff --git a/package.json b/package.json index f63ead7..db6e13a 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", + "pwa:preview": "npm run build && vite preview --host 127.0.0.1", "test": "vitest", "test:run": "vitest run", "test:ui": "vitest --ui", diff --git a/src/components/app/pixel-forge-app.ts b/src/components/app/pixel-forge-app.ts index 19eea16..2074c61 100644 --- a/src/components/app/pixel-forge-app.ts +++ b/src/components/app/pixel-forge-app.ts @@ -38,6 +38,13 @@ import { projectRepository } from "../../services/persistence/indexed-db"; import { autoSaveService } from "../../services/auto-save"; import { projectLibrary } from "../../services/project-library"; import { pwaFileHandling } from "../../services/pwa-file-handling"; +import { + PROJECT_FILE_IMPORT_REPORT_EVENT, + describeProjectFileImport, + importProjectFiles, + supportedProjectFiles, + type ProjectFileImportReport, +} from "../../services/project-file-handling"; import type { ToolType as _ToolType } from "../../stores/tools"; import { panelStore } from "../../stores/panels"; import { log } from "../../utils/log"; @@ -233,6 +240,45 @@ export class PixelForgeApp extends BaseComponent { transform: translate(-50%, -50%) translateY(-10px); } } + + .file-import-status { + position: fixed; + left: 50%; + bottom: 42px; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 14px; + max-width: min(560px, calc(100vw - 32px)); + padding: 9px 10px 9px 14px; + background: rgba(13, 16, 21, 0.97); + border: 1px solid var(--pf-color-border-strong); + border-radius: var(--pf-radius-md); + box-shadow: var(--pf-shadow-lg); + color: var(--pf-color-text-main); + font-size: 12px; + line-height: 1.4; + z-index: 10000; + } + + .file-import-status button { + flex: none; + min-height: 28px; + padding: 4px 8px; + border: 1px solid var(--pf-color-border); + border-radius: var(--pf-radius-sm); + background: transparent; + color: var(--pf-color-text-secondary); + font: inherit; + cursor: pointer; + } + + .file-import-status button:hover, + .file-import-status button:focus-visible { + border-color: var(--pf-color-accent); + color: var(--pf-color-text-main); + outline: none; + } `; @state() showResizeDialog = false; @@ -248,10 +294,13 @@ export class PixelForgeApp extends BaseComponent { @state() private hasLibraryProject = false; @state() private projectSelectionRequired = false; @state() private warningMessage: string | null = null; + @state() private fileImportMessage: string | null = null; private resizeStartY = 0; private resizeStartHeight = 0; private warningTimer: number | null = null; + private fileImportTimer: number | null = null; + private fileDropHandlingStarted = false; connectedCallback() { super.connectedCallback(); @@ -305,10 +354,16 @@ export class PixelForgeApp extends BaseComponent { "show-warning-toast", this.handleShowWarningToast as EventListener ); + window.addEventListener( + PROJECT_FILE_IMPORT_REPORT_EVENT, + this.handleProjectFileImportReport as EventListener + ); // Load saved project from IndexedDB after listeners are ready. void this.loadSavedProject().finally(() => { + if (!this.isConnected) return; pwaFileHandling.registerLaunchConsumer(); + this.startProjectFileDropHandling(); }); } @@ -327,6 +382,60 @@ export class PixelForgeApp extends BaseComponent { }, 2000); }; + private handleProjectFileImportReport = ( + event: CustomEvent + ) => { + const message = describeProjectFileImport(event.detail); + if (!message) return; + + if (event.detail.outcomes.some((outcome) => outcome.ok)) { + this.hasLibraryProject = true; + this.projectSelectionRequired = false; + this.showProjectBrowser = false; + } + + this.dismissFileImportMessage(); + this.fileImportMessage = message; + this.fileImportTimer = window.setTimeout(() => { + this.fileImportMessage = null; + this.fileImportTimer = null; + }, 6000); + }; + + private dismissFileImportMessage = () => { + if (this.fileImportTimer !== null) { + clearTimeout(this.fileImportTimer); + this.fileImportTimer = null; + } + this.fileImportMessage = null; + }; + + private startProjectFileDropHandling() { + if (this.fileDropHandlingStarted) return; + + window.addEventListener("dragover", this.handleProjectFileDragOver); + window.addEventListener("drop", this.handleProjectFileDrop); + this.fileDropHandlingStarted = true; + } + + private handleProjectFileDragOver = (event: DragEvent) => { + const files = supportedProjectFiles(getDataTransferFiles(event.dataTransfer)); + if (files.length === 0) return; + + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + }; + + private handleProjectFileDrop = (event: DragEvent) => { + const files = supportedProjectFiles(getDataTransferFiles(event.dataTransfer)); + if (files.length === 0) return; + + event.preventDefault(); + void importProjectFiles(files).catch((error) => { + log.error("Failed to import dropped project files:", error); + }); + }; + private handleProjectLoaded = async () => { await this.updateComplete; @@ -552,6 +661,14 @@ export class PixelForgeApp extends BaseComponent { "show-warning-toast", this.handleShowWarningToast as EventListener ); + window.removeEventListener( + PROJECT_FILE_IMPORT_REPORT_EVENT, + this.handleProjectFileImportReport as EventListener + ); + window.removeEventListener("dragover", this.handleProjectFileDragOver); + window.removeEventListener("drop", this.handleProjectFileDrop); + this.fileDropHandlingStarted = false; + this.dismissFileImportMessage(); } private handleCanvasCursor = (e: CustomEvent<{ x: number; y: number }>) => { @@ -745,7 +862,27 @@ export class PixelForgeApp extends BaseComponent { ${this.warningMessage ? html`
${this.warningMessage}
` : ""} + ${this.fileImportMessage + ? html` +
+ ${this.fileImportMessage} + +
+ ` + : ""} `; } } + +function getDataTransferFiles(dataTransfer: DataTransfer | null): File[] { + if (!dataTransfer) return []; + + const files = Array.from(dataTransfer.files); + if (files.length > 0) return files; + + return Array.from(dataTransfer.items) + .filter((item) => item.kind === "file") + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null); +} diff --git a/src/components/menu/pf-menu-bar.ts b/src/components/menu/pf-menu-bar.ts index 746c0b9..ae89766 100644 --- a/src/components/menu/pf-menu-bar.ts +++ b/src/components/menu/pf-menu-bar.ts @@ -6,11 +6,8 @@ import { FlipLayerCommand, RotateLayerCommand, } from "../../commands/layer-commands"; -// Dynamic imports for file handling - loaded on demand to reduce initial bundle -// import { importAseFile } from "../../services/aseprite-service"; -// import pako from "pako"; -import { type ProjectFileInput } from "../../types/project"; import { autoSaveService } from "../../services/auto-save"; +import { importProjectFiles } from "../../services/project-file-handling"; import { openReferenceImagePicker } from "../../services/reference-image-picker"; import { formatShortcut } from "../../utils/platform"; import { menuShortcuts } from "../../services/keyboard/shortcut-definitions"; @@ -531,43 +528,22 @@ export class PFMenuBar extends BaseComponent { } /** - * Unified open handler that supports: - * - .pf (compressed PixelForge project) - * - .json (uncompressed PixelForge project) - * - .ase, .aseprite (Aseprite files) + * Open one or more supported project files through the shared import path. */ async openFile() { const input = document.createElement("input"); input.type = "file"; input.accept = ".pf,.json,.ase,.aseprite"; + input.multiple = true; input.onchange = async (e: Event) => { - const file = (e.target as HTMLInputElement).files?.[0]; - if (!file) return; - - const ext = file.name.split(".").pop()?.toLowerCase(); + const files = Array.from((e.target as HTMLInputElement).files ?? []); + if (files.length === 0) return; try { - if (ext === "ase" || ext === "aseprite") { - // Aseprite format - lazy load parser - const buffer = await file.arrayBuffer(); - const { importAseFile } = await import("../../services/aseprite-service"); - await importAseFile(buffer); - } else if (ext === "pf") { - // Compressed PixelForge format - lazy load pako - const buffer = await file.arrayBuffer(); - const pako = await import("pako"); - const decompressed = pako.default.inflate(new Uint8Array(buffer), { to: "string" }); - const project = JSON.parse(decompressed) as ProjectFileInput; - await getActiveProjectContext().project.loadProject(project); - } else { - // JSON format (uncompressed) - const text = await file.text(); - const project = JSON.parse(text) as ProjectFileInput; - await getActiveProjectContext().project.loadProject(project); - } + await importProjectFiles(files); } catch (error) { - log.error("Failed to open file:", error); + log.error("Failed to import project files:", error); } }; diff --git a/src/services/project-file-handling.ts b/src/services/project-file-handling.ts new file mode 100644 index 0000000..0582b5b --- /dev/null +++ b/src/services/project-file-handling.ts @@ -0,0 +1,73 @@ +import { projectFileImportService, type ProjectFileImportOutcome } from './project-file-import'; + +export const PROJECT_FILE_IMPORT_REPORT_EVENT = 'project-file-import-report'; + +export interface ProjectFileImportReport { + outcomes: ProjectFileImportOutcome[]; + unreadableFiles: string[]; +} + +const SUPPORTED_PROJECT_FILE_EXTENSIONS = new Set(['pf', 'json', 'ase', 'aseprite']); + +export function isSupportedProjectFile(file: File): boolean { + return SUPPORTED_PROJECT_FILE_EXTENSIONS.has(getFileExtension(file.name)); +} + +export function supportedProjectFiles(files: Iterable): File[] { + return Array.from(files).filter(isSupportedProjectFile); +} + +export async function importProjectFiles(files: Iterable): Promise { + const outcomes = await projectFileImportService.importFiles(files); + const report = { outcomes, unreadableFiles: [] }; + dispatchProjectFileImportReport(report); + return report; +} + +export function dispatchProjectFileImportReport(report: ProjectFileImportReport): void { + if (report.outcomes.length === 0 && report.unreadableFiles.length === 0) return; + + window.dispatchEvent( + new CustomEvent(PROJECT_FILE_IMPORT_REPORT_EVENT, { + detail: report, + }) + ); +} + +export function describeProjectFileImport(report: ProjectFileImportReport): string | null { + const opened = report.outcomes.filter((outcome) => outcome.ok && outcome.result.opened); + const saved = report.outcomes.filter((outcome) => outcome.ok && !outcome.result.opened); + const failed = report.outcomes.filter((outcome) => !outcome.ok).length; + const failureCount = failed + report.unreadableFiles.length; + const messages: string[] = []; + + if (opened.length === 1) { + messages.push(`Opened ${opened[0].file.name}.`); + } else if (opened.length > 1) { + messages.push(`Opened ${opened.length} projects.`); + } + + if (saved.length === 1) { + messages.push('Saved 1 imported project to Project Library because all tabs are in use.'); + } else if (saved.length > 1) { + messages.push( + `Saved ${saved.length} imported projects to Project Library because all tabs are in use.` + ); + } + + if (failureCount === 1 && opened.length === 0 && saved.length === 0) { + const failedOutcome = report.outcomes.find((outcome) => !outcome.ok); + const filename = failedOutcome?.file.name ?? report.unreadableFiles[0]; + messages.push(`Could not import ${filename}.`); + } else if (failureCount === 1) { + messages.push('1 file could not be imported.'); + } else if (failureCount > 1) { + messages.push(`${failureCount} files could not be imported.`); + } + + return messages.join(' ') || null; +} + +function getFileExtension(filename: string): string { + return filename.split('.').pop()?.toLowerCase() ?? ''; +} diff --git a/src/services/pwa-file-handling.ts b/src/services/pwa-file-handling.ts index 130813a..93aa71e 100644 --- a/src/services/pwa-file-handling.ts +++ b/src/services/pwa-file-handling.ts @@ -1,20 +1,18 @@ -import { - projectFileImportService, - type ProjectFileImportOutcome, - type ProjectFileImportService, -} from './project-file-import'; +import { projectFileImportService, type ProjectFileImportService } from './project-file-import'; import { log } from '../utils/log'; +import { + dispatchProjectFileImportReport, + type ProjectFileImportReport, +} from './project-file-handling'; type ProjectFileBatchImporter = Pick; -export interface PwaFileLaunchResult { - outcomes: ProjectFileImportOutcome[]; - unreadableFiles: string[]; -} +export type PwaFileLaunchResult = ProjectFileImportReport; export class PwaFileHandlingService { private readonly importer: ProjectFileBatchImporter; private launchConsumerRegistered = false; + private launchQueue: Promise = Promise.resolve(); constructor(importer: ProjectFileBatchImporter) { this.importer = importer; @@ -32,7 +30,16 @@ export class PwaFileHandlingService { return true; } - async importLaunch(launchParams: LaunchParams): Promise { + importLaunch(launchParams: LaunchParams): Promise { + const importLaunch = this.launchQueue.then(() => this.readLaunchFiles(launchParams)); + this.launchQueue = importLaunch.then( + () => undefined, + () => undefined + ); + return importLaunch; + } + + private async readLaunchFiles(launchParams: LaunchParams): Promise { const files: File[] = []; const unreadableFiles: string[] = []; @@ -45,10 +52,12 @@ export class PwaFileHandlingService { } } - return { + const report = { outcomes: await this.importer.importFiles(files), unreadableFiles, }; + dispatchProjectFileImportReport(report); + return report; } } diff --git a/tests/components/app/pixel-forge-app.test.ts b/tests/components/app/pixel-forge-app.test.ts index 468ce30..b0cb665 100644 --- a/tests/components/app/pixel-forge-app.test.ts +++ b/tests/components/app/pixel-forge-app.test.ts @@ -28,6 +28,14 @@ const pwaFileHandlingMock = vi.hoisted(() => ({ registerLaunchConsumer: vi.fn(), })); +const projectFileHandlingMock = vi.hoisted(() => ({ + importProjectFiles: vi.fn(), + supportedProjectFiles: vi.fn((files: Iterable) => + Array.from(files).filter((file) => /\.(?:pf|json|ase|aseprite)$/i.test(file.name)) + ), + describeProjectFileImport: vi.fn(() => 'Opened portrait.pf.'), +})); + const canvasContext = new Proxy( { imageSmoothingEnabled: false }, { @@ -62,6 +70,11 @@ vi.mock('../../../src/services/pwa-file-handling', () => ({ pwaFileHandling: pwaFileHandlingMock, })); +vi.mock('../../../src/services/project-file-handling', () => ({ + PROJECT_FILE_IMPORT_REPORT_EVENT: 'project-file-import-report', + ...projectFileHandlingMock, +})); + describe('pixel-forge-app project dialogs', () => { beforeEach(() => { document.body.replaceChildren(); @@ -82,6 +95,10 @@ describe('pixel-forge-app project dialogs', () => { closedItem: null, activeItem: null, }); + projectFileHandlingMock.importProjectFiles.mockResolvedValue({ + outcomes: [], + unreadableFiles: [], + }); }); afterEach(() => { @@ -160,6 +177,68 @@ describe('pixel-forge-app project dialogs', () => { }); }); + it('imports supported dropped files without intercepting unrelated files', async () => { + await import('../../../src/components/app/pixel-forge-app'); + const element = document.createElement('pixel-forge-app') as HTMLElement; + document.body.append(element); + await vi.waitFor(() => { + expect(pwaFileHandlingMock.registerLaunchConsumer).toHaveBeenCalledOnce(); + }); + const project = new File(['project'], 'portrait.pf'); + const notes = new File(['notes'], 'notes.txt'); + const supportedDrag = fileTransferEvent('dragover', [project, notes]); + const supportedDrop = fileTransferEvent('drop', [project, notes]); + + window.dispatchEvent(supportedDrag); + window.dispatchEvent(supportedDrop); + + expect(supportedDrag.defaultPrevented).toBe(true); + expect(supportedDrop.defaultPrevented).toBe(true); + expect(projectFileHandlingMock.importProjectFiles).toHaveBeenCalledWith([project]); + + projectFileHandlingMock.importProjectFiles.mockClear(); + const unsupportedDrop = fileTransferEvent('drop', [notes]); + window.dispatchEvent(unsupportedDrop); + + expect(unsupportedDrop.defaultPrevented).toBe(false); + expect(projectFileHandlingMock.importProjectFiles).not.toHaveBeenCalled(); + }); + + it('announces project import results with dismissible, polite feedback', async () => { + await import('../../../src/components/app/pixel-forge-app'); + const element = document.createElement('pixel-forge-app') as HTMLElement & { + hasLibraryProject: boolean; + updateComplete: Promise; + }; + document.body.append(element); + const project = new File([], 'portrait.pf'); + + window.dispatchEvent( + new CustomEvent('project-file-import-report', { + detail: { + outcomes: [ + { + file: project, + ok: true, + result: { projectId: 'portrait', opened: true }, + }, + ], + unreadableFiles: [], + }, + }) + ); + await element.updateComplete; + + const status = element.shadowRoot?.querySelector('[role="status"]'); + expect(status?.textContent).toBe('Opened portrait.pf.'); + expect(status?.getAttribute('aria-live')).toBe('polite'); + expect(element.hasLibraryProject).toBe(true); + + element.shadowRoot?.querySelector('.file-import-status button')?.click(); + await element.updateComplete; + expect(element.shadowRoot?.querySelector('.file-import-status')).toBeNull(); + }); + it('opens the project browser from the project tab strip', async () => { await import('../../../src/components/app/pixel-forge-app'); @@ -331,3 +410,15 @@ describe('pixel-forge-app project dialogs', () => { expect(element.showProjectBrowser).toBe(false); }); }); + +function fileTransferEvent(type: 'dragover' | 'drop', files: File[]): DragEvent { + const event = new Event(type, { cancelable: true }) as DragEvent; + Object.defineProperty(event, 'dataTransfer', { + value: { + files, + items: [], + dropEffect: 'none', + }, + }); + return event; +} diff --git a/tests/components/menu/pf-menu-bar.test.ts b/tests/components/menu/pf-menu-bar.test.ts index 2b875ac..70154af 100644 --- a/tests/components/menu/pf-menu-bar.test.ts +++ b/tests/components/menu/pf-menu-bar.test.ts @@ -30,6 +30,7 @@ const autoSaveServiceMock = vi.hoisted(() => ({ })); const importReferenceImageFileMock = vi.hoisted(() => vi.fn(async () => null)); +const importProjectFilesMock = vi.hoisted(() => vi.fn(async () => null)); vi.mock("../../../src/stores/history", () => ({ historyStore: historyStoreMock, @@ -61,6 +62,10 @@ vi.mock("../../../src/services/reference-import-action", () => ({ importReferenceImageFile: importReferenceImageFileMock, })); +vi.mock("../../../src/services/project-file-handling", () => ({ + importProjectFiles: importProjectFilesMock, +})); + vi.mock("../../../src/commands/layer-commands", () => ({ FlipLayerCommand: class FlipLayerCommand {}, RotateLayerCommand: class RotateLayerCommand {}, @@ -426,6 +431,21 @@ describe("pf-menu-bar popovers", () => { expect(importReferenceImageFileMock).toHaveBeenCalledWith(contextA, file); }); + it("delegates multiple project files from the File menu to the shared importer", async () => { + const first = new File(["first"], "first.pf"); + const second = new File(["second"], "second.aseprite"); + const { input } = useReferenceImageInput([first, second]); + const element = await createMenuBar(); + + await element.openFile(); + await flushImport(); + + expect(input.accept).toBe(".pf,.json,.ase,.aseprite"); + expect(input.multiple).toBe(true); + expect(importProjectFilesMock).toHaveBeenCalledWith([first, second]); + expect(projectStoreMock.loadProject).not.toHaveBeenCalled(); + }); + it("does nothing when the File menu reference image picker is canceled", async () => { const context = createContext("Context A"); const { click } = useReferenceImageInput([]); diff --git a/tests/services/project-file-handling.test.ts b/tests/services/project-file-handling.test.ts new file mode 100644 index 0000000..74af76f --- /dev/null +++ b/tests/services/project-file-handling.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { + describeProjectFileImport, + isSupportedProjectFile, + supportedProjectFiles, + type ProjectFileImportReport, +} from '../../src/services/project-file-handling'; + +function successfulFile(file: File, opened = true) { + return { + file, + ok: true as const, + result: { projectId: file.name, opened }, + }; +} + +describe('project file handling', () => { + it('recognizes only supported project formats', () => { + const files = [ + new File([], 'drawing.PF'), + new File([], 'legacy.json'), + new File([], 'sprite.ase'), + new File([], 'sprite.aseprite'), + new File([], 'notes.txt'), + ]; + + expect(files.map(isSupportedProjectFile)).toEqual([true, true, true, true, false]); + expect(supportedProjectFiles(files).map((file) => file.name)).toEqual([ + 'drawing.PF', + 'legacy.json', + 'sprite.ase', + 'sprite.aseprite', + ]); + }); + + it('describes opened, library-only, and failed imports concisely', () => { + const report: ProjectFileImportReport = { + outcomes: [ + successfulFile(new File([], 'portrait.pf')), + successfulFile(new File([], 'overflow.ase'), false), + { + file: new File([], 'broken.pf'), + ok: false, + error: new Error('broken'), + }, + ], + unreadableFiles: [], + }; + + expect(describeProjectFileImport(report)).toBe( + 'Opened portrait.pf. Saved 1 imported project to Project Library because all tabs are in use. 1 file could not be imported.' + ); + }); + + it('names a lone failed file', () => { + expect( + describeProjectFileImport({ + outcomes: [], + unreadableFiles: ['private.ase'], + }) + ).toBe('Could not import private.ase.'); + }); +}); diff --git a/tests/services/pwa-file-handling.test.ts b/tests/services/pwa-file-handling.test.ts index e0ff044..c547149 100644 --- a/tests/services/pwa-file-handling.test.ts +++ b/tests/services/pwa-file-handling.test.ts @@ -61,4 +61,35 @@ describe('PwaFileHandlingService', () => { expect(result.unreadableFiles).toEqual(['private.ase']); expect(importer.importFiles).toHaveBeenCalledWith([readable]); }); + + it('serializes repeated launches before reading their handles', async () => { + let releaseFirstFile: (() => void) | undefined; + const firstFilePaused = new Promise((resolve) => { + releaseFirstFile = resolve; + }); + const first = new File([], 'first.pf'); + const second = new File([], 'second.pf'); + const firstHandle = fileHandle(first); + vi.mocked(firstHandle.getFile).mockImplementation(async () => { + await firstFilePaused; + return first; + }); + const secondHandle = fileHandle(second); + const importer = { importFiles: vi.fn(async () => []) }; + const service = new PwaFileHandlingService(importer); + + const firstLaunch = service.importLaunch({ files: [firstHandle] }); + const secondLaunch = service.importLaunch({ files: [secondHandle] }); + + await vi.waitFor(() => { + expect(firstHandle.getFile).toHaveBeenCalledOnce(); + }); + expect(secondHandle.getFile).not.toHaveBeenCalled(); + + releaseFirstFile?.(); + await Promise.all([firstLaunch, secondLaunch]); + + expect(secondHandle.getFile).toHaveBeenCalledOnce(); + expect(importer.importFiles.mock.calls.map(([files]) => files)).toEqual([[first], [second]]); + }); }); From b7914a24dc340e6033aef22abcb995601e5c9551 Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 11 Jul 2026 09:43:35 +0200 Subject: [PATCH 5/8] fix(files): harden import edge cases (#330 #331 #333) --- src/components/app/pixel-forge-app.ts | 47 ++++++++--- src/components/menu/pf-menu-bar.ts | 4 +- src/services/aseprite-service.ts | 4 + src/services/project-file-handling.ts | 28 +++++-- src/services/project-file-import.ts | 24 +++--- src/services/project-library.ts | 84 ++++++++++++++++++-- src/services/pwa-file-handling.ts | 13 +-- tests/components/app/pixel-forge-app.test.ts | 28 +++++++ tests/services/aseprite-service.test.ts | 35 +++++++- tests/services/project-file-import.test.ts | 14 +++- tests/services/project-library.test.ts | 14 ++++ 11 files changed, 251 insertions(+), 44 deletions(-) diff --git a/src/components/app/pixel-forge-app.ts b/src/components/app/pixel-forge-app.ts index 2074c61..93233f7 100644 --- a/src/components/app/pixel-forge-app.ts +++ b/src/components/app/pixel-forge-app.ts @@ -279,6 +279,18 @@ export class PixelForgeApp extends BaseComponent { color: var(--pf-color-text-main); outline: none; } + + .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; + } `; @state() showResizeDialog = false; @@ -419,8 +431,7 @@ export class PixelForgeApp extends BaseComponent { } private handleProjectFileDragOver = (event: DragEvent) => { - const files = supportedProjectFiles(getDataTransferFiles(event.dataTransfer)); - if (files.length === 0) return; + if (!hasDraggedFiles(event.dataTransfer)) return; event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; @@ -710,6 +721,22 @@ export class PixelForgeApp extends BaseComponent { localStorage.setItem("pf-timeline-height", String(this.timelineHeight)); }; + private renderFileImportStatus() { + return html` + ${this.fileImportMessage ?? ""} + ${this.fileImportMessage + ? html` +
+ + +
+ ` + : ""} + `; + } + render() { // Access panel states signal to ensure reactive updates when timeline visibility changes const isTimelineCollapsed = @@ -862,14 +889,7 @@ export class PixelForgeApp extends BaseComponent { ${this.warningMessage ? html`
${this.warningMessage}
` : ""} - ${this.fileImportMessage - ? html` -
- ${this.fileImportMessage} - -
- ` - : ""} + ${this.renderFileImportStatus()} `; } @@ -886,3 +906,10 @@ function getDataTransferFiles(dataTransfer: DataTransfer | null): File[] { .map((item) => item.getAsFile()) .filter((file): file is File => file !== null); } + +function hasDraggedFiles(dataTransfer: DataTransfer | null): boolean { + if (!dataTransfer) return false; + if (dataTransfer.files.length > 0) return true; + if (Array.from(dataTransfer.items).some((item) => item.kind === "file")) return true; + return Array.from(dataTransfer.types).includes("Files"); +} diff --git a/src/components/menu/pf-menu-bar.ts b/src/components/menu/pf-menu-bar.ts index ae89766..dff6fc0 100644 --- a/src/components/menu/pf-menu-bar.ts +++ b/src/components/menu/pf-menu-bar.ts @@ -750,9 +750,9 @@ export class PFMenuBar extends BaseComponent { Delete Current...
- + - ${this.errorMessage ? html`
${this.errorMessage}
` : ''} + ${this.errorMessage} + ${this.errorMessage + ? html` +
+ + +
+ ` + : ''} `; } } diff --git a/src/stores/workspace.ts b/src/stores/workspace.ts index 9fd5f47..ae03225 100644 --- a/src/stores/workspace.ts +++ b/src/stores/workspace.ts @@ -428,7 +428,10 @@ export class WorkspaceStore { id: projectId, activate: options.activate, }); - if (!result.ok) return result; + if (!result.ok) { + context.dispose(); + return result; + } this.autoSave.start(context); return { ok: true, item: result.item, projectId }; diff --git a/tests/components/app/pf-project-tabs.test.ts b/tests/components/app/pf-project-tabs.test.ts index b24f1e9..b85f761 100644 --- a/tests/components/app/pf-project-tabs.test.ts +++ b/tests/components/app/pf-project-tabs.test.ts @@ -173,4 +173,25 @@ describe('pf-project-tabs', () => { 'The workspace can keep up to 8 projects open at once.' ); }); + + it('clears tab-cap feedback after six seconds', async () => { + vi.useFakeTimers(); + workspaceStoreMock.items.value = Array.from({ length: 8 }, (_, index) => + createWorkspaceItem(`project-${index + 1}`, `Project ${index + 1}`) + ); + const element = await createTabs(); + + buttonWithLabel(element.shadowRoot!, 'Open project')?.click(); + await settle(element); + expect(element.shadowRoot?.querySelector('[role="status"]')?.textContent).toContain( + 'The workspace can keep up to 8 projects open at once.' + ); + + await vi.advanceTimersByTimeAsync(6000); + await settle(element); + + expect(element.shadowRoot?.querySelector('[role="status"]')?.textContent).toBe(''); + expect(element.shadowRoot?.querySelector('.error')).toBeNull(); + vi.useRealTimers(); + }); }); diff --git a/tests/services/project-file-import.test.ts b/tests/services/project-file-import.test.ts index 11b3abc..959bae1 100644 --- a/tests/services/project-file-import.test.ts +++ b/tests/services/project-file-import.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; import pako from 'pako'; import { ProjectFileImportService } from '../../src/services/project-file-import'; +import { WorkspaceStore } from '../../src/stores/workspace'; +import { + createProjectContext, + restoreDefaultProjectContext, +} from '../../src/stores/project-context'; import { PROJECT_VERSION, type ProjectFile } from '../../src/types/project'; function project(name = 'Imported project'): ProjectFile { @@ -92,6 +97,63 @@ describe('ProjectFileImportService', () => { expect(deps.projectLibrary.importProjectFile).toHaveBeenCalledOnce(); }); + it('keeps the active drawing untouched when a real workspace is full', async () => { + const activeContext = createProjectContext(); + activeContext.project.id.value = 'active-project'; + activeContext.project.name.value = 'Current drawing'; + const activeLayers = activeContext.layers.layers.value; + const activeFrames = activeContext.animation.frames.value; + const activeCels = activeContext.animation.cels.value; + const openProject = vi.fn(async () => { + throw new Error('The cap should prevent project hydration'); + }); + const workspace = new WorkspaceStore({ + initialContext: activeContext, + initialItemId: 'active-project', + itemLimit: 1, + projectLibrary: { + openProject, + createProject: vi.fn(async () => 'unused-project'), + createProjectFromFile: vi.fn(async () => 'unused-project'), + }, + autoSave: { + saveNow: vi.fn(async () => undefined), + start: vi.fn(), + stop: vi.fn(), + }, + }); + const importedProjects = new Map(); + const service = new ProjectFileImportService({ + projectLibrary: { + importProjectFile: vi.fn(async (input) => { + importedProjects.set('imported-project', structuredClone(input) as ProjectFile); + return 'imported-project'; + }), + deleteProject: vi.fn(async () => undefined), + }, + workspace, + }); + + try { + const result = await service.importFile( + new File([JSON.stringify(project('Imported drawing'))], 'drawing.json') + ); + + expect(result).toEqual({ projectId: 'imported-project', opened: false }); + expect(importedProjects.get('imported-project')?.name).toBe('Imported drawing'); + expect(openProject).not.toHaveBeenCalled(); + expect(workspace.activeItem.context).toBe(activeContext); + expect(activeContext.project.id.value).toBe('active-project'); + expect(activeContext.project.name.value).toBe('Current drawing'); + expect(activeContext.layers.layers.value).toBe(activeLayers); + expect(activeContext.animation.frames.value).toBe(activeFrames); + expect(activeContext.animation.cels.value).toBe(activeCels); + } finally { + restoreDefaultProjectContext(); + activeContext.dispose(); + } + }); + it('does not persist invalid or unsupported input', async () => { const deps = dependencies(); const service = new ProjectFileImportService(deps); diff --git a/tests/stores/workspace.test.ts b/tests/stores/workspace.test.ts index 52c0910..88e7031 100644 --- a/tests/stores/workspace.test.ts +++ b/tests/stores/workspace.test.ts @@ -615,4 +615,52 @@ describe("WorkspaceStore", () => { expect(projectLibrary.createProject).not.toHaveBeenCalled(); expect(workspace.items.value).toHaveLength(WORKSPACE_OPEN_ITEM_LIMIT); }); + + it("disposes a loaded context when the last tab is claimed during opening", async () => { + const initialContext = createTestContext("Initial Project"); + const projectLibrary = createProjectLibraryMock(); + const autoSave = createAutoSaveMock(); + let disposeLoadedContext: ReturnType | undefined; + + vi.mocked(projectLibrary.openProject).mockImplementation( + async (projectId, settings = {}) => { + const loadedContext = settings.context; + expect(loadedContext).toBeDefined(); + if (!loadedContext) throw new Error("Expected an isolated project context"); + + rememberContext(loadedContext); + disposeLoadedContext = vi.spyOn(loadedContext, "dispose"); + loadedContext.project.id.value = projectId; + expectAdded( + workspace.addContext(createTestContext("Competing Project"), { + id: "competing-project", + activate: false, + }), + ); + return makeProjectFile(`Project ${projectId}`); + }, + ); + + const workspace = new WorkspaceStore({ + initialContext, + initialItemId: "initial-project", + itemLimit: 2, + projectLibrary, + autoSave, + }); + + const result = await workspace.openProject("overflow-project"); + + expect(result).toEqual({ + ok: false, + reason: "tab-limit-reached", + message: "The workspace can keep up to 2 projects open at once.", + }); + expect(disposeLoadedContext).toHaveBeenCalledOnce(); + expect(autoSave.start).not.toHaveBeenCalled(); + expect(workspace.items.value.map((item) => item.id)).toEqual([ + "initial-project", + "competing-project", + ]); + }); }); From fe052cf2a704aaa1dd8498d45426d92bf39f5022 Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 11 Jul 2026 14:33:24 +0200 Subject: [PATCH 8/8] fix(projects): preserve pixels across tabs and saves (#337) --- src/stores/animation/index-buffer.ts | 14 ++- src/stores/animation/palette-sync.ts | 9 +- src/stores/animation/store.ts | 12 ++- src/stores/palette/store.ts | 23 ++-- src/stores/project-context.ts | 10 +- src/stores/project-store.ts | 7 ++ .../paint-by-number/guided-resume.test.ts | 17 +++ tests/stores/project-context.test.ts | 100 ++++++++++++++---- tests/stores/project-serialization.test.ts | 67 ++++++++++++ 9 files changed, 221 insertions(+), 38 deletions(-) diff --git a/src/stores/animation/index-buffer.ts b/src/stores/animation/index-buffer.ts index 409a00e..21ef840 100644 --- a/src/stores/animation/index-buffer.ts +++ b/src/stores/animation/index-buffer.ts @@ -142,15 +142,25 @@ export function rebuildAllCelCanvases(cels: Map, palette: string[]) */ export function rebuildAllIndexBuffers( cels: Map, - palette: PaletteColorSource + palette: PaletteColorSource, + addMissingColors = false ): Map { const newCels = new Map(cels); + const rebuiltBuffers = new Map(); for (const [key, cel] of newCels) { if (!cel.canvas) continue; if (cel.textCelData) continue; - const newIndexBuffer = buildIndexBufferFromCanvas(cel.canvas, palette, false); + let newIndexBuffer = rebuiltBuffers.get(cel.canvas); + if (!newIndexBuffer) { + newIndexBuffer = buildIndexBufferFromCanvas( + cel.canvas, + palette, + addMissingColors + ); + rebuiltBuffers.set(cel.canvas, newIndexBuffer); + } newCels.set(key, { ...cel, indexBuffer: newIndexBuffer }); } diff --git a/src/stores/animation/palette-sync.ts b/src/stores/animation/palette-sync.ts index f1a1981..d4f53f2 100644 --- a/src/stores/animation/palette-sync.ts +++ b/src/stores/animation/palette-sync.ts @@ -34,11 +34,15 @@ function remapCelBuffers( palette: PaletteIndexLookup, options: { skipTextCels?: boolean } = {} ): Map { + const remappedBuffers = new Set(); + for (const cel of cels.values()) { if (!cel.indexBuffer) continue; if (options.skipTextCels && cel.textCelData) continue; + if (remappedBuffers.has(cel.indexBuffer)) continue; remapBuffer(cel.indexBuffer, mapIndex); + remappedBuffers.add(cel.indexBuffer); } return rebuildChangedCels(cels, palette); @@ -176,6 +180,7 @@ function getReplacementIndex( * Returns a cleanup function to stop listening. */ export function startPaletteSync( + events: EventTarget, palette: PaletteIndexLookup, getCels: () => Map, setCels: (cels: Map) => void, @@ -232,13 +237,13 @@ export function startPaletteSync( // Add all listeners for (const [event, handler] of Object.entries(handlers)) { - window.addEventListener(event, handler as EventListener); + events.addEventListener(event, handler as EventListener); } // Return cleanup function return () => { for (const [event, handler] of Object.entries(handlers)) { - window.removeEventListener(event, handler as EventListener); + events.removeEventListener(event, handler as EventListener); } }; } diff --git a/src/stores/animation/store.ts b/src/stores/animation/store.ts index fc5f8fd..e904354 100644 --- a/src/stores/animation/store.ts +++ b/src/stores/animation/store.ts @@ -29,6 +29,7 @@ type AnimationPaletteStore = paletteSync.PaletteIndexLookup & export interface AnimationStoreDependencies { layers: AnimationLayerStore; palette: AnimationPaletteStore; + paletteEvents: EventTarget; refs: StoreRefs; } @@ -66,6 +67,7 @@ class AnimationStore { private playbackEngine: PlaybackEngine; private readonly layers: AnimationLayerStore; private readonly palette: AnimationPaletteStore; + private readonly paletteEvents: EventTarget; private sharedTransparentCanvas: HTMLCanvasElement | null = null; private readonly refs: StoreRefs; private cleanupPaletteSync: (() => void) | null = null; @@ -73,6 +75,7 @@ class AnimationStore { constructor(dependencies: AnimationStoreDependencies) { this.layers = dependencies.layers; this.palette = dependencies.palette; + this.paletteEvents = dependencies.paletteEvents; this.refs = dependencies.refs; // Set up playback engine @@ -112,6 +115,7 @@ class AnimationStore { if (this.cleanupPaletteSync) return; this.cleanupPaletteSync = paletteSync.startPaletteSync( + this.paletteEvents, this.palette, () => this.cels.value, (newCels) => { this.cels.value = newCels; }, @@ -378,8 +382,12 @@ class AnimationStore { indexBuffer.rebuildAllCelCanvases(this.cels.value, this.palette.colors.value); } - rebuildAllIndexBuffers(): void { - this.cels.value = indexBuffer.rebuildAllIndexBuffers(this.cels.value, this.palette); + rebuildAllIndexBuffers(addMissingColors = false): void { + this.cels.value = indexBuffer.rebuildAllIndexBuffers( + this.cels.value, + this.palette, + addMissingColors + ); } scanUsedColors(): Set { diff --git a/src/stores/palette/store.ts b/src/stores/palette/store.ts index 35cd97a..c257a3c 100644 --- a/src/stores/palette/store.ts +++ b/src/stores/palette/store.ts @@ -35,6 +35,7 @@ type AddColorOptions = { type PaletteLayerStore = ReturnType; export interface PaletteStoreDependencies { + events: EventTarget; layers: PaletteLayerStore; refs: StoreRefs; } @@ -75,6 +76,7 @@ class PaletteStore { private colorToIndex = new Map(); private cachedPaletteName: string | null = null; + private readonly events: EventTarget; private readonly layers: PaletteLayerStore; private readonly refs: StoreRefs; @@ -97,6 +99,7 @@ class PaletteStore { // ========================================== constructor(dependencies: PaletteStoreDependencies) { + this.events = dependencies.events; this.layers = dependencies.layers; this.refs = dependencies.refs; this.loadFromStorage(); @@ -211,7 +214,7 @@ class PaletteStore { }); if (!normalized) return; - window.dispatchEvent( + this.events.dispatchEvent( new CustomEvent('palette-color-changed', { detail: { index, color: normalized }, }) @@ -241,7 +244,7 @@ class PaletteStore { removeColor(index: number) { if (this.removeColorAtArrayIndex(index)) { - window.dispatchEvent( + this.events.dispatchEvent( new CustomEvent('palette-color-removed', { detail: { removedIndex: index + 1 }, }) @@ -252,7 +255,7 @@ class PaletteStore { removeColorByIndex(index: number) { if (!this.removeColorAtArrayIndex(index - 1)) return; - window.dispatchEvent( + this.events.dispatchEvent( new CustomEvent('palette-color-removed', { detail: { removedIndex: index }, }) @@ -272,7 +275,7 @@ class PaletteStore { this.rebuildColorMap(); this.saveToStorage(); - window.dispatchEvent( + this.events.dispatchEvent( new CustomEvent('palette-color-inserted', { detail: { insertedIndex: index }, }) @@ -292,7 +295,7 @@ class PaletteStore { this.rebuildColorMap(); this.saveToStorage(); - window.dispatchEvent( + this.events.dispatchEvent( new CustomEvent('palette-colors-reordered', { detail: { fromIndex: fromIndex + 1, toIndex: toIndex + 1 }, }) @@ -323,7 +326,7 @@ class PaletteStore { this.rebuildColorMap(); this.saveToStorage(); - window.dispatchEvent( + this.events.dispatchEvent( new CustomEvent('palette-replaced', { detail: { oldMainColors }, }) @@ -346,7 +349,7 @@ class PaletteStore { this.rebuildColorMap(); this.saveToStorage(); - window.dispatchEvent( + this.events.dispatchEvent( new CustomEvent('palette-replaced', { detail: { oldMainColors }, }) @@ -362,7 +365,7 @@ class PaletteStore { this.rebuildColorMap(); this.saveToStorage(); - window.dispatchEvent( + this.events.dispatchEvent( new CustomEvent('palette-replaced', { detail: { skipRemap: true }, }) @@ -382,7 +385,7 @@ class PaletteStore { this.rebuildColorMap(); - window.dispatchEvent( + this.events.dispatchEvent( new CustomEvent('palette-replaced', { detail: { skipRemap: true }, }) @@ -449,7 +452,7 @@ class PaletteStore { this.saveToStorage(); // Dispatch event to remap index buffers by color - window.dispatchEvent( + this.events.dispatchEvent( new CustomEvent('palette-replaced', { detail: { oldMainColors }, }) diff --git a/src/stores/project-context.ts b/src/stores/project-context.ts index 533f668..e1a2351 100644 --- a/src/stores/project-context.ts +++ b/src/stores/project-context.ts @@ -63,8 +63,14 @@ function createProjectContextStores( const guidedDrawing = createGuidedDrawingStore(); const historyHighlight = createHistoryHighlightStore(); const selection = createSelectionStore(); - const palette = createPaletteStore({ layers, refs }); - const animation = createAnimationStore({ layers, palette, refs }); + const paletteEvents = new EventTarget(); + const palette = createPaletteStore({ events: paletteEvents, layers, refs }); + const animation = createAnimationStore({ + layers, + palette, + paletteEvents, + refs, + }); const colors = createColorStore(options.colorPalette ?? palette); const history = createHistoryStore({ palette }); const project = createProjectStore({ diff --git a/src/stores/project-store.ts b/src/stores/project-store.ts index aa5f242..22523d7 100644 --- a/src/stores/project-store.ts +++ b/src/stores/project-store.ts @@ -140,6 +140,10 @@ class ProjectStore { } async saveProject(): Promise { + // Canvas pixels are the common output of every editing command. Rebuild + // indices here so canvas-only operations cannot persist stale pixel data. + this.animation.rebuildAllIndexBuffers(true); + // Convert layers to binary format (including text layer metadata) const layers = await Promise.all( this.layers.layers.value.map(async (layer) => ({ @@ -212,6 +216,9 @@ class ProjectStore { // Clear onion skin cache (old project's cels are no longer valid) onionSkinCache.clear(); + // Loading replaces the whole drawing. Drop outgoing cels before palette + // restoration can rebuild or remap canvases that no longer belong here. + this.animation.cels.value = new Map(); this.setSize(file.width, file.height); this.name.value = file.name || DEFAULT_PROJECT_NAME; diff --git a/tests/services/paint-by-number/guided-resume.test.ts b/tests/services/paint-by-number/guided-resume.test.ts index dcfd828..7ecc0c3 100644 --- a/tests/services/paint-by-number/guided-resume.test.ts +++ b/tests/services/paint-by-number/guided-resume.test.ts @@ -38,6 +38,23 @@ describe('guided project resume', () => { frameId, Uint8Array.from([1, 0]), ); + const editingCel = editingContext.animation.cels.value.get( + editingContext.animation.getCelKey(layerId, frameId), + ); + if (!editingCel) throw new Error('Expected the guided painting cel'); + // happy-dom does not retain canvas pixels, so expose the pixels produced + // from the index buffer before exercising the save boundary. + Object.defineProperty(editingCel.canvas, 'getContext', { + configurable: true, + value: () => ({ + getImageData: () => ({ + data: Uint8ClampedArray.from([ + 17, 17, 17, 255, + 0, 0, 0, 0, + ]), + }), + }), + }); const saved = await editingContext.project.saveProject(); const resumedContext = createProjectContext(); diff --git a/tests/stores/project-context.test.ts b/tests/stores/project-context.test.ts index 8ba08db..2303040 100644 --- a/tests/stores/project-context.test.ts +++ b/tests/stores/project-context.test.ts @@ -181,11 +181,84 @@ describe("ProjectContext", () => { ); contextA.dispose(); - window.dispatchEvent(new CustomEvent("palette-color-changed")); + contextB.palette.updateColor(1, "#abcdef"); expect(contextARebuild).not.toHaveBeenCalled(); expect(contextBRebuild).toHaveBeenCalledTimes(1); - expect(defaultRebuild).toHaveBeenCalledTimes(1); + expect(defaultRebuild).not.toHaveBeenCalled(); + }); + + it("keeps palette index remapping inside the context that owns the palette", () => { + const contextA = createTestContext(8, 8); + const contextB = createTestContext(8, 8); + contextA.palette.setPalette(["#ff0000", "#0000ff"]); + contextB.palette.setPalette(["#ff0000", "#0000ff"]); + + const setFirstPixel = (context: ProjectContext) => { + const layerId = context.layers.layers.value[0].id; + const frameId = context.animation.frames.value[0].id; + const cel = context.animation.cels.value.get( + context.animation.getCelKey(layerId, frameId), + ); + if (!cel) throw new Error("Expected an initial cel"); + + const buffer = new Uint8Array(cel.canvas.width * cel.canvas.height); + buffer[0] = 1; + context.animation.updateCelIndexBuffer(layerId, frameId, buffer); + return { layerId, frameId }; + }; + + const pixelA = setFirstPixel(contextA); + const pixelB = setFirstPixel(contextB); + + contextA.palette.moveColor(0, 1); + + expect( + contextA.animation.getCelIndexBuffer(pixelA.layerId, pixelA.frameId)?.[0], + ).toBe(2); + expect( + contextB.animation.getCelIndexBuffer(pixelB.layerId, pixelB.frameId)?.[0], + ).toBe(1); + }); + + it("remaps a shared linked-cel buffer only once", () => { + const context = createTestContext(8, 8); + context.palette.setPalette(["#ff0000", "#00ff00", "#0000ff"]); + const layerId = context.layers.layers.value[0].id; + const firstFrameId = context.animation.frames.value[0].id; + const firstCel = context.animation.cels.value.get( + context.animation.getCelKey(layerId, firstFrameId), + ); + if (!firstCel) throw new Error("Expected an initial cel"); + + const buffer = new Uint8Array(firstCel.canvas.width * firstCel.canvas.height); + buffer[0] = 3; + context.animation.updateCelIndexBuffer(layerId, firstFrameId, buffer); + context.animation.addFrame(false); + const secondFrameId = context.animation.currentFrameId.value; + context.animation.linkCels( + [ + context.animation.getCelKey(layerId, firstFrameId), + context.animation.getCelKey(layerId, secondFrameId), + ], + "hard", + ); + + context.animation.rebuildAllIndexBuffers(); + const firstBuffer = context.animation.getCelIndexBuffer(layerId, firstFrameId); + const secondBuffer = context.animation.getCelIndexBuffer(layerId, secondFrameId); + expect(firstBuffer).toBe(secondBuffer); + if (!firstBuffer) throw new Error("Expected a shared linked-cel buffer"); + firstBuffer[0] = 3; + + context.palette.removeColorByIndex(1); + + expect( + context.animation.getCelIndexBuffer(layerId, firstFrameId)?.[0], + ).toBe(2); + expect( + context.animation.getCelIndexBuffer(layerId, secondFrameId)?.[0], + ).toBe(2); }); it("keeps default context stores compatible with singleton exports", () => { @@ -236,28 +309,15 @@ describe("ProjectContext", () => { }); it("owns palette sync startup and cleanup", () => { - const addListener = vi.spyOn(window, "addEventListener"); - const removeListener = vi.spyOn(window, "removeEventListener"); const context = createTestContext(100, 80); + const rebuild = vi.spyOn(context.animation, "rebuildAllCelCanvases"); - expect(addListener).toHaveBeenCalledWith( - "palette-color-changed", - expect.any(Function), - ); - expect(addListener).toHaveBeenCalledWith( - "palette-replaced", - expect.any(Function), - ); + context.palette.updateColor(1, "#abcdef"); + expect(rebuild).toHaveBeenCalledTimes(1); context.dispose(); + context.palette.updateColor(1, "#fedcba"); - expect(removeListener).toHaveBeenCalledWith( - "palette-color-changed", - expect.any(Function), - ); - expect(removeListener).toHaveBeenCalledWith( - "palette-replaced", - expect.any(Function), - ); + expect(rebuild).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/stores/project-serialization.test.ts b/tests/stores/project-serialization.test.ts index 811ee62..f09f9c5 100644 --- a/tests/stores/project-serialization.test.ts +++ b/tests/stores/project-serialization.test.ts @@ -190,6 +190,73 @@ describe('project save -> load -> save round-trip (structural)', () => { expect(normalize(twice)).toEqual(normalize(once)); }); + it('drops every cel from the previous drawing when loading another project', async () => { + const firstProject = await buildSampleProject(); + const secondProject = structuredClone(firstProject); + const secondLayer = secondProject.layers[0]; + const secondFrame = secondProject.frames[0]; + secondProject.name = 'Second drawing'; + secondProject.layers = [{ ...secondLayer, id: 'second-layer' }]; + secondProject.frames = [ + { + ...secondFrame, + id: 'second-frame', + cels: secondFrame.cels + .filter((cel) => cel.layerId === secondLayer.id) + .map((cel) => ({ ...cel, layerId: 'second-layer' })), + }, + ]; + secondProject.animation.currentFrameIndex = 0; + + vi.useFakeTimers(); + await projectStore.loadProject(structuredClone(firstProject), false); + await vi.runAllTimersAsync(); + await projectStore.loadProject(secondProject, false); + await vi.runAllTimersAsync(); + vi.useRealTimers(); + + const currentLayerIds = new Set(layerStore.layers.value.map((layer) => layer.id)); + const currentFrameIds = new Set(animationStore.frames.value.map((frame) => frame.id)); + const currentCels = [...animationStore.cels.value.values()]; + + expect(currentCels).toHaveLength(1); + expect(currentCels.every((cel) => currentLayerIds.has(cel.layerId))).toBe(true); + expect(currentCels.every((cel) => currentFrameIds.has(cel.frameId))).toBe(true); + }); + + it('serializes palette indices from the current canvas pixels', async () => { + paletteStore.setPalette([]); + const layerId = layerStore.layers.value[0].id; + const frameId = animationStore.frames.value[0].id; + const celKey = animationStore.getCelKey(layerId, frameId); + const cel = animationStore.cels.value.get(celKey); + if (!cel) throw new Error('Expected an initial cel'); + + const canvas = { + width: 1, + height: 1, + getContext: () => ({ + getImageData: () => ({ + data: new Uint8ClampedArray([255, 0, 0, 255]), + }), + }), + } as unknown as HTMLCanvasElement; + const cels = new Map(animationStore.cels.value); + cels.set(celKey, { + ...cel, + canvas, + indexBuffer: new Uint8Array([0]), + linkedCelId: undefined, + linkType: undefined, + }); + animationStore.cels.value = cels; + + const saved = await projectStore.saveProject(); + + expect(saved.frames[0].cels[0].indexData).toEqual([1]); + expect(saved.palette).toEqual(['#ff0000']); + }); + it('does not write the legacy compatibility palette after drawing adds a color', async () => { await buildSampleProject(); paletteStore.getOrAddColorForDrawing('#123456');