feat: add editor polish (mobile handling, loading states, accessibility)

- Show desktop-recommended message on mobile viewports (<768px)
- Add LoadingOverlay component for canvas loading/progress states
- Add ARIA tablist/tab/aria-selected to right panel tabs
- Add "Open in Editor" link in review panel after tool processing
This commit is contained in:
SnapOtter
2026-05-07 09:16:20 +08:00
parent 791bc2d3ce
commit 76501c4fb3
5 changed files with 95 additions and 3 deletions
@@ -1,7 +1,15 @@
import { TOOLS } from "@snapotter/shared";
import { ArrowRight, ChevronDown, ChevronRight, Download, FileImage, Undo2 } from "lucide-react";
import {
ArrowRight,
ChevronDown,
ChevronRight,
Download,
FileImage,
PenTool,
Undo2,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import { formatFileSize, triggerDownload } from "@/lib/download";
import { ICON_MAP } from "@/lib/icon-map";
import { getSuggestedTools } from "@/lib/suggested-tools";
@@ -103,6 +111,15 @@ export function ReviewPanel({
</button>
</div>
{/* Open in Editor */}
<Link
to={`/editor?url=${encodeURIComponent(downloadUrl)}`}
className="flex items-center justify-center gap-1.5 w-full py-2 rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted text-xs font-medium"
>
<PenTool className="h-3.5 w-3.5" />
Open in Editor
</Link>
{/* Suggested tools */}
{suggestedTools.length > 0 && (
<div className="space-y-2">
@@ -0,0 +1,55 @@
// apps/web/src/components/editor/common/loading-overlay.tsx
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
export function LoadingOverlay() {
const loadingState = useEditorStore((s) => s.loadingState);
const setLoadingState = useEditorStore((s) => s.setLoadingState);
if (!loadingState) return null;
return (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/60 backdrop-blur-sm">
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-card border border-border shadow-lg min-w-[200px]">
{/* Operation name */}
<p className="text-sm font-medium text-foreground">{loadingState.operation}</p>
{/* Progress bar */}
{loadingState.progress !== null && (
<div className="w-full h-1.5 rounded-full bg-muted overflow-hidden">
<div
className={cn(
"h-full rounded-full bg-primary transition-all duration-200",
loadingState.progress < 0 && "animate-pulse w-full",
)}
style={
loadingState.progress >= 0
? { width: `${Math.min(100, loadingState.progress)}%` }
: undefined
}
/>
</div>
)}
{/* Indeterminate spinner when no progress */}
{loadingState.progress === null && (
<div className="w-5 h-5 border-2 border-muted border-t-primary rounded-full animate-spin" />
)}
{/* Cancel button */}
{loadingState.cancellable && (
<button
type="button"
onClick={() => setLoadingState(null)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground mt-1"
aria-label="Cancel operation"
>
<X size={12} />
Cancel
</button>
)}
</div>
</div>
);
}
@@ -21,6 +21,7 @@ import { useEditorShortcuts } from "@/hooks/use-editor-shortcuts";
import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject } from "@/types/editor";
import { BrushCursorOverlay, useEditorCursor } from "./common/custom-cursor";
import { LoadingOverlay } from "./common/loading-overlay";
import { useBrushTool } from "./tools/brush-tool";
import { useEraserTool } from "./tools/eraser-tool";
import { useFillTool } from "./tools/fill-tool";
@@ -391,6 +392,7 @@ export function EditorCanvas() {
)}
</Stage>
<BrushCursorOverlay containerRef={containerRef} />
<LoadingOverlay />
</div>
);
}
@@ -40,11 +40,13 @@ export function EditorRightPanel() {
{sourceImageUrl && <NavigatorPanel />}
{/* Tabs */}
<div className="flex items-center border-b border-border">
<div className="flex items-center border-b border-border" role="tablist">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={activeTab === tab.id}
onClick={() => setTab(tab.id)}
className={cn(
"flex-1 py-2 text-xs font-medium text-center transition-colors",
+16
View File
@@ -1,4 +1,5 @@
// apps/web/src/pages/editor-page.tsx
import { Monitor } from "lucide-react";
import { useCallback, useEffect } from "react";
import { WelcomeScreen } from "@/components/editor/common/welcome-screen";
import { EditorCanvas } from "@/components/editor/editor-canvas";
@@ -6,9 +7,11 @@ import { EditorOptionsBar } from "@/components/editor/editor-options-bar";
import { EditorRightPanel } from "@/components/editor/editor-right-panel";
import { EditorStatusBar } from "@/components/editor/editor-status-bar";
import { EditorToolbar } from "@/components/editor/editor-toolbar";
import { useMobile } from "@/hooks/use-mobile";
import { useEditorStore } from "@/stores/editor-store";
export function EditorPage() {
const isMobile = useMobile();
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
const isDirty = useEditorStore((s) => s.isDirty);
const loadImage = useEditorStore((s) => s.loadImage);
@@ -59,6 +62,19 @@ export function EditorPage() {
}
}, [loadImage]);
if (isMobile) {
return (
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
<Monitor size={48} className="text-muted-foreground mb-4" />
<h2 className="text-lg font-semibold text-foreground mb-2">Desktop Recommended</h2>
<p className="text-sm text-muted-foreground max-w-sm">
The image editor works best on desktop screens. Please switch to a device with a larger
display for the full editing experience.
</p>
</div>
);
}
return (
<div className="flex flex-col h-full overflow-hidden">
<EditorOptionsBar />