diff --git a/src/app/editor/empty.tsx b/src/app/editor/empty.tsx index fbaaec18..b16b84f0 100644 --- a/src/app/editor/empty.tsx +++ b/src/app/editor/empty.tsx @@ -2,29 +2,46 @@ import { Button } from "@roprgm/ui/button"; import { Notice } from "@roprgm/ui/notice"; import { Section } from "@roprgm/ui/section"; import { Spinner } from "@roprgm/ui/spinner"; +import { + type ComponentProps, + type ReactNode, + type RefObject, + useRef, + useState, +} from "react"; +import { flushSync } from "react-dom"; import { type Gpu, target } from "vgpu"; import { useGpu } from "vgpu-react"; import type { Workspace } from "@/app/workspace"; import { EditorFrame, EditorLayout } from "@/components/editor/layout"; import { RendererProvider } from "@/components/editor/pipeline"; import { DocumentProvider } from "@/components/editor/session"; +import { BlankIcon } from "@/components/icons/blank"; +import { DropIcon } from "@/components/icons/drop"; +import { Icon } from "@/components/icons/icon"; +import { OpenIcon } from "@/components/icons/open"; +import { SampleIcon } from "@/components/icons/sample"; import { createDocument, createResources } from "@/core/document"; import { createImageSource } from "@/core/image"; import { imageFrame } from "@/core/image/frame"; import { MaskToolProvider } from "@/features/layers/mask-tool"; import { useDisposable } from "@/hooks/use-disposable"; +import { useShortcuts } from "@/hooks/use-shortcuts"; import { AdjustDock } from "./adjust"; import { Backdrop } from "./backdrop"; import { DockTabList } from "./dock"; import { EditorHeader } from "./header"; import { createImageLayer } from "./layers"; -import { OpenImage } from "./open"; +import { FileInput } from "./open"; import { createEditorRenderer } from "./renderer"; import { PlaceholderSidebar } from "./sidebar"; import { ToolTabList } from "./tool-rail"; import { tools } from "./tools"; -type OpenProps = { onOpen: (files: File[]) => void }; +type StartProps = { + onOpen: (files: File[]) => void; + onOpenSample: () => void; +}; type EmptyState = Exclude< ReturnType, @@ -54,36 +71,228 @@ function RecoverDraft({ onRecover, onForget }: Recovery) { ); } -function Status({ state, onOpen }: { state: EmptyState } & OpenProps) { - if (state.status === "loading") { - return ; - } - if (state.status === "error") { - return ( - <> -

- Couldn't open {state.file}: {state.error} -

- - - ); - } +/** Blank canvas sizes, narrowest first; Landscape is a 12 MP photo's 4:3. */ +const canvasSizes = [ + { name: "Portrait", width: 3000, height: 4000 }, + { name: "Square", width: 3000, height: 3000 }, + { name: "Landscape", width: 4000, height: 3000 }, + { name: "Widescreen", width: 3840, height: 2160 }, +]; + +/** A white SVG of that size, so a blank canvas opens, saves, and recovers as any image file does. */ +function blankImage(width: number, height: number) { + const svg = ``; + return new File([svg], "Untitled", { type: "image/svg+xml" }); +} + +/** A canvas's proportions, outlined in the frame the other icons' shapes fill. */ +function ShapeIcon({ width, height }: { width: number; height: number }) { + const scale = 14 / Math.max(width, height); + return ( + + + + ); +} + +/** + * A flat tile over the backdrop, a row in a narrow container and a column in a wide one; its icon + * takes the color of the backdrop's motes on hover. Between views, it morphs into the tile of the + * same name while their contents swap. + */ +function StartTile({ + name, + icon, + title, + hint, + ...props +}: ComponentProps<"button"> & { + name: string; + icon: ReactNode; + title: string; + hint: string; +}) { + return ( + + ); +} + +function StartTiles({ + blank, + onOpen, + onOpenSample, + onChooseSize, +}: StartProps & { + blank: RefObject; + onChooseSize: () => void; +}) { + const input = useRef(null); + return ( + <> +
+ } + title="Open a photo" + hint="JPEG, HEIC, TIFF, or RAW" + onClick={() => input.current?.click()} + /> + } + title="Blank canvas" + hint="Start from a white image" + onClick={onChooseSize} + /> + } + title="Sample photo" + hint="Try the tools on a demo" + onClick={onOpenSample} + /> +
+

