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
259 changes: 234 additions & 25 deletions src/app/editor/empty.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Workspace["state"]["getState"]>,
Expand Down Expand Up @@ -54,36 +71,228 @@ function RecoverDraft({ onRecover, onForget }: Recovery) {
);
}

function Status({ state, onOpen }: { state: EmptyState } & OpenProps) {
if (state.status === "loading") {
return <Spinner className="size-5" />;
}
if (state.status === "error") {
return (
<>
<p className="max-w-md text-center text-secondary">
Couldn't open {state.file}: {state.error}
</p>
<OpenImage onOpen={onOpen} />
</>
);
}
/** 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 },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{ 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect width="100%" height="100%" fill="white"/></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 (
<Icon viewBox="0 0 20 20">
<rect
x={10 - (width * scale) / 2}
y={10 - (height * scale) / 2}
width={width * scale}
height={height * scale}
rx="2"
/>
</Icon>
);
}

/**
* 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 (
<button
type="button"
style={{ viewTransitionName: name }}
className="group flex cursor-pointer rounded-xl bg-white/4 p-4 text-left ring-1 ring-white/6 ring-inset backdrop-blur-xs transition-colors [view-transition-class:start-tile] hover:bg-white/8 hover:ring-white/10 focus-ring @lg:h-36"
{...props}
>
<span
style={{ viewTransitionName: `${name}-content` }}
className="flex flex-1 items-center gap-3 [view-transition-class:start-content] @lg:flex-col @lg:items-start @lg:justify-between"
>
<span className="flex text-secondary transition-colors group-hover:text-[oklch(90%_0.045_85)] *:size-6">
{icon}
</span>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{title}</span>
<span className="text-muted">{hint}</span>
</span>
</span>
</button>
);
}

function StartTiles({
blank,
onOpen,
onOpenSample,
onChooseSize,
}: StartProps & {
blank: RefObject<HTMLButtonElement | null>;
onChooseSize: () => void;
}) {
const input = useRef<HTMLInputElement>(null);
return (
<>
<div className="grid w-full max-w-[33rem] gap-2.5 @lg:grid-cols-3">
<StartTile
name="start-0"
icon={<OpenIcon />}
title="Open a photo"
hint="JPEG, HEIC, TIFF, or RAW"
onClick={() => input.current?.click()}
/>
<StartTile
ref={blank}
name="start-1"
icon={<BlankIcon />}
title="Blank canvas"
hint="Start from a white image"
onClick={onChooseSize}
/>
<StartTile
name="start-2"
icon={<SampleIcon />}
title="Sample photo"
hint="Try the tools on a demo"
onClick={onOpenSample}
/>
</div>
<p className="flex items-center gap-1.5 text-muted">
<DropIcon className="size-4" />
Or drop an image anywhere
</p>
<FileInput ref={input} onOpen={onOpen} />
</>
);
}

/** The blank canvas sizes, in the start tiles' places, so each tile morphs into one. */
function CanvasSizes({
first,
onOpen,
onBack,
}: Pick<StartProps, "onOpen"> & {
first: RefObject<HTMLButtonElement | null>;
onBack: () => void;
}) {
useShortcuts({ escape: onBack });
return (
<>
<div className="grid w-full max-w-[44rem] grid-cols-2 gap-2.5 @lg:grid-cols-4">
{canvasSizes.map(({ name, width, height }, index) => (
<StartTile
key={name}
ref={index === 0 ? first : undefined}
name={`start-${index}`}
icon={<ShapeIcon width={width} height={height} />}
title={name}
hint={`${width} × ${height}`}
onClick={() => onOpen([blankImage(width, height)])}
/>
))}
</div>
<Button variant="ghost" size="sm" onClick={onBack}>
Back
</Button>
</>
);
}

/** 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<HTMLButtonElement>(null);
const first = useRef<HTMLButtonElement>(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 (
<div className="@container flex w-full flex-col items-center gap-6 md:gap-8">
<div style={{ viewTransitionName: "start-heading" }}>{heading}</div>
{choosing ? (
<CanvasSizes
first={first}
onOpen={starts.onOpen}
onBack={() => choose(false)}
/>
) : (
<StartTiles
blank={blank}
onChooseSize={() => choose(true)}
{...starts}
/>
)}
</div>
);
}

function Welcome() {
return (
<div className="flex flex-col items-center gap-1.5">
<img
alt=""
className="mb-1.5 w-16"
height="64"
className="mb-1 w-12"
height="48"
src="/logo.svg"
width="64"
width="48"
/>
<h1 className="text-2xl font-bold">OpenLight</h1>
<p className="text-secondary">Edit photos in your browser.</p>
<OpenImage onOpen={onOpen} />
</>
</div>
);
}

function Status({ state, ...starts }: { state: EmptyState } & StartProps) {
if (state.status === "loading") {
return <Spinner className="size-5" />;
}
const heading =
state.status === "error" ? (
<p className="max-w-md text-center text-secondary">
Couldn't open {state.file}: {state.error}
</p>
) : (
<Welcome />
);
return <Starts heading={heading} {...starts} />;
}

/** 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();
Expand All @@ -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 (
Expand All @@ -118,9 +327,9 @@ export function EmptyEditor({
<EditorLayout
inert
canvas={
<div className="relative isolate grid min-h-0 min-w-0 flex-1 place-content-center justify-items-center gap-1.5 overflow-hidden bg-[radial-gradient(circle,#292929,#131313_55%)] p-6">
<div className="relative isolate flex min-h-0 min-w-0 flex-1 flex-col items-center justify-center overflow-hidden bg-[radial-gradient(circle,#292929,#131313_55%)] p-6">
<Backdrop />
<Status state={state} onOpen={onOpen} />
<Status state={state} {...starts} />
{draft && <RecoverDraft {...draft} />}
</div>
}
Expand Down
11 changes: 10 additions & 1 deletion src/app/editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,17 +232,26 @@ function DocumentEditor({
type EditorProps = {
state: ReturnType<Workspace["state"]["getState"]>;
onOpen: (files: File[]) => void;
onOpenSample: () => void;
onDismissFailure: () => void;
draft?: Recovery;
};
function EditorContent({
state,
onOpen,
onOpenSample,
onDismissFailure,
draft,
}: EditorProps) {
if (state.status !== "ready") {
return <EmptyEditor state={state} onOpen={onOpen} draft={draft} />;
return (
<EmptyEditor
state={state}
onOpen={onOpen}
onOpenSample={onOpenSample}
draft={draft}
/>
);
}
return (
<DocumentProvider key={state.document.id} value={state.document}>
Expand Down
13 changes: 0 additions & 13 deletions src/app/editor/open.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<HTMLInputElement>(null);
return (
<p className="mt-4 text-secondary">
Drop an image here or{" "}
<TextLink onClick={() => input.current?.click()}>choose a file</TextLink>
<FileInput ref={input} onOpen={onOpen} />
</p>
);
}

/** 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<HTMLInputElement>(null);
Expand Down
6 changes: 4 additions & 2 deletions src/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -24,6 +25,7 @@ export function App() {
<Editor
state={state}
onOpen={controls.openFiles}
onOpenSample={() => void controls.loadUrl(samplePhoto)}
onDismissFailure={workspace.dismissFailure}
draft={recovery}
/>
Expand Down
Loading
Loading