Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions docs/verification/pwa.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
74 changes: 68 additions & 6 deletions src/components/app/pf-project-tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}
}

Expand All @@ -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 }));
}

Expand Down Expand Up @@ -236,7 +288,17 @@ export class PFProjectTabs extends BaseComponent {
+
</button>
</nav>
${this.errorMessage ? html`<div class="error">${this.errorMessage}</div>` : ''}
<span class="visually-hidden" role="status" aria-live="polite" aria-atomic="true"
>${this.errorMessage}</span
>
${this.errorMessage
? html`
<div class="error">
<span aria-hidden="true">${this.errorMessage}</span>
<button type="button" @click=${this.dismissFeedback}>Dismiss</button>
</div>
`
: ''}
`;
}
}
Expand Down
169 changes: 168 additions & 1 deletion src/components/app/pixel-forge-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@
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";
Expand Down Expand Up @@ -232,6 +240,57 @@
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;
Expand All @@ -247,10 +306,13 @@
@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();
Expand Down Expand Up @@ -304,9 +366,17 @@
"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 }>) => {
Expand All @@ -324,6 +394,59 @@
}, 2000);
};

private handleProjectFileImportReport = (
event: CustomEvent<ProjectFileImportReport>
) => {
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;

Expand Down Expand Up @@ -372,7 +495,7 @@

private handleShowOpenFileDialog = () => {
// Trigger the menu bar's open file method
const menuBar = this.shadowRoot?.querySelector("pf-menu-bar") as any;

Check warning on line 498 in src/components/app/pixel-forge-app.ts

View workflow job for this annotation

GitHub Actions / checks

Unexpected any. Specify a different type
menuBar?.openFile();
};

Expand Down Expand Up @@ -549,6 +672,14 @@
"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 }>) => {
Expand Down Expand Up @@ -590,6 +721,22 @@
localStorage.setItem("pf-timeline-height", String(this.timelineHeight));
};

private renderFileImportStatus() {
return html`
<span class="visually-hidden" role="status" aria-live="polite"
>${this.fileImportMessage ?? ""}</span
>
${this.fileImportMessage
? html`
<div class="file-import-status">
<span aria-hidden="true">${this.fileImportMessage}</span>
<button type="button" @click=${this.dismissFileImportMessage}>Dismiss</button>
</div>
`
: ""}
`;
}

render() {
// Access panel states signal to ensure reactive updates when timeline visibility changes
const isTimelineCollapsed =
Expand Down Expand Up @@ -742,7 +889,27 @@
${this.warningMessage
? html`<div class="warning-toast">${this.warningMessage}</div>`
: ""}
${this.renderFileImportStatus()}
<pf-pwa-update-toast></pf-pwa-update-toast>
`;
}
}

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");
}
Loading
Loading