Open History to see the last commit
1import type { PreviewViewportSetting, PreviewViewportSize } from "@t3tools/contracts";
2import {
3 useEffect,
4 useRef,
5 useState,
6 type KeyboardEvent as ReactKeyboardEvent,
7 type PointerEvent as ReactPointerEvent,
8} from "react";
9
10import { useStableCallback } from "../hooks/useStableCallback";
11import { type BrowserViewportTarget, commitBrowserViewportChange } from "./browserViewportActions";
12import {
13 browserViewportSettingKey,
14 resizeBrowserViewportFromRail,
15 resizeFreeformViewport,
16 resolveBrowserDeviceViewportArea,
17 resolveBrowserDeviceViewportLayout,
18 resolveBrowserViewportLayout,
19 type BrowserViewportResizeDirection,
20} from "./browserViewportLayout";
21
22interface ViewportDrag extends PreviewViewportSize {
23 readonly sourceKey: string;
24 readonly direction: BrowserViewportResizeDirection;
25}
26
27const KEYBOARD_RESIZE_COMMIT_DELAY_MS = 150;
28
29export function useBrowserViewportResize(options: {
30 readonly target: BrowserViewportTarget;
31 readonly viewport: PreviewViewportSetting;
32 readonly zoomFactor: number;
33 readonly containerSize: PreviewViewportSize;
34 readonly deviceToolbarVisible: boolean;
35 readonly aspectRatio: number | null;
36}) {
37 const { target, viewport, zoomFactor, containerSize, deviceToolbarVisible, aspectRatio } =
38 options;
39 const dragCleanupRef = useRef<(() => void) | null>(null);
40 const dragVersionRef = useRef(0);
41 const keyboardCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
42 const keyboardViewportRef = useRef<ViewportDrag | null>(null);
43 const [dragViewport, setDragViewport] = useState<ViewportDrag | null>(null);
44 const sourceViewportKey = browserViewportSettingKey(viewport);
45 const isCurrentSource = useStableCallback((key: string) => key === sourceViewportKey);
46 const activeDrag = dragViewport?.sourceKey === sourceViewportKey ? dragViewport : null;
47 const effectiveViewport = activeDrag
48 ? ({
49 _tag: "freeform",
50 width: activeDrag.width,
51 height: activeDrag.height,
52 } as const satisfies PreviewViewportSetting)
53 : viewport;
54 const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
55 const viewportContainerSize = deviceToolbarVisible
56 ? resolveBrowserDeviceViewportArea(containerSize)
57 : containerSize;
58 const layout =
59 deviceToolbarVisible && effectiveViewport._tag !== "fill"
60 ? resolveBrowserDeviceViewportLayout(containerSize, effectiveViewport, zoomFactor)
61 : resolveBrowserViewportLayout(containerSize, effectiveViewport, zoomFactor);
62
63 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- cancels the drag and the keyboard commit timer when the component unmounts
64 useEffect(
65 () => () => {
66 dragVersionRef.current += 1;
67 dragCleanupRef.current?.();
68 if (keyboardCommitTimerRef.current !== null) {
69 clearTimeout(keyboardCommitTimerRef.current);
70 }
71 keyboardCommitTimerRef.current = null;
72 keyboardViewportRef.current = null;
73 },
74 [],
75 );
76
77 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- cancels the pending keyboard viewport commit timer once the source viewport changes
78 useEffect(() => {
79 const pending = keyboardViewportRef.current;
80 if (!pending || pending.sourceKey === sourceViewportKey) return;
81 if (keyboardCommitTimerRef.current !== null) {
82 clearTimeout(keyboardCommitTimerRef.current);
83 keyboardCommitTimerRef.current = null;
84 }
85 keyboardViewportRef.current = null;
86 }, [sourceViewportKey]);
87
88 const commitViewportChange = (next: PreviewViewportSetting) => {
89 dragVersionRef.current += 1;
90 dragCleanupRef.current?.();
91 if (keyboardCommitTimerRef.current !== null) {
92 clearTimeout(keyboardCommitTimerRef.current);
93 keyboardCommitTimerRef.current = null;
94 }
95 keyboardViewportRef.current = null;
96 setDragViewport(null);
97 return commitBrowserViewportChange(target, next);
98 };
99
100 const clearDrag = () => setDragViewport(null);
101 const commitDrag = (next: PreviewViewportSetting) => {
102 const version = ++dragVersionRef.current;
103 const clearIfCurrent = () => {
104 if (dragVersionRef.current === version) clearDrag();
105 };
106 void commitBrowserViewportChange(target, next).then(clearIfCurrent, clearIfCurrent);
107 };
108
109 const handleResizeKeyDown = (
110 direction: BrowserViewportResizeDirection,
111 event: ReactKeyboardEvent<HTMLButtonElement>,
112 ) => {
113 if (effectiveViewport._tag === "fill") return;
114 const controlsWidth = direction.includes("east") || direction.includes("west");
115 const controlsHeight = direction.includes("north") || direction.includes("south");
116 const step = (event.shiftKey ? 50 : 10) * normalizedZoomFactor;
117 const delta =
118 event.key === "ArrowLeft" && controlsWidth
119 ? { x: -step, y: 0 }
120 : event.key === "ArrowRight" && controlsWidth
121 ? { x: step, y: 0 }
122 : event.key === "ArrowUp" && controlsHeight
123 ? { x: 0, y: -step }
124 : event.key === "ArrowDown" && controlsHeight
125 ? { x: 0, y: step }
126 : null;
127 if (!delta) return;
128 event.preventDefault();
129 event.stopPropagation();
130 const pending = keyboardViewportRef.current;
131 const base = pending?.sourceKey === sourceViewportKey ? pending : effectiveViewport;
132 const next = resizeFreeformViewport(
133 base,
134 delta,
135 zoomFactor,
136 direction,
137 aspectRatio ?? undefined,
138 );
139 if (next.width === base.width && next.height === base.height) return;
140 const keyboardViewport = { sourceKey: sourceViewportKey, ...next, direction };
141 keyboardViewportRef.current = keyboardViewport;
142 setDragViewport(keyboardViewport);
143 if (keyboardCommitTimerRef.current !== null) {
144 clearTimeout(keyboardCommitTimerRef.current);
145 }
146 keyboardCommitTimerRef.current = setTimeout(() => {
147 keyboardCommitTimerRef.current = null;
148 const latest = keyboardViewportRef.current;
149 if (!latest || !isCurrentSource(latest.sourceKey)) return;
150 keyboardViewportRef.current = null;
151 commitDrag({ _tag: "freeform", width: latest.width, height: latest.height });
152 }, KEYBOARD_RESIZE_COMMIT_DELAY_MS);
153 };
154
155 const handleResizePointerDown = (
156 direction: BrowserViewportResizeDirection,
157 event: ReactPointerEvent<HTMLButtonElement>,
158 ) => {
159 if (effectiveViewport._tag === "fill") return;
160 event.preventDefault();
161 event.stopPropagation();
162 if (keyboardCommitTimerRef.current !== null) {
163 clearTimeout(keyboardCommitTimerRef.current);
164 keyboardCommitTimerRef.current = null;
165 }
166 keyboardViewportRef.current = null;
167 dragCleanupRef.current?.();
168 dragVersionRef.current += 1;
169 const pointerId = event.pointerId;
170 const target = event.currentTarget;
171 const startX = event.clientX;
172 const startY = event.clientY;
173 const startWidth = effectiveViewport.width;
174 const startHeight = effectiveViewport.height;
175 const dragZoomFactor = normalizedZoomFactor * layout.viewportScale;
176 let latest = { width: startWidth, height: startHeight };
177 setDragViewport({
178 sourceKey: sourceViewportKey,
179 width: startWidth,
180 height: startHeight,
181 direction,
182 });
183 try {
184 target.setPointerCapture(pointerId);
185 } catch {}
186
187 const sourceChanged = () => !isCurrentSource(sourceViewportKey);
188 const move = (moveEvent: PointerEvent) => {
189 if (moveEvent.pointerId !== pointerId) return;
190 if (sourceChanged()) {
191 cleanup();
192 dragVersionRef.current += 1;
193 clearDrag();
194 return;
195 }
196 moveEvent.preventDefault();
197 const { width, height } = resizeBrowserViewportFromRail(
198 { width: startWidth, height: startHeight },
199 {
200 x: moveEvent.clientX - startX,
201 y: moveEvent.clientY - startY,
202 },
203 viewportContainerSize,
204 dragZoomFactor,
205 direction,
206 aspectRatio ?? undefined,
207 );
208 latest = { width, height };
209 setDragViewport({ sourceKey: sourceViewportKey, width, height, direction });
210 };
211 function cleanup() {
212 window.removeEventListener("pointermove", move);
213 window.removeEventListener("pointerup", finish);
214 window.removeEventListener("pointercancel", cancel);
215 dragCleanupRef.current = null;
216 try {
217 target.releasePointerCapture(pointerId);
218 } catch {
219 // The browser may already have released capture on pointerup.
220 }
221 }
222 function finish(upEvent: PointerEvent) {
223 if (upEvent.pointerId !== pointerId) return;
224 cleanup();
225 if (sourceChanged() || (latest.width === startWidth && latest.height === startHeight)) {
226 clearDrag();
227 return;
228 }
229 commitDrag({
230 _tag: "freeform",
231 width: latest.width,
232 height: latest.height,
233 });
234 }
235 function cancel(cancelEvent: PointerEvent) {
236 if (cancelEvent.pointerId !== pointerId) return;
237 cleanup();
238 dragVersionRef.current += 1;
239 clearDrag();
240 }
241 dragCleanupRef.current = cleanup;
242 window.addEventListener("pointermove", move, { passive: false });
243 window.addEventListener("pointerup", finish);
244 window.addEventListener("pointercancel", cancel);
245 };
246
247 return {
248 activeDrag,
249 commitViewportChange,
250 effectiveViewport,
251 handleResizeKeyDown,
252 handleResizePointerDown,
253 layout,
254 };
255}