|
| 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