Skip to content

Commit 17a755c

Browse files
committed
feat(storymap): PDF handout generator for the Story Map plugin
Add a PDF Handout Generator to the Story Map panel (#830). A new "Handout (PDF)" dialog lets the user select which chapter views to include, choose the paper size and orientation, and set a document title and footer. Generating flies the live map to each selected chapter, captures the rendered view, and assembles a clean multi-page PDF (one chapter per page) saved through the existing Tauri/browser save bridge. The map returns to its original view when done. The PDF builder (storymap-pdf.ts) is pure and unit tested; map capture reuses the Print Layout captureMapImage helper. Fixes #830
1 parent 261beac commit 17a755c

5 files changed

Lines changed: 762 additions & 0 deletions

File tree

Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
1+
import { type RefObject, useCallback, useEffect, useMemo, useState } from "react";
2+
import { useTranslation } from "react-i18next";
3+
import type { StoryMap } from "@geolibre/core";
4+
import type { MapController } from "@geolibre/map";
5+
import {
6+
Button,
7+
Dialog,
8+
DialogContent,
9+
DialogDescription,
10+
DialogHeader,
11+
DialogTitle,
12+
Input,
13+
Label,
14+
ScrollArea,
15+
Select,
16+
Separator,
17+
} from "@geolibre/ui";
18+
import { FileDown, Loader2 } from "lucide-react";
19+
import { captureMapImage } from "../../lib/print-layout-export";
20+
import {
21+
PAPER_SIZES,
22+
type Orientation,
23+
type PaperSizeId,
24+
} from "../../lib/print-layout";
25+
import {
26+
buildStoryMapHandoutPdf,
27+
type HandoutChapter,
28+
} from "../../lib/storymap-pdf";
29+
import { saveBinaryFileWithFallback } from "../../lib/tauri-io";
30+
31+
interface StoryMapHandoutDialogProps {
32+
open: boolean;
33+
onOpenChange: (open: boolean) => void;
34+
story: StoryMap;
35+
mapControllerRef: RefObject<MapController | null>;
36+
}
37+
38+
/** Maximum time to wait for the map to settle (tiles loaded) per chapter. */
39+
const IDLE_TIMEOUT_MS = 5000;
40+
41+
/**
42+
* Jump the map to a chapter location and resolve once it has rendered all
43+
* tiles (the `idle` event), with a timeout so a chapter that never fully loads
44+
* (e.g. a throttled tab) cannot stall the whole export.
45+
*/
46+
function jumpAndWaitIdle(
47+
map: maplibregl.Map,
48+
location: StoryMap["chapters"][number]["location"],
49+
): Promise<void> {
50+
return new Promise((resolve) => {
51+
let settled = false;
52+
const finish = () => {
53+
if (settled) return;
54+
settled = true;
55+
map.off("idle", finish);
56+
clearTimeout(timer);
57+
resolve();
58+
};
59+
const timer = setTimeout(finish, IDLE_TIMEOUT_MS);
60+
map.on("idle", finish);
61+
map.jumpTo({
62+
center: location.center,
63+
zoom: location.zoom,
64+
pitch: location.pitch,
65+
bearing: location.bearing,
66+
});
67+
});
68+
}
69+
70+
function slugify(title: string): string {
71+
return (
72+
title
73+
.toLowerCase()
74+
.replace(/[^a-z0-9]+/g, "-")
75+
.replace(/^-+|-+$/g, "") || "story-map"
76+
);
77+
}
78+
79+
/**
80+
* Export selected story-map chapters as a multi-page PDF handout (GH #830).
81+
*
82+
* The user picks which chapter views to include, the paper size and
83+
* orientation, and a document title and footer. Generating flies the live map
84+
* to each selected chapter, captures the rendered view, and assembles a clean
85+
* PDF (one chapter per page) that is saved to disk. The map is returned to its
86+
* original view when the export finishes.
87+
*/
88+
export function StoryMapHandoutDialog({
89+
open,
90+
onOpenChange,
91+
story,
92+
mapControllerRef,
93+
}: StoryMapHandoutDialogProps) {
94+
const { t } = useTranslation();
95+
const chapters = story.chapters;
96+
97+
const [selected, setSelected] = useState<Record<string, boolean>>({});
98+
const [paperSize, setPaperSize] = useState<PaperSizeId>("a4");
99+
const [orientation, setOrientation] = useState<Orientation>("landscape");
100+
const [title, setTitle] = useState("");
101+
const [footer, setFooter] = useState("");
102+
const [generating, setGenerating] = useState(false);
103+
const [progress, setProgress] = useState<{ current: number; total: number } | null>(
104+
null,
105+
);
106+
const [error, setError] = useState<string | null>(null);
107+
108+
// Seed the selection (all chapters) and the title/footer from the story each
109+
// time the dialog opens, so it reflects the latest story without clobbering
110+
// edits made while it is open.
111+
useEffect(() => {
112+
if (!open) return;
113+
setSelected(Object.fromEntries(chapters.map((c) => [c.id, true])));
114+
setTitle(story.title);
115+
setFooter(story.footer);
116+
setError(null);
117+
setProgress(null);
118+
// Only re-seed on open; chapters/story are read at that moment.
119+
// eslint-disable-next-line react-hooks/exhaustive-deps
120+
}, [open]);
121+
122+
const selectedCount = useMemo(
123+
() => chapters.filter((c) => selected[c.id]).length,
124+
[chapters, selected],
125+
);
126+
127+
const allSelected = selectedCount === chapters.length && chapters.length > 0;
128+
129+
const toggleAll = useCallback(() => {
130+
const next = !allSelected;
131+
setSelected(Object.fromEntries(chapters.map((c) => [c.id, next])));
132+
}, [allSelected, chapters]);
133+
134+
const handleGenerate = useCallback(async () => {
135+
setError(null);
136+
const map = mapControllerRef.current?.getMap();
137+
if (!map) {
138+
setError(t("storymap.handout.noMap"));
139+
return;
140+
}
141+
const chosen = chapters.filter((c) => selected[c.id]);
142+
if (chosen.length === 0) {
143+
setError(t("storymap.handout.noneSelected"));
144+
return;
145+
}
146+
147+
const original = mapControllerRef.current?.readView();
148+
setGenerating(true);
149+
try {
150+
const captures: HandoutChapter[] = [];
151+
for (let i = 0; i < chosen.length; i++) {
152+
const chapter = chosen[i];
153+
setProgress({ current: i + 1, total: chosen.length });
154+
await jumpAndWaitIdle(map, chapter.location);
155+
const shot = captureMapImage(map);
156+
captures.push({
157+
title: chapter.title,
158+
description: chapter.description,
159+
image: shot.image,
160+
imageWidth: shot.width,
161+
imageHeight: shot.height,
162+
});
163+
}
164+
const bytes = buildStoryMapHandoutPdf(captures, {
165+
paperSize,
166+
orientation,
167+
title,
168+
footer,
169+
});
170+
const saved = await saveBinaryFileWithFallback(bytes, {
171+
defaultName: `${slugify(title || story.title)}-handout.pdf`,
172+
filters: [{ name: t("storymap.handout.pdfFile"), extensions: ["pdf"] }],
173+
browserTypes: [
174+
{
175+
description: t("storymap.handout.pdfFile"),
176+
accept: { "application/pdf": [".pdf"] },
177+
},
178+
],
179+
mimeType: "application/pdf",
180+
});
181+
if (saved !== null) onOpenChange(false);
182+
} catch (err) {
183+
setError(err instanceof Error ? err.message : String(err));
184+
} finally {
185+
// Always return the map to where the user left it, even on failure.
186+
if (original) {
187+
map.jumpTo({
188+
center: original.center,
189+
zoom: original.zoom,
190+
bearing: original.bearing,
191+
pitch: original.pitch,
192+
});
193+
}
194+
setGenerating(false);
195+
setProgress(null);
196+
}
197+
}, [
198+
chapters,
199+
selected,
200+
paperSize,
201+
orientation,
202+
title,
203+
footer,
204+
story.title,
205+
mapControllerRef,
206+
onOpenChange,
207+
t,
208+
]);
209+
210+
return (
211+
<Dialog
212+
open={open}
213+
onOpenChange={(next: boolean) => !generating && onOpenChange(next)}
214+
>
215+
<DialogContent className="flex max-h-[88vh] w-[min(92vw,34rem)] flex-col gap-0 p-0">
216+
<DialogHeader className="border-b px-5 py-4">
217+
<DialogTitle className="flex items-center gap-2">
218+
<FileDown className="h-4 w-4" />
219+
{t("storymap.handout.title")}
220+
</DialogTitle>
221+
<DialogDescription>
222+
{t("storymap.handout.description")}
223+
</DialogDescription>
224+
</DialogHeader>
225+
226+
<ScrollArea className="min-h-0 flex-1">
227+
<div className="space-y-4 px-5 py-4">
228+
<div>
229+
<div className="mb-2 flex items-center justify-between">
230+
<h3 className="text-sm font-semibold">
231+
{t("storymap.handout.screens", {
232+
count: selectedCount,
233+
total: chapters.length,
234+
})}
235+
</h3>
236+
<Button
237+
size="sm"
238+
variant="ghost"
239+
className="h-7 px-2 text-xs"
240+
onClick={toggleAll}
241+
>
242+
{allSelected
243+
? t("storymap.handout.selectNone")
244+
: t("storymap.handout.selectAll")}
245+
</Button>
246+
</div>
247+
<div className="space-y-1 rounded-md border p-2">
248+
{chapters.map((chapter, index) => (
249+
<label
250+
key={chapter.id}
251+
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted"
252+
>
253+
<input
254+
type="checkbox"
255+
checked={selected[chapter.id] ?? false}
256+
onChange={(e) =>
257+
setSelected((prev) => ({
258+
...prev,
259+
[chapter.id]: e.target.checked,
260+
}))
261+
}
262+
/>
263+
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted text-xs">
264+
{index + 1}
265+
</span>
266+
<span className="truncate">
267+
{chapter.title || t("storymap.untitledChapter")}
268+
</span>
269+
</label>
270+
))}
271+
</div>
272+
</div>
273+
274+
<Separator />
275+
276+
<div className="grid grid-cols-2 gap-3">
277+
<Field label={t("storymap.handout.paperSize")}>
278+
<Select
279+
value={paperSize}
280+
onChange={(e) =>
281+
setPaperSize(e.target.value as PaperSizeId)
282+
}
283+
>
284+
{PAPER_SIZES.filter((p) => p.group === "paper").map((p) => (
285+
<option key={p.id} value={p.id}>
286+
{p.label}
287+
</option>
288+
))}
289+
</Select>
290+
</Field>
291+
<Field label={t("storymap.handout.orientation")}>
292+
<Select
293+
value={orientation}
294+
onChange={(e) =>
295+
setOrientation(e.target.value as Orientation)
296+
}
297+
>
298+
<option value="portrait">
299+
{t("storymap.handout.portrait")}
300+
</option>
301+
<option value="landscape">
302+
{t("storymap.handout.landscape")}
303+
</option>
304+
</Select>
305+
</Field>
306+
</div>
307+
308+
<Field label={t("storymap.handout.documentTitle")}>
309+
<Input
310+
value={title}
311+
onChange={(e) => setTitle(e.target.value)}
312+
placeholder={t("storymap.handout.documentTitlePlaceholder")}
313+
/>
314+
</Field>
315+
<Field label={t("storymap.handout.footerText")}>
316+
<Input
317+
value={footer}
318+
onChange={(e) => setFooter(e.target.value)}
319+
placeholder={t("storymap.handout.footerPlaceholder")}
320+
/>
321+
</Field>
322+
</div>
323+
</ScrollArea>
324+
325+
<div className="flex items-center justify-between gap-2 border-t px-5 py-3">
326+
<div className="min-h-[1.25rem] text-xs">
327+
{error ? (
328+
<span className="text-destructive">{error}</span>
329+
) : progress ? (
330+
<span className="text-muted-foreground">
331+
{t("storymap.handout.progress", {
332+
current: progress.current,
333+
total: progress.total,
334+
})}
335+
</span>
336+
) : null}
337+
</div>
338+
<div className="flex items-center gap-2">
339+
<Button
340+
variant="outline"
341+
size="sm"
342+
disabled={generating}
343+
onClick={() => onOpenChange(false)}
344+
>
345+
{t("common.cancel")}
346+
</Button>
347+
<Button
348+
size="sm"
349+
disabled={generating || selectedCount === 0}
350+
onClick={() => void handleGenerate()}
351+
>
352+
{generating ? (
353+
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
354+
) : (
355+
<FileDown className="mr-1 h-4 w-4" />
356+
)}
357+
{t("storymap.handout.generate")}
358+
</Button>
359+
</div>
360+
</div>
361+
</DialogContent>
362+
</Dialog>
363+
);
364+
}
365+
366+
function Field({
367+
label,
368+
children,
369+
}: {
370+
label: string;
371+
children: React.ReactNode;
372+
}) {
373+
return (
374+
<div className="space-y-1">
375+
<Label className="text-xs">{label}</Label>
376+
{children}
377+
</div>
378+
);
379+
}

0 commit comments

Comments
 (0)