File size: 10,977 Bytes
63522a5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
import { useCallback, useEffect, RefObject, useRef } from "react";
import { IMessageToSend } from "#/stores/conversation-store";
import { EPS } from "#/utils/constants";
import { getStyleHeightPx, setStyleHeightPx } from "#/utils/utils";
import { focusContentEditableAtEnd } from "#/components/features/chat/utils/chat-input.utils";
import { useDragResize } from "./use-drag-resize";

// Constants
const DEFAULT_MIN_HEIGHT = 20;
const DEFAULT_MAX_HEIGHT = 120;
const HEIGHT_INCREMENT = 20;
const MANUAL_OVERSIZE_THRESHOLD = 50;

// Manual height tracking utilities
const useManualHeight = () => {
  const hasUserResizedRef = useRef(false);
  const manualHeightRef = useRef<number | null>(null);
  return { hasUserResizedRef, manualHeightRef };
};

interface UseAutoResizeOptions {
  minHeight?: number;
  maxHeight?: number;
  enableManualResize?: boolean;
  onGripDragStart?: () => void;
  onGripDragEnd?: () => void;
  onHeightChange?: (height: number) => void; // New callback for height changes
  value?: IMessageToSend;
  /** Called once `value` has been applied to the element (one-shot consume). */
  onValueApplied?: () => void;
}

interface UseAutoResizeReturn {
  smartResize: () => void;
  handleGripMouseDown: (e: React.MouseEvent) => void;
  handleGripTouchStart: (e: React.TouchEvent) => void;
  increaseHeightForEmptyContent: () => void;
  resetManualResize: () => void;
}

// Height management utilities
interface HeightConstraints {
  minHeight: number;
  maxHeight: number;
}

interface HeightMeasurements {
  currentHeight: number;
  currentStyleHeight: number;
  contentHeight: number;
}

interface ResizeStrategy {
  finalHeight: number;
  overflowY: "hidden" | "auto";
}

const applyHeightToElement = (
  element: HTMLElement,
  height: number,
  constraints: HeightConstraints,
): number => {
  const { minHeight, maxHeight } = constraints;
  const finalHeight = Math.max(minHeight, Math.min(height, maxHeight));

  element.style.setProperty("height", `${finalHeight}px`);
  element.style.setProperty(
    "overflow-y",
    finalHeight >= maxHeight ? "auto" : "hidden",
  );

  return finalHeight;
};

const isManuallyOversized = (
  currentHeight: number,
  contentHeight: number,
  threshold = MANUAL_OVERSIZE_THRESHOLD,
): boolean => currentHeight > contentHeight + threshold;

const measureElementHeights = (
  element: HTMLElement,
  minHeight: number,
): HeightMeasurements => {
  // Use the previous explicit style height as the "current" for restore, not offsetHeight
  const currentStyleHeight = getStyleHeightPx(element, minHeight);
  const currentHeight = currentStyleHeight;

  // Temporarily reset to measure content
  element.style.setProperty("height", "auto");
  const contentHeight = element.scrollHeight;

  // Restore height
  setStyleHeightPx(element, currentStyleHeight);

  return {
    currentHeight,
    currentStyleHeight,
    contentHeight,
  };
};

const applyResizeStrategy = (
  element: HTMLElement,
  strategy: ResizeStrategy,
): void => {
  const { finalHeight, overflowY } = strategy;
  const elementRef = element;
  elementRef.style.height = `${finalHeight}px`;
  elementRef.style.overflowY = overflowY;
};

const executeHeightCallback = (
  height: number,
  onHeightChange?: (height: number) => void,
): void => {
  if (onHeightChange) {
    onHeightChange(height);
  }
};

