feat(3d): integrate ZJB scene with project context
This commit is contained in:
@@ -0,0 +1,592 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import Draggable from "react-draggable";
|
||||
import {
|
||||
FiCalendar,
|
||||
FiChevronDown,
|
||||
FiChevronLeft,
|
||||
FiChevronRight,
|
||||
FiPause,
|
||||
FiPlay,
|
||||
FiRotateCcw,
|
||||
FiSkipBack,
|
||||
FiSkipForward,
|
||||
FiX,
|
||||
FiZap,
|
||||
} from "react-icons/fi";
|
||||
|
||||
import {
|
||||
formatTimelineTime,
|
||||
getRoundedCurrentTimelineMinutes,
|
||||
normalizeTimelineMinutes,
|
||||
} from "@components/olmap/core/Controls/timelineTime";
|
||||
|
||||
const DEFAULT_PLAY_INTERVAL_MS = 2_500;
|
||||
const WEEK_LABELS = ["一", "二", "三", "四", "五", "六", "日"];
|
||||
const glassClass =
|
||||
"bg-[rgba(234,244,250,0.62)] [backdrop-filter:blur(26px)_saturate(145%)_contrast(96%)] [-webkit-backdrop-filter:blur(26px)_saturate(145%)_contrast(96%)] [box-shadow:inset_0_1px_0_rgba(255,255,255,0.90),inset_0_-1px_0_rgba(112,145,168,0.16),0_18px_50px_rgba(15,43,69,0.20)] ring-1 ring-white/60";
|
||||
const calendarGlassClass =
|
||||
"bg-[rgba(238,246,251,0.88)] [backdrop-filter:blur(30px)_saturate(140%)_contrast(95%)] [-webkit-backdrop-filter:blur(30px)_saturate(140%)_contrast(95%)] [box-shadow:inset_0_1px_0_rgba(255,255,255,0.96),inset_0_-1px_0_rgba(112,145,168,0.18),0_22px_60px_rgba(15,43,69,0.24)] ring-1 ring-white/75";
|
||||
|
||||
export type ThreeDimensionalTimelineProps = {
|
||||
selectedDate: Date;
|
||||
currentTime: number;
|
||||
durationMinutes: number;
|
||||
stepMinutes: number;
|
||||
disabled?: boolean;
|
||||
sidePanelOpen?: boolean;
|
||||
onClose: () => void;
|
||||
onSelectedDateChange: (date: Date) => void;
|
||||
onCurrentTimeChange: (minutes: number) => void;
|
||||
};
|
||||
|
||||
const startOfDay = (date: Date) =>
|
||||
new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
|
||||
const isSameDay = (left: Date, right: Date) =>
|
||||
left.getFullYear() === right.getFullYear() &&
|
||||
left.getMonth() === right.getMonth() &&
|
||||
left.getDate() === right.getDate();
|
||||
|
||||
const addDays = (date: Date, amount: number) =>
|
||||
new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount, 12);
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year} / ${month} / ${day}`;
|
||||
};
|
||||
|
||||
export function ThreeDimensionalTimeline({
|
||||
selectedDate,
|
||||
currentTime,
|
||||
durationMinutes,
|
||||
stepMinutes,
|
||||
disabled = false,
|
||||
sidePanelOpen = false,
|
||||
onClose,
|
||||
onSelectedDateChange,
|
||||
onCurrentTimeChange,
|
||||
}: ThreeDimensionalTimelineProps) {
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [playIntervalMs, setPlayIntervalMs] = useState(DEFAULT_PLAY_INTERVAL_MS);
|
||||
const [previewTime, setPreviewTime] = useState<number | null>(null);
|
||||
const safeCurrentTime = normalizeTimelineMinutes(
|
||||
previewTime ?? currentTime,
|
||||
0,
|
||||
durationMinutes,
|
||||
);
|
||||
|
||||
const advance = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
const next = safeCurrentTime + stepMinutes * direction;
|
||||
onCurrentTimeChange(
|
||||
next > durationMinutes ? 0 : next < 0 ? durationMinutes : next,
|
||||
);
|
||||
},
|
||||
[durationMinutes, onCurrentTimeChange, safeCurrentTime, stepMinutes],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing || disabled) return;
|
||||
const intervalId = window.setInterval(() => advance(1), playIntervalMs);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [advance, disabled, playIntervalMs, playing]);
|
||||
|
||||
const marks = useMemo(
|
||||
() =>
|
||||
[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||
const value = Math.round((durationMinutes * ratio) / stepMinutes) * stepMinutes;
|
||||
return formatTimelineTime(Math.min(durationMinutes, value), 0, durationMinutes);
|
||||
}),
|
||||
[durationMinutes, stepMinutes],
|
||||
);
|
||||
|
||||
const resetToCurrentTime = () => {
|
||||
const now = new Date();
|
||||
setPlaying(false);
|
||||
onSelectedDateChange(now);
|
||||
onCurrentTimeChange(
|
||||
getRoundedCurrentTimelineMinutes(now, stepMinutes, durationMinutes),
|
||||
);
|
||||
};
|
||||
|
||||
const commitPreview = (value?: string) => {
|
||||
const next = Number(value ?? previewTime ?? safeCurrentTime);
|
||||
if (!Number.isFinite(next)) return;
|
||||
setPreviewTime(null);
|
||||
onCurrentTimeChange(next);
|
||||
};
|
||||
|
||||
const progress = durationMinutes > 0
|
||||
? Math.min(100, Math.max(0, (safeCurrentTime / durationMinutes) * 100))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"pointer-events-none absolute inset-x-2 bottom-2 z-20 flex justify-center transition-[right] duration-200 md:left-4 md:bottom-4",
|
||||
sidePanelOpen ? "md:right-[416px]" : "md:right-4",
|
||||
)}
|
||||
>
|
||||
<Draggable
|
||||
nodeRef={timelineRef}
|
||||
handle=".timeline-drag-handle"
|
||||
cancel="button, input, select, [role='dialog']"
|
||||
>
|
||||
<section
|
||||
ref={timelineRef}
|
||||
aria-label="三维场景时间轴"
|
||||
className={clsx(
|
||||
glassClass,
|
||||
"pointer-events-auto relative w-full max-w-[950px] rounded-2xl opacity-95 transition-opacity duration-200 hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<div className="timeline-drag-handle relative flex h-7 cursor-move touch-none items-center justify-center rounded-t-2xl border-b border-white/40 bg-white/10">
|
||||
<span aria-hidden="true" className="h-1 w-10 rounded-full bg-slate-400/60 transition-colors hover:bg-slate-500/70" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="收起时间轴"
|
||||
title="收起时间轴"
|
||||
onClick={onClose}
|
||||
className="absolute right-1 top-1/2 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-lg text-slate-500 transition-[transform,color,background-color] hover:bg-white/60 hover:text-slate-800 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"
|
||||
>
|
||||
<FiX />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-3 pt-3 md:px-4 md:pb-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-slate-800">结果时间轴</span>
|
||||
<span className="rounded-md bg-blue-50/60 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 ring-1 ring-blue-200/55">{stepMinutes} 分钟步长</span>
|
||||
<time className="ml-auto hidden text-xs tabular-nums text-slate-500 sm:block">
|
||||
{formatDate(selectedDate)} {formatTimelineTime(safeCurrentTime, 0, durationMinutes)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<div className="grid gap-2.5 sm:grid-cols-[minmax(260px,0.85fr)_minmax(350px,1.15fr)]">
|
||||
<div className="rounded-xl bg-white/25 px-3 py-2 ring-1 ring-white/40">
|
||||
<span className="mb-1.5 block text-[11px] font-medium text-slate-500">数据日期</span>
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<SquareButton label="后退一天" disabled={disabled} onClick={() => onSelectedDateChange(addDays(selectedDate, -1))}>
|
||||
<FiChevronLeft />
|
||||
</SquareButton>
|
||||
<CalendarPicker
|
||||
selectedDate={selectedDate}
|
||||
disabled={disabled}
|
||||
onChange={onSelectedDateChange}
|
||||
/>
|
||||
<SquareButton
|
||||
label="前进一天"
|
||||
disabled={disabled || startOfDay(selectedDate).getTime() >= startOfDay(new Date()).getTime()}
|
||||
onClick={() => onSelectedDateChange(addDays(selectedDate, 1))}
|
||||
>
|
||||
<FiChevronRight />
|
||||
</SquareButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-white/25 px-3 py-2 ring-1 ring-white/40">
|
||||
<span className="mb-1.5 block text-[11px] font-medium text-slate-500">播放控制</span>
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<PlaybackSpeedPicker
|
||||
value={playIntervalMs}
|
||||
disabled={disabled}
|
||||
onChange={setPlayIntervalMs}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<TimelineButton label={`后退 ${stepMinutes} 分钟`} disabled={disabled} onClick={() => advance(-1)}><FiSkipBack /></TimelineButton>
|
||||
<TimelineButton label={playing ? "暂停播放" : "播放时间轴"} disabled={disabled} active={playing} onClick={() => setPlaying((value) => !value)}>{playing ? <FiPause /> : <FiPlay className="translate-x-px" />}</TimelineButton>
|
||||
<TimelineButton label={`前进 ${stepMinutes} 分钟`} disabled={disabled} onClick={() => advance(1)}><FiSkipForward /></TimelineButton>
|
||||
</div>
|
||||
<TimelineButton label="回到当前时刻" disabled={disabled} onClick={resetToCurrentTime}><FiRotateCcw /></TimelineButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 rounded-xl bg-white/20 px-3 pb-2 pt-2.5 ring-1 ring-white/35">
|
||||
<div className="mb-1.5 flex items-baseline gap-2">
|
||||
<span className="text-[11px] text-slate-500">查询时刻</span>
|
||||
<strong className="text-sm font-semibold tabular-nums text-slate-800">{formatTimelineTime(safeCurrentTime, 0, durationMinutes)}</strong>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="range"
|
||||
aria-label="三维场景查询时刻"
|
||||
min={0}
|
||||
max={durationMinutes}
|
||||
step={stepMinutes}
|
||||
value={safeCurrentTime}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setPreviewTime(Number(event.target.value))}
|
||||
onPointerUp={(event) => commitPreview(event.currentTarget.value)}
|
||||
onKeyUp={(event) => commitPreview(event.currentTarget.value)}
|
||||
onBlur={(event) => previewTime !== null && commitPreview(event.currentTarget.value)}
|
||||
className="block h-1.5 w-full cursor-pointer appearance-none rounded-full disabled:cursor-not-allowed disabled:opacity-40 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-blue-600 [&::-moz-range-thumb]:shadow-md [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-600 [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:ring-[3px] [&::-webkit-slider-thumb]:ring-white/80"
|
||||
style={{ background: `linear-gradient(90deg, #2563eb 0%, #2563eb ${progress}%, rgba(148,163,184,.42) ${progress}%, rgba(148,163,184,.42) 100%)` }}
|
||||
/>
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 justify-between px-0.5">
|
||||
{[0, 1, 2, 3, 4].map((mark) => <span key={mark} className="h-1 w-1 rounded-full bg-white/90 shadow-sm" />)}
|
||||
</div>
|
||||
</div>
|
||||
<div aria-hidden="true" className="mt-1.5 flex justify-between text-[10px] tabular-nums text-slate-500">
|
||||
{marks.map((mark, index) => <span key={`${mark}-${index}`} className={clsx(index > 0 && index < marks.length - 1 && "hidden sm:inline")}>{mark}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Draggable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CalendarView = "days" | "months" | "years";
|
||||
|
||||
function CalendarPicker({ selectedDate, disabled, onChange }: { selectedDate: Date; disabled: boolean; onChange: (date: Date) => void }) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const calendarRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [visibleMonth, setVisibleMonth] = useState(() => new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
|
||||
const [focusedDate, setFocusedDate] = useState(() => startOfDay(selectedDate));
|
||||
const [view, setView] = useState<CalendarView>("days");
|
||||
const [yearPageStart, setYearPageStart] = useState(() => Math.floor(selectedDate.getFullYear() / 12) * 12);
|
||||
const today = startOfDay(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!wrapperRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || view !== "days") return;
|
||||
window.requestAnimationFrame(() => {
|
||||
calendarRef.current
|
||||
?.querySelector<HTMLButtonElement>("[data-calendar-focused='true']")
|
||||
?.focus();
|
||||
});
|
||||
}, [focusedDate, open, view, visibleMonth]);
|
||||
|
||||
const days = useMemo(() => {
|
||||
const firstWeekday = (visibleMonth.getDay() + 6) % 7;
|
||||
return Array.from({ length: 42 }, (_, index) =>
|
||||
new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), index - firstWeekday + 1, 12),
|
||||
);
|
||||
}, [visibleMonth]);
|
||||
|
||||
const selectDate = (date: Date) => {
|
||||
onChange(date);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const selectToday = () => {
|
||||
const now = new Date();
|
||||
onChange(now);
|
||||
setFocusedDate(startOfDay(now));
|
||||
setVisibleMonth(new Date(now.getFullYear(), now.getMonth(), 1));
|
||||
setYearPageStart(Math.floor(now.getFullYear() / 12) * 12);
|
||||
setView("days");
|
||||
};
|
||||
|
||||
const showDate = (date: Date) => {
|
||||
const bounded = startOfDay(date).getTime() > today.getTime() ? today : startOfDay(date);
|
||||
setFocusedDate(bounded);
|
||||
setVisibleMonth(new Date(bounded.getFullYear(), bounded.getMonth(), 1));
|
||||
};
|
||||
|
||||
const handleCalendarKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (view !== "days") return;
|
||||
let next: Date | null = null;
|
||||
if (event.key === "ArrowLeft") next = addDays(focusedDate, -1);
|
||||
if (event.key === "ArrowRight") next = addDays(focusedDate, 1);
|
||||
if (event.key === "ArrowUp") next = addDays(focusedDate, -7);
|
||||
if (event.key === "ArrowDown") next = addDays(focusedDate, 7);
|
||||
if (event.key === "PageUp") next = new Date(focusedDate.getFullYear(), focusedDate.getMonth() - 1, focusedDate.getDate(), 12);
|
||||
if (event.key === "PageDown") next = new Date(focusedDate.getFullYear(), focusedDate.getMonth() + 1, focusedDate.getDate(), 12);
|
||||
const weekday = (focusedDate.getDay() + 6) % 7;
|
||||
if (event.key === "Home") next = addDays(focusedDate, -weekday);
|
||||
if (event.key === "End") next = addDays(focusedDate, 6 - weekday);
|
||||
if (!next) return;
|
||||
event.preventDefault();
|
||||
showDate(next);
|
||||
};
|
||||
|
||||
const atLatestPeriod =
|
||||
view === "days"
|
||||
? visibleMonth.getFullYear() === today.getFullYear() && visibleMonth.getMonth() >= today.getMonth()
|
||||
: view === "months"
|
||||
? visibleMonth.getFullYear() >= today.getFullYear()
|
||||
: yearPageStart + 11 >= today.getFullYear();
|
||||
|
||||
const movePeriod = (direction: -1 | 1) => {
|
||||
if (view === "days") {
|
||||
setVisibleMonth((date) => new Date(date.getFullYear(), date.getMonth() + direction, 1));
|
||||
return;
|
||||
}
|
||||
if (view === "months") {
|
||||
setVisibleMonth((date) => new Date(date.getFullYear() + direction, date.getMonth(), 1));
|
||||
return;
|
||||
}
|
||||
setYearPageStart((year) => year + direction * 12);
|
||||
};
|
||||
|
||||
const title = view === "days"
|
||||
? `${visibleMonth.getFullYear()} 年 ${visibleMonth.getMonth() + 1} 月`
|
||||
: view === "months"
|
||||
? `${visibleMonth.getFullYear()} 年`
|
||||
: `${yearPageStart} 至 ${yearPageStart + 11} 年`;
|
||||
|
||||
const openCalendar = () => {
|
||||
const selected = startOfDay(selectedDate);
|
||||
setVisibleMonth(new Date(selected.getFullYear(), selected.getMonth(), 1));
|
||||
setFocusedDate(selected);
|
||||
setYearPageStart(Math.floor(selected.getFullYear() / 12) * 12);
|
||||
setView("days");
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`选择日期,当前 ${formatDate(selectedDate)}`}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (open) setOpen(false);
|
||||
else openCalendar();
|
||||
}}
|
||||
className={clsx(
|
||||
"flex h-10 min-w-[154px] items-center gap-2 rounded-xl bg-white/48 px-3 text-sm tabular-nums text-slate-700 ring-1 ring-white/65 transition-[transform,background-color,box-shadow] hover:bg-white/72 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-40",
|
||||
open && "bg-white/78 ring-2 ring-blue-500/35",
|
||||
)}
|
||||
>
|
||||
<FiCalendar className="text-blue-600" />
|
||||
<span>{formatDate(selectedDate)}</span>
|
||||
<FiChevronDown className={clsx("ml-auto text-xs text-slate-500 transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
ref={calendarRef}
|
||||
role="dialog"
|
||||
aria-label="选择数据日期"
|
||||
onKeyDown={handleCalendarKeyDown}
|
||||
className={clsx(
|
||||
calendarGlassClass,
|
||||
"absolute bottom-[calc(100%+10px)] left-[-46px] z-50 w-[min(312px,calc(100vw-32px))] rounded-2xl p-3 sm:left-0",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center">
|
||||
<button type="button" aria-label={view === "years" ? "前十二年" : view === "months" ? "上一年" : "上个月"} onClick={() => movePeriod(-1)} className="grid h-10 w-10 place-items-center rounded-xl text-slate-600 transition hover:bg-white/55 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"><FiChevronLeft /></button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={view === "days" ? "选择月份" : view === "months" ? "选择年份" : "返回日期"}
|
||||
aria-live="polite"
|
||||
onClick={() => setView((current) => current === "days" ? "months" : current === "months" ? "years" : "days")}
|
||||
className="min-h-10 flex-1 rounded-xl px-2 text-center text-sm font-semibold tabular-nums text-slate-800 transition hover:bg-white/55 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
<button type="button" onClick={selectToday} className="min-h-10 rounded-xl px-2.5 text-[11px] font-semibold text-blue-700 transition hover:bg-white/55 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40">今天</button>
|
||||
<button type="button" aria-label={view === "years" ? "后十二年" : view === "months" ? "下一年" : "下个月"} disabled={atLatestPeriod} onClick={() => movePeriod(1)} className="grid h-10 w-10 place-items-center rounded-xl text-slate-600 transition hover:bg-white/55 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-25"><FiChevronRight /></button>
|
||||
</div>
|
||||
{view === "days" && (
|
||||
<>
|
||||
<div className="grid grid-cols-7 text-center">
|
||||
{WEEK_LABELS.map((label, index) => <span key={label} className={clsx("py-1 text-[10px] font-medium", index > 4 ? "text-blue-600" : "text-slate-500")}>{label}</span>)}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{days.map((date) => {
|
||||
const selected = isSameDay(date, selectedDate);
|
||||
const current = isSameDay(date, today);
|
||||
const focused = isSameDay(date, focusedDate);
|
||||
const outsideMonth = date.getMonth() !== visibleMonth.getMonth();
|
||||
const future = startOfDay(date).getTime() > today.getTime();
|
||||
return (
|
||||
<button
|
||||
key={date.toISOString()}
|
||||
type="button"
|
||||
aria-label={`${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日${current ? ",今天" : ""}`}
|
||||
aria-pressed={selected}
|
||||
data-calendar-focused={focused}
|
||||
tabIndex={focused ? 0 : -1}
|
||||
disabled={future}
|
||||
onFocus={() => setFocusedDate(startOfDay(date))}
|
||||
onClick={() => selectDate(date)}
|
||||
className={clsx(
|
||||
"relative grid h-9 w-9 place-items-center rounded-xl text-xs tabular-nums transition-[transform,color,background-color,box-shadow] active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/45 disabled:cursor-not-allowed disabled:opacity-20",
|
||||
selected
|
||||
? "bg-blue-600 font-semibold text-white shadow-md shadow-blue-700/20"
|
||||
: "text-slate-700 hover:bg-white/65 hover:text-blue-700",
|
||||
outsideMonth && !selected && "text-slate-400",
|
||||
current && !selected && "font-semibold text-blue-700 after:absolute after:bottom-1 after:h-1 after:w-1 after:rounded-full after:bg-blue-600",
|
||||
)}
|
||||
>
|
||||
{date.getDate()}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{view === "months" && (
|
||||
<div role="grid" aria-label={`${visibleMonth.getFullYear()} 年月份`} className="grid grid-cols-3 gap-1 py-1">
|
||||
{Array.from({ length: 12 }, (_, month) => {
|
||||
const future = visibleMonth.getFullYear() > today.getFullYear() || (visibleMonth.getFullYear() === today.getFullYear() && month > today.getMonth());
|
||||
const selected = selectedDate.getFullYear() === visibleMonth.getFullYear() && selectedDate.getMonth() === month;
|
||||
return (
|
||||
<button key={month} type="button" role="gridcell" aria-selected={selected} disabled={future} onClick={() => { setVisibleMonth(new Date(visibleMonth.getFullYear(), month, 1)); setView("days"); }} className={clsx("min-h-11 rounded-xl text-sm transition hover:bg-white/60 hover:text-blue-700 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-20", selected ? "bg-blue-600 font-semibold text-white shadow-sm hover:bg-blue-600 hover:text-white" : "text-slate-700")}>{month + 1} 月</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{view === "years" && (
|
||||
<div role="grid" aria-label="选择年份" className="grid grid-cols-3 gap-1 py-1">
|
||||
{Array.from({ length: 12 }, (_, offset) => yearPageStart + offset).map((year) => {
|
||||
const future = year > today.getFullYear();
|
||||
const selected = year === selectedDate.getFullYear();
|
||||
return (
|
||||
<button key={year} type="button" role="gridcell" aria-selected={selected} disabled={future} onClick={() => { const month = year === today.getFullYear() ? Math.min(visibleMonth.getMonth(), today.getMonth()) : visibleMonth.getMonth(); setVisibleMonth(new Date(year, month, 1)); setView("months"); }} className={clsx("min-h-11 rounded-xl text-sm tabular-nums transition hover:bg-white/60 hover:text-blue-700 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-20", selected ? "bg-blue-600 font-semibold text-white shadow-sm hover:bg-blue-600 hover:text-white" : "text-slate-700")}>{year}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const playbackSpeedOptions = [
|
||||
{ value: 1000, label: "1.0×", description: "每秒一步" },
|
||||
{ value: 2500, label: "0.4×", description: "2.5 秒一步" },
|
||||
{ value: 5000, label: "0.2×", description: "5 秒一步" },
|
||||
];
|
||||
|
||||
function PlaybackSpeedPicker({ value, disabled, onChange }: { value: number; disabled: boolean; onChange: (value: number) => void }) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = playbackSpeedOptions.find((option) => option.value === value) ?? playbackSpeedOptions[1];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!wrapperRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`播放速度,当前 ${selected.label}`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className={clsx(
|
||||
"flex h-10 min-w-[86px] items-center gap-2 rounded-xl bg-white/46 px-2.5 text-xs font-medium tabular-nums text-slate-700 ring-1 ring-white/65 transition-[transform,background-color,box-shadow] hover:bg-white/72 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-40",
|
||||
open && "bg-white/78 ring-2 ring-blue-500/35",
|
||||
)}
|
||||
>
|
||||
<FiZap aria-hidden="true" className="text-sm text-blue-600" />
|
||||
<span>{selected.label}</span>
|
||||
<FiChevronDown aria-hidden="true" className={clsx("ml-auto text-xs text-slate-500 transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="选择播放速度"
|
||||
className={clsx(
|
||||
calendarGlassClass,
|
||||
"absolute bottom-[calc(100%+8px)] left-0 z-50 w-36 overflow-hidden rounded-xl p-1.5",
|
||||
)}
|
||||
>
|
||||
{playbackSpeedOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={option.value === value}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={clsx(
|
||||
"flex min-h-11 w-full items-center rounded-lg px-3 text-left transition-[transform,color,background-color] active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500/40",
|
||||
option.value === value
|
||||
? "bg-blue-600 text-white shadow-sm shadow-blue-700/20"
|
||||
: "text-slate-700 hover:bg-white/60 hover:text-blue-700",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-semibold tabular-nums">{option.label}</span>
|
||||
<span className={clsx("ml-auto text-[10px]", option.value === value ? "text-blue-100" : "text-slate-500")}>{option.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SquareButton({ label, disabled, onClick, children }: { label: string; disabled: boolean; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button type="button" aria-label={label} title={label} disabled={disabled} onClick={onClick} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-white/42 text-base text-slate-600 ring-1 ring-white/60 transition-[transform,color,background-color] hover:bg-blue-50/75 hover:text-blue-700 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-35">
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineButton({ label, disabled, active = false, onClick, children }: { label: string; disabled: boolean; active?: boolean; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
"grid h-10 w-10 shrink-0 place-items-center rounded-full text-base transition-[transform,color,background-color,box-shadow] duration-150 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-35",
|
||||
active
|
||||
? "bg-blue-600 text-white shadow-md shadow-blue-700/25"
|
||||
: "bg-slate-100/52 text-slate-600 ring-1 ring-white/55 hover:bg-blue-50/80 hover:text-blue-700",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user