diff --git a/web/apps/labelstudio/src/pages/CreateProject/Config/Config.jsx b/web/apps/labelstudio/src/pages/CreateProject/Config/Config.jsx index d7d0718ab014..e10f8032c68d 100644 --- a/web/apps/labelstudio/src/pages/CreateProject/Config/Config.jsx +++ b/web/apps/labelstudio/src/pages/CreateProject/Config/Config.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState, useRef } from "react"; import CM from "codemirror"; import { Button, cnm } from "@humansignal/ui"; import { IconTrash } from "@humansignal/icons"; @@ -18,6 +18,8 @@ import tags from "@humansignal/core/lib/utils/schema/tags.json"; import { UnsavedChanges } from "./UnsavedChanges"; import { Checkbox, CodeEditor, Select } from "@humansignal/ui"; import snakeCase from "lodash/snakeCase"; +import { useConfigResizer } from "./useConfigResizer"; +import { ConfigResizer } from "./ConfigResizer"; const wizardClass = cn("wizard"); const configClass = cn("configure"); @@ -352,6 +354,34 @@ const Configurator = ({ const [visualLoaded, loadVisual] = React.useState(configure === "visual"); const [waiting, setWaiting] = React.useState(false); const [saved, setSaved] = React.useState(false); + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(undefined); + + // Resizer hook + const { editorWidthPixels, setEditorWidthPixels, constraints } = useConfigResizer({ + projectId: project?.id, + containerWidth, + }); + + // Track container width for resizer constraints + useEffect(() => { + if (!containerRef.current) return; + + const updateWidth = () => { + if (containerRef.current) { + setContainerWidth(containerRef.current.clientWidth); + } + }; + + const resizeObserver = new ResizeObserver(updateWidth); + resizeObserver.observe(containerRef.current); + + updateWidth(); + + return () => { + resizeObserver.disconnect(); + }; + }, []); // config update is debounced because of user input const [configToCheck, setConfigToCheck] = React.useState(); @@ -485,95 +515,120 @@ const Configurator = ({ return (
-
-

Labeling Interface{hasChanges ? " *" : ""}

-
- - -
-
- {configure === "code" && ( -
- { - if (e.code === "Escape") e.stopPropagation(); - }} - onChange={(editor, data, value) => onChange(value)} - /> -
- )} - {visualLoaded && ( -
- {isEmptyConfig(config) && } - - {template.controls.map((control) => ( - - ))} - -
- )} -
- {disableSaveButton !== true && onSaveClick && ( - - {saved && ( -
- Saved! -
- )} +
+
+

Labeling Interface{hasChanges ? " *" : ""}

+
- {isFF(FF_UNSAVED_CHANGES) && } - - )} + +
+
+ {configure === "code" && ( +
+ { + if (e.code === "Escape") e.stopPropagation(); + }} + onChange={(_editor, _data, value) => onChange(value)} + /> +
+ )} + {visualLoaded && ( +
+ {isEmptyConfig(config) && } + + {template.controls.map((control) => ( + + ))} + +
+ )} +
+ {disableSaveButton !== true && onSaveClick && ( + + {saved && ( +
+ + Saved! + +
+ )} + + {isFF(FF_UNSAVED_CHANGES) && } +
+ )} +
+
+ + +
-
); }; @@ -629,7 +684,7 @@ export const ConfigPage = ({ if (externalColumns?.length) setColumns(externalColumns); }, [externalColumns]); - const [warning, setWarning] = React.useState(); + const [warning, _setWarning] = React.useState(); React.useEffect(() => { const fetchData = async () => { @@ -644,8 +699,8 @@ export const ConfigPage = ({ setColumns(res.common_data_columns); } } - fetchData(); }; + fetchData(); }, [columns, project]); const onSelectRecipe = React.useCallback((recipe) => { diff --git a/web/apps/labelstudio/src/pages/CreateProject/Config/Config.scss b/web/apps/labelstudio/src/pages/CreateProject/Config/Config.scss index 9b1e1d9e8158..82541a89b4e8 100644 --- a/web/apps/labelstudio/src/pages/CreateProject/Config/Config.scss +++ b/web/apps/labelstudio/src/pages/CreateProject/Config/Config.scss @@ -22,20 +22,26 @@ $scroll-width: 5px; min-height: 0; display: flex; align-items: stretch; + position: relative; >* { flex: 50%; + min-width: 0; } } .wizard .configure__container { display: flex; - flex-direction: column; - padding: 16px 16px - $scroll-width 16px 20px; + flex-direction: row; + padding: 0; overflow-y: scroll; background-color: var(--color-neutral-background); gap: var(--spacing-base); + & > div { + padding: 16px 16px - $scroll-width 16px 20px; + } + &::-webkit-scrollbar { width: $scroll-width; } @@ -250,10 +256,11 @@ $scroll-width: 5px; line-height: 1; } -.configure__container>header { +.configure__container > div > header { display: flex; justify-content: flex-end; align-items: center; + padding: var(--spacing-tight) 0; } .configure__editor { @@ -272,7 +279,7 @@ $scroll-width: 5px; } } -.configure__container>header .toggle-items { +.configure__container > div > header .toggle-items { margin-left: auto; } @@ -538,18 +545,17 @@ $scroll-width: 5px; } .wizard .configure__preview { - background-color: var(--color-neutral-surface); flex-grow: 10; min-width: 500px; - padding: 16px 16px 0; + padding: 0; overflow-y: auto; - border-left: 1px solid var(--color-neutral-border); display: flex; flex-direction: column; color: var(--color-neutral-content); + height: 100%; h3 { - margin: 8px 0 16px; + padding: var(--spacing-tight) 0; font-size: 16px; color: var(--color-neutral-content); } @@ -563,6 +569,10 @@ $scroll-width: 5px; &-ui { flex: 1; min-height: 0; + background-color: var(--color-neutral-surface); + border: 1px solid var(--color-neutral-border); + border-radius: var(--corner-radius-small); + padding: var(--spacing-base); } &-error { diff --git a/web/apps/labelstudio/src/pages/CreateProject/Config/ConfigResizer.jsx b/web/apps/labelstudio/src/pages/CreateProject/Config/ConfigResizer.jsx new file mode 100644 index 000000000000..6236bee48c6c --- /dev/null +++ b/web/apps/labelstudio/src/pages/CreateProject/Config/ConfigResizer.jsx @@ -0,0 +1,70 @@ +import { useCallback, useRef, useState } from "react"; +import { cn } from "../../../utils/bem"; +import "./ConfigResizer.scss"; + +const calculateEditorWidth = (initialWidth, initialX, currentX, minWidth, maxWidth) => { + // Calculate offset from initial position + // Dragging right (currentX > initialX) should increase editor width + const offset = currentX - initialX; + const newWidth = initialWidth + offset; + return Math.max(minWidth, Math.min(maxWidth, newWidth)); +}; + +export const ConfigResizer = ({ containerRef, editorWidthPixels, onResize, onResizeFinished, constraints }) => { + const [isResizing, setIsResizing] = useState(false); + const handleRef = useRef(null); + + const handleMouseDown = useCallback( + (evt) => { + evt.stopPropagation(); + evt.preventDefault(); + + const container = containerRef.current; + if (!container) return; + + const initialX = evt.pageX; + const initialWidth = editorWidthPixels; + let newWidth = editorWidthPixels; + + const onMouseMove = (e) => { + newWidth = calculateEditorWidth( + initialWidth, + initialX, + e.pageX, + constraints.minEditorWidth, + constraints.maxEditorWidth, + ); + + onResize(newWidth); + }; + + const onMouseUp = () => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + document.body.style.removeProperty("user-select"); + document.body.style.removeProperty("cursor"); + + setIsResizing(false); + + if (newWidth !== editorWidthPixels && onResizeFinished) { + onResizeFinished(newWidth); + } + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + document.body.style.userSelect = "none"; + document.body.style.cursor = "col-resize"; + setIsResizing(true); + }, + [containerRef, editorWidthPixels, onResize, onResizeFinished, constraints], + ); + + return ( +
+ ); +}; diff --git a/web/apps/labelstudio/src/pages/CreateProject/Config/ConfigResizer.scss b/web/apps/labelstudio/src/pages/CreateProject/Config/ConfigResizer.scss new file mode 100644 index 000000000000..aa323e959203 --- /dev/null +++ b/web/apps/labelstudio/src/pages/CreateProject/Config/ConfigResizer.scss @@ -0,0 +1,24 @@ +.config-resizer { + &__handle { + --handle-size: 2px; + --handle-size-hover: 3px; + + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: var(--handle-size); + cursor: col-resize; + z-index: 100; + background: var(--color-neutral-border); + transform: translateX(-50%); + transition: width 0.15s ease, background-color 0.15s ease; + + &:hover, + &_resizing { + width: var(--handle-size-hover); + background-color: var(--color-primary-border-subtle); + } + } +} + diff --git a/web/apps/labelstudio/src/pages/CreateProject/Config/useConfigResizer.js b/web/apps/labelstudio/src/pages/CreateProject/Config/useConfigResizer.js new file mode 100644 index 000000000000..6041393004c1 --- /dev/null +++ b/web/apps/labelstudio/src/pages/CreateProject/Config/useConfigResizer.js @@ -0,0 +1,167 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +// Gap between columns (1rem / --spacing-base = 16px) +const COLUMN_GAP = 16; + +// Minimum widths in pixels +const MIN_EDITOR_WIDTH = 400; +const MIN_PREVIEW_WIDTH = 500; + +// Default editor width (left side) +const DEFAULT_EDITOR_WIDTH = 500; + +export const useConfigResizer = ({ projectId, containerWidth }) => { + // Generate storage key based on project ID + // This allows separate storage for different projects + const storageKey = useMemo( + () => (projectId ? `config-editor-width:${projectId}` : "config-editor-width"), + [projectId], + ); + + // Initialize state from localStorage or use default + // This runs once on mount and handles initial localStorage read + const [editorWidthPixels, setEditorWidthPixelsInternal] = useState(() => { + try { + const item = window.localStorage.getItem(storageKey); + if (item) { + return JSON.parse(item); + } + } catch { + // If error reading from localStorage, use default + } + return DEFAULT_EDITOR_WIDTH; + }); + + // Track previous storage key to detect key changes (project switches) + const prevStorageKeyRef = useRef(storageKey); + // Track if we just loaded a value from localStorage (to prevent clamping from resetting it) + const justLoadedFromStorageRef = useRef(false); + + // Single effect that handles both: + // 1. Reading from localStorage when storage key changes (project switch) + // 2. Writing to localStorage when the value changes + useEffect(() => { + const currentKey = storageKey; + const prevKey = prevStorageKeyRef.current; + + // If storage key changed, reload value from localStorage for the new key + if (prevKey !== currentKey) { + justLoadedFromStorageRef.current = true; + try { + const item = window.localStorage.getItem(currentKey); + if (item) { + const parsedValue = JSON.parse(item); + setEditorWidthPixelsInternal(parsedValue); + } else { + // No stored value for this key, use default + setEditorWidthPixelsInternal(DEFAULT_EDITOR_WIDTH); + } + } catch { + // Error reading, use default + setEditorWidthPixelsInternal(DEFAULT_EDITOR_WIDTH); + } + prevStorageKeyRef.current = currentKey; + } else { + // Key hasn't changed, just write current value to localStorage + try { + window.localStorage.setItem(currentKey, JSON.stringify(editorWidthPixels)); + } catch { + // Ignore write errors (e.g., quota exceeded) + } + } + }, [storageKey, editorWidthPixels]); + + // Calculate min/max constraints based on container width + const constraints = useMemo(() => { + if (!containerWidth) { + return { + minEditorWidth: DEFAULT_EDITOR_WIDTH, + maxEditorWidth: DEFAULT_EDITOR_WIDTH * 2, + }; + } + + // Minimum editor width + const minEditorWidth = MIN_EDITOR_WIDTH; + + // Maximum editor width ensures preview column has minimum width + const maxEditorWidth = containerWidth - MIN_PREVIEW_WIDTH - COLUMN_GAP; + + return { + minEditorWidth, + maxEditorWidth: Math.max(minEditorWidth, maxEditorWidth), + }; + }, [containerWidth]); + + // Track previous container width to only clamp when container actually resizes + const prevContainerWidthRef = useRef(containerWidth); + + // Clamp width when container resizes (not when project switches) + // This ensures the editor width stays within valid bounds when container size changes + useEffect(() => { + if (!constraints.minEditorWidth || !constraints.maxEditorWidth) { + prevContainerWidthRef.current = containerWidth; + return; + } + + // Don't clamp if we just loaded a value from localStorage (project switch) + // Reset the flag after checking it + if (justLoadedFromStorageRef.current) { + justLoadedFromStorageRef.current = false; + prevContainerWidthRef.current = containerWidth; + return; + } + + // Only clamp if container width actually changed + const containerWidthChanged = + prevContainerWidthRef.current !== undefined && prevContainerWidthRef.current !== containerWidth; + + if (containerWidthChanged) { + // Check if current width is out of bounds and clamp if needed + const clampedWidth = Math.max( + constraints.minEditorWidth, + Math.min(constraints.maxEditorWidth, editorWidthPixels), + ); + + // Only update if clamping is needed + if (clampedWidth !== editorWidthPixels) { + setEditorWidthPixelsInternal(clampedWidth); + } + } + + prevContainerWidthRef.current = containerWidth; + }, [containerWidth, constraints.minEditorWidth, constraints.maxEditorWidth, editorWidthPixels]); + + // Wrapped setter that automatically clamps values to valid constraints + // This ensures all width updates respect min/max bounds + const setEditorWidthPixels = useCallback( + (value) => { + setEditorWidthPixelsInternal((prev) => { + const newValue = typeof value === "function" ? value(prev) : value; + + // Clamp to constraints if available + if (constraints.minEditorWidth !== undefined && constraints.maxEditorWidth !== undefined) { + return Math.max(constraints.minEditorWidth, Math.min(constraints.maxEditorWidth, newValue)); + } + + return newValue; + }); + }, + [constraints.minEditorWidth, constraints.maxEditorWidth], + ); + + // Calculate preview width from editor width + const previewWidthPixels = useMemo(() => { + if (!containerWidth) return 0; + return Math.max(MIN_PREVIEW_WIDTH, containerWidth - editorWidthPixels - COLUMN_GAP); + }, [containerWidth, editorWidthPixels]); + + return { + editorWidthPixels, + setEditorWidthPixels, + previewWidthPixels, + constraints: { + minEditorWidth: constraints.minEditorWidth, + maxEditorWidth: constraints.maxEditorWidth, + }, + }; +};