import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, } from "react"; import { AnimatePresence, motion } from "motion/react"; import { getProjectIdentity, type DeploymentInfo, type DocumentRefreshOptions, type HtmlPageBlock, type ReaderDocument, type SlideSourceEntry, type WorkspaceManifestPress, } from "../document-model"; import { MousePointer2, Play } from "lucide-react"; import { CommentLocationMarker, CommentReviewDock, resolveInlineSavedComment, useInspector, useInspectorComments, type PendingComment, } from "./inspector"; import { BOOKMARKS_SECTION_CLASS, DocumentNavigation, CurrentPagePanel, PageThumbnails, PublicPage, useReaderRuntime, usePageViewportScale, useViewMode, } from "../reader"; import { ReaderStage, InlineSourceEditorLayer, SourceTreeEditorPanel, useDocumentWorkbenchModel, useInlineDocumentEditor, type InlineDocumentSourceTarget, } from "./document"; import { ExportControl, PageZoomDock, ReaderPreviewControl, SearchControl, SearchPanel, WorkbenchOverflowControl, useDeploymentWorkbench, } from "./actions"; import type { WorkbenchPanel } from "./panels"; import { SHELL_COMPACT_MAX_WIDTH, SHELL_COMPACT_MEDIA_QUERY, SHELL_DRAWER_BREAKPOINT, WorkbenchShell, } from "./shell"; import { WorkbenchToolbarActions } from "./shell/WorkbenchToolbarActions"; import { ToastProvider } from "../shared"; import { cn } from "../core/cn"; import { WorkbenchEditStatusProvider } from "./WorkbenchEditStatusContext"; import { WorkbenchRebuildOverlay } from "./WorkbenchRebuildOverlay"; import { WorkbenchDialog, WorkbenchDialogAction, WorkbenchDialogBody, WorkbenchDialogStrong, WorkbenchDialogText, } from "./dialog"; import { useWorkbenchNavigation } from "./hooks/useWorkbenchNavigation"; import { useWorkbenchBookmarkGuide } from "./hooks/useWorkbenchBookmarkGuide"; import { useSlideActions } from "./hooks/useSlideActions"; import { Button } from "@/openpress/ui/button"; import { Textarea } from "@/openpress/ui/textarea"; import { TOOLBAR_ACTION_CLASS, TOOLBAR_ACTION_LABEL_CLASS, TOOLBAR_ACTION_PRIMARY_CLASS, } from "./toolbarClasses"; import { WorkspaceAppearanceBoundary, useWorkspaceAppearance, } from "../app/workspaceAppearance"; import { useHotkey } from "../hotkeys"; import { ChangePreviewComparison, ChangePreviewControl, firstChangePageIndex, useChangeComparisonStacked, useChangePreview, } from "./changes"; const WORKBENCH_THUMBNAILS_SECTION_CLASS = [ "openpress-panel-section openpress-panel-section--thumbnails", "grid min-h-0 grid-rows-[auto_minmax(0,1fr)] overflow-hidden px-[14px] pb-3 pt-2", ].join(" "); const WORKSPACE_ACTION_LABEL_CLASS = "text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--op-workspace-text-muted)]"; const WORKBENCH_COMMENT_BADGE_CLASS = [ "pointer-events-none absolute right-[5px] top-[5px] grid min-h-[14px] min-w-[14px] place-items-center rounded-full", "bg-[var(--op-workspace-accent)] px-[3px] text-[8px] font-black leading-none text-white", "shadow-[0_0_0_1px_var(--op-workspace-surface)]", ].join(" "); const WORKBENCH_SLIDE_MAIN_CLASS = [ "op-workspace-slide-main flex h-full min-h-0 flex-col overflow-hidden", ].join(" "); const WORKBENCH_SLIDE_STAGE_CLASS = [ "op-workspace-slide-stage min-h-0 flex-1 !h-auto", ].join(" "); const WORKBENCH_SLIDE_PAGES_CLASS = [ "op-workspace-slide-pages !content-center !items-center !gap-0 !px-4 !py-0", ].join(" "); const WORKBENCH_SLIDE_NOTES_DOCK_CLASS = [ "op-workspace-slide-notes-dock grid min-h-[116px] max-h-[24vh] flex-none grid-rows-[auto_minmax(0,1fr)] gap-2", "border-t border-[var(--op-workspace-border-muted)] bg-[var(--op-workspace-panel-bg)] px-6 py-4 text-[var(--op-workspace-text)]", ].join(" "); const WORKBENCH_SLIDE_NOTES_HEADER_CLASS = [ "flex min-w-0 items-center justify-between gap-4", ].join(" "); const WORKBENCH_SLIDE_NOTES_TEXT_CLASS = [ "m-0 min-h-0 overflow-y-auto whitespace-pre-wrap text-[13px] leading-relaxed text-[var(--op-workspace-text-soft)]", "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden", ].join(" "); const WORKBENCH_SLIDE_NOTES_INPUT_CLASS = [ "m-0 min-h-[64px] w-full resize-none overflow-y-auto rounded-[var(--op-workspace-radius-sm)]", "border border-[var(--op-workspace-border-muted)] bg-[var(--op-workspace-surface-muted)] px-3 py-2", "text-[13px] leading-relaxed text-[var(--op-workspace-text)] outline-none", "placeholder:text-[var(--op-workspace-text-muted)]", "hover:border-[var(--op-workspace-border)] focus:border-[var(--op-workspace-accent-border)]", "focus:shadow-[0_0_0_1px_var(--op-workspace-accent-border)] disabled:cursor-progress disabled:opacity-70", "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden", ].join(" "); const WORKBENCH_SLIDE_NOTES_SAVE_CLASS = [ "op-ui-button !h-7 !rounded-[var(--op-workspace-radius-sm)] px-2.5 text-[11px] font-semibold", "border border-[var(--op-workspace-accent-border)] bg-[var(--op-workspace-accent-surface)]", "text-[var(--op-workspace-accent)] hover:bg-[color-mix(in_srgb,var(--op-workspace-accent)_15%,transparent)]", "disabled:border-[var(--op-workspace-border-muted)] disabled:bg-transparent disabled:text-[var(--op-workspace-text-muted)]", ].join(" "); const WORKBENCH_THEME_TRANSPARENT_BACKDROP_CLASS = [ "!bg-transparent !backdrop-blur-0 supports-backdrop-filter:!backdrop-blur-0 ![backdrop-filter:none]", ].join(" "); const PAGE_EDIT_EDITOR_CLASS = [ "op-workspace-page-edit-editor h-full min-h-0 overflow-hidden bg-[var(--op-workspace-main-bg)] text-[var(--op-workspace-text)]", ].join(" "); const WORKBENCH_MAIN_MOTION_CLASS = "h-full min-h-0"; const WORKBENCH_PANEL_STATE_STORAGE_KEY = "openpress:workspace:panels"; const WORKBENCH_PAGE_SCALE_STORAGE_KEY_PREFIX = "openpress:workspace:page-scale-mode"; const WORKBENCH_MAIN_MOTION_TRANSITION = { duration: 0.18, ease: [0.22, 0.61, 0.36, 1], } as const; type OptimisticAddedSlide = { id: string; page: HtmlPageBlock; }; type HtmlWorkbenchProps = { document: ReaderDocument; pages: Array; style: CSSProperties; workspaceMode: boolean; deploymentInfo: DeploymentInfo; // Active Press slug — threaded down to useDeploymentWorkbench so the // local PDF export endpoint can pick the right Press in multi-Press // workspaces. Null when the workspace is at the gallery root. pressSlug?: string | null; workspacePresses?: WorkspaceManifestPress[]; onSelectWorkspacePress?: (press: WorkspaceManifestPress) => void; onDocumentRefresh?: (options?: DocumentRefreshOptions) => void | Promise; onBackToWorkspace?: () => void; onOpenWorkspaceSettings?: () => void; onOpenPresentation?: (pageIndex: number) => void; // Optional extension panels are exposed through an on-demand Tools drawer // so they do not permanently reduce the document canvas. extraControlPanels?: WorkbenchPanel[]; }; export function HtmlWorkbench(props: HtmlWorkbenchProps) { return ( ); } function HtmlWorkbenchInner({ document, pages, style, workspaceMode, deploymentInfo, pressSlug = null, workspacePresses, onSelectWorkspacePress, onDocumentRefresh, onBackToWorkspace, onOpenWorkspaceSettings, onOpenPresentation, extraControlPanels, }: HtmlWorkbenchProps) { const workspaceAppearance = useWorkspaceAppearance(); const [pageWorkspaceMode, setPageWorkspaceMode] = useState<"view" | "source">("view"); const sourceContainerRef = useRef(null); const [sourceContainerVersion, setSourceContainerVersion] = useState(0); const setSourceContainerNode = useCallback((node: HTMLDivElement | null) => { if (sourceContainerRef.current === node) return; sourceContainerRef.current = node; setSourceContainerVersion((version) => version + 1); }, []); const pendingAddedSlideIdRef = useRef(null); const pendingSelectSlideIndexRef = useRef(null); const pendingCrossPressCommentRef = useRef(null); const [optimisticAddedSlides, setOptimisticAddedSlides] = useState([]); const [optimisticRemovedSlideIds, setOptimisticRemovedSlideIds] = useState([]); const [optimisticSkippedSlideIds, setOptimisticSkippedSlideIds] = useState([]); const [optimisticUnskippedSlideIds, setOptimisticUnskippedSlideIds] = useState([]); const { viewMode } = useViewMode(); const projectIdentity = getProjectIdentity(document.meta); const activePressTitle = useMemo(() => { const manifestTitle = pressSlug ? workspacePresses?.find((press) => press.slug === pressSlug)?.title : null; return projectIdentity.name || manifestTitle || pressSlug || "Press Theme"; }, [pressSlug, projectIdentity.name, workspacePresses]); const changePreview = useChangePreview({ workspaceMode, pressSlug }); const [changeReviewActive, setChangeReviewActive] = useState(false); const changeComparisonStacked = useChangeComparisonStacked(changeReviewActive); useEffect(() => setChangeReviewActive(false), [pressSlug]); useEffect(() => { if (changeReviewActive && !changePreview.preview?.document) setChangeReviewActive(false); }, [changePreview.preview?.document, changeReviewActive]); const pressType = normalizePressType(document.meta.type); const isSlidePress = pressType === "slides"; const pageEditModeAvailable = workspaceMode && !isSlidePress; const pageSourceEditMode = pageEditModeAvailable && pageWorkspaceMode === "source"; const pageInlineEditMode = pageEditModeAvailable && !pageSourceEditMode; const [documentInfoOpen, setDocumentInfoOpen] = useState(false); useEffect(() => { if (pageEditModeAvailable || pageWorkspaceMode === "view") return; setPageWorkspaceMode("view"); }, [pageEditModeAvailable, pageWorkspaceMode]); const baseSourceSlides = useMemo( () => document.source?.slides ?? [], [document.source?.slides], ); useEffect(() => { if (!isSlidePress) return; const baseSlideIds = new Set(baseSourceSlides.map((slide) => slide.id)); const baseSlideById = new Map(baseSourceSlides.map((slide) => [slide.id, slide])); setOptimisticAddedSlides((current) => current.filter((slide) => !baseSlideIds.has(slide.id))); setOptimisticRemovedSlideIds((current) => current.filter((id) => baseSlideIds.has(id))); setOptimisticSkippedSlideIds((current) => current.filter((id) => baseSlideById.get(id)?.skip !== true)); setOptimisticUnskippedSlideIds((current) => current.filter((id) => baseSlideById.get(id)?.skip === true)); }, [baseSourceSlides, isSlidePress]); const optimisticRemovedSlideIdSet = useMemo( () => new Set(optimisticRemovedSlideIds), [optimisticRemovedSlideIds], ); const optimisticSkippedSlideIdSet = useMemo( () => new Set(optimisticSkippedSlideIds), [optimisticSkippedSlideIds], ); const optimisticUnskippedSlideIdSet = useMemo( () => new Set(optimisticUnskippedSlideIds), [optimisticUnskippedSlideIds], ); const sourceSlides = useMemo((): SlideSourceEntry[] => { if (!isSlidePress) return baseSourceSlides; const out = baseSourceSlides .filter((slide) => !optimisticRemovedSlideIdSet.has(slide.id)) .map((slide) => ({ ...slide, skip: optimisticSkippedSlideIdSet.has(slide.id) ? true : optimisticUnskippedSlideIdSet.has(slide.id) ? false : slide.skip, })); const known = new Set(out.map((slide) => slide.id)); for (const slide of optimisticAddedSlides) { if (!known.has(slide.id)) out.push({ id: slide.id, skip: false }); } return out; }, [ baseSourceSlides, isSlidePress, optimisticAddedSlides, optimisticRemovedSlideIdSet, optimisticSkippedSlideIdSet, optimisticUnskippedSlideIdSet, ]); const optimisticAddedPageById = useMemo( () => new Map(optimisticAddedSlides.map((slide) => [slide.id, slide.page])), [optimisticAddedSlides], ); const displayPages = useMemo(() => { if (!isSlidePress) return pages; const next = pages.filter((page) => { if (typeof page.frameKey !== "string") return true; if (optimisticRemovedSlideIdSet.has(page.frameKey)) return false; if (optimisticSkippedSlideIdSet.has(page.frameKey)) return false; return true; }); const existing = new Set(next.map((page) => page.frameKey).filter((key): key is string => typeof key === "string")); for (const slide of optimisticAddedSlides) { if (!existing.has(slide.id)) next.push(slide.page); } return renumberPages(next); }, [ isSlidePress, optimisticAddedSlides, optimisticRemovedSlideIdSet, optimisticSkippedSlideIdSet, pages, ]); const { anchorPageMap, bookmarks, figures, sourceBlockMap, sourceBlocksByPath, projectMentionItems, tables, } = useDocumentWorkbenchModel(document, displayPages); const inspector = useInspector(document, { enabled: workspaceMode }); const changeComparisonDocument = changeReviewActive ? changePreview.preview?.document ?? null : null; const readerPageCount = changeComparisonDocument ? Math.max(displayPages.length, changeComparisonDocument.blocks.length, 1) : Math.max(displayPages.length, 1); const reader = useReaderRuntime({ pageCount: readerPageCount, leftPanelBreakpoint: SHELL_DRAWER_BREAKPOINT, rightPanelBreakpoint: SHELL_DRAWER_BREAKPOINT, panelStateStorageKey: WORKBENCH_PANEL_STATE_STORAGE_KEY, initialPanelState: { leftPanelOpen: !isNarrowWorkspaceViewport(), rightPanelOpen: false, }, }); useLayoutEffect(() => { if (isSlidePress && !changeReviewActive) { // Comparison offsets must not shift the centered, non-scrollable slide stage. reader.stageRef.current?.scrollTo({ left: 0, top: 0, behavior: "instant" }); } }, [changeReviewActive, isSlidePress, reader.stageRef]); const setSearchPanelOpen = useCallback((nextOpen: boolean) => { if ( nextOpen && typeof window !== "undefined" && window.innerWidth <= SHELL_COMPACT_MAX_WIDTH && reader.leftPanelOpen ) { reader.toggleLeftPanel(); } reader.setRightPanelOpen(nextOpen); }, [reader.leftPanelOpen, reader.setRightPanelOpen, reader.toggleLeftPanel]); useHotkey("workspace.toggle-bookmarks", reader.toggleLeftPanel, { enabled: !pageSourceEditMode }); const suppressBookmarkGuideRemapRef = useRef(false); useWorkbenchBookmarkGuide({ bookmarks, currentPageIndex: reader.currentPageIndex, documentKey: document.meta.renderId ?? document, storageKey: pressSlug ? `openpress:workbench:bookmark-guide:${pressSlug}` : null, setPage: reader.setPage, suppressDocumentRemap: suppressBookmarkGuideRemapRef.current, }); const stagePages = displayPages; const stageCurrentPageIndex = reader.currentPageIndex; const renderedStagePages = useMemo(() => { if (!isSlidePress) return stagePages; const activePage = stagePages[stageCurrentPageIndex] ?? stagePages[0]; return activePage ? [activePage] : stagePages; }, [isSlidePress, stageCurrentPageIndex, stagePages]); const registerStagePage = reader.registerPage; const pageViewport = usePageViewportScale({ stageRef: reader.stageRef, pageContainerRef: sourceContainerRef, pageCount: readerPageCount, layoutMode: changeReviewActive && !changeComparisonStacked ? "spread" : "single", scaleModeStorageKey: pressSlug ? `${WORKBENCH_PAGE_SCALE_STORAGE_KEY_PREFIX}:${encodeURIComponent(pressSlug)}` : undefined, viewportKey: `page-view:${changeReviewActive ? changeComparisonStacked ? "change-stack" : "change-spread" : "current"}`, }); const deployment = useDeploymentWorkbench({ deploymentInfo, pressSlug }); const [sourceEditorTarget, setSourceEditorTarget] = useState(null); const pendingInlineScrollRestoreRef = useRef<{ stageScrollTop: number; blockId: string | null; targetViewportTop: number | null; } | null>(null); const [deleteSlideTarget, setDeleteSlideTarget] = useState<{ id: string; pageIndex: number } | null>(null); const togglePageSourceMode = useCallback(() => { setSourceEditorTarget(null); setChangeReviewActive(false); inspector.setInspectorMode(false); setPageWorkspaceMode((current) => current === "source" ? "view" : "source"); }, [inspector.setInspectorMode]); const handlePageSourceSaved = useCallback(async (options?: DocumentRefreshOptions) => { await onDocumentRefresh?.(options); setPageWorkspaceMode("view"); }, [onDocumentRefresh]); const handleInlineDocumentEdited = useCallback(async (options?: DocumentRefreshOptions) => { const stage = reader.stageRef.current; suppressBookmarkGuideRemapRef.current = true; pendingInlineScrollRestoreRef.current = stage ? { stageScrollTop: stage.scrollTop, blockId: sourceEditorTarget?.block.id ?? null, targetViewportTop: sourceEditorTarget?.element.isConnected ? sourceEditorTarget.element.getBoundingClientRect().top : null, } : null; try { await onDocumentRefresh?.(options); } catch (error) { pendingInlineScrollRestoreRef.current = null; suppressBookmarkGuideRemapRef.current = false; throw error; } }, [onDocumentRefresh, reader.stageRef, sourceEditorTarget]); useLayoutEffect(() => { const pending = pendingInlineScrollRestoreRef.current; const stage = reader.stageRef.current; if (!pending || !stage) { pendingInlineScrollRestoreRef.current = null; suppressBookmarkGuideRemapRef.current = false; return; } const nextTarget = pending.blockId ? Array.from(stage.querySelectorAll("[data-openpress-block-id]")) .find((element) => element.dataset.openpressBlockId === pending.blockId) : null; if (nextTarget && pending.targetViewportTop !== null) { const delta = nextTarget.getBoundingClientRect().top - pending.targetViewportTop; stage.scrollTop += delta; } else { stage.scrollTop = pending.stageScrollTop; } pendingInlineScrollRestoreRef.current = null; suppressBookmarkGuideRemapRef.current = false; }, [document, reader.stageRef]); const closeCompactBookmarks = useCallback(() => { if (isNarrowWorkspaceViewport() && reader.leftPanelOpen) reader.toggleLeftPanel(); }, [reader.leftPanelOpen, reader.toggleLeftPanel]); const { selectWorkspaceAnchor, selectWorkspacePage } = useWorkbenchNavigation({ anchorPageMap, pages: displayPages, setPage: reader.setPage, onAfterSelectPage: closeCompactBookmarks, }); const skippedSlideIds = useMemo( () => new Set(sourceSlides .filter((slide) => slide.skip === true) .map((slide) => slide.id)), [sourceSlides], ); const pageByFrameKey = useMemo(() => { const next = new Map(); for (const page of displayPages) { if (typeof page.frameKey === "string") next.set(page.frameKey, page); } return next; }, [displayPages]); const thumbnailPages = useMemo(() => { if (!isSlidePress || !sourceSlides.length) return displayPages; return sourceSlides.map((slide, index): HtmlPageBlock & { skipped?: boolean; missingPreview?: boolean } => { const rendered = pageByFrameKey.get(slide.id) ?? optimisticAddedPageById.get(slide.id); if (rendered) return { ...rendered, skipped: slide.skip === true }; return { id: `slide-source-${slide.id}`, kind: "htmlPage", title: slide.id, pageNumber: index + 1, html: "", frameKey: slide.id, className: "openpress-slide-source-placeholder", skipped: slide.skip === true, missingPreview: true, }; }); }, [displayPages, isSlidePress, optimisticAddedPageById, pageByFrameKey, sourceSlides]); const currentThumbnailIndex = useMemo(() => { const frameKey = displayPages[reader.currentPageIndex]?.frameKey; if (typeof frameKey !== "string") return reader.currentPageIndex; const index = thumbnailPages.findIndex((page) => page.frameKey === frameKey); return index >= 0 ? index : reader.currentPageIndex; }, [displayPages, reader.currentPageIndex, thumbnailPages]); const selectThumbnailPage = useCallback((pageIndex: number, options?: { behavior?: ScrollBehavior }) => { const frameKey = thumbnailPages[pageIndex]?.frameKey; const renderedIndex = displayPages.findIndex((page) => page.frameKey === frameKey); if (renderedIndex < 0) return; selectWorkspacePage(renderedIndex, options); }, [displayPages, selectWorkspacePage, thumbnailPages]); useEffect(() => { const pendingSlideId = pendingAddedSlideIdRef.current; if (!pendingSlideId) return; const nextIndex = displayPages.findIndex((page) => page.frameKey === pendingSlideId); if (nextIndex < 0) return; pendingAddedSlideIdRef.current = null; pendingSelectSlideIndexRef.current = nextIndex; }, [displayPages]); useEffect(() => { const nextIndex = pendingSelectSlideIndexRef.current; if (nextIndex === null) return; pendingSelectSlideIndexRef.current = null; selectWorkspacePage(nextIndex, { behavior: "smooth" }); }, [displayPages, selectWorkspacePage]); // Inline source editing and inspector commenting are mutually exclusive // interaction modes on the same blocks. While inspector mode is on, the // user is selecting blocks to comment on — keeping contenteditable + the // text cursor active would (a) show the I-beam instead of the inspector // crosshair, (b) allow accidental text selection that paints the whole // page (notably covers) with the browser ::selection color. useEffect(() => { if (!pageSourceEditMode) return; setSourceEditorTarget(null); if (inspector.inspectorMode) inspector.setInspectorMode(false); }, [inspector.inspectorMode, inspector.setInspectorMode, pageSourceEditMode]); const inlineEditEnabled = workspaceMode && !changeReviewActive && !inspector.inspectorMode && (isSlidePress || pageInlineEditMode); useInlineDocumentEditor({ enabled: inlineEditEnabled, sourceContainerRef, sourceContainerVersion, sourceBlockMap, pressSlug, onOpenSourceBlock: setSourceEditorTarget, onDocumentEdited: handleInlineDocumentEdited, }); const slideActions = useSlideActions(pressSlug ?? "", onDocumentRefresh); const handleReorderPages = useCallback( (fromIndex: number, toIndex: number) => { const reordered = [...thumbnailPages]; const [moved] = reordered.splice(fromIndex, 1); reordered.splice(toIndex, 0, moved); const order = reordered .map((p) => p.frameKey) .filter((k): k is string => typeof k === "string"); if (order.length !== reordered.length) return; slideActions.reorder(order); }, [slideActions, thumbnailPages], ); const handleAddSlide = useCallback(() => { slideActions.add({ onAdded: (slide) => { pendingAddedSlideIdRef.current = slide.id; setOptimisticAddedSlides((current) => appendOptimisticSlide(current, { id: slide.id, page: createOptimisticSlidePage({ slideId: slide.id, fallbackTitle: slide.id, }), })); }, }); }, [slideActions]); const handleDeleteSlide = useCallback((pageIndex: number) => { const slideId = thumbnailPages[pageIndex]?.frameKey; if (!slideId || thumbnailPages.length <= 1) return; setDeleteSlideTarget({ id: slideId, pageIndex }); }, [thumbnailPages]); const handleCancelDeleteSlide = useCallback(() => { setDeleteSlideTarget(null); }, []); const handleConfirmDeleteSlide = useCallback(() => { if (!deleteSlideTarget) return; const renderedIndex = displayPages.findIndex((page) => page.frameKey === deleteSlideTarget.id); pendingSelectSlideIndexRef.current = nextIndexAfterRemoval(renderedIndex, displayPages.length); setOptimisticRemovedSlideIds((current) => appendUnique(current, deleteSlideTarget.id)); setOptimisticSkippedSlideIds((current) => removeValue(current, deleteSlideTarget.id)); setOptimisticUnskippedSlideIds((current) => removeValue(current, deleteSlideTarget.id)); setOptimisticAddedSlides((current) => current.filter((slide) => slide.id !== deleteSlideTarget.id)); slideActions.remove(deleteSlideTarget.id); setDeleteSlideTarget(null); }, [deleteSlideTarget, displayPages, slideActions]); const handleToggleSkipSlide = useCallback((pageIndex: number) => { const slideId = thumbnailPages[pageIndex]?.frameKey; if (!slideId) return; if (skippedSlideIds.has(slideId)) { setOptimisticUnskippedSlideIds((current) => appendUnique(current, slideId)); setOptimisticSkippedSlideIds((current) => removeValue(current, slideId)); slideActions.unskip(slideId); return; } const renderedIndex = displayPages.findIndex((page) => page.frameKey === slideId); pendingSelectSlideIndexRef.current = nextIndexAfterRemoval(renderedIndex, displayPages.length); setOptimisticSkippedSlideIds((current) => appendUnique(current, slideId)); setOptimisticUnskippedSlideIds((current) => removeValue(current, slideId)); slideActions.skip(slideId); }, [displayPages, skippedSlideIds, slideActions, thumbnailPages]); const comments = useInspectorComments({ workspaceMode, inspector, sourceBlockMap, sourceBlocksByPath, sourceContainerRef, onSelectWorkspacePage: selectWorkspacePage, }); const activeCommentIndex = comments.activeCommentId ? comments.pendingComments.findIndex((comment) => comment.id === comments.activeCommentId) : -1; const commentLocationMarkerLabel = activeCommentIndex >= 0 ? String(activeCommentIndex + 1) : "+"; const handleChangeReviewActiveChange = useCallback((nextActive: boolean) => { if (nextActive && !changePreview.preview?.document) return; setSourceEditorTarget(null); setPageWorkspaceMode("view"); if (nextActive) { inspector.setInspectorMode(false); } setChangeReviewActive(nextActive); if (!nextActive) return; const firstPageIndex = firstChangePageIndex( changePreview.preview?.proposals ?? [], sourceBlocksByPath, document, ); if (firstPageIndex !== null) reader.setPage(firstPageIndex, { behavior: "smooth" }); }, [changePreview.preview, document, inspector.setInspectorMode, reader, sourceBlocksByPath]); const handleInspectorModeChange = useCallback((nextActive: boolean) => { if (nextActive) setChangeReviewActive(false); inspector.setInspectorMode(nextActive); }, [inspector.setInspectorMode]); const handleSelectPendingComment = useCallback((comment: PendingComment) => { setChangeReviewActive(false); const targetPress = findWorkspacePressForCommentPath(comment.path, workspacePresses); if (targetPress && targetPress.slug !== pressSlug && onSelectWorkspacePress) { pendingCrossPressCommentRef.current = comment; onSelectWorkspacePress(targetPress); return; } comments.handleSelectPendingComment(comment); }, [ comments.handleSelectPendingComment, onSelectWorkspacePress, pressSlug, workspacePresses, ]); useEffect(() => { const pendingComment = pendingCrossPressCommentRef.current; if (!pendingComment) return; const targetPress = findWorkspacePressForCommentPath(pendingComment.path, workspacePresses); if (targetPress && targetPress.slug !== pressSlug) return; if (resolveInlineSavedComment(pendingComment, sourceBlocksByPath).length === 0) return; pendingCrossPressCommentRef.current = null; comments.handleSelectPendingComment(pendingComment); }, [ comments.handleSelectPendingComment, pressSlug, sourceBlocksByPath, workspacePresses, ]); const currentSlideFrameKey = displayPages[reader.currentPageIndex]?.frameKey; const currentSlideNotes = isSlidePress && typeof currentSlideFrameKey === "string" ? sourceSlides.find((slide) => slide.id === currentSlideFrameKey)?.notes?.trim() ?? "" : ""; const handleSaveCurrentSlideNotes = useCallback((notes: string) => { if (!workspaceMode || typeof currentSlideFrameKey !== "string") return Promise.resolve(false); return slideActions.updateNotes(currentSlideFrameKey, notes); }, [currentSlideFrameKey, slideActions, workspaceMode]); const currentDocumentPageIndex = Math.min( Math.max(reader.currentPageIndex, 0), Math.max(displayPages.length - 1, 0), ); const openCurrentSlidePresentation = useCallback(() => { if (!isSlidePress || !onOpenPresentation) return; onOpenPresentation(currentDocumentPageIndex); }, [currentDocumentPageIndex, isSlidePress, onOpenPresentation]); // Memoize so composer keystrokes (which only flip `comments.inspectorCommentText`) // don't rebuild the toolbar JSX. The toolbar depends on deploy and Press // routing state, but never on the composer draft text. const toolbarActions = useMemo(() => ( setDocumentInfoOpen(true)} mdx={pageEditModeAvailable ? { active: pageSourceEditMode, onToggle: togglePageSourceMode, } : undefined} deployment={deployment.localDeployEnabled ? { info: deployment.currentDeploymentInfo, status: deployment.status, onDeploy: deployment.handleDeploy, } : undefined} panels={extraControlPanels ?? []} /> {isSlidePress && onOpenPresentation ? ( ) : null} )} /> ), [ activePressTitle, comments.pendingComments.length, changePreview.clear, changePreview.error, changePreview.preview, changePreview.refresh, changePreview.status, changeReviewActive, deployment.currentDeploymentInfo, deployment.handleDeploy, deployment.handleOpenWorkbenchPdf, deployment.handleOpenWorkbenchWord, deployment.localDeployEnabled, deployment.pdfActionStatus, deployment.pdfButtonDisabled, deployment.status, deployment.wordActionStatus, deployment.wordButtonDisabled, displayPages, document.theme, extraControlPanels, inspector.inspectorMode, handleInspectorModeChange, handleChangeReviewActiveChange, onBackToWorkspace, onOpenWorkspaceSettings, onOpenPresentation, openCurrentSlidePresentation, onSelectWorkspacePress, pageSourceEditMode, pageEditModeAvailable, pressSlug, pressType, reader.leftPanelOpen, reader.toggleLeftPanel, currentDocumentPageIndex, isSlidePress, selectWorkspacePage, reader.rightPanelOpen, setSearchPanelOpen, togglePageSourceMode, workspaceMode, workspacePresses, ]); const mainTransitionKey = `${pressSlug ?? document.meta.title}:${pressType}:${pageWorkspaceMode}`; return ( {toolbarActions} {!isSlidePress && (bookmarks.length > 0 || figures.length > 0 || tables.length > 0) ? (
) : (
)}
setSearchPanelOpen(false)} /> {pageSourceEditMode ? (
) : (
{changeReviewActive && changeComparisonDocument ? ( ) : ( )} {workspaceMode ? ( setSourceEditorTarget(null)} onDocumentEdited={handleInlineDocumentEdited} geometryVersion={`${pageViewport.scaleMode}:${pageViewport.scale}`} /> ) : null} {isSlidePress ? ( ) : null}
)}
{!pageSourceEditMode ? ( ) : null} {workspaceMode && inspector.inspectorMode && !pageSourceEditMode ? ( ) : null} {workspaceMode && inspector.inspectorMode && !pageSourceEditMode ? ( ) : null} {deleteSlideTarget ? ( Cancel Delete slide )} > Delete {deleteSlideTarget.id} from this deck? This removes the slide folder from source. You can still recover it from version control if needed. ) : null} setDocumentInfoOpen(false)} title={activePressTitle} pressType={pressType} theme={document.theme} pages={displayPages} />
); } function CommentInspectorControl({ workspaceMode, inspectorMode, onInspectorModeChange, commentCount, }: { workspaceMode: boolean; inspectorMode: boolean; onInspectorModeChange: (enabled: boolean) => void; commentCount: number; }) { if (!workspaceMode) return null; const badgeLabel = commentCount > 99 ? "99+" : String(commentCount); const title = inspectorMode ? "關閉註解工具" : "開啟註解工具"; return ( ); } function isNarrowWorkspaceViewport() { if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; return window.matchMedia(SHELL_COMPACT_MEDIA_QUERY).matches; } function appendUnique(values: string[], value: string) { return values.includes(value) ? values : [...values, value]; } function removeValue(values: string[], value: string) { return values.filter((current) => current !== value); } function findWorkspacePressForCommentPath(path: string | undefined, presses?: WorkspaceManifestPress[]) { if (!path || !presses?.length) return null; const normalizedPath = path.replace(/\\/g, "/").replace(/^\/+/, ""); const orderedPresses = [...presses].sort((left, right) => right.slug.length - left.slug.length); return orderedPresses.find((press) => { const slug = press.slug.replace(/^\/+|\/+$/g, ""); if (!slug) return false; return normalizedPath === slug || normalizedPath.startsWith(`${slug}/`) || normalizedPath.includes(`/press/${slug}/`) || normalizedPath.startsWith(`press/${slug}/`) || normalizedPath.includes(`/${slug}/`); }) ?? null; } function appendOptimisticSlide(slides: OptimisticAddedSlide[], slide: OptimisticAddedSlide) { return [...slides.filter((current) => current.id !== slide.id), slide]; } function nextIndexAfterRemoval(removedIndex: number, pageCount: number) { if (removedIndex < 0) return null; const nextCount = Math.max(pageCount - 1, 1); return Math.min(removedIndex, nextCount - 1); } function renumberPages(pages: HtmlPageBlock[]) { return pages.map((page, index) => ( page.pageNumber === index + 1 ? page : { ...page, pageNumber: index + 1 } )); } function createOptimisticSlidePage({ slideId, fallbackTitle, }: { slideId: string; fallbackTitle: string; }): HtmlPageBlock { return { id: `optimistic-slide-${slideId}`, kind: "htmlPage", title: fallbackTitle, pageNumber: 1, frameKey: slideId, html: `

${escapeHtml(fallbackTitle)}

`, }; } function escapeHtml(value: string) { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function SlideSpeakerNotesDock({ frameKey, notes, editable, onSave, }: { frameKey?: string; notes: string; editable: boolean; onSave?: (notes: string) => Promise; }) { const [draft, setDraft] = useState(notes); const [committedNotes, setCommittedNotes] = useState(notes); const [saving, setSaving] = useState(false); useEffect(() => { setDraft(notes); setCommittedNotes(notes); setSaving(false); }, [frameKey, notes]); const dirty = draft !== committedNotes; const save = useCallback(async () => { if (!editable || !frameKey || !onSave || !dirty || saving) return; setSaving(true); const saved = await onSave(draft); if (saved) setCommittedNotes(draft); setSaving(false); }, [dirty, draft, editable, frameKey, onSave, saving]); return (
Speaker Notes {frameKey ? `Slide: ${frameKey}` : "Current slide"}
{editable ? ( ) : null}
{editable ? (