import { useLayoutEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, } from "react"; import { ChevronDown } from "lucide-react"; import { Tooltip } from "radix-ui"; import { cn } from "../core/cn"; import type { BookmarkItem, CaptionDirectoryItem } from "../document-model"; import { Panel } from "../shared"; import { SHELL_PANEL_BODY_PADDING_X_CLASS, SHELL_PANEL_HEADER_CLASS, SHELL_PANEL_HEADER_TRIGGER_CLASS, SHELL_PANEL_TITLE_CLASS, } from "../shared/shellPanelLanguage"; import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/openpress/ui/dropdown-menu"; export const BOOKMARKS_SECTION_CLASS = [ "openpress-panel-section openpress-panel-section--bookmarks", "grid min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden pt-0", ].join(" "); export const BOOKMARKS_NAV_CLASS = [ "reader-bookmarks h-full min-h-0 overflow-y-auto pb-5 pt-1.5", SHELL_PANEL_BODY_PADDING_X_CLASS, "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden", ].join(" "); export const BOOKMARKS_RAIL_CLASS = "reader-bookmarks-rail hidden"; const ASSET_EMPTY_CLASS = "openpress-asset-empty !m-0 !px-[30px] !py-0 !text-xs !leading-normal !text-[var(--op-workspace-text-muted)]"; const CURRENT_PAGE_SECTION_CLASS = "openpress-panel-section--current row-start-3 min-w-0 min-h-0 border-b-0 py-[14px] pb-5"; const CURRENT_PAGE_HEADING_CLASS = "openpress-panel-heading m-0 px-4 pb-2.5 text-[9px] font-medium uppercase tracking-[0.12em] text-[var(--op-workspace-text-muted)]"; const CURRENT_PAGE_CARD_CLASS = "openpress-current-page-card grid gap-1.5 px-4 py-0"; const CURRENT_PAGE_NUMBER_CLASS = [ "openpress-current-page-card__number flex items-baseline gap-[7px] text-lg font-normal leading-none tracking-[0.05em]", "text-[var(--op-workspace-text-soft)] [font-variant-numeric:tabular-nums]", ].join(" "); const CURRENT_PAGE_PREFIX_CLASS = "openpress-current-page-card__prefix text-[10px] font-medium tracking-[0.08em] text-[var(--op-workspace-text-muted)]"; const CURRENT_PAGE_SEPARATOR_CLASS = "sep text-xs leading-none text-[var(--op-workspace-text-muted)]"; const CURRENT_PAGE_TOTAL_CLASS = "text-xs text-[var(--op-workspace-text-muted)]"; const CURRENT_PAGE_TITLE_CLASS = "openpress-current-page-card__title overflow-hidden text-ellipsis whitespace-nowrap text-[11px] leading-[1.4] text-[var(--op-workspace-text-muted)]"; const CURRENT_PAGE_PROGRESS_CLASS = "openpress-current-page-card__progress h-0.5 w-full overflow-hidden bg-[var(--op-workspace-progress-track)]"; const CURRENT_PAGE_PROGRESS_BAR_CLASS = "block h-full w-[var(--progress,0%)] bg-[var(--op-workspace-progress-bar)] transition-[width] duration-[260ms] ease-[cubic-bezier(0.22,0.61,0.36,1)]"; const BOOKMARK_GROUP_CLASS = "bookmark-group"; const BOOKMARK_ITEM_CLASS = [ "bookmark-item grid w-full min-w-0 cursor-pointer items-baseline border-0 text-left text-[var(--op-workspace-text-muted)]", "[font-family:inherit] hover:text-[var(--op-workspace-text)]", "[column-gap:8px]", ].join(" "); const BOOKMARK_ITEM_ACTIVE_CLASS = "is-active text-[var(--op-workspace-text)]"; const BOOKMARK_ITEM_CURRENT_CLASS = "is-active text-[var(--op-workspace-accent)]"; const BOOKMARK_INDEX_CLASS = [ "bookmark-index block min-w-0 whitespace-nowrap text-inherit tracking-[0.04em]", "[font-variant-numeric:tabular-nums]", ].join(" "); const BOOKMARK_H2_CLASS = "bookmark-h2 grid-cols-[24px_minmax(0,1fr)] py-2 pb-[7px] text-[13px] font-medium leading-[1.42] [font-family:inherit]"; const BOOKMARK_H2_INDEX_CLASS = "text-xs font-medium leading-[1.35] text-[var(--op-workspace-text-soft)]"; const BOOKMARK_H3_CLASS = "bookmark-h3 grid-cols-[24px_minmax(0,1fr)] py-1 pl-8 text-[13px] leading-[1.42] [font-family:inherit]"; const BOOKMARK_H4_CLASS = "bookmark-h4 grid-cols-[36px_minmax(0,1fr)] py-[3px] pl-[52px] text-xs leading-[1.38] [font-family:inherit]"; const BOOKMARK_SUBGROUP_CLASS = "bookmark-subgroup flex flex-col"; const BOOKMARK_TITLE_CLASS = [ "bookmark-title block min-w-0 overflow-visible whitespace-normal tracking-normal [font-family:inherit]", "[line-break:loose] [overflow-wrap:normal] [word-break:keep-all] [-webkit-line-clamp:unset]", ].join(" "); const BOOKMARK_TITLE_TWO_LINE_CLASS = ""; const BOOKMARK_TITLE_ONE_LINE_CLASS = ""; const BOOKMARK_SUBS_CLASS = [ "bookmark-subs block", "transition-[max-height,opacity,padding-bottom,transform] duration-[340ms,180ms,340ms,340ms]", "ease-[cubic-bezier(0.22,0.61,0.36,1),ease,cubic-bezier(0.22,0.61,0.36,1),cubic-bezier(0.22,0.61,0.36,1)] will-change-[max-height,opacity,transform]", ].join(" "); const BOOKMARK_SUBS_OPEN_CLASS = "is-open"; const DIRECTORY_CONTROL_CLASS = [ SHELL_PANEL_HEADER_CLASS, ].join(" "); const DIRECTORY_TRIGGER_CLASS = [ SHELL_PANEL_HEADER_TRIGGER_CLASS, SHELL_PANEL_TITLE_CLASS, ].join(" "); const DIRECTORY_MENU_CLASS = [ "grid w-[168px] gap-0.5 rounded-[var(--op-workspace-radius-md)] border border-[var(--op-workspace-border)]", "bg-[var(--op-workspace-surface-raised)] p-1.5 text-[var(--op-workspace-text-soft)] shadow-[var(--op-workspace-shadow-popover)]", ].join(" "); const DIRECTORY_OPTION_CLASS = [ "min-h-8 cursor-pointer rounded-[var(--op-workspace-radius-sm)] px-2.5 text-xs [font-family:inherit]", "hover:bg-[var(--op-workspace-surface-hover)] focus:bg-[var(--op-workspace-surface-hover)] focus:outline-none", "[&_[data-slot=dropdown-menu-radio-item-indicator]]:hidden", ].join(" "); const DIRECTORY_OPTION_ACTIVE_CLASS = "text-[var(--op-workspace-accent)]"; const CAPTION_DIRECTORY_LIST_CLASS = "flex flex-col pt-1"; const CAPTION_DIRECTORY_ITEM_CLASS = [ BOOKMARK_ITEM_CLASS, "grid-cols-[40px_minmax(0,1fr)] py-2 text-[13px] leading-[1.4] [font-family:inherit]", ].join(" "); const CAPTION_DIRECTORY_LABEL_CLASS = "text-[11px] font-medium leading-[1.4] text-[var(--op-workspace-text-soft)]"; const CAPTION_DIRECTORY_TITLE_CLASS = [ "bookmark-title min-w-0 overflow-hidden tracking-normal [font-family:inherit]", "[line-break:loose] [overflow-wrap:anywhere] [word-break:normal]", ].join(" "); const CAPTION_DIRECTORY_TOOLTIP_CLASS = [ "op-workspace op-workspace-overlay z-[100] max-w-[min(300px,calc(100vw-24px))]", "rounded-[var(--op-workspace-radius-md)] border border-[var(--op-workspace-border)]", "bg-[var(--op-workspace-surface-raised)] px-3 py-2 text-xs leading-[1.45]", "text-[var(--op-workspace-text-soft)] shadow-[var(--op-workspace-shadow-popover)]", ].join(" "); type DirectoryMode = "contents" | "figure" | "table"; const DIRECTORY_LABELS: Record = { contents: "主目錄", figure: "圖目錄", table: "表目錄", }; const DIRECTORY_OPTIONS = ["contents", "figure", "table"] as const; type BookmarkSelectOptions = { behavior?: ScrollBehavior; }; export function DocumentNavigation({ bookmarks, figures, tables, currentPageIndex, onSelectPage, }: { bookmarks: BookmarkItem[]; figures: CaptionDirectoryItem[]; tables: CaptionDirectoryItem[]; currentPageIndex: number; onSelectPage: (pageIndex: number, options?: BookmarkSelectOptions) => void; }) { const [mode, setMode] = useState("contents"); return (
setMode(value as DirectoryMode)}> {DIRECTORY_OPTIONS.map((value) => ( {DIRECTORY_LABELS[value]} ))}
); } function CaptionDirectory({ kind, items, currentPageIndex, onSelectPage, }: { kind: "figure" | "table"; items: CaptionDirectoryItem[]; currentPageIndex: number; onSelectPage: (pageIndex: number, options?: BookmarkSelectOptions) => void; }) { if (items.length === 0) { return ( {kind === "figure" ? "尚無圖目錄" : "尚無表目錄"} ); } return (
{items.map((item) => { return ( ); })}
); } function CaptionDirectoryEntry({ item, active, onSelectPage, }: { item: CaptionDirectoryItem; active: boolean; onSelectPage: (pageIndex: number, options?: BookmarkSelectOptions) => void; }) { const titleRef = useRef(null); const [overflowing, setOverflowing] = useState(false); useLayoutEffect(() => { const title = titleRef.current; if (!title) return; let cancelled = false; const measure = () => { if (!cancelled) { setOverflowing(isClampedTextOverflowing({ clientHeight: title.clientHeight, scrollHeight: title.scrollHeight, clientWidth: title.clientWidth, scrollWidth: title.scrollWidth, unclampedHeight: measureUnclampedTextHeight(title), })); } }; measure(); const observer = typeof ResizeObserver === "function" ? new ResizeObserver(measure) : null; observer?.observe(title); window.addEventListener("resize", measure); void document.fonts?.ready.then(measure); return () => { cancelled = true; observer?.disconnect(); window.removeEventListener("resize", measure); }; }, [item.title]); return ( {overflowing ? ( {item.title} ) : null} ); } export function isClampedTextOverflowing( element: Pick & { unclampedHeight?: number; }, ) { return element.scrollHeight > element.clientHeight + 1 || element.scrollWidth > element.clientWidth + 1 || (element.unclampedHeight ?? 0) > element.clientHeight + 1; } function measureUnclampedTextHeight(element: HTMLElement) { if (element.clientWidth <= 0) return 0; const clone = element.cloneNode(true) as HTMLElement; const computed = element.ownerDocument.defaultView?.getComputedStyle(element); clone.removeAttribute("data-openpress-caption-directory-title"); clone.removeAttribute("data-openpress-text-overflow"); Object.assign(clone.style, { position: "fixed", inset: "0 auto auto -100000px", width: `${element.clientWidth}px`, height: "auto", maxHeight: "none", boxSizing: "border-box", margin: "0", padding: computed?.padding ?? "0", border: "0", font: computed?.font ?? "", fontFamily: computed?.fontFamily ?? "", fontSize: computed?.fontSize ?? "", fontStyle: computed?.fontStyle ?? "", fontWeight: computed?.fontWeight ?? "", fontStretch: computed?.fontStretch ?? "", lineHeight: computed?.lineHeight ?? "", letterSpacing: computed?.letterSpacing ?? "", wordSpacing: computed?.wordSpacing ?? "", textTransform: computed?.textTransform ?? "", textIndent: computed?.textIndent ?? "", whiteSpace: computed?.whiteSpace ?? "", wordBreak: computed?.wordBreak ?? "", overflowWrap: computed?.overflowWrap ?? "", overflow: "visible", visibility: "hidden", pointerEvents: "none", display: "block", webkitLineClamp: "unset", webkitBoxOrient: "initial", }); (element.parentElement ?? element.ownerDocument.body).append(clone); const height = clone.getBoundingClientRect().height; clone.remove(); return height; } export function Bookmarks({ items, currentPageIndex, onSelectPage, }: { items: BookmarkItem[]; currentPageIndex: number; onSelectPage: (pageIndex: number, options?: BookmarkSelectOptions) => void; }) { const goToPage = (event: ReactMouseEvent, pageIndex: number) => { event.preventDefault(); onSelectPage(pageIndex, { behavior: "smooth" }); }; if (items.length === 0) { return 尚無書籤; } return ( <> {items.map((item, index) => { const groupActive = currentPageIndex >= item.pageIndex && currentPageIndex <= item.endPageIndex; const groupState = currentPageIndex < item.pageIndex ? "future" : groupActive ? "active" : "read"; const activeSub = item.subs.find((sub) => currentPageIndex >= sub.pageIndex && currentPageIndex <= sub.endPageIndex); const h2SelfActive = groupActive && !activeSub; const itemLabel = item.label ?? String(index + 1).padStart(2, "0"); return (
{item.subs.map((sub, subIndex) => { const subActive = currentPageIndex >= sub.pageIndex && currentPageIndex <= sub.endPageIndex; const subState = currentPageIndex < sub.pageIndex ? "future" : subActive ? "active" : "read"; const activeTopic = sub.subs.find((topic) => currentPageIndex >= topic.pageIndex && currentPageIndex <= topic.endPageIndex); const subSelfActive = subActive && !activeTopic; const subLabel = sub.label ?? `${itemLabel}.${subIndex + 1}`; return (
{sub.subs.map((topic, topicIndex) => { const topicActive = currentPageIndex >= topic.pageIndex && currentPageIndex <= topic.endPageIndex; const topicState = currentPageIndex < topic.pageIndex ? "future" : topicActive ? "active" : "read"; const topicLabel = topic.label ?? `${subLabel}.${topicIndex + 1}`; return ( ); })}
); })}
); })} ); } export function CurrentPagePanel({ currentPageLabel, totalPageLabel, progressPercent, title, pageLabelPrefix, showHeading = true, showTitle = true, }: { currentPageLabel: string; totalPageLabel: string; progressPercent: number; title: string; pageLabelPrefix?: string; showHeading?: boolean; showTitle?: boolean; }) { return ( {showHeading ?

目前頁面

: null}
{pageLabelPrefix ? {pageLabelPrefix} : null} {currentPageLabel} / {totalPageLabel}
{showTitle ?
{title}
: null}
); }