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/pf-project-tabs.ts b/src/components/app/pf-project-tabs.ts index 511704f..50de6af 100644 --- a/src/components/app/pf-project-tabs.ts +++ b/src/components/app/pf-project-tabs.ts @@ -131,25 +131,77 @@ export class PFProjectTabs extends BaseComponent { } .error { + display: flex; + align-items: center; + gap: 8px; padding: 5px 10px 7px; color: #f0aaa2; font-size: 12px; } + + .error button { + padding: 2px 6px; + border: 1px solid currentColor; + border-radius: var(--pf-radius-sm); + } + + .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; + } `; @state() private errorMessage = ''; + private feedbackTimer: number | null = null; + + disconnectedCallback() { + this.dismissFeedback(); + super.disconnectedCallback(); + } + + private showFeedback(message: string) { + this.dismissFeedback(); + this.errorMessage = message; + this.feedbackTimer = window.setTimeout(() => { + this.errorMessage = ''; + this.feedbackTimer = null; + }, 6000); + } + + private dismissFeedback = () => { + if (this.feedbackTimer !== null) { + clearTimeout(this.feedbackTimer); + this.feedbackTimer = null; + } + this.errorMessage = ''; + }; private activateItem(itemId: string) { const result = workspaceStore.activate(itemId); - this.errorMessage = result.ok ? '' : result.message; + if (result.ok) { + this.dismissFeedback(); + } else { + this.showFeedback(result.message); + } } private async closeItem(itemId: string) { try { const result = await workspaceStore.closeProject(itemId); - this.errorMessage = result.ok ? '' : result.message; + if (result.ok) { + this.dismissFeedback(); + } else { + this.showFeedback(result.message); + } } catch { - this.errorMessage = 'Could not close project.'; + this.showFeedback('Could not close project.'); } } @@ -168,11 +220,11 @@ export class PFProjectTabs extends BaseComponent { private openProjectBrowser() { if (workspaceStore.items.value.length >= WORKSPACE_OPEN_ITEM_LIMIT) { - this.errorMessage = workspaceItemLimitMessage(); + this.showFeedback(workspaceItemLimitMessage()); return; } - this.errorMessage = ''; + this.dismissFeedback(); this.dispatchEvent(new CustomEvent('show-project-browser', { bubbles: true, composed: true })); } @@ -236,7 +288,17 @@ export class PFProjectTabs extends BaseComponent { + - ${this.errorMessage ? html`
${this.errorMessage}
` : ''} + ${this.errorMessage} + ${this.errorMessage + ? html` +
+ + +
+ ` + : ''} `; } } diff --git a/src/components/app/pixel-forge-app.ts b/src/components/app/pixel-forge-app.ts index 7d8cf47..93233f7 100644 --- a/src/components/app/pixel-forge-app.ts +++ b/src/components/app/pixel-forge-app.ts @@ -37,6 +37,14 @@ 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 { + 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"; @@ -232,6 +240,57 @@ 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; + } + + .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; @@ -247,10 +306,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(); @@ -304,9 +366,17 @@ 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. - this.loadSavedProject(); + void this.loadSavedProject().finally(() => { + if (!this.isConnected) return; + pwaFileHandling.registerLaunchConsumer(); + this.startProjectFileDropHandling(); + }); } private handleShowWarningToast = (e: CustomEvent<{ message: string }>) => { @@ -324,6 +394,59 @@ 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) => { + if (!hasDraggedFiles(event.dataTransfer)) 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; @@ -549,6 +672,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 }>) => { @@ -590,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 = @@ -742,7 +889,27 @@ export class PixelForgeApp extends BaseComponent { ${this.warningMessage ? html`
${this.warningMessage}
` : ""} + ${this.renderFileImportStatus()} `; } } + +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); +} + +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 746c0b9..dff6fc0 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); } }; @@ -774,9 +750,9 @@ export class PFMenuBar extends BaseComponent { Delete Current...
- +