| 1 | "use client"; |
| 2 | |
| 3 | import { Check, ChevronDown, Loader2 } from "lucide-react"; |
| 4 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 5 | |
| 6 | import { |
| 7 | applyThemeToSyntax, |
| 8 | changeInfographicTemplate, |
| 9 | convertInfographicData, |
| 10 | parseInfographicTemplate, |
| 11 | type InfographicPaletteThemeColors, |
| 12 | } from "@/components/notebook/presentation/editor/utils/infographic-utils"; |
| 13 | import { PALETTE_DROP_MUTABLE_KEY } from "@/components/notebook/presentation/editor/utils/paletteDrop"; |
| 14 | import { ScrollList, type ScrollListRange } from "@/components/ui/scroll-list"; |
| 15 | import { INFOGRAPHIC_CATEGORIES } from "@/constants/antv-templates"; |
| 16 | import { renderInfographicPreviewHtml } from "@/hooks/presentation/infographic/infographic-preview-renderer"; |
| 17 | import { resolvePresentationThemeData } from "@/lib/presentation/theme-resolution"; |
| 18 | import { cn } from "@/lib/utils"; |
| 19 | import { usePresentationState } from "@/states/presentation-state"; |
| 20 | import { usePresentationTheme } from "../../providers/PresentationThemeProvider"; |
| 21 | import { PanelSearchFilter } from "./PanelSearchFilter"; |
| 22 | import { matchesPanelSearch } from "./PanelSearchFilter"; |
| 23 | |
| 24 | const VIRTUAL_ROW_OVERSCAN = 1_200; |
| 25 | const PREVIEW_LOOKAHEAD_PX = 3_600; |
| 26 | const PREVIEW_LOOKBEHIND_PX = 1_000; |
| 27 | const HEADER_ROW_HEIGHT = 37; |
| 28 | const CARD_ROW_HEIGHT = 159; |
| 29 | const ROW_GAP = 0; |
| 30 | |
| 31 | type InfographicPreviewCache = { |
| 32 | failedKeys: Set<string>; |
| 33 | markupByKey: Map<string, string>; |
| 34 | }; |
| 35 | |
| 36 | type InfographicPreviewRequest = { |
| 37 | currentSyntax: string; |
| 38 | currentTemplate: string | null; |
| 39 | isDark: boolean; |
| 40 | templates: string[]; |
| 41 | themeColors: InfographicPaletteThemeColors | null; |
| 42 | }; |
| 43 | |
| 44 | type PreviewCandidate<TItem> = { |
| 45 | distance: number; |
| 46 | item: TItem; |
| 47 | }; |
| 48 | |
| 49 | type VirtualHeaderRow = { |
| 50 | categoryKey: string; |
| 51 | categoryName: string; |
| 52 | collapsed: boolean; |
| 53 | count: number; |
| 54 | height: number; |
| 55 | key: string; |
| 56 | type: "header"; |
| 57 | }; |
| 58 | |
| 59 | type VirtualCardRow = { |
| 60 | categoryKey: string; |
| 61 | categoryName: string; |
| 62 | height: number; |
| 63 | key: string; |
| 64 | templates: string[]; |
| 65 | type: "cards"; |
| 66 | }; |
| 67 | |
| 68 | type VirtualTemplateRow = VirtualHeaderRow | VirtualCardRow; |
| 69 | type TemplateCategory = (typeof INFOGRAPHIC_CATEGORIES)[number]; |
| 70 | |
| 71 | const infographicPreviewMarkupCache = new Map<string, string>(); |
| 72 | const infographicPreviewFailureCache = new Set<string>(); |
| 73 | const infographicPreviewPromiseCache = new Map<string, Promise<void>>(); |
| 74 | |
| 75 | function getTemplateLabel(templateId: string): string { |
| 76 | return templateId |
| 77 | .split("-") |
| 78 | .filter(Boolean) |
| 79 | .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) |
| 80 | .join(" "); |
| 81 | } |
| 82 | |
| 83 | function getInfographicPreviewCacheKey( |
| 84 | templateId: string, |
| 85 | currentSyntax: string, |
| 86 | currentTemplate: string | null, |
| 87 | isDark: boolean, |
| 88 | themeColors: InfographicPaletteThemeColors | null, |
| 89 | ): string { |
| 90 | return [ |
| 91 | templateId, |
| 92 | currentTemplate ?? "", |
| 93 | currentSyntax, |
| 94 | isDark ? "dark" : "light", |
| 95 | themeColors?.primary ?? "", |
| 96 | themeColors?.accent ?? "", |
| 97 | themeColors?.smartLayout ?? "", |
| 98 | themeColors?.text ?? "", |
| 99 | themeColors?.heading ?? "", |
| 100 | themeColors?.cardBackground ?? "", |
| 101 | ].join("|"); |
| 102 | } |
| 103 | |
| 104 | async function renderInfographicPreviewMarkup({ |
| 105 | currentSyntax, |
| 106 | currentTemplate, |
| 107 | isDark, |
| 108 | templateId, |
| 109 | themeColors, |
| 110 | }: { |
| 111 | currentSyntax: string; |
| 112 | currentTemplate: string | null; |
| 113 | isDark: boolean; |
| 114 | templateId: string; |
| 115 | themeColors: InfographicPaletteThemeColors | null; |
| 116 | }): Promise<string> { |
| 117 | if (!currentSyntax || !currentTemplate) { |
| 118 | throw new Error( |
| 119 | "Infographic preview requires current syntax and template.", |
| 120 | ); |
| 121 | } |
| 122 | |
| 123 | const converted = convertInfographicData( |
| 124 | currentSyntax, |
| 125 | currentTemplate, |
| 126 | templateId, |
| 127 | ); |
| 128 | |
| 129 | return renderInfographicPreviewHtml( |
| 130 | applyThemeToSyntax(converted, isDark, themeColors), |
| 131 | ); |
| 132 | } |
| 133 | |
| 134 | async function ensureInfographicPreviewMarkup( |
| 135 | templateId: string, |
| 136 | currentSyntax: string, |
| 137 | currentTemplate: string | null, |
| 138 | isDark: boolean, |
| 139 | themeColors: InfographicPaletteThemeColors | null, |
| 140 | ): Promise<void> { |
| 141 | const cacheKey = getInfographicPreviewCacheKey( |
| 142 | templateId, |
| 143 | currentSyntax, |
| 144 | currentTemplate, |
| 145 | isDark, |
| 146 | themeColors, |
| 147 | ); |
| 148 | |
| 149 | if ( |
| 150 | infographicPreviewMarkupCache.has(cacheKey) || |
| 151 | infographicPreviewFailureCache.has(cacheKey) |
| 152 | ) { |
| 153 | return; |
| 154 | } |
| 155 | |
| 156 | const existingPromise = infographicPreviewPromiseCache.get(cacheKey); |
| 157 | if (existingPromise) { |
| 158 | await existingPromise; |
| 159 | return; |
| 160 | } |
| 161 | |
| 162 | const previewPromise = renderInfographicPreviewMarkup({ |
| 163 | currentSyntax, |
| 164 | currentTemplate, |
| 165 | isDark, |
| 166 | templateId, |
| 167 | themeColors, |
| 168 | }) |
| 169 | .then((markup) => { |
| 170 | infographicPreviewMarkupCache.set(cacheKey, markup); |
| 171 | }) |
| 172 | .catch((error: unknown) => { |
| 173 | console.error("Failed to render infographic preview:", error); |
| 174 | infographicPreviewFailureCache.add(cacheKey); |
| 175 | }) |
| 176 | .finally(() => { |
| 177 | infographicPreviewPromiseCache.delete(cacheKey); |
| 178 | }); |
| 179 | |
| 180 | infographicPreviewPromiseCache.set(cacheKey, previewPromise); |
| 181 | await previewPromise; |
| 182 | } |
| 183 | |
| 184 | function getPendingPreviewTemplate( |
| 185 | templates: string[], |
| 186 | currentSyntax: string, |
| 187 | currentTemplate: string | null, |
| 188 | isDark: boolean, |
| 189 | themeColors: InfographicPaletteThemeColors | null, |
| 190 | ): string | undefined { |
| 191 | return templates.find((templateId) => { |
| 192 | const cacheKey = getInfographicPreviewCacheKey( |
| 193 | templateId, |
| 194 | currentSyntax, |
| 195 | currentTemplate, |
| 196 | isDark, |
| 197 | themeColors, |
| 198 | ); |
| 199 | return ( |
| 200 | !infographicPreviewMarkupCache.has(cacheKey) && |
| 201 | !infographicPreviewFailureCache.has(cacheKey) && |
| 202 | !infographicPreviewPromiseCache.has(cacheKey) |
| 203 | ); |
| 204 | }); |
| 205 | } |
| 206 | |
| 207 | function useInfographicPreviewCache( |
| 208 | isDark: boolean, |
| 209 | themeColors: InfographicPaletteThemeColors | null, |
| 210 | requestedTemplates: string[], |
| 211 | currentSyntax: string, |
| 212 | currentTemplate: string | null, |
| 213 | ): InfographicPreviewCache { |
| 214 | const [, setCacheVersion] = useState(0); |
| 215 | const isMountedRef = useRef(false); |
| 216 | const isPreloadingRef = useRef(false); |
| 217 | const latestRequestRef = useRef<InfographicPreviewRequest>({ |
| 218 | currentSyntax, |
| 219 | currentTemplate, |
| 220 | isDark, |
| 221 | templates: requestedTemplates, |
| 222 | themeColors, |
| 223 | }); |
| 224 | |
| 225 | useEffect(() => { |
| 226 | isMountedRef.current = true; |
| 227 | |
| 228 | return () => { |
| 229 | isMountedRef.current = false; |
| 230 | }; |
| 231 | }, []); |
| 232 | |
| 233 | useEffect(() => { |
| 234 | latestRequestRef.current = { |
| 235 | currentSyntax, |
| 236 | currentTemplate, |
| 237 | isDark, |
| 238 | templates: requestedTemplates, |
| 239 | themeColors, |
| 240 | }; |
| 241 | if (!currentSyntax || !currentTemplate) return; |
| 242 | |
| 243 | async function preloadRequestedPreviews() { |
| 244 | if (isPreloadingRef.current) return; |
| 245 | |
| 246 | isPreloadingRef.current = true; |
| 247 | |
| 248 | try { |
| 249 | const request = latestRequestRef.current; |
| 250 | const templateId = getPendingPreviewTemplate( |
| 251 | request.templates, |
| 252 | request.currentSyntax, |
| 253 | request.currentTemplate, |
| 254 | request.isDark, |
| 255 | request.themeColors, |
| 256 | ); |
| 257 | |
| 258 | if (templateId) { |
| 259 | await ensureInfographicPreviewMarkup( |
| 260 | templateId, |
| 261 | request.currentSyntax, |
| 262 | request.currentTemplate, |
| 263 | request.isDark, |
| 264 | request.themeColors, |
| 265 | ); |
| 266 | |
| 267 | if (isMountedRef.current) { |
| 268 | setCacheVersion((version) => version + 1); |
| 269 | } |
| 270 | } |
| 271 | } finally { |
| 272 | isPreloadingRef.current = false; |
| 273 | const request = latestRequestRef.current; |
| 274 | |
| 275 | if ( |
| 276 | isMountedRef.current && |
| 277 | getPendingPreviewTemplate( |
| 278 | request.templates, |
| 279 | request.currentSyntax, |
| 280 | request.currentTemplate, |
| 281 | request.isDark, |
| 282 | request.themeColors, |
| 283 | ) |
| 284 | ) { |
| 285 | void preloadRequestedPreviews(); |
| 286 | } |
| 287 | |
| 288 | isPreloadingRef.current = false; |
| 289 | const latestRequest = latestRequestRef.current; |
| 290 | |
| 291 | if ( |
| 292 | isMountedRef.current && |
| 293 | getPendingPreviewTemplate( |
| 294 | latestRequest.templates, |
| 295 | latestRequest.currentSyntax, |
| 296 | latestRequest.currentTemplate, |
| 297 | latestRequest.isDark, |
| 298 | latestRequest.themeColors, |
| 299 | ) |
| 300 | ) { |
| 301 | void preloadRequestedPreviews(); |
| 302 | } |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | void preloadRequestedPreviews(); |
| 307 | }, [currentSyntax, currentTemplate, isDark, requestedTemplates, themeColors]); |
| 308 | |
| 309 | const currentFailedKeys = new Set<string>(); |
| 310 | const markupByKey = new Map<string, string>(); |
| 311 | |
| 312 | for (const category of INFOGRAPHIC_CATEGORIES) { |
| 313 | for (const templateId of category.templates) { |
| 314 | const cacheKey = getInfographicPreviewCacheKey( |
| 315 | templateId, |
| 316 | currentSyntax, |
| 317 | currentTemplate, |
| 318 | isDark, |
| 319 | themeColors, |
| 320 | ); |
| 321 | const markup = infographicPreviewMarkupCache.get(cacheKey); |
| 322 | |
| 323 | if (markup) { |
| 324 | markupByKey.set(cacheKey, markup); |
| 325 | } |
| 326 | if (infographicPreviewFailureCache.has(cacheKey)) { |
| 327 | currentFailedKeys.add(cacheKey); |
| 328 | } |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | return { |
| 333 | failedKeys: currentFailedKeys, |
| 334 | markupByKey, |
| 335 | }; |
| 336 | } |
| 337 | |
| 338 | function buildVirtualRows( |
| 339 | categories: TemplateCategory[], |
| 340 | collapsedCategoryKeys: ReadonlySet<string>, |
| 341 | ): VirtualTemplateRow[] { |
| 342 | return categories.flatMap<VirtualTemplateRow>((category) => { |
| 343 | const cardRows: VirtualCardRow[] = []; |
| 344 | const collapsed = collapsedCategoryKeys.has(category.key); |
| 345 | |
| 346 | if (!collapsed) { |
| 347 | for (let index = 0; index < category.templates.length; index += 2) { |
| 348 | cardRows.push({ |
| 349 | type: "cards", |
| 350 | key: `${category.key}-cards-${index}`, |
| 351 | categoryKey: category.key, |
| 352 | categoryName: category.name, |
| 353 | templates: category.templates.slice(index, index + 2), |
| 354 | height: CARD_ROW_HEIGHT, |
| 355 | }); |
| 356 | } |
| 357 | } |
| 358 | |
| 359 | return [ |
| 360 | { |
| 361 | type: "header", |
| 362 | key: `${category.key}-header`, |
| 363 | categoryKey: category.key, |
| 364 | categoryName: category.name, |
| 365 | collapsed, |
| 366 | count: category.templates.length, |
| 367 | height: HEADER_ROW_HEIGHT, |
| 368 | }, |
| 369 | ...cardRows, |
| 370 | ]; |
| 371 | }); |
| 372 | } |
| 373 | |
| 374 | function getRequestedPreviewTemplates( |
| 375 | rows: VirtualTemplateRow[], |
| 376 | scrollTop: number, |
| 377 | viewportHeight: number, |
| 378 | ): string[] { |
| 379 | const visibleStart = scrollTop; |
| 380 | const visibleEnd = scrollTop + viewportHeight; |
| 381 | const preloadStart = Math.max(0, scrollTop - PREVIEW_LOOKBEHIND_PX); |
| 382 | const preloadEnd = visibleEnd + PREVIEW_LOOKAHEAD_PX; |
| 383 | const visibleTemplates: string[] = []; |
| 384 | const nearbyCandidates: PreviewCandidate<string>[] = []; |
| 385 | let top = 0; |
| 386 | |
| 387 | for (const row of rows) { |
| 388 | const rowHeight = row.height + ROW_GAP; |
| 389 | const rowBottom = top + rowHeight; |
| 390 | const isCardRow = row.type === "cards"; |
| 391 | const isVisible = rowBottom >= visibleStart && top <= visibleEnd; |
| 392 | const isNearViewport = rowBottom >= preloadStart && top <= preloadEnd; |
| 393 | |
| 394 | if (isCardRow && isVisible) { |
| 395 | visibleTemplates.push(...row.templates); |
| 396 | } else if (isCardRow && isNearViewport) { |
| 397 | const distance = |
| 398 | rowBottom < visibleStart ? visibleStart - rowBottom : top - visibleEnd; |
| 399 | |
| 400 | for (const templateId of row.templates) { |
| 401 | nearbyCandidates.push({ distance, item: templateId }); |
| 402 | } |
| 403 | } |
| 404 | |
| 405 | top += rowHeight; |
| 406 | } |
| 407 | |
| 408 | return [ |
| 409 | ...visibleTemplates, |
| 410 | ...nearbyCandidates |
| 411 | .sort((left, right) => left.distance - right.distance) |
| 412 | .map((candidate) => candidate.item), |
| 413 | ]; |
| 414 | } |
| 415 | |
| 416 | function getActiveCategory( |
| 417 | rows: VirtualTemplateRow[], |
| 418 | scrollTop: number, |
| 419 | ): { row: VirtualHeaderRow; top: number } | null { |
| 420 | let top = 0; |
| 421 | let activeHeader: { row: VirtualHeaderRow; top: number } | null = null; |
| 422 | |
| 423 | for (const row of rows) { |
| 424 | if (top > scrollTop + HEADER_ROW_HEIGHT) { |
| 425 | break; |
| 426 | } |
| 427 | |
| 428 | if (row.type === "header") { |
| 429 | activeHeader = { row, top }; |
| 430 | } |
| 431 | top += row.height + ROW_GAP; |
| 432 | } |
| 433 | |
| 434 | return activeHeader; |
| 435 | } |
| 436 | |
| 437 | export function InfographicEditorControls() { |
| 438 | const { resolvedTheme } = usePresentationTheme(); |
| 439 | const isDark = resolvedTheme === "dark"; |
| 440 | const presentationTheme = usePresentationState((state) => state.theme); |
| 441 | const customThemeData = usePresentationState( |
| 442 | (state) => state.customThemeData, |
| 443 | ); |
| 444 | const themeColors = useMemo<InfographicPaletteThemeColors | null>(() => { |
| 445 | return ( |
| 446 | resolvePresentationThemeData({ |
| 447 | customThemeData, |
| 448 | theme: presentationTheme, |
| 449 | })?.colors ?? null |
| 450 | ); |
| 451 | }, [customThemeData, presentationTheme]); |
| 452 | |
| 453 | const boundUpdateElement = usePresentationState((s) => s.boundUpdateElement); |
| 454 | const setPaletteDropTarget = usePresentationState( |
| 455 | (s) => s.setPaletteDropTarget, |
| 456 | ); |
| 457 | |
| 458 | const [isConverting, setIsConverting] = useState(false); |
| 459 | const [scrollRange, setScrollRange] = useState<ScrollListRange>({ |
| 460 | scrollTop: 0, |
| 461 | viewportHeight: 0, |
| 462 | }); |
| 463 | const [collapsedCategoryKeys, setCollapsedCategoryKeys] = useState< |
| 464 | Set<string> |
| 465 | >(() => new Set()); |
| 466 | const [searchQuery, setSearchQuery] = useState(""); |
| 467 | |
| 468 | // Track which template is currently applied (updates on each conversion) |
| 469 | const [appliedTemplate, setAppliedTemplate] = useState<string | null>(null); |
| 470 | |
| 471 | // Committed syntax: captured once when the panel opens. |
| 472 | // All previews and conversions derive from this base syntax, |
| 473 | // so we never re-render previews after each conversion. |
| 474 | const [committedSyntax, setCommittedSyntax] = useState<string>(""); |
| 475 | const committedTemplate = useMemo( |
| 476 | () => parseInfographicTemplate(committedSyntax), |
| 477 | [committedSyntax], |
| 478 | ); |
| 479 | const hasCommitted = useRef(false); |
| 480 | |
| 481 | const currentSlideId = usePresentationState((s) => s.currentSlideId); |
| 482 | const slides = usePresentationState((s) => s.slides); |
| 483 | |
| 484 | // Find and commit the syntax once when the panel first opens |
| 485 | useEffect(() => { |
| 486 | if (hasCommitted.current || !currentSlideId) return; |
| 487 | |
| 488 | const slide = slides.find((s) => s.id === currentSlideId); |
| 489 | if (!slide?.content) return; |
| 490 | |
| 491 | const findInfographicSyntax = (nodes: unknown[]): string | null => { |
| 492 | for (const node of nodes) { |
| 493 | const n = node as Record<string, unknown>; |
| 494 | if (n.type === "antv-infographic" && typeof n.syntax === "string") { |
| 495 | return n.syntax; |
| 496 | } |
| 497 | if (Array.isArray(n.children)) { |
| 498 | const found = findInfographicSyntax(n.children as unknown[]); |
| 499 | if (found) return found; |
| 500 | } |
| 501 | } |
| 502 | return null; |
| 503 | }; |
| 504 | |
| 505 | const syntax = findInfographicSyntax(slide.content as unknown[]); |
| 506 | if (syntax) { |
| 507 | setCommittedSyntax(syntax); |
| 508 | const template = parseInfographicTemplate(syntax); |
| 509 | setAppliedTemplate(template); |
| 510 | hasCommitted.current = true; |
| 511 | } |
| 512 | }, [currentSlideId, slides]); |
| 513 | |
| 514 | const filteredCategories = useMemo( |
| 515 | () => |
| 516 | INFOGRAPHIC_CATEGORIES.map((category) => ({ |
| 517 | ...category, |
| 518 | templates: category.templates.filter((templateId) => { |
| 519 | return matchesPanelSearch(searchQuery, [ |
| 520 | getTemplateLabel(templateId), |
| 521 | templateId, |
| 522 | category.name, |
| 523 | category.key, |
| 524 | ]); |
| 525 | }), |
| 526 | })).filter((category) => category.templates.length > 0), |
| 527 | [searchQuery], |
| 528 | ); |
| 529 | const visibleTemplateCount = useMemo( |
| 530 | () => |
| 531 | filteredCategories.reduce( |
| 532 | (total, category) => total + category.templates.length, |
| 533 | 0, |
| 534 | ), |
| 535 | [filteredCategories], |
| 536 | ); |
| 537 | const virtualRows = useMemo( |
| 538 | () => buildVirtualRows(filteredCategories, collapsedCategoryKeys), |
| 539 | [collapsedCategoryKeys, filteredCategories], |
| 540 | ); |
| 541 | const requestedTemplates = useMemo( |
| 542 | () => |
| 543 | getRequestedPreviewTemplates( |
| 544 | virtualRows, |
| 545 | scrollRange.scrollTop, |
| 546 | scrollRange.viewportHeight, |
| 547 | ), |
| 548 | [scrollRange.scrollTop, scrollRange.viewportHeight, virtualRows], |
| 549 | ); |
| 550 | const previewCache = useInfographicPreviewCache( |
| 551 | isDark, |
| 552 | themeColors, |
| 553 | requestedTemplates, |
| 554 | committedSyntax, |
| 555 | committedTemplate, |
| 556 | ); |
| 557 | const activeCategory = getActiveCategory(virtualRows, scrollRange.scrollTop); |
| 558 | const shouldShowStickyCategory = |
| 559 | activeCategory != null && activeCategory.top < scrollRange.scrollTop; |
| 560 | const scrollListKey = useMemo( |
| 561 | () => [searchQuery, ...[...collapsedCategoryKeys].sort()].join("|"), |
| 562 | [collapsedCategoryKeys, searchQuery], |
| 563 | ); |
| 564 | |
| 565 | const handleTemplateChange = useCallback( |
| 566 | (newTemplateId: string) => { |
| 567 | if ( |
| 568 | !committedSyntax || |
| 569 | !boundUpdateElement || |
| 570 | newTemplateId === appliedTemplate |
| 571 | ) |
| 572 | return; |
| 573 | |
| 574 | setIsConverting(true); |
| 575 | |
| 576 | // Always convert from the committed (original) syntax |
| 577 | const newSyntax = committedTemplate |
| 578 | ? convertInfographicData( |
| 579 | committedSyntax, |
| 580 | committedTemplate, |
| 581 | newTemplateId, |
| 582 | ) |
| 583 | : changeInfographicTemplate(committedSyntax, newTemplateId); |
| 584 | |
| 585 | setPaletteDropTarget(null); |
| 586 | boundUpdateElement({ |
| 587 | syntax: newSyntax, |
| 588 | data: undefined, |
| 589 | [PALETTE_DROP_MUTABLE_KEY]: false, |
| 590 | }); |
| 591 | setAppliedTemplate(newTemplateId); |
| 592 | |
| 593 | setTimeout(() => { |
| 594 | setIsConverting(false); |
| 595 | }, 500); |
| 596 | }, |
| 597 | [ |
| 598 | committedSyntax, |
| 599 | committedTemplate, |
| 600 | appliedTemplate, |
| 601 | boundUpdateElement, |
| 602 | setPaletteDropTarget, |
| 603 | ], |
| 604 | ); |
| 605 | |
| 606 | const toggleCategory = useCallback((categoryKey: string) => { |
| 607 | setCollapsedCategoryKeys((currentKeys) => { |
| 608 | const nextKeys = new Set(currentKeys); |
| 609 | |
| 610 | if (nextKeys.has(categoryKey)) { |
| 611 | nextKeys.delete(categoryKey); |
| 612 | } else { |
| 613 | nextKeys.add(categoryKey); |
| 614 | } |
| 615 | |
| 616 | return nextKeys; |
| 617 | }); |
| 618 | }, []); |
| 619 | |
| 620 | if (!boundUpdateElement) { |
| 621 | return ( |
| 622 | <div className="flex h-full flex-col items-center justify-center p-6 text-center"> |
| 623 | <p className="text-sm text-muted-foreground"> |
| 624 | Select an infographic element to edit it. |
| 625 | </p> |
| 626 | </div> |
| 627 | ); |
| 628 | } |
| 629 | |
| 630 | const renderTemplateRow = ({ item: row }: { item: VirtualTemplateRow }) => |
| 631 | row.type === "header" ? ( |
| 632 | <InfographicCategoryTrigger row={row} onToggle={toggleCategory} /> |
| 633 | ) : ( |
| 634 | <div className="grid h-full grid-cols-2 gap-3 px-4 py-2"> |
| 635 | {row.templates.map((templateId) => { |
| 636 | const cacheKey = getInfographicPreviewCacheKey( |
| 637 | templateId, |
| 638 | committedSyntax, |
| 639 | committedTemplate, |
| 640 | isDark, |
| 641 | themeColors, |
| 642 | ); |
| 643 | |
| 644 | return ( |
| 645 | <InfographicCard |
| 646 | key={templateId} |
| 647 | templateId={templateId} |
| 648 | isSelected={templateId === appliedTemplate} |
| 649 | previewMarkup={previewCache.markupByKey.get(cacheKey)} |
| 650 | hasPreviewError={previewCache.failedKeys.has(cacheKey)} |
| 651 | onSelectTemplate={handleTemplateChange} |
| 652 | /> |
| 653 | ); |
| 654 | })} |
| 655 | </div> |
| 656 | ); |
| 657 | |
| 658 | return ( |
| 659 | <div className="relative flex h-full flex-col"> |
| 660 | <PanelSearchFilter |
| 661 | onQueryChange={setSearchQuery} |
| 662 | placeholder="Search templates..." |
| 663 | query={searchQuery} |
| 664 | /> |
| 665 | <div className="relative min-h-0 flex-1"> |
| 666 | {visibleTemplateCount > 0 ? ( |
| 667 | <> |
| 668 | {isConverting ? ( |
| 669 | <div className="absolute top-0 right-0 left-0 z-30 flex h-9 items-center justify-center gap-2 border-b bg-background/95 text-sm text-muted-foreground backdrop-blur"> |
| 670 | <Loader2 className="size-4 animate-spin" /> |
| 671 | <span>Converting…</span> |
| 672 | </div> |
| 673 | ) : shouldShowStickyCategory ? ( |
| 674 | <div className="absolute top-0 right-0 left-0 z-20 border-b bg-background/95 backdrop-blur"> |
| 675 | <InfographicCategoryTrigger |
| 676 | row={activeCategory.row} |
| 677 | onToggle={toggleCategory} |
| 678 | sticky |
| 679 | /> |
| 680 | </div> |
| 681 | ) : null} |
| 682 | |
| 683 | <ScrollList |
| 684 | key={scrollListKey} |
| 685 | items={virtualRows} |
| 686 | getItemKey={(row) => row.key} |
| 687 | getItemHeight={(row) => row.height} |
| 688 | gap={ROW_GAP} |
| 689 | overscan={VIRTUAL_ROW_OVERSCAN} |
| 690 | paddingBottom={20} |
| 691 | onRangeChange={(range) => { |
| 692 | setScrollRange((currentRange) => |
| 693 | currentRange.scrollTop === range.scrollTop && |
| 694 | currentRange.viewportHeight === range.viewportHeight |
| 695 | ? currentRange |
| 696 | : range, |
| 697 | ); |
| 698 | }} |
| 699 | renderItem={renderTemplateRow} |
| 700 | /> |
| 701 | </> |
| 702 | ) : ( |
| 703 | <div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground"> |
| 704 | No infographic templates match your search. |
| 705 | </div> |
| 706 | )} |
| 707 | </div> |
| 708 | </div> |
| 709 | ); |
| 710 | } |
| 711 | |
| 712 | function InfographicCategoryTrigger({ |
| 713 | onToggle, |
| 714 | row, |
| 715 | sticky = false, |
| 716 | }: { |
| 717 | onToggle: (categoryKey: string) => void; |
| 718 | row: VirtualHeaderRow; |
| 719 | sticky?: boolean; |
| 720 | }) { |
| 721 | return ( |
| 722 | <button |
| 723 | type="button" |
| 724 | aria-expanded={!row.collapsed} |
| 725 | onClick={() => onToggle(row.categoryKey)} |
| 726 | className={cn( |
| 727 | "flex h-full w-full items-center justify-between gap-3 border-b bg-background/95 px-4 text-left transition-colors hover:bg-muted/35 focus-visible:bg-muted/50 focus-visible:outline-none", |
| 728 | sticky && "h-9 border-b-0", |
| 729 | )} |
| 730 | > |
| 731 | <span className="min-w-0 text-xs font-semibold text-muted-foreground"> |
| 732 | {row.categoryName}{" "} |
| 733 | <span className="font-normal opacity-70">({row.count})</span> |
| 734 | </span> |
| 735 | <ChevronDown |
| 736 | className={cn( |
| 737 | "size-4 shrink-0 text-muted-foreground transition-transform", |
| 738 | row.collapsed && "-rotate-90", |
| 739 | )} |
| 740 | /> |
| 741 | </button> |
| 742 | ); |
| 743 | } |
| 744 | |
| 745 | function InfographicCard({ |
| 746 | templateId, |
| 747 | isSelected, |
| 748 | previewMarkup, |
| 749 | hasPreviewError, |
| 750 | onSelectTemplate, |
| 751 | }: { |
| 752 | templateId: string; |
| 753 | isSelected: boolean; |
| 754 | previewMarkup: string | undefined; |
| 755 | hasPreviewError: boolean; |
| 756 | onSelectTemplate: (templateId: string) => void; |
| 757 | }) { |
| 758 | return ( |
| 759 | <button |
| 760 | type="button" |
| 761 | onClick={() => onSelectTemplate(templateId)} |
| 762 | className={cn( |
| 763 | "group relative h-full rounded-md border p-2 text-left transition hover:border-primary hover:shadow focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none", |
| 764 | isSelected && "border-primary ring-1 ring-primary", |
| 765 | )} |
| 766 | > |
| 767 | <InfographicPreview |
| 768 | previewMarkup={previewMarkup} |
| 769 | hasPreviewError={hasPreviewError} |
| 770 | /> |
| 771 | <div className="mt-1.5 flex items-start gap-1 px-0.5"> |
| 772 | <span className="line-clamp-2 text-xs leading-snug text-muted-foreground"> |
| 773 | {getTemplateLabel(templateId)} |
| 774 | </span> |
| 775 | </div> |
| 776 | {isSelected && ( |
| 777 | <div className="absolute top-3 right-3 flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground shadow"> |
| 778 | <Check className="size-3" /> |
| 779 | </div> |
| 780 | )} |
| 781 | </button> |
| 782 | ); |
| 783 | } |
| 784 | |
| 785 | function InfographicPreview({ |
| 786 | previewMarkup, |
| 787 | hasPreviewError, |
| 788 | }: { |
| 789 | previewMarkup: string | undefined; |
| 790 | hasPreviewError: boolean; |
| 791 | }) { |
| 792 | const previewRef = useRef<HTMLDivElement | null>(null); |
| 793 | |
| 794 | useEffect(() => { |
| 795 | const container = previewRef.current; |
| 796 | if (!container) return; |
| 797 | |
| 798 | container.replaceChildren(); |
| 799 | if (!previewMarkup) return; |
| 800 | |
| 801 | const template = document.createElement("template"); |
| 802 | template.innerHTML = previewMarkup; |
| 803 | container.replaceChildren(template.content.cloneNode(true)); |
| 804 | }, [previewMarkup]); |
| 805 | |
| 806 | return ( |
| 807 | <div className="pointer-events-none relative aspect-video w-full overflow-hidden rounded-sm border bg-card select-none"> |
| 808 | {!previewMarkup && !hasPreviewError && ( |
| 809 | <div className="absolute inset-0 flex items-center justify-center bg-muted/20"> |
| 810 | <Loader2 className="size-4 animate-spin text-muted-foreground" /> |
| 811 | </div> |
| 812 | )} |
| 813 | {hasPreviewError && ( |
| 814 | <div className="absolute inset-0 z-10 flex items-center justify-center bg-muted/10 p-2 text-center text-xs text-muted-foreground"> |
| 815 | Preview unavailable |
| 816 | </div> |
| 817 | )} |
| 818 | <div |
| 819 | ref={previewRef} |
| 820 | className="h-full w-full p-1.5 [&_svg]:h-full [&_svg]:w-full" |
| 821 | /> |
| 822 | </div> |
| 823 | ); |
| 824 | } |
| 825 |