+ + Or drop an image anywhere +

+ + + ); +} + +/** The blank canvas sizes, in the start tiles' places, so each tile morphs into one. */ +function CanvasSizes({ + first, + onOpen, + onBack, +}: Pick & { + first: RefObject; + onBack: () => void; +}) { + useShortcuts({ escape: onBack }); return ( <> +
+ {canvasSizes.map(({ name, width, height }, index) => ( + } + title={name} + hint={`${width} × ${height}`} + onClick={() => onOpen([blankImage(width, height)])} + /> + ))} +
+ + + ); +} + +/** The ways to begin under a heading; Blank canvas turns the tiles into its sizes and back. */ +function Starts({ + heading, + ...starts +}: StartProps & { + heading: ReactNode; +}) { + const [choosing, setChoosing] = useState(false); + const blank = useRef(null); + const first = useRef(null); + + /** Swaps the tiles inside a view transition where the browser has one, then focuses what shows. */ + function choose(next: boolean) { + const swap = () => { + flushSync(() => setChoosing(next)); + (next ? first : blank).current?.focus(); + }; + if ("startViewTransition" in window.document) { + window.document.startViewTransition(swap); + } else { + swap(); + } + } + + return ( +
+
{heading}
+ {choosing ? ( + choose(false)} + /> + ) : ( + choose(true)} + {...starts} + /> + )} +
+ ); +} + +function Welcome() { + return ( +

OpenLight

Edit photos in your browser.

- - +
); } +function Status({ state, ...starts }: { state: EmptyState } & StartProps) { + if (state.status === "loading") { + return ; + } + const heading = + state.status === "error" ? ( +

+ Couldn't open {state.file}: {state.error} +

+ ) : ( + + ); + return ; +} + /** A scene over a blank pixel, so the real panels mount with defaults; it never enters the workspace. */ function createEmptyDocument(gpu: Gpu) { const resources = createResources(); @@ -101,9 +310,9 @@ function createEmptyDocument(gpu: Gpu) { /** The editor shell without a document: the light backdrop and workspace status fill the canvas area, beside inert controls. */ export function EmptyEditor({ state, - onOpen, draft, -}: { state: EmptyState; draft?: Recovery } & OpenProps) { + ...starts +}: { state: EmptyState; draft?: Recovery } & StartProps) { const gpu = useGpu(); const document = useDisposable(() => createEmptyDocument(gpu), [gpu]); return ( @@ -118,9 +327,9 @@ export function EmptyEditor({ +
- + {draft && }
} diff --git a/src/app/editor/index.tsx b/src/app/editor/index.tsx index 15d6e229..5c5bb1c1 100644 --- a/src/app/editor/index.tsx +++ b/src/app/editor/index.tsx @@ -232,17 +232,26 @@ function DocumentEditor({ type EditorProps = { state: ReturnType; onOpen: (files: File[]) => void; + onOpenSample: () => void; onDismissFailure: () => void; draft?: Recovery; }; function EditorContent({ state, onOpen, + onOpenSample, onDismissFailure, draft, }: EditorProps) { if (state.status !== "ready") { - return ; + return ( + + ); } return ( diff --git a/src/app/editor/open.tsx b/src/app/editor/open.tsx index 95132c4a..72e79eda 100644 --- a/src/app/editor/open.tsx +++ b/src/app/editor/open.tsx @@ -6,7 +6,6 @@ import { createContext, type RefObject, useContext, useRef } from "react"; import { sceneExtension } from "@/app/loaders/scene"; import type { OpenFailure } from "@/app/workspace"; import { OpenIcon } from "@/components/icons/open"; -import { TextLink } from "@/components/ui/text-link"; import { accept } from "@/core/image/decode"; import { useShortcuts } from "@/hooks/use-shortcuts"; @@ -47,18 +46,6 @@ export function FileInput({ ); } -/** The drop hint doubles as the picker: "choose a file" opens the input. */ -export function OpenImage({ onOpen }: OpenProps) { - const input = useRef(null); - return ( -

- Drop an image here or{" "} - input.current?.click()}>choose a file - -

- ); -} - /** Opens another image or scene from the header, in place of the current document once it loads. */ export function OpenButton({ onOpen }: OpenProps) { const input = useRef(null); diff --git a/src/app/index.tsx b/src/app/index.tsx index 31f5f43b..75e9ffbf 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -5,8 +5,9 @@ import { Editor } from "@/app/editor"; import { useWorkspace } from "@/app/workspace/use-workspace"; import { useFileDrop } from "@/hooks/use-file-drop"; -/** Visiting /demo opens a bundled photo instead of the empty state. */ -const startup = location.pathname === "/demo" ? "/images/demo.jpg" : undefined; +/** A bundled photo that the empty state offers and that visiting /demo opens. */ +const samplePhoto = "/images/demo.jpg"; +const startup = location.pathname === "/demo" ? samplePhoto : undefined; /** The experimental assistant mounts only when the URL asks for it, as in `/?assistant`. */ const assistant = new URLSearchParams(location.search).has("assistant"); @@ -24,6 +25,7 @@ export function App() { void controls.loadUrl(samplePhoto)} onDismissFailure={workspace.dismissFailure} draft={recovery} /> diff --git a/src/components/icons/blank.tsx b/src/components/icons/blank.tsx new file mode 100644 index 00000000..26d2a53f --- /dev/null +++ b/src/components/icons/blank.tsx @@ -0,0 +1,10 @@ +import { Icon, type IconProps } from "./icon"; + +export function BlankIcon(props: IconProps) { + return ( + + + + + ); +} diff --git a/src/components/icons/drop.tsx b/src/components/icons/drop.tsx new file mode 100644 index 00000000..e4445383 --- /dev/null +++ b/src/components/icons/drop.tsx @@ -0,0 +1,9 @@ +import { Icon, type IconProps } from "./icon"; + +export function DropIcon(props: IconProps) { + return ( + + + + ); +} diff --git a/src/components/icons/sample.tsx b/src/components/icons/sample.tsx new file mode 100644 index 00000000..2c445832 --- /dev/null +++ b/src/components/icons/sample.tsx @@ -0,0 +1,11 @@ +import { Icon, type IconProps } from "./icon"; + +export function SampleIcon(props: IconProps) { + return ( + + + + + + ); +} diff --git a/src/index.css b/src/index.css index 5a3d04d4..0ef3ce0b 100644 --- a/src/index.css +++ b/src/index.css @@ -34,3 +34,38 @@ color: var(--color-foreground); } } + +/* A start tile's box morphs into the one that takes its place, as the assistant's card moves; + its content leaves before the new content shows, so their text never overlaps. */ +::view-transition-group(.start-tile), +::view-transition-group(.start-content) { + animation-duration: 300ms; + animation-timing-function: cubic-bezier(0.2, 0, 0, 1); +} + +::view-transition-old(.start-content) { + animation-duration: 100ms; +} + +::view-transition-new(.start-content) { + animation-delay: 100ms; + animation-duration: 200ms; +} + +/* A tile with no counterpart shows once the others have moved aside, and leaves before they do. */ +::view-transition-new(.start-tile):only-child { + animation-delay: 150ms; + animation-duration: 150ms; +} + +::view-transition-old(.start-tile):only-child { + animation-duration: 100ms; +} + +@media (prefers-reduced-motion: reduce) { + ::view-transition-group(*), + ::view-transition-old(*), + ::view-transition-new(*) { + animation: none; + } +} diff --git a/tests/start.e2e.ts b/tests/start.e2e.ts new file mode 100644 index 00000000..4a149bb8 --- /dev/null +++ b/tests/start.e2e.ts @@ -0,0 +1,22 @@ +import { expect, test } from "./fixtures"; +import { readImage } from "./images"; + +test("start from a blank canvas or the sample photo", async ({ page }) => { + const opened = () => + page.evaluate(() => { + const { documentId, file, size } = window.openlight.getState(); + return documentId && { file, size }; + }); + await page.goto("/"); + await page.getByRole("button", { name: /Blank canvas/ }).click(); + await page.getByRole("button", { name: /Square/ }).click(); + await expect.poll(opened).toEqual({ file: "Untitled", size: [3000, 3000] }); + expect(await readImage(page)).toMatchObject({ + size: [3000, 3000], + center: [255, 255, 255, 255], + }); + + await page.goto("/"); + await page.getByRole("button", { name: /Sample photo/ }).click(); + await expect.poll(opened).toEqual({ file: "demo.jpg", size: [1400, 764] }); +});