// DOM manipulation utilities
export const useAutoResize = (
  elementRef: RefObject<HTMLElement | null>,
  options: UseAutoResizeOptions = {},
): UseAutoResizeReturn => {
  const pendingSmartRef = useRef<number | null>(null);

  const {
    minHeight = DEFAULT_MIN_HEIGHT,
    maxHeight = DEFAULT_MAX_HEIGHT,
    enableManualResize = false,
    value,
    onValueApplied,
    onGripDragStart,
    onGripDragEnd,
    onHeightChange,
  } = options;

  const constraints: HeightConstraints = { minHeight, maxHeight };
  const { hasUserResizedRef, manualHeightRef } = useManualHeight();

  const resetManualResize = () => {
    hasUserResizedRef.current = false;
    manualHeightRef.current = null;
  };

  // Wrap onHeightChange to track manual height during drag
  const handleExternalHeightChange = useCallback(
    (elementHeight: number) => {
      onHeightChange?.(elementHeight);
      if (hasUserResizedRef.current) {
        manualHeightRef.current = elementHeight;
      }
    },
    [onHeightChange],
  );

  // Handle drag start - set manual mode flag
  const handleDragStart = useCallback(() => {
    hasUserResizedRef.current = true;
    onGripDragStart?.();
  }, [onGripDragStart]);

  // Handle drag end - clear manual mode if at minimum height
  const handleDragEnd = useCallback(() => {
    const textareaElement = elementRef.current;
    if (textareaElement) {
      const currentHeight = getStyleHeightPx(textareaElement, minHeight);
      if (Math.abs(currentHeight - minHeight) <= EPS) {
        hasUserResizedRef.current = false;
        manualHeightRef.current = null;
      }
    }
    onGripDragEnd?.();
  }, [minHeight, onGripDragEnd]);

  // Use the drag resize hook for manual resizing functionality
  const { handleGripMouseDown, handleGripTouchStart } = useDragResize({
    elementRef,
    minHeight,
    maxHeight,
    onGripDragStart: enableManualResize ? handleDragStart : undefined,
    onGripDragEnd: enableManualResize ? handleDragEnd : undefined,
    onHeightChange: handleExternalHeightChange,
  });

  // Handle content that fits within current height
  const handleContentFitsInCurrentHeight = useCallback(
    (
      element: HTMLElement,
      currentHeight: number,
      contentHeight: number,
    ): void => {
      // If user manually resized and we're above min height, preserve their chosen height
      if (hasUserResizedRef.current && currentHeight > minHeight) {
        applyResizeStrategy(element, {
          finalHeight: currentHeight,
          overflowY: "hidden",
        });
        executeHeightCallback(currentHeight, onHeightChange);
        return;
      }

      // Otherwise allow shrinking towards content (respect minHeight)
      const finalHeight = Math.max(contentHeight, minHeight);
      applyResizeStrategy(element, {
        finalHeight,
        overflowY: "hidden",
      });
      executeHeightCallback(finalHeight, onHeightChange);
    },
    [minHeight, onHeightChange],
  );

  // Handle content that exceeds current height but within max height
  const handleContentExceedsCurrentHeight = useCallback(
    (
      element: HTMLElement,
      currentHeight: number,
      contentHeight: number,
    ): void => {
      // Grow unless the element is manually oversized beyond content significantly
      if (!isManuallyOversized(currentHeight, contentHeight)) {
        const finalHeight = Math.max(contentHeight, minHeight);
        applyResizeStrategy(element, {
          finalHeight,
          overflowY: "hidden",
        });
        executeHeightCallback(finalHeight, onHeightChange);
        return;
      }

      // Keep manual height and allow scrolling as needed
      applyResizeStrategy(element, {
        finalHeight: currentHeight,
        overflowY: "auto",
      });
      executeHeightCallback(currentHeight, onHeightChange);
    },
    [minHeight, onHeightChange],
  );

  // Handle content that exceeds max height
  const handleContentExceedsMaxHeight = useCallback(
    (element: HTMLElement) => {
      applyResizeStrategy(element, {
        finalHeight: maxHeight,
        overflowY: "auto",
      });
      executeHeightCallback(maxHeight, onHeightChange);
    },
    [maxHeight, onHeightChange],
  );

  // Debounced smartResize body
  const smartResizeBody = useCallback(() => {
    const element = elementRef.current;
    if (!element) return;

    const textIsEmpty = (element.textContent ?? "").trim().length === 0;

    // If empty content and we have a manual height above min, preserve it
    if (
      textIsEmpty &&
      hasUserResizedRef.current &&
      manualHeightRef.current &&
      manualHeightRef.current > minHeight + EPS
    ) {
      setStyleHeightPx(element, manualHeightRef.current);
      element.style.overflowY = "hidden";
      executeHeightCallback(manualHeightRef.current, onHeightChange);
      return;
    }

    // Measure element heights
    const measurements = measureElementHeights(element, minHeight);
    const { currentHeight, contentHeight } = measurements;

    // If content fits within current height
    if (contentHeight <= currentHeight) {
      handleContentFitsInCurrentHeight(element, currentHeight, contentHeight);
      return;
    }

    // If content exceeds current height but within max
    if (contentHeight <= maxHeight) {
      handleContentExceedsCurrentHeight(element, currentHeight, contentHeight);
      return;
    }

    // Content exceeds max height
    handleContentExceedsMaxHeight(element);
  }, [
    elementRef,
    minHeight,
    maxHeight,
    onHeightChange,
    handleContentFitsInCurrentHeight,
    handleContentExceedsCurrentHeight,
    handleContentExceedsMaxHeight,
  ]);

  // rAF-debounced smartResize wrapper to collapse bursts
  const smartResize = useCallback(() => {
    if (pendingSmartRef.current) cancelAnimationFrame(pendingSmartRef.current);
    pendingSmartRef.current = requestAnimationFrame(() => {
      pendingSmartRef.current = null;
      smartResizeBody();
    });
  }, [smartResizeBody]);

  // Function to increase height when content is empty
  const increaseHeightForEmptyContent = () => {
    const element = elementRef.current;
    if (!element) return;

    const currentHeight = getStyleHeightPx(element, minHeight);
    const newHeight = Math.min(currentHeight + HEIGHT_INCREMENT, maxHeight);

    if (newHeight > currentHeight) {
      const finalHeight = applyHeightToElement(element, newHeight, constraints);

      // Execute height change callback
      executeHeightCallback(finalHeight, onHeightChange);

      // Set manual mode for Shift+Enter height increases
      hasUserResizedRef.current = true;
      manualHeightRef.current = finalHeight;
    }
  };

  // Update content and resize when value prop changes
  useEffect(() => {
    if (value === undefined) {
      return undefined;
    }

    let rafId = 0;

    const applyValue = () => {
      const element = elementRef.current;
      if (!element) {
        return false;
      }

      element.textContent = value.text;
      smartResize();
      focusContentEditableAtEnd(element);
      onValueApplied?.(); // one-shot: let the caller clear the value
      return true;
    };

    if (!applyValue()) {
      rafId = requestAnimationFrame(() => {
        applyValue();
      });
    }

    return () => {
      if (rafId) {
        cancelAnimationFrame(rafId);
      }
    };
  }, [value, smartResize, onValueApplied]);

  // Initialize auto-resize on mount
  useEffect(() => {
    smartResize();
  }, [smartResize]);

  return {
    smartResize,
    handleGripMouseDown,
    handleGripTouchStart,
    increaseHeightForEmptyContent,
    resetManualResize,
  };
};