mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -1,7 +1,15 @@
|
|||||||
import { TOOLS } from "@snapotter/shared";
|
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 { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { formatFileSize, triggerDownload } from "@/lib/download";
|
import { formatFileSize, triggerDownload } from "@/lib/download";
|
||||||
import { ICON_MAP } from "@/lib/icon-map";
|
import { ICON_MAP } from "@/lib/icon-map";
|
||||||
import { getSuggestedTools } from "@/lib/suggested-tools";
|
import { getSuggestedTools } from "@/lib/suggested-tools";
|
||||||
@@ -103,6 +111,15 @@ export function ReviewPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Suggested tools */}
|
||||||
{suggestedTools.length > 0 && (
|
{suggestedTools.length > 0 && (
|
||||||
<div className="space-y-2">
|
<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 { useEditorStore } from "@/stores/editor-store";
|
||||||
import type { CanvasObject } from "@/types/editor";
|
import type { CanvasObject } from "@/types/editor";
|
||||||
import { BrushCursorOverlay, useEditorCursor } from "./common/custom-cursor";
|
import { BrushCursorOverlay, useEditorCursor } from "./common/custom-cursor";
|
||||||
|
import { LoadingOverlay } from "./common/loading-overlay";
|
||||||
import { useBrushTool } from "./tools/brush-tool";
|
import { useBrushTool } from "./tools/brush-tool";
|
||||||
import { useEraserTool } from "./tools/eraser-tool";
|
import { useEraserTool } from "./tools/eraser-tool";
|
||||||
import { useFillTool } from "./tools/fill-tool";
|
import { useFillTool } from "./tools/fill-tool";
|
||||||
@@ -391,6 +392,7 @@ export function EditorCanvas() {
|
|||||||
)}
|
)}
|
||||||
</Stage>
|
</Stage>
|
||||||
<BrushCursorOverlay containerRef={containerRef} />
|
<BrushCursorOverlay containerRef={containerRef} />
|
||||||
|
<LoadingOverlay />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,11 +40,13 @@ export function EditorRightPanel() {
|
|||||||
{sourceImageUrl && <NavigatorPanel />}
|
{sourceImageUrl && <NavigatorPanel />}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex items-center border-b border-border">
|
<div className="flex items-center border-b border-border" role="tablist">
|
||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab.id}
|
||||||
onClick={() => setTab(tab.id)}
|
onClick={() => setTab(tab.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 py-2 text-xs font-medium text-center transition-colors",
|
"flex-1 py-2 text-xs font-medium text-center transition-colors",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// apps/web/src/pages/editor-page.tsx
|
// apps/web/src/pages/editor-page.tsx
|
||||||
|
import { Monitor } from "lucide-react";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { WelcomeScreen } from "@/components/editor/common/welcome-screen";
|
import { WelcomeScreen } from "@/components/editor/common/welcome-screen";
|
||||||
import { EditorCanvas } from "@/components/editor/editor-canvas";
|
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 { EditorRightPanel } from "@/components/editor/editor-right-panel";
|
||||||
import { EditorStatusBar } from "@/components/editor/editor-status-bar";
|
import { EditorStatusBar } from "@/components/editor/editor-status-bar";
|
||||||
import { EditorToolbar } from "@/components/editor/editor-toolbar";
|
import { EditorToolbar } from "@/components/editor/editor-toolbar";
|
||||||
|
import { useMobile } from "@/hooks/use-mobile";
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
|
|
||||||
export function EditorPage() {
|
export function EditorPage() {
|
||||||
|
const isMobile = useMobile();
|
||||||
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
|
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
|
||||||
const isDirty = useEditorStore((s) => s.isDirty);
|
const isDirty = useEditorStore((s) => s.isDirty);
|
||||||
const loadImage = useEditorStore((s) => s.loadImage);
|
const loadImage = useEditorStore((s) => s.loadImage);
|
||||||
@@ -59,6 +62,19 @@ export function EditorPage() {
|
|||||||
}
|
}
|
||||||
}, [loadImage]);
|
}, [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 (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
<EditorOptionsBar />
|
<EditorOptionsBar />
|
||||||
|
|||||||
Reference in New Issue
Block a user