refactor: replace CanvasTimelineRuler with native range input slider in TimelineBar

This commit is contained in:
taDuc
2026-05-26 23:26:50 +07:00
parent 55e8f13e32
commit 184abb25b4
7 changed files with 323 additions and 565 deletions
+12 -5
View File
@@ -793,11 +793,11 @@ function EditorPageContent() {
const filteredDraft = activeTimelineFilterEnabled
? {
...activeDraft,
features: activeDraft.features.filter((feature) =>
isFeatureVisibleAtYear(feature, clampYearToFixedRange(Math.trunc(activeTimelineYear)))
),
}
...activeDraft,
features: activeDraft.features.filter((feature) =>
isFeatureVisibleAtYear(feature, clampYearToFixedRange(Math.trunc(activeTimelineYear)))
),
}
: activeDraft;
if (viewMode === "local") {
@@ -2968,6 +2968,13 @@ function EditorPageContent() {
changesCount={pendingSaveCount}
undoStack={editor.undoStack}
width={leftPanelWidth}
imageOverlay={imageOverlay}
onPickImageOverlay={handlePickImageOverlay}
onPasteImageOverlay={handlePasteImageOverlay}
imageOverlayKeyboardEnabled={imageOverlayKeyboardEnabled}
onImageOverlayKeyboardEnabledChange={setImageOverlayKeyboardEnabled}
onImageOverlayOpacityChange={handleImageOverlayOpacityChange}
onRemoveImageOverlay={handleRemoveImageOverlay}
/>
<ResizeHandle
+27 -1
View File
@@ -17,9 +17,35 @@
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes borderPulse {
0% {
border-color: rgba(255, 255, 255, 0.6);
box-shadow:
0 10px 30px -10px rgba(0, 0, 0, 0.15),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.7),
inset 0 -1px 1px 0 rgba(255, 255, 255, 0.2);
}
50% {
border-color: rgba(16, 185, 129, 0.55);
box-shadow:
0 10px 30px -10px rgba(0, 0, 0, 0.15),
0 0 12px rgba(16, 185, 129, 0.25),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.75),
inset 0 -1px 1px 0 rgba(255, 255, 255, 0.2);
}
100% {
border-color: rgba(255, 255, 255, 0.6);
box-shadow:
0 10px 30px -10px rgba(0, 0, 0, 0.15),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.7),
inset 0 -1px 1px 0 rgba(255, 255, 255, 0.2);
}
}
.containerLoading {
border-color: rgba(16, 185, 129, 0.45);
animation: borderPulse 2s infinite ease-in-out;
}
.flexWrapper {
+29 -1
View File
@@ -10,6 +10,8 @@ import { CommitPanel } from "./editor/CommitPanel";
import { CommitHistoryPanel } from "./editor/CommitHistoryPanel";
import { UndoListPanel } from "./editor/UndoListPanel";
import { SubmitModal } from "./editor/SubmitModal";
import ImageOverlayPanel from "./editor/ImageOverlayPanel";
import type { MapImageOverlay } from "@/uhm/components/map/imageOverlay";
type Props = {
mode: EditorMode;
@@ -38,6 +40,13 @@ type Props = {
changesCount: number;
undoStack: UndoAction[];
width?: number;
imageOverlay: MapImageOverlay | null;
onPickImageOverlay: (file: File | null) => void;
onPasteImageOverlay: () => void;
imageOverlayKeyboardEnabled: boolean;
onImageOverlayKeyboardEnabledChange: (enabled: boolean) => void;
onImageOverlayOpacityChange: (opacity: number) => void;
onRemoveImageOverlay: () => void;
};
export default function Editor({
@@ -61,7 +70,14 @@ export default function Editor({
commits,
changesCount,
undoStack,
width = 280,
width = 350,
imageOverlay,
onPickImageOverlay,
onPasteImageOverlay,
imageOverlayKeyboardEnabled,
onImageOverlayKeyboardEnabledChange,
onImageOverlayOpacityChange,
onRemoveImageOverlay,
}: Props) {
// State đóng/mở modal submit project.
const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false);
@@ -107,6 +123,18 @@ export default function Editor({
latestCommitLabel={latestCommitLabel}
/>
<div style={{ marginTop: 10 }}>
<ImageOverlayPanel
overlay={imageOverlay}
onPickImage={onPickImageOverlay}
onPasteImage={onPasteImageOverlay}
keyboardEnabled={imageOverlayKeyboardEnabled}
onKeyboardEnabledChange={onImageOverlayKeyboardEnabledChange}
onOpacityChange={onImageOverlayOpacityChange}
onRemove={onRemoveImageOverlay}
/>
</div>
<ToolsPanel
mode={mode}
setMode={setMode}
+237 -238
View File
@@ -311,249 +311,248 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
pointerEvents: "none",
}}
>
<div
style={{
width: "fit-content",
maxWidth: "95%",
margin: "0 auto",
display: "flex",
alignItems: "center",
gap: "10px",
background: "rgba(15, 23, 42, 0.88)",
border: "1px solid rgba(148, 163, 184, 0.38)",
borderRadius: "999px",
padding: "8px 12px",
color: "#e2e8f0",
backdropFilter: "blur(3px)",
pointerEvents: "auto",
}}
>
<label
title={
isGlobeProjection
? "Dang o che do hinh cau (globe)"
: "Dang o che do trai phang (flat)"
}
style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
padding: "0 6px",
userSelect: "none",
cursor: "pointer",
flexShrink: 0,
}}
>
<input
type="checkbox"
checked={isGlobeProjection}
onChange={(e) => setIsGlobeProjection(e.target.checked)}
aria-label="Toggle globe projection"
style={{ display: "none" }}
/>
<span
aria-hidden="true"
style={{
position: "relative",
width: "42px",
height: "22px",
borderRadius: "999px",
border: "1px solid rgba(148, 163, 184, 0.45)",
background: isGlobeProjection
? "rgba(56, 189, 248, 0.30)"
: "rgba(148, 163, 184, 0.18)",
boxShadow: "inset 0 0 0 1px rgba(15, 23, 42, 0.35)",
transition: "background 160ms ease",
}}
>
<span
style={{
position: "absolute",
top: "2px",
left: isGlobeProjection ? "22px" : "2px",
width: "18px",
height: "18px",
borderRadius: "999px",
background: isGlobeProjection ? "#38bdf8" : "#e2e8f0",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.35)",
transition: "left 160ms ease, background 160ms ease",
}}
/>
</span>
<span
style={{
fontSize: "12px",
color: isGlobeProjection ? "#7dd3fc" : "#cbd5e1",
fontWeight: 700,
minWidth: "40px",
}}
>
{isGlobeProjection ? "Globe" : "Flat"}
</span>
</label>
{onViewModeChange ? (
<div style={{ display: "flex", background: "rgba(15, 23, 42, 0.6)", borderRadius: "999px", padding: "2px", border: "1px solid rgba(148, 163, 184, 0.2)", gap: "2px", flexShrink: 0 }}>
<button
type="button"
onClick={() => onViewModeChange("local")}
style={{
padding: "4px 10px",
borderRadius: "999px",
fontSize: "12px",
fontWeight: 700,
background: viewMode === "local" ? "#2563eb" : "transparent",
color: viewMode === "local" ? "white" : "#94a3b8",
border: "none",
cursor: "pointer",
transition: "background 150ms, color 150ms",
}}
>
LOCAL
</button>
<button
type="button"
onClick={() => onViewModeChange("global")}
style={{
padding: "4px 10px",
borderRadius: "999px",
fontSize: "12px",
fontWeight: 700,
background: viewMode === "global" ? "#2563eb" : "transparent",
color: viewMode === "global" ? "white" : "#94a3b8",
border: "none",
cursor: "pointer",
transition: "background 150ms, color 150ms",
}}
>
GLOBAL
</button>
</div>
) : null}
{onEnterPreview || onExitPreview ? (
<button
type="button"
onClick={isPreviewMode ? onExitPreview : onEnterPreview}
style={{
...zoomButtonStyle,
width: "auto",
minWidth: "76px",
padding: "0 12px",
background: isPreviewMode ? "#334155" : "#166534",
fontWeight: 800,
flexShrink: 0,
}}
aria-label={isPreviewMode ? "Exit preview" : "Enter preview"}
title={isPreviewMode ? "Thoat preview" : "Xem nhu nguoi dung"}
>
{isPreviewMode ? "Editor" : "Preview"}
</button>
) : null}
<button
type="button"
onClick={() => handleZoomByStep(-0.8)}
style={{ ...zoomButtonStyle, flexShrink: 0 }}
aria-label="Zoom out"
>
-
</button>
<input
type="range"
min={zoomBounds.min}
max={zoomBounds.max}
step={0.1}
value={zoomLevel}
onPointerDown={(event) => {
event.stopPropagation();
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Browser may reject capture for non-primary pointers; drag lock still works.
}
beginZoomSliderDrag();
}}
onPointerUp={(event) => {
event.stopPropagation();
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// Ignore if capture was already released.
}
endZoomSliderDrag();
}}
onPointerCancel={endZoomSliderDrag}
onBlur={endZoomSliderDrag}
onChange={(event) => handleZoomSliderChange(Number(event.target.value))}
style={{
flex: 1,
minWidth: "60px",
accentColor: "#38bdf8",
cursor: "pointer",
}}
aria-label="Map zoom"
/>
<button
type="button"
onClick={() => handleZoomByStep(0.8)}
style={{ ...zoomButtonStyle, flexShrink: 0 }}
aria-label="Zoom in"
>
+
</button>
<div
style={{
minWidth: "56px",
textAlign: "right",
fontSize: "12px",
color: "#cbd5e1",
fontVariantNumeric: "tabular-nums",
flexShrink: 0,
width: "fit-content",
maxWidth: "95%",
margin: "0 auto",
display: "flex",
alignItems: "center",
gap: "10px",
background: "rgba(15, 23, 42, 0.88)",
border: "1px solid rgba(148, 163, 184, 0.38)",
borderRadius: "999px",
padding: "8px 12px",
color: "#e2e8f0",
backdropFilter: "blur(3px)",
pointerEvents: "auto",
}}
>
{zoomLevel.toFixed(1)}x
</div>
<label
title={
isGlobeProjection
? "Dang o che do hinh cau (globe)"
: "Dang o che do trai phang (flat)"
}
style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
padding: "0 6px",
userSelect: "none",
cursor: "pointer",
flexShrink: 0,
}}
>
<input
type="checkbox"
checked={isGlobeProjection}
onChange={(e) => setIsGlobeProjection(e.target.checked)}
aria-label="Toggle globe projection"
style={{ display: "none" }}
/>
<span
aria-hidden="true"
style={{
position: "relative",
width: "42px",
height: "22px",
borderRadius: "999px",
border: "1px solid rgba(148, 163, 184, 0.45)",
background: isGlobeProjection
? "rgba(56, 189, 248, 0.30)"
: "rgba(148, 163, 184, 0.18)",
boxShadow: "inset 0 0 0 1px rgba(15, 23, 42, 0.35)",
transition: "background 160ms ease",
}}
>
<span
style={{
position: "absolute",
top: "2px",
left: isGlobeProjection ? "22px" : "2px",
width: "18px",
height: "18px",
borderRadius: "999px",
background: isGlobeProjection ? "#38bdf8" : "#e2e8f0",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.35)",
transition: "left 160ms ease, background 160ms ease",
}}
/>
</span>
<span
style={{
fontSize: "12px",
color: isGlobeProjection ? "#7dd3fc" : "#cbd5e1",
fontWeight: 700,
minWidth: "40px",
}}
>
{isGlobeProjection ? "Globe" : "Flat"}
</span>
</label>
{onPlayPreviewReplay ? (
<button
type="button"
onClick={onPlayPreviewReplay}
style={{
...zoomButtonStyle,
width: "auto",
minWidth: "64px",
padding: "0 12px",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: "7px",
background: "#2563eb",
fontSize: "13px",
fontWeight: 800,
flexShrink: 0,
}}
aria-label="Play selected replay"
title="Play replay của geometry đang chọn"
>
<span
aria-hidden="true"
style={{
width: 0,
height: 0,
borderTop: "5px solid transparent",
borderBottom: "5px solid transparent",
borderLeft: "8px solid currentColor",
}}
/>
Play
</button>
) : null}
</div>
{onViewModeChange ? (
<div style={{ display: "flex", background: "rgba(15, 23, 42, 0.6)", borderRadius: "999px", padding: "2px", border: "1px solid rgba(148, 163, 184, 0.2)", gap: "2px", flexShrink: 0 }}>
<button
type="button"
onClick={() => onViewModeChange("local")}
style={{
padding: "4px 10px",
borderRadius: "999px",
fontSize: "12px",
fontWeight: 700,
background: viewMode === "local" ? "#2563eb" : "transparent",
color: viewMode === "local" ? "white" : "#94a3b8",
border: "none",
cursor: "pointer",
transition: "background 150ms, color 150ms",
}}
>
LOCAL
</button>
<button
type="button"
onClick={() => onViewModeChange("global")}
style={{
padding: "4px 10px",
borderRadius: "999px",
fontSize: "12px",
fontWeight: 700,
background: viewMode === "global" ? "#2563eb" : "transparent",
color: viewMode === "global" ? "white" : "#94a3b8",
border: "none",
cursor: "pointer",
transition: "background 150ms, color 150ms",
}}
>
GLOBAL
</button>
</div>
) : null}
{onEnterPreview || onExitPreview ? (
<button
type="button"
onClick={isPreviewMode ? onExitPreview : onEnterPreview}
style={{
...zoomButtonStyle,
width: "auto",
minWidth: "76px",
padding: "0 12px",
background: isPreviewMode ? "#334155" : "#166534",
fontWeight: 800,
flexShrink: 0,
}}
aria-label={isPreviewMode ? "Exit preview" : "Enter preview"}
title={isPreviewMode ? "Thoat preview" : "Xem nhu nguoi dung"}
>
{isPreviewMode ? "Editor" : "Preview"}
</button>
) : null}
{onPlayPreviewReplay ? (
<button
type="button"
onClick={onPlayPreviewReplay}
style={{
...zoomButtonStyle,
width: "auto",
minWidth: "64px",
padding: "0 12px",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: "7px",
background: "#2563eb",
fontSize: "13px",
fontWeight: 800,
flexShrink: 0,
}}
aria-label="Play selected replay"
title="Play replay của geometry đang chọn"
>
<span
aria-hidden="true"
style={{
width: 0,
height: 0,
borderTop: "5px solid transparent",
borderBottom: "5px solid transparent",
borderLeft: "8px solid currentColor",
}}
/>
Play
</button>
) : null}
<button
type="button"
onClick={() => handleZoomByStep(-0.8)}
style={{ ...zoomButtonStyle, flexShrink: 0 }}
aria-label="Zoom out"
>
-
</button>
<input
type="range"
min={zoomBounds.min}
max={zoomBounds.max}
step={0.1}
value={zoomLevel}
onPointerDown={(event) => {
event.stopPropagation();
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Browser may reject capture for non-primary pointers; drag lock still works.
}
beginZoomSliderDrag();
}}
onPointerUp={(event) => {
event.stopPropagation();
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// Ignore if capture was already released.
}
endZoomSliderDrag();
}}
onPointerCancel={endZoomSliderDrag}
onBlur={endZoomSliderDrag}
onChange={(event) => handleZoomSliderChange(Number(event.target.value))}
style={{
flex: 1,
minWidth: "60px",
accentColor: "#38bdf8",
cursor: "pointer",
}}
aria-label="Map zoom"
/>
<button
type="button"
onClick={() => handleZoomByStep(0.8)}
style={{ ...zoomButtonStyle, flexShrink: 0 }}
aria-label="Zoom in"
>
+
</button>
<div
style={{
minWidth: "56px",
textAlign: "right",
fontSize: "12px",
color: "#cbd5e1",
fontVariantNumeric: "tabular-nums",
flexShrink: 0,
}}
>
{zoomLevel.toFixed(1)}x
</div>
</div>
</div>
) : null}
</div>
-4
View File
@@ -58,10 +58,6 @@ export function useMapInstance() {
style: getBaseMapStyle(),
center: [0, 20],
zoom: 2,
maxTileCacheSize: 150,
fadeDuration: 0,
collectResourceTiming: false,
crossSourceCollisions: false,
});
mapRef.current = map;
+15 -313
View File
@@ -82,12 +82,6 @@ export default function TimelineBar({
}
}, [lower, upper, commitYearChange]);
const handleDragYearChange = useCallback((nextVal: number) => {
const clamped = clampYearValue(Math.trunc(nextVal), lower, upper);
localYearRef.current = clamped;
setLocalYear(clamped);
}, [lower, upper]);
const finishLocalYearChange = useCallback(() => {
commitYearChange(localYearRef.current);
setLocalYear(null);
@@ -169,14 +163,23 @@ export default function TimelineBar({
</span>
</button>
) : null}
<CanvasTimelineRuler
year={displayYear}
onYearChange={handleDragYearChange}
onYearCommit={finishLocalYearChange}
minYear={lower}
maxYear={upper}
<span className={styles.labelBounds}>{formatYear(lower)}</span>
<input
type="range"
min={lower}
max={upper}
step={1}
value={displayYear}
onChange={(event) => handleLocalYearChange(Number(event.target.value))}
onMouseUp={finishLocalYearChange}
onTouchEnd={finishLocalYearChange}
disabled={effectiveDisabled}
className={styles.slider}
aria-label="Timeline year"
/>
<span className={styles.labelBoundsRight}>
{formatYear(upper)}
</span>
<div className={styles.numberWrapper}>
<input
type="number"
@@ -256,304 +259,3 @@ function formatYear(year: number): string {
}
return `${year}`;
}
interface CanvasRulerProps {
year: number;
onYearChange: (year: number) => void;
onYearCommit: () => void;
minYear: number;
maxYear: number;
disabled?: boolean;
}
function CanvasTimelineRuler({
year,
onYearChange,
onYearCommit,
minYear,
maxYear,
disabled = false,
}: CanvasRulerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// Visible span (in years)
const [span, setSpan] = useState(400); // default show 400 years
// Dimensions
const [dimensions, setDimensions] = useState({ width: 0, height: 48 });
// Dragging state
const dragRef = useRef<{
isDragging: boolean;
startX: number;
startYear: number;
hasDragged: boolean;
} | null>(null);
// Sync dimensions using ResizeObserver
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
if (!entries || !entries[0]) return;
const { width, height } = entries[0].contentRect;
setDimensions({ width, height: height || 48 });
});
observer.observe(container);
return () => observer.disconnect();
}, []);
// Draw the ruler on canvas
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || dimensions.width === 0) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Support High DPI / Retina screens
const dpr = window.devicePixelRatio || 1;
canvas.width = dimensions.width * dpr;
canvas.height = dimensions.height * dpr;
ctx.scale(dpr, dpr);
const width = dimensions.width;
const height = dimensions.height;
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Center year is the selected year
const centerYear = year;
const startYear = centerYear - span / 2;
const endYear = centerYear + span / 2;
const yearToX = (y: number) => {
return ((y - startYear) / span) * width;
};
// Determine tick step based on span
let majorStep = 100;
let mediumStep = 10;
let minorStep = 1;
if (span > 3000) {
majorStep = 1000;
mediumStep = 100;
minorStep = 10;
} else if (span > 1500) {
majorStep = 500;
mediumStep = 50;
minorStep = 10;
} else if (span > 600) {
majorStep = 100;
mediumStep = 20;
minorStep = 5;
} else if (span > 200) {
majorStep = 100;
mediumStep = 10;
minorStep = 1;
} else if (span > 60) {
majorStep = 50;
mediumStep = 10;
minorStep = 1;
} else {
majorStep = 10;
mediumStep = 5;
minorStep = 1;
}
// Ticks drawing bounds
const firstMajor = Math.floor(startYear / majorStep) * majorStep;
const lastMajor = Math.ceil(endYear / majorStep) * majorStep;
const pixelsPerYear = width / span;
const showMinor = pixelsPerYear * minorStep >= 3;
const showMedium = pixelsPerYear * mediumStep >= 5;
// Draw ruler track baseline
ctx.beginPath();
ctx.moveTo(0, height - 8);
ctx.lineTo(width, height - 8);
ctx.strokeStyle = "rgba(255, 255, 255, 0.15)";
ctx.lineWidth = 1;
ctx.stroke();
// 1. Draw minor & medium ticks
ctx.beginPath();
for (let y = Math.floor(startYear); y <= Math.ceil(endYear); y++) {
if (y < minYear || y > maxYear) continue;
const isMajor = y % majorStep === 0;
const isMedium = y % mediumStep === 0;
const isMinor = y % minorStep === 0;
if (isMajor) continue;
let tickHeight = 0;
if (isMedium && showMedium) {
tickHeight = 7;
ctx.strokeStyle = "rgba(255, 255, 255, 0.35)";
} else if (isMinor && showMinor) {
tickHeight = 4;
ctx.strokeStyle = "rgba(255, 255, 255, 0.12)";
}
if (tickHeight > 0) {
const x = yearToX(y);
ctx.moveTo(x, height - 8);
ctx.lineTo(x, height - 8 - tickHeight);
}
}
ctx.lineWidth = 1;
ctx.stroke();
// 2. Draw major ticks and labels
ctx.fillStyle = "rgba(255, 255, 255, 0.75)";
ctx.font = "600 10px system-ui, -apple-system, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "top";
for (let y = firstMajor; y <= lastMajor; y += majorStep) {
if (y < minYear || y > maxYear) continue;
const x = yearToX(y);
// Draw tick line
ctx.beginPath();
ctx.moveTo(x, height - 8);
ctx.lineTo(x, height - 20);
ctx.strokeStyle = "rgba(255, 255, 255, 0.65)";
ctx.lineWidth = 1.25;
ctx.stroke();
// Draw label
const label = formatYear(y);
ctx.fillText(label, x, height - 33);
}
// 3. Draw needle indicator in the center
const needleX = width / 2;
ctx.beginPath();
ctx.moveTo(needleX, 0);
ctx.lineTo(needleX, height - 4);
ctx.strokeStyle = "#10b981";
ctx.lineWidth = 2;
ctx.shadowColor = "rgba(16, 185, 129, 0.6)";
ctx.shadowBlur = 6;
ctx.stroke();
// Draw needle head triangle
ctx.fillStyle = "#10b981";
ctx.beginPath();
ctx.moveTo(needleX - 5, 0);
ctx.lineTo(needleX + 5, 0);
ctx.lineTo(needleX, 6);
ctx.closePath();
ctx.fill();
ctx.shadowBlur = 0;
}, [year, span, dimensions, minYear, maxYear]);
const handleWheel = (e: React.WheelEvent) => {
if (disabled) return;
e.preventDefault();
const zoomFactor = e.deltaY > 0 ? 1.15 : 0.85;
const nextSpan = Math.max(10, Math.min(10000, span * zoomFactor));
setSpan(Math.round(nextSpan));
};
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (disabled) return;
e.preventDefault();
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {}
dragRef.current = {
isDragging: true,
startX: e.clientX,
startYear: year,
hasDragged: false,
};
};
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!dragRef.current || !dragRef.current.isDragging) return;
e.preventDefault();
const dx = e.clientX - dragRef.current.startX;
if (Math.abs(dx) > 3) {
dragRef.current.hasDragged = true;
}
const yearsPerPixel = span / dimensions.width;
const deltaYears = -dx * yearsPerPixel;
const nextYear = clampYearValue(Math.round(dragRef.current.startYear + deltaYears), minYear, maxYear);
onYearChange(nextYear);
};
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!dragRef.current) return;
e.preventDefault();
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {}
const dragInfo = dragRef.current;
dragRef.current = null;
if (!dragInfo.hasDragged) {
// Click to jump
const canvas = canvasRef.current;
if (canvas) {
const rect = canvas.getBoundingClientRect();
const clickedX = e.clientX - rect.left;
const centerYear = year;
const startYear = centerYear - span / 2;
const clickedYear = clampYearValue(
Math.round(startYear + (clickedX / rect.width) * span),
minYear,
maxYear
);
onYearChange(clickedYear);
}
}
onYearCommit();
};
return (
<div
ref={containerRef}
style={{
flex: 1,
height: 44,
position: "relative",
background: "rgba(255, 255, 255, 0.04)",
borderRadius: 22,
border: "1px solid rgba(255, 255, 255, 0.08)",
overflow: "hidden",
cursor: disabled ? "not-allowed" : "ew-resize",
}}
onWheel={handleWheel}
>
<canvas
ref={canvasRef}
style={{
display: "block",
width: "100%",
height: "100%",
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
/>
</div>
);
}
+1 -1
View File
@@ -276,7 +276,7 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
geoSearchResults: [],
isGeoSearching: false,
requestedActiveWikiId: null,
leftPanelWidth: 280,
leftPanelWidth: 320,
rightPanelWidth: 420,
timelineFilterEnabled: true,
geometryBindingFilterEnabled: true,