Jelajahi Sumber

Collapsible resizers (#3330)

* Collapsible resizable divs

Co-authored-by: Tim O'Farrell <tofarr@gmai.com>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
tofarr 1 tahun lalu
induk
melakukan
930ee27037
2 mengubah file dengan 120 tambahan dan 28 penghapusan
  1. 5 5
      frontend/src/App.tsx
  2. 115 23
      frontend/src/components/Resizable.tsx

+ 5 - 5
frontend/src/App.tsx

@@ -83,19 +83,19 @@ function App(): JSX.Element {
           className="grow h-full min-h-0 min-w-0 px-3 pt-3"
           initialSize={500}
           firstChild={<ChatInterface />}
-          firstClassName="min-w-[500px] rounded-xl overflow-hidden border border-neutral-600"
+          firstClassName="rounded-xl overflow-hidden border border-neutral-600"
           secondChild={
             <Container
               orientation={Orientation.VERTICAL}
-              className="grow h-full min-h-0 min-w-0"
+              className="h-full min-h-0 min-w-0"
               initialSize={window.innerHeight - 300}
               firstChild={<Workspace />}
-              firstClassName="min-h-72 rounded-xl border border-neutral-600 bg-neutral-800 flex flex-col overflow-hidden"
+              firstClassName="rounded-xl border border-neutral-600 bg-neutral-800 flex flex-col overflow-hidden"
               secondChild={<Terminal />}
-              secondClassName="min-h-72 rounded-xl border border-neutral-600 bg-neutral-800"
+              secondClassName="rounded-xl border border-neutral-600 bg-neutral-800"
             />
           }
-          secondClassName="flex flex-col overflow-hidden grow min-w-[500px]"
+          secondClassName="flex flex-col overflow-hidden"
         />
       </div>
       <Controls setSettingOpen={onSettingsModalOpen} />

+ 115 - 23
frontend/src/components/Resizable.tsx

@@ -1,11 +1,24 @@
-import React, { useEffect, useRef, useState } from "react";
+import React, { CSSProperties, useEffect, useRef, useState } from "react";
+import {
+  VscChevronDown,
+  VscChevronLeft,
+  VscChevronRight,
+  VscChevronUp,
+} from "react-icons/vsc";
 import { twMerge } from "tailwind-merge";
+import IconButton from "./IconButton";
 
 export enum Orientation {
   HORIZONTAL = "horizontal",
   VERTICAL = "vertical",
 }
 
+enum Collapse {
+  COLLAPSED = "collapsed",
+  SPLIT = "split",
+  FILLED = "filled",
+}
+
 type ContainerProps = {
   firstChild: React.ReactNode;
   firstClassName: string | undefined;
@@ -28,30 +41,40 @@ export function Container({
   const [firstSize, setFirstSize] = useState<number>(initialSize);
   const [dividerPosition, setDividerPosition] = useState<number | null>(null);
   const firstRef = useRef<HTMLDivElement>(null);
+  const secondRef = useRef<HTMLDivElement>(null);
+  const [collapse, setCollapse] = useState<Collapse>(Collapse.SPLIT);
+  const isHorizontal = orientation === Orientation.HORIZONTAL;
 
   useEffect(() => {
     if (dividerPosition == null || !firstRef.current) {
       return undefined;
     }
     const getFirstSizeFromEvent = (e: MouseEvent) => {
-      const position =
-        orientation === Orientation.HORIZONTAL ? e.clientX : e.clientY;
+      const position = isHorizontal ? e.clientX : e.clientY;
       return firstSize + position - dividerPosition;
     };
     const onMouseMove = (e: MouseEvent) => {
       e.preventDefault();
-      const newFirstSize = getFirstSizeFromEvent(e);
+      const newFirstSize = `${getFirstSizeFromEvent(e)}px`;
       const { current } = firstRef;
       if (current) {
-        if (orientation === Orientation.HORIZONTAL) {
-          current.style.width = `${newFirstSize}px`;
+        if (isHorizontal) {
+          current.style.width = newFirstSize;
+          current.style.minWidth = newFirstSize;
         } else {
-          current.style.height = `${newFirstSize}px`;
+          current.style.height = newFirstSize;
+          current.style.minHeight = newFirstSize;
         }
       }
     };
     const onMouseUp = (e: MouseEvent) => {
       e.preventDefault();
+      if (firstRef.current) {
+        firstRef.current.style.transition = "";
+      }
+      if (secondRef.current) {
+        secondRef.current.style.transition = "";
+      }
       setFirstSize(getFirstSizeFromEvent(e));
       setDividerPosition(null);
       document.removeEventListener("mousemove", onMouseMove);
@@ -67,33 +90,102 @@ export function Container({
 
   const onMouseDown = (e: React.MouseEvent) => {
     e.preventDefault();
-    const position =
-      orientation === Orientation.HORIZONTAL ? e.clientX : e.clientY;
+    if (firstRef.current) {
+      firstRef.current.style.transition = "none";
+    }
+    if (secondRef.current) {
+      secondRef.current.style.transition = "none";
+    }
+    const position = isHorizontal ? e.clientX : e.clientY;
     setDividerPosition(position);
   };
 
   const getStyleForFirst = () => {
-    if (orientation === Orientation.HORIZONTAL) {
-      return { width: `${firstSize}px` };
+    const style: CSSProperties = { overflow: "hidden" };
+    if (collapse === Collapse.COLLAPSED) {
+      style.opacity = 0;
+      style.width = 0;
+      style.minWidth = 0;
+      style.height = 0;
+      style.minHeight = 0;
+    } else if (collapse === Collapse.SPLIT) {
+      const firstSizePx = `${firstSize}px`;
+      if (isHorizontal) {
+        style.width = firstSizePx;
+        style.minWidth = firstSizePx;
+      } else {
+        style.height = firstSizePx;
+        style.minHeight = firstSizePx;
+      }
+    } else {
+      style.flexGrow = 1;
+    }
+    return style;
+  };
+
+  const getStyleForSecond = () => {
+    const style: CSSProperties = { overflow: "hidden" };
+    if (collapse === Collapse.FILLED) {
+      style.opacity = 0;
+      style.width = 0;
+      style.minWidth = 0;
+      style.height = 0;
+      style.minHeight = 0;
+    } else if (collapse === Collapse.SPLIT) {
+      style.flexGrow = 1;
+    } else {
+      style.flexGrow = 1;
+    }
+    return style;
+  };
+
+  const onCollapse = () => {
+    if (collapse === Collapse.SPLIT) {
+      setCollapse(Collapse.COLLAPSED);
+    } else {
+      setCollapse(Collapse.SPLIT);
+    }
+  };
+
+  const onExpand = () => {
+    if (collapse === Collapse.SPLIT) {
+      setCollapse(Collapse.FILLED);
+    } else {
+      setCollapse(Collapse.SPLIT);
     }
-    return { height: `${firstSize}px` };
   };
 
   return (
-    <div
-      className={twMerge(
-        `flex ${orientation === Orientation.HORIZONTAL ? "" : "flex-col"}`,
-        className,
-      )}
-    >
-      <div ref={firstRef} className={firstClassName} style={getStyleForFirst()}>
+    <div className={twMerge("flex", !isHorizontal && "flex-col", className)}>
+      <div
+        ref={firstRef}
+        className={twMerge(firstClassName, "transition-all ease-soft-spring")}
+        style={getStyleForFirst()}
+      >
         {firstChild}
       </div>
       <div
-        className={`${orientation === Orientation.VERTICAL ? "cursor-ns-resize h-3" : "cursor-ew-resize w-3"} shrink-0`}
-        onMouseDown={onMouseDown}
-      />
-      <div className={twMerge(secondClassName, "flex-1")}>{secondChild}</div>
+        className={`${isHorizontal ? "cursor-ew-resize w-3 flex-col" : "cursor-ns-resize h-3 flex-row"} shrink-0 flex justify-center items-center`}
+        onMouseDown={collapse === Collapse.SPLIT ? onMouseDown : undefined}
+      >
+        <IconButton
+          icon={isHorizontal ? <VscChevronLeft /> : <VscChevronUp />}
+          ariaLabel="Collapse"
+          onClick={onCollapse}
+        />
+        <IconButton
+          icon={isHorizontal ? <VscChevronRight /> : <VscChevronDown />}
+          ariaLabel="Expand"
+          onClick={onExpand}
+        />
+      </div>
+      <div
+        ref={secondRef}
+        className={twMerge(secondClassName, "transition-all ease-soft-spring")}
+        style={getStyleForSecond()}
+      >
+        {secondChild}
+      </div>
     </div>
   );
 }