diff --git a/messages/en.json b/messages/en.json
index 8ffd105..ecb3a8c 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -145,5 +145,15 @@
"update_reload": "Reload",
"license_label_license": "License",
- "license_label_author": "Author"
+ "license_label_author": "Author",
+
+ "typst_status_loading_wasm": "Loading Typst engine…",
+ "typst_status_fetching_packages": "Fetching Typst package: {package}",
+ "typst_status_compiling": "Compiling Typst…",
+ "typst_error_no_main": "Could not find a Typst main file (main.typ)",
+ "typst_error_compile_failed": "Typst compile failed ({count} errors)",
+ "typst_error_runtime_init": "Failed to initialize Typst engine",
+ "typst_error_timeout": "Typst compilation timed out",
+ "typst_diag_severity_error": "error",
+ "typst_diag_severity_warning": "warning"
}
diff --git a/messages/ja.json b/messages/ja.json
index 0290281..2d413be 100644
--- a/messages/ja.json
+++ b/messages/ja.json
@@ -145,5 +145,15 @@
"update_reload": "再読み込み",
"license_label_license": "ライセンス",
- "license_label_author": "作者"
+ "license_label_author": "作者",
+
+ "typst_status_loading_wasm": "Typst エンジンを読み込み中…",
+ "typst_status_fetching_packages": "Typst パッケージを取得中: {package}",
+ "typst_status_compiling": "Typst をコンパイル中…",
+ "typst_error_no_main": "Typst の主ファイル (main.typ) が見つかりません",
+ "typst_error_compile_failed": "Typst のコンパイルに失敗しました ({count} 件のエラー)",
+ "typst_error_runtime_init": "Typst エンジンの初期化に失敗しました",
+ "typst_error_timeout": "Typst のコンパイルがタイムアウトしました",
+ "typst_diag_severity_error": "エラー",
+ "typst_diag_severity_warning": "警告"
}
diff --git a/package.json b/package.json
index f70dd49..8f135c2 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,8 @@
"dependencies": {
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
+ "@myriaddreamin/typst-ts-web-compiler": "^0.6.0",
+ "@myriaddreamin/typst.ts": "^0.6.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slot": "^1.2.4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 49f351e..0c97311 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -14,6 +14,12 @@ importers:
'@fontsource/geist-sans':
specifier: ^5.2.5
version: 5.2.5
+ '@myriaddreamin/typst-ts-web-compiler':
+ specifier: ^0.6.0
+ version: 0.6.0
+ '@myriaddreamin/typst.ts':
+ specifier: ^0.6.0
+ version: 0.6.0(@myriaddreamin/typst-ts-web-compiler@0.6.0)
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -1073,6 +1079,20 @@ packages:
'@lix-js/server-protocol-schema@0.1.1':
resolution: {integrity: sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==}
+ '@myriaddreamin/typst-ts-web-compiler@0.6.0':
+ resolution: {integrity: sha512-P/eIJ5RnfElj0NYzn5PI296t/IwWtgqUyyTMi5Jm5X3V5kZfskkH+LI7mSQe8tEyxwgCvxbxvFe5adinA3K8Gg==}
+
+ '@myriaddreamin/typst.ts@0.6.0':
+ resolution: {integrity: sha512-IUpetG0NyF2H6eXRm4j+NsbanJHIvyrffHEijqYb6q128sLWQgT1FYJS+h7dtjXmrBEfnIl1mI80DyfDR6kB/w==}
+ peerDependencies:
+ '@myriaddreamin/typst-ts-renderer': ^0.6.0
+ '@myriaddreamin/typst-ts-web-compiler': ^0.6.0
+ peerDependenciesMeta:
+ '@myriaddreamin/typst-ts-renderer':
+ optional: true
+ '@myriaddreamin/typst-ts-web-compiler':
+ optional: true
+
'@napi-rs/canvas-android-arm64@0.1.99':
resolution: {integrity: sha512-9OCRt8VVxA17m32NWZKyNC2qamdaS/SC5CEOIQwFngRq0DIeVm4PDal+6Ljnhqm2whZiC63DNuKZ4xSp2nbj9w==}
engines: {node: '>= 10'}
@@ -4907,6 +4927,14 @@ snapshots:
'@lix-js/server-protocol-schema@0.1.1': {}
+ '@myriaddreamin/typst-ts-web-compiler@0.6.0': {}
+
+ '@myriaddreamin/typst.ts@0.6.0(@myriaddreamin/typst-ts-web-compiler@0.6.0)':
+ dependencies:
+ idb: 7.1.1
+ optionalDependencies:
+ '@myriaddreamin/typst-ts-web-compiler': 0.6.0
+
'@napi-rs/canvas-android-arm64@0.1.99':
optional: true
diff --git a/src/components/TypstDiagnosticList.test.tsx b/src/components/TypstDiagnosticList.test.tsx
new file mode 100644
index 0000000..57ade38
--- /dev/null
+++ b/src/components/TypstDiagnosticList.test.tsx
@@ -0,0 +1,31 @@
+import { render } from "vitest-browser-react";
+import { describe, expect, it } from "vitest";
+import type { TypstDiagnostic } from "#src/lib/typst";
+import { TypstDiagnosticList } from "./TypstDiagnosticList";
+
+describe("TypstDiagnosticList", () => {
+ it("renders one row per diagnostic with file:line:col and message", async () => {
+ const items: TypstDiagnostic[] = [
+ {
+ severity: "error",
+ path: "main.typ",
+ line: 12,
+ column: 5,
+ message: "unknown variable: foo",
+ },
+ {
+ severity: "warning",
+ path: "main.typ",
+ line: 20,
+ column: 1,
+ message: "deprecated",
+ },
+ ];
+ const screen = await render();
+ await expect.element(screen.getByText("main.typ:12:5")).toBeVisible();
+ await expect
+ .element(screen.getByText("unknown variable: foo"))
+ .toBeVisible();
+ await expect.element(screen.getByText("main.typ:20:1")).toBeVisible();
+ });
+});
diff --git a/src/components/TypstDiagnosticList.tsx b/src/components/TypstDiagnosticList.tsx
new file mode 100644
index 0000000..2168e7f
--- /dev/null
+++ b/src/components/TypstDiagnosticList.tsx
@@ -0,0 +1,47 @@
+import { AlertCircleIcon, AlertTriangleIcon } from "lucide-react";
+import type { TypstDiagnostic } from "#src/lib/typst";
+import { cn } from "#src/lib/utils";
+import * as m from "#src/paraglide/messages.js";
+
+interface Props {
+ items: TypstDiagnostic[];
+ max?: number;
+}
+
+export function TypstDiagnosticList({ items, max = 20 }: Props) {
+ const errors = items.filter((d) => d.severity === "error").length;
+ const shown = items.slice(0, max);
+ const remaining = items.length - shown.length;
+ return (
+
+
+ {m.typst_error_compile_failed({ count: String(errors) })}
+
+
+ {shown.map((d, i) => (
+ -
+ {d.severity === "error" ? (
+
+ ) : (
+
+ )}
+
+ {d.path}:{d.line}:{d.column}
+
+
+ {d.message}
+
+
+ ))}
+
+ {remaining > 0 && (
+
+ {remaining} more
+ )}
+
+ );
+}
diff --git a/src/lib/recent-store.ts b/src/lib/recent-store.ts
index d5ec31f..76ffc9b 100644
--- a/src/lib/recent-store.ts
+++ b/src/lib/recent-store.ts
@@ -1,16 +1,35 @@
import { type DBSchema, type IDBPDatabase, openDB } from "idb";
-export type RecentFile = {
+export interface RecentPdfFile {
+ kind?: "pdf";
id: string;
name: string;
- lastOpened: number;
handle?: FileSystemFileHandle;
configHandle?: FileSystemFileHandle;
configName?: string;
file?: File;
configFile?: File;
+ lastOpened: number;
thumbnail?: string;
-};
+}
+
+export interface RecentTypstFile {
+ kind: "typst";
+ id: string;
+ name: string;
+ mainPath: string;
+ handle?: FileSystemFileHandle;
+ assetHandles?: FileSystemFileHandle[];
+ file?: File;
+ assetFiles?: File[];
+ configHandle?: FileSystemFileHandle;
+ configFile?: File;
+ configName?: string;
+ lastOpened: number;
+ thumbnail?: string;
+}
+
+export type RecentFile = RecentPdfFile | RecentTypstFile;
export type Settings = {
saveHistory: boolean;
@@ -70,14 +89,22 @@ export function openDb(): Promise {
export async function getRecentFiles(
db: IDBPDatabase,
): Promise {
- return db.getAll(DB_STORE);
+ const all = await db.getAll(DB_STORE);
+ // Migration: entries stored before the discriminated union had no `kind`.
+ // Treat missing `kind` as "pdf".
+ return all.map((item) =>
+ item.kind === undefined ? { ...item, kind: "pdf" as const } : item,
+ );
}
export async function getRecentFileById(
db: IDBPDatabase,
id: string,
): Promise {
- return db.get(DB_STORE, id);
+ const item = await db.get(DB_STORE, id);
+ if (!item) return undefined;
+ // Migration: entries stored before the discriminated union had no `kind`.
+ return item.kind === undefined ? { ...item, kind: "pdf" as const } : item;
}
export async function upsertRecent(
diff --git a/src/lib/typst-source-detect.test.ts b/src/lib/typst-source-detect.test.ts
new file mode 100644
index 0000000..2c7733e
--- /dev/null
+++ b/src/lib/typst-source-detect.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, it } from "vitest";
+import {
+ containsTypst,
+ entriesToTypstSources,
+ filesToTypstSources,
+ pickMainTypst,
+ type TypstSource,
+} from "./typst-source-detect";
+
+const src = (path: string): TypstSource => ({ path, data: new Uint8Array() });
+
+describe("containsTypst", () => {
+ it("returns true when any file ends with .typ", () => {
+ expect(containsTypst([src("main.typ")])).toBe(true);
+ expect(containsTypst([src("a.pdf"), src("b.typ")])).toBe(true);
+ });
+ it("returns false otherwise", () => {
+ expect(containsTypst([src("a.pdf")])).toBe(false);
+ expect(containsTypst([])).toBe(false);
+ });
+});
+
+describe("pickMainTypst", () => {
+ it("returns the only .typ when single", () => {
+ expect(pickMainTypst([src("hello.typ")])).toBe("hello.typ");
+ });
+ it("prefers main.typ at root when multiple", () => {
+ expect(
+ pickMainTypst([src("intro.typ"), src("main.typ"), src("appendix.typ")]),
+ ).toBe("main.typ");
+ });
+ it("falls back to shallowest then alphabetical when no main.typ", () => {
+ expect(
+ pickMainTypst([
+ src("chapters/01.typ"),
+ src("chapters/02.typ"),
+ src("zoo.typ"),
+ src("alpha.typ"),
+ ]),
+ ).toBe("alpha.typ");
+ });
+ it("returns null when no .typ", () => {
+ expect(pickMainTypst([src("a.pdf")])).toBe(null);
+ });
+});
+
+describe("filesToTypstSources", () => {
+ it("uses webkitRelativePath when present, otherwise file name", async () => {
+ const a = new File(["hello"], "main.typ", { type: "text/plain" });
+ const b = new File([new Uint8Array([1, 2])], "logo.png");
+ Object.defineProperty(b, "webkitRelativePath", {
+ value: "assets/logo.png",
+ });
+ const result = await filesToTypstSources([a, b]);
+ expect(result.map((r) => r.path)).toEqual(["main.typ", "assets/logo.png"]);
+ expect(result[1].data).toEqual(new Uint8Array([1, 2]));
+ });
+});
+
+describe("entriesToTypstSources", () => {
+ it("recursively expands directory entries with relative paths", async () => {
+ type MockEntry = {
+ isFile: boolean;
+ isDirectory: boolean;
+ name: string;
+ file?: (cb: (f: File) => void) => void;
+ createReader?: () => {
+ readEntries: (cb: (e: MockEntry[]) => void) => void;
+ };
+ };
+ function fileEntry(name: string, content: string): MockEntry {
+ return {
+ isFile: true,
+ isDirectory: false,
+ name,
+ file: (cb: (f: File) => void) => cb(new File([content], name)),
+ };
+ }
+ function dirEntry(name: string, children: MockEntry[]): MockEntry {
+ return {
+ isFile: false,
+ isDirectory: true,
+ name,
+ createReader: () => {
+ let exhausted = false;
+ return {
+ readEntries: (cb: (e: MockEntry[]) => void) => {
+ if (exhausted) return cb([]);
+ exhausted = true;
+ cb(children);
+ },
+ };
+ },
+ };
+ }
+ const root = [
+ fileEntry("main.typ", "= Hi"),
+ dirEntry("img", [fileEntry("a.png", "x")]),
+ ];
+ const result = await entriesToTypstSources(root);
+ expect(result.map((r) => r.path).sort()).toEqual(["img/a.png", "main.typ"]);
+ });
+});
diff --git a/src/lib/typst-source-detect.ts b/src/lib/typst-source-detect.ts
new file mode 100644
index 0000000..718aab4
--- /dev/null
+++ b/src/lib/typst-source-detect.ts
@@ -0,0 +1,122 @@
+export interface TypstSource {
+ /** Project-root-relative path with no leading slash. e.g. "main.typ", "images/logo.png" */
+ path: string;
+ data: Uint8Array;
+}
+
+/**
+ * Path-only view of a Typst input. `containsTypst` and `pickMainTypst` only
+ * need the path; this lets callers decide whether to load the bytes (which is
+ * costly for large PDF decks that pass through the same `handleFiles` entry).
+ */
+export interface TypstSourceMeta {
+ path: string;
+}
+
+export function containsTypst(
+ sources: ReadonlyArray,
+): boolean {
+ return sources.some((s) => /\.typ$/i.test(s.path));
+}
+
+function depth(path: string): number {
+ return path.split("/").length;
+}
+
+export function pickMainTypst(
+ sources: ReadonlyArray,
+): string | null {
+ const typs = sources.filter((s) => /\.typ$/i.test(s.path));
+ if (typs.length === 0) return null;
+ if (typs.length === 1) return typs[0].path;
+ const root = typs.find((s) => s.path.toLowerCase() === "main.typ");
+ if (root) return root.path;
+ const sorted = [...typs].sort((a, b) => {
+ const d = depth(a.path) - depth(b.path);
+ if (d !== 0) return d;
+ return a.path.localeCompare(b.path);
+ });
+ return sorted[0].path;
+}
+
+type FsEntry = {
+ isFile: boolean;
+ isDirectory: boolean;
+ name: string;
+ file?: (cb: (f: File) => void, err?: (e: unknown) => void) => void;
+ createReader?: () => {
+ readEntries: (
+ cb: (entries: FsEntry[]) => void,
+ err?: (e: unknown) => void,
+ ) => void;
+ };
+};
+
+function readDirAll(
+ reader: ReturnType>,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const all: FsEntry[] = [];
+ const pump = () =>
+ reader.readEntries((batch) => {
+ if (batch.length === 0) return resolve(all);
+ all.push(...batch);
+ pump();
+ }, reject);
+ pump();
+ });
+}
+
+async function entryToSources(
+ entry: FsEntry,
+ prefix: string,
+): Promise {
+ const path = prefix ? `${prefix}/${entry.name}` : entry.name;
+ if (entry.isFile && entry.file) {
+ const file = await new Promise((res, rej) => entry.file!(res, rej));
+ return [{ path, data: new Uint8Array(await file.arrayBuffer()) }];
+ }
+ if (entry.isDirectory && entry.createReader) {
+ const reader = entry.createReader();
+ const children = await readDirAll(reader);
+ const nested = await Promise.all(
+ children.map((c) => entryToSources(c, path)),
+ );
+ return nested.flat();
+ }
+ return [];
+}
+
+export async function entriesToTypstSources(
+ entries: ReadonlyArray,
+): Promise {
+ const filtered = entries.filter((e): e is FsEntry => !!e);
+ const nested = await Promise.all(filtered.map((e) => entryToSources(e, "")));
+ return nested.flat();
+}
+
+/**
+ * Cheap path-only view of `File[]`. Use this for `containsTypst` /
+ * `pickMainTypst` checks before deciding whether to materialize the bytes
+ * via `filesToTypstSources`.
+ */
+export function filesToTypstMeta(files: File[]): TypstSourceMeta[] {
+ return files.map((f) => ({
+ path:
+ (f as File & { webkitRelativePath?: string }).webkitRelativePath ||
+ f.name,
+ }));
+}
+
+export async function filesToTypstSources(
+ files: File[],
+): Promise {
+ return Promise.all(
+ files.map(async (f) => ({
+ path:
+ (f as File & { webkitRelativePath?: string }).webkitRelativePath ||
+ f.name,
+ data: new Uint8Array(await f.arrayBuffer()),
+ })),
+ );
+}
diff --git a/src/lib/typst.test.ts b/src/lib/typst.test.ts
new file mode 100644
index 0000000..e7f8a4f
--- /dev/null
+++ b/src/lib/typst.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import { compileTypst } from "./typst";
+
+const enc = (s: string) => new TextEncoder().encode(s);
+
+describe("compileTypst", () => {
+ it("compiles a minimal .typ to a PDF", async () => {
+ const res = await compileTypst({
+ sources: [{ path: "main.typ", data: enc("= Hello\nWorld") }],
+ mainPath: "main.typ",
+ });
+ expect(res.ok).toBe(true);
+ if (res.ok) {
+ expect(res.pdf.length).toBeGreaterThan(100);
+ expect(new TextDecoder().decode(res.pdf.slice(0, 5))).toBe("%PDF-");
+ }
+ }, 60_000);
+
+ it("returns diagnostics on syntax error", async () => {
+ const res = await compileTypst({
+ sources: [{ path: "main.typ", data: enc("#let x = (\n") }],
+ mainPath: "main.typ",
+ });
+ expect(res.ok).toBe(false);
+ if (!res.ok) {
+ expect(res.diagnostics.length).toBeGreaterThan(0);
+ expect(res.diagnostics[0].severity).toBe("error");
+ }
+ }, 60_000);
+
+ it("emits loading-wasm progress before compiling", async () => {
+ const stages: string[] = [];
+ await compileTypst(
+ {
+ sources: [{ path: "main.typ", data: enc("= Hi") }],
+ mainPath: "main.typ",
+ },
+ { onProgress: (p) => stages.push(p.stage) },
+ );
+ expect(stages[0]).toBe("loading-wasm");
+ expect(stages).toContain("compiling");
+ }, 60_000);
+});
diff --git a/src/lib/typst.ts b/src/lib/typst.ts
new file mode 100644
index 0000000..91ff1d1
--- /dev/null
+++ b/src/lib/typst.ts
@@ -0,0 +1,84 @@
+import type { TypstSource } from "./typst-source-detect";
+export type { TypstSource } from "./typst-source-detect";
+
+export interface TypstDiagnostic {
+ severity: "error" | "warning";
+ path: string;
+ line: number; // 1-origin
+ column: number; // 1-origin
+ message: string;
+ package?: string;
+}
+
+export type TypstProgress =
+ | { stage: "loading-wasm" }
+ | { stage: "fetching-packages"; current?: string }
+ | { stage: "compiling" };
+
+export interface CompileRequest {
+ sources: TypstSource[];
+ mainPath: string;
+}
+
+export type CompileResult =
+ | { ok: true; pdf: Uint8Array }
+ | { ok: false; diagnostics: TypstDiagnostic[] };
+
+export interface CompileOptions {
+ signal?: AbortSignal;
+ onProgress?: (p: TypstProgress) => void;
+}
+
+export type WorkerInbound = { kind: "compile"; req: CompileRequest };
+export type WorkerOutbound =
+ | { kind: "progress"; payload: TypstProgress }
+ | { kind: "result"; payload: CompileResult }
+ | { kind: "fatal"; message: string };
+
+export function compileTypst(
+ req: CompileRequest,
+ opts: CompileOptions = {},
+): Promise {
+ return new Promise((resolve, reject) => {
+ const worker = new Worker(
+ new URL("../workers/typst-worker.ts", import.meta.url),
+ { type: "module" },
+ );
+ const cleanup = () => {
+ worker.terminate();
+ opts.signal?.removeEventListener("abort", onAbort);
+ };
+ const onAbort = () => {
+ cleanup();
+ reject(new DOMException("Aborted", "AbortError"));
+ };
+ if (opts.signal) {
+ if (opts.signal.aborted) return onAbort();
+ opts.signal.addEventListener("abort", onAbort);
+ }
+
+ worker.addEventListener("message", (ev: MessageEvent) => {
+ const msg = ev.data;
+ switch (msg.kind) {
+ case "progress":
+ opts.onProgress?.(msg.payload);
+ break;
+ case "result":
+ cleanup();
+ resolve(msg.payload);
+ break;
+ case "fatal":
+ cleanup();
+ reject(new Error(msg.message));
+ break;
+ }
+ });
+ worker.addEventListener("error", (ev) => {
+ cleanup();
+ reject(new Error(ev.message || "worker error"));
+ });
+
+ const inbound: WorkerInbound = { kind: "compile", req };
+ worker.postMessage(inbound);
+ });
+}
diff --git a/src/routes/$locale/(main)/-index/HeroSection.tsx b/src/routes/$locale/(main)/-index/HeroSection.tsx
index 9d57c54..eeb97cf 100644
--- a/src/routes/$locale/(main)/-index/HeroSection.tsx
+++ b/src/routes/$locale/(main)/-index/HeroSection.tsx
@@ -1,9 +1,55 @@
import { FileIcon, LinkIcon, PlayIcon, PlusIcon } from "lucide-react";
import { type DragEvent, useId, useRef, useState } from "react";
+import type React from "react";
import { Button } from "#src/components/ui/button";
import { cn } from "#src/lib/utils";
import * as m from "#src/paraglide/messages.js";
+type FsEntry = {
+ isFile: boolean;
+ isDirectory: boolean;
+ name: string;
+ file?: (cb: (f: File) => void, err?: (e: unknown) => void) => void;
+ createReader?: () => {
+ readEntries: (cb: (entries: FsEntry[]) => void, err?: (e: unknown) => void) => void;
+ };
+};
+
+async function expandDroppedItems(list: DataTransferItemList): Promise {
+ const entries: FsEntry[] = [];
+ for (const it of Array.from(list)) {
+ const getEntry = (it as DataTransferItem & {
+ webkitGetAsEntry?: () => FsEntry | null;
+ }).webkitGetAsEntry;
+ if (typeof getEntry === "function") {
+ const e = getEntry.call(it);
+ if (e) entries.push(e);
+ }
+ }
+ if (entries.length === 0) return [];
+
+ const out: File[] = [];
+ async function walk(entry: FsEntry, prefix: string): Promise {
+ const path = prefix ? `${prefix}/${entry.name}` : entry.name;
+ if (entry.isFile && entry.file) {
+ const file: File = await new Promise((res, rej) => entry.file!(res, rej));
+ Object.defineProperty(file, "webkitRelativePath", { value: path });
+ out.push(file);
+ return;
+ }
+ if (entry.isDirectory && entry.createReader) {
+ const reader = entry.createReader();
+ let batch: FsEntry[] = [];
+ do {
+ batch = await new Promise((res, rej) => reader.readEntries(res, rej));
+ for (const c of batch) await walk(c, path);
+ } while (batch.length > 0);
+ }
+ }
+ for (const e of entries) await walk(e, "");
+ return out;
+}
+
const isMac = /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent);
const DEMO_BASE =
@@ -20,7 +66,7 @@ function demoUrls(locale: string): { pdf: string; pdfpc: string } {
}
interface HeroSectionProps {
- status: string | null;
+ status: React.ReactNode | null;
inputId: string;
supportsFSA: boolean;
locale: string;
@@ -45,10 +91,20 @@ export function HeroSection({
const [pdfpcUrlValue, setPdfpcUrlValue] = useState("");
const urlFormId = useId();
- function handleDrop(event: DragEvent) {
+ async function handleDrop(event: DragEvent) {
event.preventDefault();
setDragActive(false);
- const files = Array.from(event.dataTransfer.files);
+ const items = event.dataTransfer.items;
+ const supportsEntries =
+ items != null &&
+ Array.from(items).some(
+ (it) =>
+ typeof (it as DataTransferItem & { webkitGetAsEntry?: unknown })
+ .webkitGetAsEntry === "function",
+ );
+ const files = supportsEntries
+ ? await expandDroppedItems(items)
+ : Array.from(event.dataTransfer.files);
if (files.length > 0) void onFilesSelected(files);
}
@@ -198,14 +254,14 @@ export function HeroSection({
{m.hero_url_hint()}
)}
- {status && (
-
+ {status !== null && status !== undefined && status !== false && (
+ {status}
)}