import { useEffect, useState } from "react"; import { ChevronDown, Minus, Plus } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { MAX_FIXED_PAGE_VIEWPORT_PERCENT, MIN_FIXED_PAGE_VIEWPORT_PERCENT, PAGE_VIEWPORT_SCALE_OPTIONS, currentPageViewportPercent, pageViewportScaleModeFromPercent, stepPageViewportScale, type PageViewportScaleMode, } from "../../reader/pageViewportScaleModel"; import { usePageZoomKeyboardShortcuts } from "../../reader/usePageZoomKeyboardShortcuts"; import { Button } from "@/openpress/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/openpress/ui/dropdown-menu"; const PANEL_ZOOM_DOCK_CLASS = [ "grid grid-cols-[32px_minmax(0,1fr)_32px] items-center gap-1", "border-t border-[var(--op-workspace-border-muted)] px-3 py-2", ].join(" "); const FLOATING_ZOOM_DOCK_CLASS = [ "absolute bottom-[calc(16px+env(safe-area-inset-bottom))] right-4 z-20", "grid grid-cols-[32px_minmax(72px,1fr)_32px] items-center gap-1", "rounded-[var(--op-workspace-radius-md)] border border-[var(--op-workspace-border)]", "bg-[var(--op-workspace-surface-raised)] p-1 shadow-[var(--op-workspace-shadow-floating)]", "max-[520px]:left-1/2 max-[520px]:right-auto max-[520px]:-translate-x-1/2", ].join(" "); const FLOATING_ZOOM_DOCK_WITH_BOTTOM_OVERLAY_CLASS = [ "max-[860px]:!bottom-auto max-[860px]:!left-auto max-[860px]:!right-3 max-[860px]:!top-3", "max-[860px]:!-translate-x-0", ].join(" "); const ZOOM_DOCK_ICON_BUTTON_CLASS = [ "h-8 w-8 rounded-[var(--op-workspace-radius-sm)] p-0", "!bg-transparent text-[var(--op-workspace-text-muted)]", "hover:!bg-transparent hover:text-[var(--op-workspace-text)] active:!bg-transparent", "[&[aria-expanded=true]]:!bg-transparent", ].join(" "); const ZOOM_DOCK_VALUE_CLASS = [ "h-8 min-w-0 justify-center gap-1.5 rounded-[var(--op-workspace-radius-sm)] px-2", "!bg-transparent text-[13px] font-[650] text-[var(--op-workspace-text-soft)]", "[font-family:var(--op-workspace-font-mono)]", "hover:!bg-transparent hover:text-[var(--op-workspace-text)] active:!bg-transparent", "[&[aria-expanded=true]]:!bg-transparent [&[aria-expanded=true]]:!text-[var(--op-workspace-accent)]", ].join(" "); type ZoomValueMotionDirection = "up" | "down" | "still"; interface ZoomValueMotionContext { direction: ZoomValueMotionDirection; reduceMotion: boolean; } function zoomValueMotionDirectionForMode( mode: PageViewportScaleMode, currentPercent: number, ): ZoomValueMotionDirection { if (!mode.startsWith("scale-")) return "still"; const targetPercent = Number.parseInt(mode.slice("scale-".length), 10); if (targetPercent > currentPercent) return "up"; if (targetPercent < currentPercent) return "down"; return "still"; } const ZOOM_VALUE_MOTION_VARIANTS = { enter: ({ direction, reduceMotion }: ZoomValueMotionContext) => ({ opacity: reduceMotion ? 1 : 0, y: reduceMotion ? 0 : direction === "up" ? 8 : direction === "down" ? -8 : 0, }), center: { opacity: 1, y: 0 }, exit: ({ direction, reduceMotion }: ZoomValueMotionContext) => ({ opacity: reduceMotion ? 1 : 0, y: reduceMotion ? 0 : direction === "up" ? -8 : direction === "down" ? 8 : 0, }), }; function AnimatedZoomValue({ direction, label, reduceMotion, }: { direction: ZoomValueMotionDirection; label: string; reduceMotion: boolean; }) { const [entryDirection] = useState(direction); return ( {label} ); } const ZOOM_DOCK_MENU_CLASS = [ "op-ui-menu w-[220px] rounded-[10px] border border-[var(--op-workspace-border)]", "bg-[var(--op-workspace-surface-raised)] p-2 shadow-[var(--op-workspace-shadow-popover)]", ].join(" "); const ZOOM_DOCK_MENU_ITEM_CLASS = "min-h-8 rounded-[var(--op-workspace-radius-sm)] px-2 text-xs"; const ZOOM_DOCK_CUSTOM_CLASS = [ "grid grid-cols-[minmax(0,1fr)_64px_auto] items-center gap-2 px-2 py-1.5", "text-xs text-[var(--op-workspace-text-muted)]", ].join(" "); const ZOOM_DOCK_CUSTOM_INPUT_CLASS = [ "h-8 w-full rounded-[var(--op-workspace-radius-sm)] border border-[var(--op-workspace-border)]", "bg-[var(--op-workspace-surface)] px-2 text-right text-[var(--op-workspace-text)] outline-none", "focus:border-[var(--op-workspace-accent-border)]", ].join(" "); const DOCK_ZOOM_OPTIONS: Array<{ value: PageViewportScaleMode; label: string }> = [ { value: "fit-width", label: "符合頁面寬度" }, { value: "fit-page", label: "符合全開頁面" }, ...PAGE_VIEWPORT_SCALE_OPTIONS.filter((option) => option.value.startsWith("scale-")), ]; export interface PageZoomDockProps { scaleMode: PageViewportScaleMode; scale: number; scaleLabel: string; placement: "panel" | "floating"; avoidBottomOverlay?: boolean; onScaleModeChange: (mode: PageViewportScaleMode) => void; } export function PageZoomDock({ scaleMode, scale, scaleLabel, placement, avoidBottomOverlay = false, onScaleModeChange, }: PageZoomDockProps) { const [open, setOpen] = useState(false); const percent = currentPageViewportPercent(scaleMode, scale); const [customValue, setCustomValue] = useState(String(percent)); const [motionDirection, setMotionDirection] = useState("still"); const [motionRevision, setMotionRevision] = useState(0); const reduceMotion = useReducedMotion() === true; useEffect(() => { if (!open) setCustomValue(String(percent)); }, [open, percent]); const beginValueMotion = (direction: ZoomValueMotionDirection) => { setMotionDirection(direction); setMotionRevision((revision) => revision + 1); }; usePageZoomKeyboardShortcuts({ onStep: (deltaPercent) => { beginValueMotion(deltaPercent > 0 ? "up" : "down"); onScaleModeChange(stepPageViewportScale(scaleMode, scale, deltaPercent)); }, }); const applyCustom = () => { const normalized = customValue.trim(); if (!/^\d+$/.test(normalized)) { setCustomValue(String(percent)); return; } const mode = pageViewportScaleModeFromPercent(Number.parseInt(normalized, 10)); beginValueMotion(zoomValueMotionDirectionForMode(mode, percent)); onScaleModeChange(mode); setCustomValue(mode.slice("scale-".length)); setOpen(false); }; return (
{DOCK_ZOOM_OPTIONS.map((option) => ( { beginValueMotion(zoomValueMotionDirectionForMode(option.value, percent)); onScaleModeChange(option.value); }} > {option.label} ))}
); }