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