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 (
);
}