From b422440b67d2c10eb342974dbaad210b6dcce7f1 Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Mon, 5 May 2025 12:56:58 -0500 Subject: [PATCH 01/87] feat: OPTIC-1799: LabelStudio Playground 2.0 --- web/apps/playground/.babelrc | 11 +++ web/apps/playground/API.mdc | 0 web/apps/playground/ARCHITECTURE.mdc | 0 web/apps/playground/EMBED.mdc | 0 web/apps/playground/README.mdc | 0 web/apps/playground/jest.config.ts | 14 ++++ web/apps/playground/project.json | 92 ++++++++++++++++++++++++++ web/apps/playground/src/index.html | 14 ++++ web/apps/playground/src/main.tsx | 7 ++ web/apps/playground/tsconfig.app.json | 23 +++++++ web/apps/playground/tsconfig.json | 31 +++++++++ web/apps/playground/tsconfig.spec.json | 24 +++++++ 12 files changed, 216 insertions(+) create mode 100644 web/apps/playground/.babelrc create mode 100644 web/apps/playground/API.mdc create mode 100644 web/apps/playground/ARCHITECTURE.mdc create mode 100644 web/apps/playground/EMBED.mdc create mode 100644 web/apps/playground/README.mdc create mode 100644 web/apps/playground/jest.config.ts create mode 100644 web/apps/playground/project.json create mode 100644 web/apps/playground/src/index.html create mode 100644 web/apps/playground/src/main.tsx create mode 100644 web/apps/playground/tsconfig.app.json create mode 100644 web/apps/playground/tsconfig.json create mode 100644 web/apps/playground/tsconfig.spec.json diff --git a/web/apps/playground/.babelrc b/web/apps/playground/.babelrc new file mode 100644 index 000000000000..88ee27b140c6 --- /dev/null +++ b/web/apps/playground/.babelrc @@ -0,0 +1,11 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic" + } + ] + ], + "plugins": [] +} diff --git a/web/apps/playground/API.mdc b/web/apps/playground/API.mdc new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/web/apps/playground/ARCHITECTURE.mdc b/web/apps/playground/ARCHITECTURE.mdc new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/web/apps/playground/EMBED.mdc b/web/apps/playground/EMBED.mdc new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/web/apps/playground/README.mdc b/web/apps/playground/README.mdc new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/web/apps/playground/jest.config.ts b/web/apps/playground/jest.config.ts new file mode 100644 index 000000000000..8d5cb6498343 --- /dev/null +++ b/web/apps/playground/jest.config.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +export default { + displayName: "playground", + preset: "../../jest.preset.js", + transform: { + "^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "@nx/react/plugins/jest", + "^.+\\.[tj]sx?$": ["babel-jest", { presets: ["@nx/react/babel"] }], + }, + moduleFileExtensions: ["ts", "tsx", "js", "jsx"], + moduleNameMapper: { + "^apps/playground/(.*)$": "/$1", + }, + coverageDirectory: "../../coverage/apps/playground", +}; diff --git a/web/apps/playground/project.json b/web/apps/playground/project.json new file mode 100644 index 000000000000..6ca747726bed --- /dev/null +++ b/web/apps/playground/project.json @@ -0,0 +1,92 @@ +{ + "name": "playground", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/playground/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@nx/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "compiler": "babel", + "outputPath": "dist/apps/playground", + "index": "apps/playground/src/index.html", + "baseHref": "/", + "main": "apps/playground/src/main.tsx", + "tsConfig": "apps/playground/tsconfig.app.json", + "assets": [], + "styles": [], + "scripts": [], + "isolatedConfig": true, + "webpackConfig": "webpack.config.js" + }, + "configurations": { + "development": { + "extractLicenses": false, + "optimization": false, + "sourceMap": true, + "vendorChunk": true + }, + "production": { + "fileReplacements": [ + { + "replace": "apps/playground/src/environments/environment.ts", + "with": "apps/playground/src/environments/environment.prod.ts" + } + ], + "optimization": true, + "sourceMap": false, + "namedChunks": false, + "extractLicenses": true, + "vendorChunk": false + } + } + }, + "serve": { + "executor": "@nx/webpack:dev-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "playground:build", + "hmr": true + }, + "configurations": { + "development": { + "buildTarget": "playground:build:development" + }, + "production": { + "buildTarget": "playground:build:production", + "hmr": false + } + } + }, + "serve-static": { + "executor": "@nx/web:file-server", + "options": { + "buildTarget": "playground:build" + } + }, + "unit": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/playground/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "version": { + "executor": "nx:run-commands", + "options": { + "cwd": "apps/playground", + "command": "node ../../tools/version/version.mjs" + } + } + }, + "tags": [] +} diff --git a/web/apps/playground/src/index.html b/web/apps/playground/src/index.html new file mode 100644 index 000000000000..cbc34378272d --- /dev/null +++ b/web/apps/playground/src/index.html @@ -0,0 +1,14 @@ + + + + + LabelStudio Playground + + + + + + +
+ + diff --git a/web/apps/playground/src/main.tsx b/web/apps/playground/src/main.tsx new file mode 100644 index 000000000000..017e49f9e329 --- /dev/null +++ b/web/apps/playground/src/main.tsx @@ -0,0 +1,7 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; + +const PlaygroundApp = () =>
LabelStudio Playground coming soon...
; + +const root = createRoot(document.getElementById("root")!); +root.render(); diff --git a/web/apps/playground/tsconfig.app.json b/web/apps/playground/tsconfig.app.json new file mode 100644 index 000000000000..06e3087e41b8 --- /dev/null +++ b/web/apps/playground/tsconfig.app.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["node"] + }, + "files": [ + "../../node_modules/@nx/react/typings/cssmodule.d.ts", + "../../node_modules/@nx/react/typings/image.d.ts" + ], + "exclude": [ + "jest.config.ts", + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.spec.tsx", + "src/**/*.test.tsx", + "src/**/*.spec.js", + "src/**/*.test.js", + "src/**/*.spec.jsx", + "src/**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/web/apps/playground/tsconfig.json b/web/apps/playground/tsconfig.json new file mode 100644 index 000000000000..f10372b0480e --- /dev/null +++ b/web/apps/playground/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ESNEXT", + "module": "commonjs", + "allowJs": true, + "checkJs": false, + "jsx": "preserve", + "strict": true, + "rootDirs": [ + "./src" + ], + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "exclude": [ + "./dist", + "./lib" + ], + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../tsconfig.base.json" +} diff --git a/web/apps/playground/tsconfig.spec.json b/web/apps/playground/tsconfig.spec.json new file mode 100644 index 000000000000..989ff64de568 --- /dev/null +++ b/web/apps/playground/tsconfig.spec.json @@ -0,0 +1,24 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ], + "files": [ + "../../node_modules/@nx/react/typings/cssmodule.d.ts", + "../../node_modules/@nx/react/typings/image.d.ts" + ] +} From 75b82f3c4988365b67c63e340c5b1a39f1fbfdc2 Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Mon, 5 May 2025 14:34:30 -0500 Subject: [PATCH 02/87] updating docs and code structure --- web/apps/playground/ARCHITECTURE.mdc | 92 +++++++++++++++++++ web/apps/playground/project.json | 7 -- .../src/components/PlaygroundApp.tsx | 78 ++++++++++++++++ .../src/components/PlaygroundPreview.tsx | 58 ++++++++++++ web/apps/playground/src/main.tsx | 3 +- 5 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 web/apps/playground/src/components/PlaygroundApp.tsx create mode 100644 web/apps/playground/src/components/PlaygroundPreview.tsx diff --git a/web/apps/playground/ARCHITECTURE.mdc b/web/apps/playground/ARCHITECTURE.mdc index e69de29bb2d1..0c77c5521486 100644 --- a/web/apps/playground/ARCHITECTURE.mdc +++ b/web/apps/playground/ARCHITECTURE.mdc @@ -0,0 +1,92 @@ +--- +description: +globs: +alwaysApply: false +--- +# Playground Architecture + +## Overview +The LabelStudio Playground is a standalone, embeddable React application for editing and previewing LabelStudio XML labeling configurations. It is designed to be embedded via iframe in documentation or external web applications, providing a focused environment for working with XML-based labeling configs and live previewing the LabelStudio interface. + +## Main Components + +### 1. `PlaygroundApp` +- **Location:** `src/components/PlaygroundApp.tsx` +- **Role:** The main application component. Handles: + - State for the XML config, loading, error, and LabelStudio interfaces. + - Parsing URL parameters for config and interface options. + - Fetching and decoding configs from base64 or external URLs. + - Rendering the code editor and preview panels side by side using Tailwind CSS for layout. + +### 2. `PlaygroundPreview` +- **Location:** `src/components/PlaygroundPreview.tsx` +- **Role:** Renders the live LabelStudio preview panel. + - Dynamically loads the `@humansignal/editor` package and instantiates a LabelStudio instance with the current config and interface options. + - Handles cleanup of the LabelStudio instance on config/interface changes or unmount. + - Displays loading and error states using Tailwind classes. + +### 3. `main.tsx` +- **Role:** Entry point. Mounts the `PlaygroundApp` to the DOM. + +## Data Flow +- On load, `PlaygroundApp` parses URL parameters: + - `?config` (base64-encoded XML config) + - `?configUrl` (URL to fetch XML config) + - `?interfaces` (comma-separated list of LabelStudio interface options) +- The config is loaded and stored in React state. +- The code editor is a controlled component, updating the config state on change. +- The preview panel receives the current config and interface options as props and re-renders the LabelStudio instance accordingly. + +## Underlying Libraries + +### React +- The app is built with React (function components, hooks, strict mode). +- State and effects are managed with `useState` and `useEffect`. + +### Tailwind CSS +- All layout, spacing, color, and typography is handled with Tailwind utility classes. +- Only semantic and token-based Tailwind classes are used, following project rules. + +### @humansignal/ui +- Provides the `CodeEditor` component for XML editing. +- Ensures consistent UI and design token usage across the app. + +### @humansignal/editor +- Provides the LabelStudio labeling interface for live preview. +- Dynamically imported in the preview panel for performance and to avoid loading unnecessary code until needed. +- The LabelStudio instance is created with the current config and interface options, and is destroyed/cleaned up on changes. + +## URL-based API +- The app can be configured via URL parameters: + - `?config` (base64-encoded XML config string) + - `?configUrl` (URL to fetch XML config) + - `?interfaces` (comma-separated list of LabelStudio interface options) +- This allows external documentation or apps to embed the playground with preloaded configs and custom preview options. + +## Embeddability +- The app is fully responsive and designed to be embedded via iframe. +- All UI is self-contained and does not require authentication or external state. + +## Extensibility +- Components are split into single-file-per-component for maintainability, and all live under `src/components/`. +- The code editor and preview logic are decoupled, allowing for future enhancements (e.g., validation, additional preview options, custom data, etc). +- The app can be extended to support more URL parameters, additional LabelStudio features, or integration with other HumanSignal libraries. + +## Directory Structure + +``` +web/apps/playground/ + README.mdc + API.mdc + ARCHITECTURE.mdc + EMBED.mdc + src/ + main.tsx + index.html + components/ + PlaygroundApp.tsx + PlaygroundPreview.tsx +``` + +## Summary +The Playground app is a modern, modular, and embeddable tool for experimenting with and sharing LabelStudio configs. It leverages the HumanSignal UI and editor libraries, is styled with Tailwind, and is designed for easy integration into documentation and external web applications. diff --git a/web/apps/playground/project.json b/web/apps/playground/project.json index 6ca747726bed..1af7afb4def6 100644 --- a/web/apps/playground/project.json +++ b/web/apps/playground/project.json @@ -79,13 +79,6 @@ "codeCoverage": true } } - }, - "version": { - "executor": "nx:run-commands", - "options": { - "cwd": "apps/playground", - "command": "node ../../tools/version/version.mjs" - } } }, "tags": [] diff --git a/web/apps/playground/src/components/PlaygroundApp.tsx b/web/apps/playground/src/components/PlaygroundApp.tsx new file mode 100644 index 000000000000..9f49ce433aa2 --- /dev/null +++ b/web/apps/playground/src/components/PlaygroundApp.tsx @@ -0,0 +1,78 @@ +import React, { useEffect, useState } from "react"; +import { CodeEditor } from "@humansignal/ui"; +import { PlaygroundPreview } from "./PlaygroundPreview"; + +const DEFAULT_CONFIG = '\n \n'; + +// Supported query params: +// ?config (base64), ?configUrl (URL), ?interfaces=comma,separated,list +function getQueryParams() { + return new URLSearchParams(window.location.search); +} + +const getInterfacesFromParams = (params: URLSearchParams): string[] => { + const interfacesParam = params.get("interfaces"); + if (!interfacesParam) return ["side-column"]; + return interfacesParam.split(",").map((s) => s.trim()).filter(Boolean); +}; + +export const PlaygroundApp = () => { + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [interfaces, setInterfaces] = useState(["side-column"]); + + useEffect(() => { + const params = getQueryParams(); + const configParam = params.get("config"); + const configUrl = params.get("configUrl"); + setInterfaces(getInterfacesFromParams(params)); + + async function loadConfig() { + if (configParam) { + try { + const decoded = atob(configParam); + setConfig(decoded); + } catch (e) { + setError("Failed to decode base64 config. Are you sure it's a valid base64 string?"); + } + return; + } + if (configUrl) { + setLoading(true); + try { + const res = await fetch(configUrl); + if (!res.ok) throw new Error("Failed to fetch config from URL."); + const text = await res.text(); + setConfig(text); + } catch (e) { + setError("Failed to fetch config from URL."); + } finally { + setLoading(false); + } + } + } + loadConfig(); + }, []); + + return ( +
+
+

LabelStudio Config Editor

+ setConfig(value)} + options={{ mode: "xml", lineNumbers: true }} + border + controlled + /> +
+
+

Preview

+
+ +
+
+
+ ); +}; diff --git a/web/apps/playground/src/components/PlaygroundPreview.tsx b/web/apps/playground/src/components/PlaygroundPreview.tsx new file mode 100644 index 000000000000..156d4f5b423d --- /dev/null +++ b/web/apps/playground/src/components/PlaygroundPreview.tsx @@ -0,0 +1,58 @@ +import { useEffect, useRef } from "react"; +import type { FC } from "react"; + +interface PlaygroundPreviewProps { + config: string; + loading: boolean; + error: string | null; + interfaces: string[]; +} + +export const PlaygroundPreview: FC = ({ config, loading, error, interfaces }) => { + const rootRef = useRef(null); + const lsfInstance = useRef(null); + + useEffect(() => { + let isMounted = true; + let LabelStudio: any; + let dependencies: any; + + async function loadLSF() { + dependencies = await import("@humansignal/editor"); + LabelStudio = (window as any).LabelStudio || dependencies.LabelStudio; + if (!LabelStudio || !rootRef.current) return; + if (lsfInstance.current) { + lsfInstance.current.destroy(); + lsfInstance.current = null; + } + lsfInstance.current = new LabelStudio(rootRef.current, { + config, + task: { id: 1, data: {}, annotations: [], predictions: [] }, + interfaces, + }); + } + if (!loading && !error && config) { + loadLSF(); + } + return () => { + isMounted = false; + if (lsfInstance.current) { + lsfInstance.current.destroy(); + lsfInstance.current = null; + } + }; + // eslint-disable-next-line + }, [config, loading, error, interfaces]); + + return ( +
+ {error ? ( +
{error}
+ ) : loading ? ( +
Loading config...
+ ) : ( +
+ )} +
+ ); +}; diff --git a/web/apps/playground/src/main.tsx b/web/apps/playground/src/main.tsx index 017e49f9e329..e6d5519d1ec4 100644 --- a/web/apps/playground/src/main.tsx +++ b/web/apps/playground/src/main.tsx @@ -1,7 +1,6 @@ import React from "react"; import { createRoot } from "react-dom/client"; - -const PlaygroundApp = () =>
LabelStudio Playground coming soon...
; +import { PlaygroundApp } from "./components/PlaygroundApp"; const root = createRoot(document.getElementById("root")!); root.render(); From 400b990cb2cfc2fc5248e4d819a9073bae06cb31 Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Mon, 5 May 2025 14:41:48 -0500 Subject: [PATCH 03/87] refactor to use Jotai atoms --- web/apps/playground/src/atoms/configAtoms.ts | 8 +++++ .../src/components/PlaygroundApp.tsx | 35 ++++++++----------- web/apps/playground/src/utils/query.ts | 12 +++++++ 3 files changed, 35 insertions(+), 20 deletions(-) create mode 100644 web/apps/playground/src/atoms/configAtoms.ts create mode 100644 web/apps/playground/src/utils/query.ts diff --git a/web/apps/playground/src/atoms/configAtoms.ts b/web/apps/playground/src/atoms/configAtoms.ts new file mode 100644 index 000000000000..a7be52efac42 --- /dev/null +++ b/web/apps/playground/src/atoms/configAtoms.ts @@ -0,0 +1,8 @@ +import { atom } from "jotai"; + +export const defaultConfig = "\n \n"; + +export const configAtom = atom(defaultConfig); +export const loadingAtom = atom(false); +export const errorAtom = atom(null); +export const interfacesAtom = atom(["side-column"]); diff --git a/web/apps/playground/src/components/PlaygroundApp.tsx b/web/apps/playground/src/components/PlaygroundApp.tsx index 9f49ce433aa2..a97a7b0c4910 100644 --- a/web/apps/playground/src/components/PlaygroundApp.tsx +++ b/web/apps/playground/src/components/PlaygroundApp.tsx @@ -1,26 +1,20 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect } from "react"; +import { useAtom } from "jotai"; import { CodeEditor } from "@humansignal/ui"; import { PlaygroundPreview } from "./PlaygroundPreview"; - -const DEFAULT_CONFIG = '\n \n'; - -// Supported query params: -// ?config (base64), ?configUrl (URL), ?interfaces=comma,separated,list -function getQueryParams() { - return new URLSearchParams(window.location.search); -} - -const getInterfacesFromParams = (params: URLSearchParams): string[] => { - const interfacesParam = params.get("interfaces"); - if (!interfacesParam) return ["side-column"]; - return interfacesParam.split(",").map((s) => s.trim()).filter(Boolean); -}; +import { + configAtom, + loadingAtom, + errorAtom, + interfacesAtom, +} from "../atoms/configAtoms"; +import { getQueryParams, getInterfacesFromParams } from "../utils/query"; export const PlaygroundApp = () => { - const [config, setConfig] = useState(DEFAULT_CONFIG); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [interfaces, setInterfaces] = useState(["side-column"]); + const [config, setConfig] = useAtom(configAtom); + const [loading, setLoading] = useAtom(loadingAtom); + const [error, setError] = useAtom(errorAtom); + const [interfaces, setInterfaces] = useAtom(interfacesAtom); useEffect(() => { const params = getQueryParams(); @@ -53,7 +47,8 @@ export const PlaygroundApp = () => { } } loadConfig(); - }, []); + // eslint-disable-next-line + }, [setConfig, setError, setLoading, setInterfaces]); return (
diff --git a/web/apps/playground/src/utils/query.ts b/web/apps/playground/src/utils/query.ts new file mode 100644 index 000000000000..399c0f122998 --- /dev/null +++ b/web/apps/playground/src/utils/query.ts @@ -0,0 +1,12 @@ +export function getQueryParams(): URLSearchParams { + return new URLSearchParams(window.location.search); +} + +export function getInterfacesFromParams(params: URLSearchParams): string[] { + const interfacesParam = params.get("interfaces"); + if (!interfacesParam) return ["side-column"]; + return interfacesParam + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} From 7749a51d31e2e08be06e2c0ef66954411832c0e7 Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Mon, 5 May 2025 14:43:20 -0500 Subject: [PATCH 04/87] add playground to top level commands for web --- web/package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index 165251e6bec5..3df0d0ac2b9f 100644 --- a/web/package.json +++ b/web/package.json @@ -36,7 +36,9 @@ "design-tokens": "nx design-tokens ui", "extract-antd-no-reset": "nx extract-antd-no-reset editor", "storybook:serve": "nx storybook storybook", - "storybook:build": "nx build-storybook storybook" + "storybook:build": "nx build-storybook storybook", + "playground:serve": "nx serve playground", + "playground:build": "nx build playground" }, "husky": { "hooks": { From a3ee374ec176eecf4567851ec4f0f2300767a5c3 Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Mon, 5 May 2025 16:15:50 -0500 Subject: [PATCH 05/87] use the right styles --- web/apps/playground/project.json | 1 + web/apps/playground/src/main.tsx | 3 +++ web/package.json | 4 ++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/web/apps/playground/project.json b/web/apps/playground/project.json index 1af7afb4def6..efb3fdfc5424 100644 --- a/web/apps/playground/project.json +++ b/web/apps/playground/project.json @@ -48,6 +48,7 @@ "defaultConfiguration": "development", "options": { "buildTarget": "playground:build", + "port": 4200, "hmr": true }, "configurations": { diff --git a/web/apps/playground/src/main.tsx b/web/apps/playground/src/main.tsx index e6d5519d1ec4..34dbb8d84162 100644 --- a/web/apps/playground/src/main.tsx +++ b/web/apps/playground/src/main.tsx @@ -2,5 +2,8 @@ import React from "react"; import { createRoot } from "react-dom/client"; import { PlaygroundApp } from "./components/PlaygroundApp"; +import "@humansignal/ui/src/styles.scss"; +import "@humansignal/ui/src/tailwind.css"; + const root = createRoot(document.getElementById("root")!); root.render(); diff --git a/web/package.json b/web/package.json index 3df0d0ac2b9f..d16c53990729 100644 --- a/web/package.json +++ b/web/package.json @@ -37,8 +37,8 @@ "extract-antd-no-reset": "nx extract-antd-no-reset editor", "storybook:serve": "nx storybook storybook", "storybook:build": "nx build-storybook storybook", - "playground:serve": "nx serve playground", - "playground:build": "nx build playground" + "playground:serve": "FRONTEND_HOSTNAME=http://localhost:4200 MODE=standalone nx run playground:serve:development", + "playground:build": "NODE_ENV=production MODE=standalone nx run playground:build:production" }, "husky": { "hooks": { From 5dd75d907bbb86b7af114372f5b08de6c368c466 Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Mon, 5 May 2025 16:53:00 -0500 Subject: [PATCH 06/87] more styling --- .../src/components/PlaygroundApp.module.scss | 10 +++ .../src/components/PlaygroundApp.tsx | 74 +++++++++++++++---- .../src/components/PlaygroundPreview.tsx | 2 +- 3 files changed, 70 insertions(+), 16 deletions(-) create mode 100644 web/apps/playground/src/components/PlaygroundApp.module.scss diff --git a/web/apps/playground/src/components/PlaygroundApp.module.scss b/web/apps/playground/src/components/PlaygroundApp.module.scss new file mode 100644 index 000000000000..d2bce5c188df --- /dev/null +++ b/web/apps/playground/src/components/PlaygroundApp.module.scss @@ -0,0 +1,10 @@ +.root { + :global(.react-codemirror2 .CodeMirror) { + border: none; + border-radius: 0; + } +} + +body { + border: 1px solid var(--color-neutral-border); +} \ No newline at end of file diff --git a/web/apps/playground/src/components/PlaygroundApp.tsx b/web/apps/playground/src/components/PlaygroundApp.tsx index a97a7b0c4910..4591eef2f1aa 100644 --- a/web/apps/playground/src/components/PlaygroundApp.tsx +++ b/web/apps/playground/src/components/PlaygroundApp.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useAtom } from "jotai"; import { CodeEditor } from "@humansignal/ui"; import { PlaygroundPreview } from "./PlaygroundPreview"; @@ -9,12 +9,16 @@ import { interfacesAtom, } from "../atoms/configAtoms"; import { getQueryParams, getInterfacesFromParams } from "../utils/query"; +import { cnm } from "@humansignal/shad/utils"; +import styles from "./PlaygroundApp.module.scss"; export const PlaygroundApp = () => { const [config, setConfig] = useAtom(configAtom); const [loading, setLoading] = useAtom(loadingAtom); const [error, setError] = useAtom(errorAtom); const [interfaces, setInterfaces] = useAtom(interfacesAtom); + const [editorWidth, setEditorWidth] = useState(50); // percent + const dragging = useRef(false); useEffect(() => { const params = getQueryParams(); @@ -50,22 +54,62 @@ export const PlaygroundApp = () => { // eslint-disable-next-line }, [setConfig, setError, setLoading, setInterfaces]); + // Draggable divider logic + useEffect(() => { + const onMouseMove = (e: MouseEvent) => { + if (!dragging.current) return; + const percent = (e.clientX / window.innerWidth) * 100; + setEditorWidth(Math.max(20, Math.min(80, percent))); + }; + const onMouseUp = () => { + dragging.current = false; + }; + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, []); + return ( -
-
-

LabelStudio Config Editor

- setConfig(value)} - options={{ mode: "xml", lineNumbers: true }} - border - controlled - /> +
+ {/* Minimal top bar */} +
+ LabelStudio Playground
-
-

Preview

-
- + {/* Editor/Preview split */} +
+ {/* Editor Panel */} +
+
+ setConfig(value)} + options={{ mode: "xml", lineNumbers: true }} + border={false} + controlled + /> +
+
+ {/* Divider */} +
(dragging.current = true)} + role="separator" + aria-orientation="vertical" + tabIndex={-1} + /> + {/* Preview Panel */} +
+
+ +
diff --git a/web/apps/playground/src/components/PlaygroundPreview.tsx b/web/apps/playground/src/components/PlaygroundPreview.tsx index 156d4f5b423d..17780efd1d13 100644 --- a/web/apps/playground/src/components/PlaygroundPreview.tsx +++ b/web/apps/playground/src/components/PlaygroundPreview.tsx @@ -45,7 +45,7 @@ export const PlaygroundPreview: FC = ({ config, loading, }, [config, loading, error, interfaces]); return ( -
+
{error ? (
{error}
) : loading ? ( From 4a54d68321ac78ddfda5d05e727d0727aa46b6bd Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Mon, 5 May 2025 17:12:29 -0500 Subject: [PATCH 07/87] fix editor --- web/apps/playground/ARCHITECTURE.mdc | 39 ++++++++++++++----- .../src/components/PlaygroundApp.tsx | 1 - 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/web/apps/playground/ARCHITECTURE.mdc b/web/apps/playground/ARCHITECTURE.mdc index 0c77c5521486..e92e43d9ec88 100644 --- a/web/apps/playground/ARCHITECTURE.mdc +++ b/web/apps/playground/ARCHITECTURE.mdc @@ -13,14 +13,15 @@ The LabelStudio Playground is a standalone, embeddable React application for edi ### 1. `PlaygroundApp` - **Location:** `src/components/PlaygroundApp.tsx` - **Role:** The main application component. Handles: - - State for the XML config, loading, error, and LabelStudio interfaces. - - Parsing URL parameters for config and interface options. - - Fetching and decoding configs from base64 or external URLs. - - Rendering the code editor and preview panels side by side using Tailwind CSS for layout. + - UI rendering and atom wiring only; all state is managed via Jotai atoms. + - Reads and writes config, loading, error, and interfaces state via atoms. + - Uses utility functions for parsing URL parameters and interface options. + - Renders the code editor and preview panels side by side using Tailwind CSS for layout. ### 2. `PlaygroundPreview` - **Location:** `src/components/PlaygroundPreview.tsx` - **Role:** Renders the live LabelStudio preview panel. + - Receives all state as props (from atoms in PlaygroundApp). - Dynamically loads the `@humansignal/editor` package and instantiates a LabelStudio instance with the current config and interface options. - Handles cleanup of the LabelStudio instance on config/interface changes or unmount. - Displays loading and error states using Tailwind classes. @@ -28,20 +29,34 @@ The LabelStudio Playground is a standalone, embeddable React application for edi ### 3. `main.tsx` - **Role:** Entry point. Mounts the `PlaygroundApp` to the DOM. +## State Management +- All application state (config, loading, error, interfaces) is managed using [Jotai](https://jotai.org/) atoms, defined in `src/atoms/configAtoms.ts`. +- Components use the `useAtom` hook to read and write state. +- This ensures a strict separation of state logic from UI, and enables easy extension and testing. + +## Utility Functions +- All logic for parsing URL parameters and interface options is placed in strict utility functions in `src/utils/query.ts`. +- Components import and use these utilities for all non-UI logic. + ## Data Flow -- On load, `PlaygroundApp` parses URL parameters: +- On load, `PlaygroundApp` uses utility functions to parse URL parameters: - `?config` (base64-encoded XML config) - `?configUrl` (URL to fetch XML config) - `?interfaces` (comma-separated list of LabelStudio interface options) -- The config is loaded and stored in React state. -- The code editor is a controlled component, updating the config state on change. +- The config, loading, error, and interfaces state are set via Jotai atoms. +- The code editor is a controlled component, updating the config atom on change. - The preview panel receives the current config and interface options as props and re-renders the LabelStudio instance accordingly. ## Underlying Libraries ### React - The app is built with React (function components, hooks, strict mode). -- State and effects are managed with `useState` and `useEffect`. +- State and effects are managed with Jotai atoms and hooks. + +### Jotai +- Used for all state management (config, loading, error, interfaces). +- Atoms are defined in `src/atoms/configAtoms.ts`. +- Components use `useAtom` for state access and updates. ### Tailwind CSS - All layout, spacing, color, and typography is handled with Tailwind utility classes. @@ -69,6 +84,8 @@ The LabelStudio Playground is a standalone, embeddable React application for edi ## Extensibility - Components are split into single-file-per-component for maintainability, and all live under `src/components/`. +- All state is managed via Jotai atoms in `src/atoms/`. +- All logic is placed in strict utility functions in `src/utils/`. - The code editor and preview logic are decoupled, allowing for future enhancements (e.g., validation, additional preview options, custom data, etc). - The app can be extended to support more URL parameters, additional LabelStudio features, or integration with other HumanSignal libraries. @@ -86,7 +103,11 @@ web/apps/playground/ components/ PlaygroundApp.tsx PlaygroundPreview.tsx + atoms/ + configAtoms.ts + utils/ + query.ts ``` ## Summary -The Playground app is a modern, modular, and embeddable tool for experimenting with and sharing LabelStudio configs. It leverages the HumanSignal UI and editor libraries, is styled with Tailwind, and is designed for easy integration into documentation and external web applications. +The Playground app is a modern, modular, and embeddable tool for experimenting with and sharing LabelStudio configs. It leverages the HumanSignal UI and editor libraries, is styled with Tailwind, uses Jotai for state, and is designed for easy integration into documentation and external web applications. diff --git a/web/apps/playground/src/components/PlaygroundApp.tsx b/web/apps/playground/src/components/PlaygroundApp.tsx index 4591eef2f1aa..2f108f1b12df 100644 --- a/web/apps/playground/src/components/PlaygroundApp.tsx +++ b/web/apps/playground/src/components/PlaygroundApp.tsx @@ -93,7 +93,6 @@ export const PlaygroundApp = () => { onChange={(_editor, _data, value) => setConfig(value)} options={{ mode: "xml", lineNumbers: true }} border={false} - controlled />
From 6609d7ca8a91b578cdf8842efbb7aa4286dfe3d8 Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Tue, 6 May 2025 05:23:56 -0500 Subject: [PATCH 08/87] fix mounting/updating --- .../src/components/PlaygroundPreview.tsx | 45 ++++-- .../src/utils/generateSampleTask.ts | 141 ++++++++++++++++++ 2 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 web/apps/playground/src/utils/generateSampleTask.ts diff --git a/web/apps/playground/src/components/PlaygroundPreview.tsx b/web/apps/playground/src/components/PlaygroundPreview.tsx index 17780efd1d13..6c1e30e5256a 100644 --- a/web/apps/playground/src/components/PlaygroundPreview.tsx +++ b/web/apps/playground/src/components/PlaygroundPreview.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from "react"; import type { FC } from "react"; +import { generateSampleTaskFromConfig } from "../utils/generateSampleTask"; interface PlaygroundPreviewProps { config: string; @@ -11,35 +12,55 @@ interface PlaygroundPreviewProps { export const PlaygroundPreview: FC = ({ config, loading, error, interfaces }) => { const rootRef = useRef(null); const lsfInstance = useRef(null); + const rafId = useRef(null); useEffect(() => { - let isMounted = true; let LabelStudio: any; let dependencies: any; - async function loadLSF() { - dependencies = await import("@humansignal/editor"); - LabelStudio = (window as any).LabelStudio || dependencies.LabelStudio; - if (!LabelStudio || !rootRef.current) return; + function cleanup() { if (lsfInstance.current) { lsfInstance.current.destroy(); lsfInstance.current = null; + if (rootRef.current) { + rootRef.current.innerHTML = ""; + } + } + if (rafId.current !== null) { + cancelAnimationFrame(rafId.current); + rafId.current = null; } + } + + async function loadLSF() { + dependencies = await import("@humansignal/editor"); + LabelStudio = dependencies.LabelStudio; + if (!LabelStudio || !rootRef.current) return; + cleanup(); + const sampleTask = generateSampleTaskFromConfig(config); + const annotations = sampleTask.annotation + ? [{ id: 1, result: [sampleTask.annotation] }] + : [{ id: 1, result: [] }]; lsfInstance.current = new LabelStudio(rootRef.current, { config, - task: { id: 1, data: {}, annotations: [], predictions: [] }, + task: { + id: 1, + data: sampleTask.data, + annotations, + predictions: [], + }, interfaces, }); } + if (!loading && !error && config) { - loadLSF(); + rafId.current = requestAnimationFrame(() => { + loadLSF(); + }); } + return () => { - isMounted = false; - if (lsfInstance.current) { - lsfInstance.current.destroy(); - lsfInstance.current = null; - } + cleanup(); }; // eslint-disable-next-line }, [config, loading, error, interfaces]); diff --git a/web/apps/playground/src/utils/generateSampleTask.ts b/web/apps/playground/src/utils/generateSampleTask.ts new file mode 100644 index 000000000000..b19d3214382d --- /dev/null +++ b/web/apps/playground/src/utils/generateSampleTask.ts @@ -0,0 +1,141 @@ +// Utility to generate a sample task from a Label Studio XML config +export function generateSampleTaskFromConfig(config: string): { + id: number; + data: Record; + annotation?: any; +} { + const parser = new DOMParser(); + let xml: Document; + try { + xml = parser.parseFromString(config, "text/xml"); + } catch (e) { + return { id: 1, data: {} }; + } + + // Try to find a root-level comment with a JSON object + let userData: Record | undefined = undefined; + let userAnnotation: any = undefined; + const root = xml.documentElement; + if (root) { + for (let i = 0; i < root.childNodes.length; i++) { + const node = root.childNodes[i]; + if (node.nodeType === Node.COMMENT_NODE) { + try { + const json = JSON.parse(node.nodeValue || ""); + if (typeof json === "object" && json !== null) { + if (typeof json.data === "object" && json.data !== null) { + userData = json.data; + } + if (typeof json.annotation === "object" && json.annotation !== null) { + userAnnotation = json.annotation; + } + if (!userData && !userAnnotation) { + userData = json; + } + if (userData || userAnnotation) { + break; + } + } + } catch (e) { + // Ignore invalid JSON in comments + } + } + } + } + + // Find all elements with a value attribute that starts with $ + const data: Record = userData ? { ...userData } : {}; + const valueNodes = Array.from(xml.querySelectorAll("[value]")); + + valueNodes.forEach((node) => { + const valueAttr = node.getAttribute("value"); + if (!valueAttr || !valueAttr.startsWith("$") || valueAttr.length < 2) return; + const key = valueAttr.slice(1); + if (data[key] !== undefined) return; // already set + + // Guess sample value based on tag name or valueList + const tag = node.tagName.toLowerCase(); + if (tag === "image" || tag === "hyperimage") { + if (node.hasAttribute("valueList")) { + data[key] = [ + "https://htx-pub.s3.amazonaws.com/demo/images/image1.jpg", + "https://htx-pub.s3.amazonaws.com/demo/images/image2.jpg", + ]; + } else { + data[key] = "https://htx-pub.s3.amazonaws.com/demo/images/image1.jpg"; + } + } else if (tag === "audio") { + data[key] = "https://htx-pub.s3.amazonaws.com/demo/audio/sample1.wav"; + } else if (tag === "video") { + data[key] = "https://htx-pub.s3.amazonaws.com/demo/video/sample1.mp4"; + } else if (tag === "text" || tag === "hypertext") { + data[key] = "Sample text for labeling."; + } else if (tag === "paragraphs") { + data[key] = [{ text: "First paragraph." }, { text: "Second paragraph." }]; + } else if (tag === "timeseries") { + data[key] = [ + { time: 0, value: 1 }, + { time: 1, value: 2 }, + ]; + } else if (tag === "choices" || tag.endsWith("labels")) { + data[key] = ["Option 1", "Option 2"]; + } else if (tag === "taxonomy") { + data[key] = [ + { + value: "Category 1", + children: [{ value: "Subcategory 1.1" }, { value: "Subcategory 1.2" }], + }, + { value: "Category 2" }, + ]; + } else if (tag === "table") { + data[key] = [ + { col1: "Row 1, Col 1", col2: "Row 1, Col 2" }, + { col1: "Row 2, Col 1", col2: "Row 2, Col 2" }, + ]; + } else if (tag === "list") { + data[key] = ["Item 1", "Item 2", "Item 3"]; + } else if (tag === "html") { + data[key] = "Sample HTML content"; + } else if (tag === "rating") { + data[key] = 4; + } else if (tag === "number") { + data[key] = 42; + } else if (tag === "date" || tag === "datetime") { + data[key] = new Date().toISOString(); + } else if (tag === "textarea") { + data[key] = "Sample multiline text."; + } else if (tag === "pairwise") { + data[key] = [ + { id: 1, text: "Option A" }, + { id: 2, text: "Option B" }, + ]; + } else if (tag === "ranker") { + data[key] = [ + { id: 1, text: "Ranked 1" }, + { id: 2, text: "Ranked 2" }, + ]; + } else if (tag === "repeater") { + data[key] = [{ text: "Repeat 1" }, { text: "Repeat 2" }]; + } else { + data[key] = `Sample value for ${key}`; + } + }); + + // Also handle dynamic label lists (e.g., ) + const dynamicLabelNodes = Array.from( + xml.querySelectorAll( + "labels, brushlabels, polygonlabels, keypointlabels, ellipselabels, rectanglelabels, paragraphlabels, hypertextlabels, timeserieslabels", + ), + ); + dynamicLabelNodes.forEach((node) => { + const valueAttr = node.getAttribute("value"); + if (!valueAttr || !valueAttr.startsWith("$") || valueAttr.length < 2) return; + const key = valueAttr.slice(1); + if (data[key] === undefined) { + data[key] = [{ value: "Dynamic Label 1" }, { value: "Dynamic Label 2" }]; + } + }); + + // Return annotation if provided, else undefined + return { id: 1, data, annotation: userAnnotation }; +} From 9b408a695105f4f00e92c3a00e4fce7abf3c3b2a Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Tue, 6 May 2025 06:09:55 -0500 Subject: [PATCH 09/87] fixing code editor --- .../src/components/PlaygroundApp.module.scss | 4 + .../src/components/PlaygroundApp.tsx | 45 +- .../src/components/PlaygroundPreview.tsx | 5 +- web/apps/playground/src/utils/codeEditor.ts | 23 + web/apps/playground/src/utils/schema.json | 2641 +++++++++++++++++ 5 files changed, 2702 insertions(+), 16 deletions(-) create mode 100644 web/apps/playground/src/utils/codeEditor.ts create mode 100644 web/apps/playground/src/utils/schema.json diff --git a/web/apps/playground/src/components/PlaygroundApp.module.scss b/web/apps/playground/src/components/PlaygroundApp.module.scss index d2bce5c188df..f43a7c21cc05 100644 --- a/web/apps/playground/src/components/PlaygroundApp.module.scss +++ b/web/apps/playground/src/components/PlaygroundApp.module.scss @@ -3,6 +3,10 @@ border: none; border-radius: 0; } + + :global(.lsf-tabs-panel__body) { + height: 100%; + } } body { diff --git a/web/apps/playground/src/components/PlaygroundApp.tsx b/web/apps/playground/src/components/PlaygroundApp.tsx index 2f108f1b12df..d9d741a8c309 100644 --- a/web/apps/playground/src/components/PlaygroundApp.tsx +++ b/web/apps/playground/src/components/PlaygroundApp.tsx @@ -2,16 +2,15 @@ import React, { useEffect, useRef, useState } from "react"; import { useAtom } from "jotai"; import { CodeEditor } from "@humansignal/ui"; import { PlaygroundPreview } from "./PlaygroundPreview"; -import { - configAtom, - loadingAtom, - errorAtom, - interfacesAtom, -} from "../atoms/configAtoms"; +import { configAtom, loadingAtom, errorAtom, interfacesAtom } from "../atoms/configAtoms"; import { getQueryParams, getInterfacesFromParams } from "../utils/query"; +import { completeAfter, completeIfInTag } from "../utils/codeEditor"; +import tags from "../utils/schema.json"; + import { cnm } from "@humansignal/shad/utils"; import styles from "./PlaygroundApp.module.scss"; + export const PlaygroundApp = () => { const [config, setConfig] = useAtom(configAtom); const [loading, setLoading] = useAtom(loadingAtom); @@ -19,6 +18,7 @@ export const PlaygroundApp = () => { const [interfaces, setInterfaces] = useAtom(interfacesAtom); const [editorWidth, setEditorWidth] = useState(50); // percent const dragging = useRef(false); + const editorRef = useRef(null); useEffect(() => { const params = getQueryParams(); @@ -30,6 +30,7 @@ export const PlaygroundApp = () => { if (configParam) { try { const decoded = atob(configParam); + setConfig(decoded); } catch (e) { setError("Failed to decode base64 config. Are you sure it's a valid base64 string?"); @@ -73,9 +74,11 @@ export const PlaygroundApp = () => { }, []); return ( -
+
{/* Minimal top bar */}
LabelStudio Playground @@ -83,16 +86,30 @@ export const PlaygroundApp = () => { {/* Editor/Preview split */}
{/* Editor Panel */} -
+
setConfig(value)} - options={{ mode: "xml", lineNumbers: true }} border={false} + // @ts-ignore + autoCloseTags + smartIndent + detach + extensions={["hint", "xml-hint"]} + options={{ + mode: "xml", + theme: "default", + lineNumbers: true, + extraKeys: { + "'<'": completeAfter, + "' '": completeIfInTag, + "'='": completeIfInTag, + "Ctrl-Space": "autocomplete", + }, + hintOptions: { schemaInfo: tags }, + }} />
diff --git a/web/apps/playground/src/components/PlaygroundPreview.tsx b/web/apps/playground/src/components/PlaygroundPreview.tsx index 6c1e30e5256a..b76a4e490f6e 100644 --- a/web/apps/playground/src/components/PlaygroundPreview.tsx +++ b/web/apps/playground/src/components/PlaygroundPreview.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react"; +import { unmountComponentAtNode } from "react-dom"; import type { FC } from "react"; import { generateSampleTaskFromConfig } from "../utils/generateSampleTask"; @@ -23,7 +24,7 @@ export const PlaygroundPreview: FC = ({ config, loading, lsfInstance.current.destroy(); lsfInstance.current = null; if (rootRef.current) { - rootRef.current.innerHTML = ""; + unmountComponentAtNode(rootRef.current); } } if (rafId.current !== null) { @@ -66,7 +67,7 @@ export const PlaygroundPreview: FC = ({ config, loading, }, [config, loading, error, interfaces]); return ( -
+
{error ? (
{error}
) : loading ? ( diff --git a/web/apps/playground/src/utils/codeEditor.ts b/web/apps/playground/src/utils/codeEditor.ts new file mode 100644 index 000000000000..5daf0acdda26 --- /dev/null +++ b/web/apps/playground/src/utils/codeEditor.ts @@ -0,0 +1,23 @@ +// @ts-ignore +import CM from "codemirror"; + +export function completeAfter(cm: CM.Editor, pred: () => boolean) { + if (!pred || pred()) { + setTimeout(() => { + if (!cm.state.completionActive) cm.showHint({ completeSingle: false }); + }, 100); + } + return CM.Pass; +} + +export function completeIfInTag(cm: CM.Editor) { + return completeAfter(cm, () => { + const token = cm.getTokenAt(cm.getCursor()); + + if (token.type === "string" && (!/['"]$/.test(token.string) || token.string.length === 1)) return false; + + const inner = CM.innerMode(cm.getMode(), token.state).state; + + return inner.tagName; + }); +} diff --git a/web/apps/playground/src/utils/schema.json b/web/apps/playground/src/utils/schema.json new file mode 100644 index 000000000000..c97aca01a020 --- /dev/null +++ b/web/apps/playground/src/utils/schema.json @@ -0,0 +1,2641 @@ +{ + "Audio": { + "name": "Audio", + "description": "The Audio tag plays audio and shows its waveform. Use for audio annotation tasks where you want to label regions of audio, see the waveform, and manipulate audio during annotation.\n\nUse with the following data types: audio", + "attrs": { + "name": { + "name": "name", + "description": "Name of the element", + "type": "string", + "required": true + }, + "value": { + "name": "value", + "description": "Data field containing path or a URL to the audio.", + "type": "string", + "required": true + }, + "defaultspeed": { + "name": "defaultspeed", + "description": "Default speed level (from 0.5 to 2).", + "type": "string", + "required": false, + "default": 1 + }, + "defaultscale": { + "name": "defaultscale", + "description": "Audio pane default y-scale for waveform.", + "type": "string", + "required": false, + "default": 1 + }, + "defaultzoom": { + "name": "defaultzoom", + "description": "Default zoom level for waveform. (from 1 to 1500).", + "type": "string", + "required": false, + "default": 1 + }, + "defaultvolume": { + "name": "defaultvolume", + "description": "Default volume level (from 0 to 1).", + "type": "string", + "required": false, + "default": 1 + }, + "hotkey": { + "name": "hotkey", + "description": "Hotkey used to play or pause audio.", + "type": "string", + "required": false + }, + "sync": { + "name": "sync", + "description": "Object name to sync with.", + "type": "string", + "required": false + }, + "height": { + "name": "height", + "description": "Total height of the audio player.", + "type": "string", + "required": false, + "default": 96 + }, + "waveheight": { + "name": "waveheight", + "description": "Minimum height of a waveform when in `splitchannels` mode with multiple channels to display.", + "type": "string", + "required": false, + "default": 32 + }, + "splitchannels": { + "name": "splitchannels", + "description": "Display multiple audio channels separately, if the audio file has more than one channel. (**NOTE: Requires more memory to operate.**)", + "type": ["true", "false"], + "required": false, + "default": false + }, + "decoder": { + "name": "decoder", + "description": "Decoder type to use to decode audio data. (`\"webaudio\"` or `\"ffmpeg\"`)", + "type": "string", + "required": false, + "default": "webaudio" + }, + "player": { + "name": "player", + "description": "Player type to use to play audio data. (`\"html5\"` or `\"webaudio\"`)", + "type": "string", + "required": false, + "default": "html5" + } + } + }, + "HyperText": { + "name": "HyperText", + "description": "The `HyperText` tag displays hypertext markup for labeling. Use for labeling HTML-encoded text and webpages for NER and NLP projects.\n\nUse with the following data types: HTML.", + "attrs": { + "name": { + "name": "name", + "description": "Name of the element", + "type": "string", + "required": true + }, + "value": { + "name": "value", + "description": "Value of the element", + "type": "string", + "required": true + }, + "valueType": { + "name": "valueType", + "description": "Whether the text is stored directly in uploaded data or needs to be loaded from a URL", + "type": ["url", "text"], + "required": false, + "default": "text" + }, + "inline": { + "name": "inline", + "description": "Whether to embed HTML directly in Label Studio or use an iframe", + "type": ["true", "false"], + "required": false, + "default": false + }, + "saveTextResult": { + "name": "saveTextResult", + "description": "Whether to store labeled text along with the results. By default, doesn't store text for `valueType=url`", + "type": ["yes", "no"], + "required": false + }, + "encoding": { + "name": "encoding", + "description": "How to decode values from encoded strings", + "type": ["none", "base64", "base64unicode"], + "required": false + }, + "selectionEnabled": { + "name": "selectionEnabled", + "description": "Enable or disable selection", + "type": ["true", "false"], + "required": false, + "default": true + }, + "clickableLinks": { + "name": "clickableLinks", + "description": "Whether to allow opening resources from links in the hypertext markup.", + "type": ["true", "false"], + "required": false, + "default": false + }, + "highlightColor": { + "name": "highlightColor", + "description": "Hex string with highlight color, if not provided uses the labels color", + "type": "string", + "required": false + }, + "showLabels": { + "name": "showLabels", + "description": "Whether or not to show labels next to the region; unset (by default) — use editor settings; true/false — override settings", + "type": ["true", "false"], + "required": false + }, + "granularity": { + "name": "granularity", + "description": "Control region selection granularity", + "type": ["symbol", "word", "sentence", "paragraph"], + "required": false + } + } + }, + "Image": { + "name": "Image", + "description": "The `Image` tag shows an image on the page. Use for all image annotation tasks to display an image on the labeling interface.\n\nUse with the following data types: images.\n\nWhen you annotate image regions with this tag, the annotations are saved as percentages of the original size of the image, from 0-100.", + "attrs": { + "name": { + "name": "name", + "description": "Name of the element", + "type": "string", + "required": true + }, + "value": { + "name": "value", + "description": "Data field containing a path or URL to the image", + "type": "string", + "required": true + }, + "valueList": { + "name": "valueList", + "description": "References a variable that holds a list of image URLs", + "type": "string", + "required": false + }, + "smoothing": { + "name": "smoothing", + "description": "Enable smoothing, by default it uses user settings", + "type": ["true", "false"], + "required": false + }, + "width": { + "name": "width", + "description": "Image width", + "type": "string", + "required": false, + "default": "100%" + }, + "maxWidth": { + "name": "maxWidth", + "description": "Maximum image width", + "type": "string", + "required": false, + "default": "750px" + }, + "zoom": { + "name": "zoom", + "description": "Enable zooming an image with the mouse wheel", + "type": ["true", "false"], + "required": false, + "default": false + }, + "negativeZoom": { + "name": "negativeZoom", + "description": "Enable zooming out an image", + "type": ["true", "false"], + "required": false, + "default": false + }, + "zoomBy": { + "name": "zoomBy", + "description": "Scale factor", + "type": "float", + "required": false, + "default": 1.1 + }, + "grid": { + "name": "grid", + "description": "Whether to show a grid", + "type": ["true", "false"], + "required": false, + "default": false + }, + "gridSize": { + "name": "gridSize", + "description": "Specify size of the grid", + "type": "number", + "required": false, + "default": 30 + }, + "gridColor": { + "name": "gridColor", + "description": "Color of the grid in hex, opacity is 0.15", + "type": "string", + "required": false, + "default": "#EEEEF4" + }, + "zoomControl": { + "name": "zoomControl", + "description": "Show zoom controls in toolbar", + "type": ["true", "false"], + "required": false, + "default": false + }, + "brightnessControl": { + "name": "brightnessControl", + "description": "Show brightness control in toolbar", + "type": ["true", "false"], + "required": false, + "default": false + }, + "contrastControl": { + "name": "contrastControl", + "description": "Show contrast control in toolbar", + "type": ["true", "false"], + "required": false, + "default": false + }, + "rotateControl": { + "name": "rotateControl", + "description": "Show rotate control in toolbar", + "type": ["true", "false"], + "required": false, + "default": false + }, + "crosshair": { + "name": "crosshair", + "description": "Show crosshair cursor", + "type": ["true", "false"], + "required": false, + "default": false + }, + "horizontalAlignment": { + "name": "horizontalAlignment", + "description": "Where to align image horizontally. Can be one of \"left\", \"center\", or \"right\"", + "type": ["left", "center", "right"], + "required": false, + "default": "left" + }, + "verticalAlignment": { + "name": "verticalAlignment", + "description": "Where to align image vertically. Can be one of \"top\", \"center\", or \"bottom\"", + "type": ["top", "center", "bottom"], + "required": false, + "default": "top" + }, + "defaultZoom": { + "name": "defaultZoom", + "description": "Specify the initial zoom of the image within the viewport while preserving its ratio. Can be one of \"auto\", \"original\", or \"fit\"", + "type": ["auto", "original", "fit"], + "required": false, + "default": "fit" + }, + "crossOrigin": { + "name": "crossOrigin", + "description": "Configures CORS cross domain behavior for this image, either \"none\", \"anonymous\", or \"use-credentials\", similar to [DOM `img` crossOrigin property](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/crossOrigin).", + "type": ["none", "anonymous", "use-credentials"], + "required": false, + "default": "none" + } + } + }, + "List": { + "name": "List", + "description": "The `List` tag is used to display a list of similar items like articles, search results, etc. Task data in the `value` parameter should be an array of objects with `id`, `title`, `body`, and `html` fields.\n\nIt's much more lightweight to use `List` than to group other tags like Text. Also, you can attach classifications to provide additional data about this list.\n\nThe `List` tag can be used with the `Ranker` tag to rank items or pick relevant items from a list.\nItems can be styled in `Style` tag by using `.htx-ranker-item` class.", + "attrs": { + "name": { + "name": "name", + "description": "Name of the element", + "type": "string", + "required": true + }, + "value": { + "name": "value", + "description": "Data field containing a JSON with array of objects (id, title, body) to rank", + "type": "string", + "required": true + }, + "title": { + "name": "title", + "description": "Title of the list", + "type": "string", + "required": false + } + } + }, + "Paragraphs": { + "name": "Paragraphs", + "description": "The `Paragraphs` tag displays paragraphs of text on the labeling interface. Use to label dialogue transcripts for NLP and NER projects.\nThe `Paragraphs` tag expects task data formatted as an array of objects like the following:\n[{ $nameKey: \"Author name\", $textKey: \"Text\" }, ... ]\n\nUse with the following data types: text.", + "attrs": { + "name": { + "name": "name", + "description": "Name of the element", + "type": "string", + "required": true + }, + "value": { + "name": "value", + "description": "Data field containing the paragraph content", + "type": "string", + "required": true + }, + "valueType": { + "name": "valueType", + "description": "Whether the data is stored directly in uploaded JSON data or needs to be loaded from a URL", + "type": ["json", "url"], + "required": false, + "default": "json" + }, + "audioUrl": { + "name": "audioUrl", + "description": "Audio to sync phrases with", + "type": "string", + "required": false + }, + "sync": { + "name": "sync", + "description": "Object name to sync with", + "type": "string", + "required": false + }, + "showPlayer": { + "name": "showPlayer", + "description": "Whether to show audio player above the paragraphs. Ignored if sync object is audio", + "type": ["true", "false"], + "required": false, + "default": false + }, + "saveTextResult": { + "name": "saveTextResult", + "description": "Whether to store labeled text along with the results. By default, doesn't store text for `valueType=url`", + "type": ["no", "yes"], + "required": false, + "default": "yes" + }, + "layout": { + "name": "layout", + "description": "Whether to use a dialogue-style layout or not", + "type": ["none", "dialogue"], + "required": false, + "default": "none" + }, + "nameKey": { + "name": "nameKey", + "description": "The key field to use for name", + "type": "string", + "required": false, + "default": "author" + }, + "textKey": { + "name": "textKey", + "description": "The key field to use for the text", + "type": "string", + "required": false, + "default": "text" + }, + "contextScroll": { + "name": "contextScroll", + "description": "Turn on contextual scroll mode", + "type": ["true", "false"], + "required": false, + "default": false + } + } + }, + "Table": { + "name": "Table", + "description": "The `Table` tag is used to display object keys and values in a table.", + "attrs": { + "value": { + "name": "value", + "description": "Data field value containing JSON type for Table", + "type": "string", + "required": true + }, + "valueType": { + "name": "valueType", + "description": "Value to define the data type in Table", + "type": "string", + "required": false + } + } + }, + "Text": { + "name": "Text", + "description": "The `Text` tag shows text that can be labeled. Use to display any type of text on the labeling interface.\nYou can use `` to preserve all spaces in the text, otherwise spaces are trimmed when displayed and saved in the results.\nEvery space in the text sample is counted when calculating result offsets, for example for NER labeling tasks.\n\nUse with the following data types: text.", + "attrs": { + "name": { + "name": "name", + "description": "Name of the element", + "type": "string", + "required": true + }, + "value": { + "name": "value", + "description": "Data field containing text or a UR", + "type": "string", + "required": true + }, + "valueType": { + "name": "valueType", + "description": "Whether the text is stored directly in uploaded data or needs to be loaded from a URL", + "type": ["url", "text"], + "required": false, + "default": "text" + }, + "saveTextResult": { + "name": "saveTextResult", + "description": "Whether to store labeled text along with the results. By default, doesn't store text for `valueType=url`", + "type": ["yes", "no"], + "required": false + }, + "encoding": { + "name": "encoding", + "description": "How to decode values from encoded strings", + "type": ["none", "base64", "base64unicode"], + "required": false + }, + "selectionEnabled": { + "name": "selectionEnabled", + "description": "Enable or disable selection", + "type": ["true", "false"], + "required": false, + "default": true + }, + "highlightColor": { + "name": "highlightColor", + "description": "Hex string with highlight color, if not provided uses the labels color", + "type": "string", + "required": false + }, + "showLabels": { + "name": "showLabels", + "description": "Whether or not to show labels next to the region; unset (by default) — use editor settings; true/false — override settings", + "type": ["true", "false"], + "required": false + }, + "granularity": { + "name": "granularity", + "description": "Control region selection granularity", + "type": ["symbol", "word", "sentence", "paragraph"], + "required": false + } + } + }, + "TimeSeries": { + "name": "TimeSeries", + "description": "The `TimeSeries` tag can be used to label time series data. Read more about Time Series Labeling on [the time series template page](../templates/time_series.html).\n\nNote: The time axis in your data must be sorted, otherwise the TimeSeries tag does not work.\nTo use autogenerated indices as time axes, don't use the `timeColumn` parameter.\n\nUse with the following data types: time series.", + "attrs": { + "name": { + "name": "name", + "description": "Name of the element", + "type": "string", + "required": true + }, + "value": { + "name": "value", + "description": "Key used to look up the data, either URLs for your time-series if valueType=url, otherwise expects JSON", + "type": "string", + "required": true + }, + "valueType": { + "name": "valueType", + "description": "Format of time series data provided. If set to \"url\" then Label Studio loads value references inside `value` key, otherwise it expects JSON.", + "type": ["url", "json"], + "required": false, + "default": "url" + }, + "timeColumn": { + "name": "timeColumn", + "description": "Column name or index that provides temporal values. If your time series data has no temporal column then one is automatically generated.", + "type": "string", + "required": false + }, + "timeFormat": { + "name": "timeFormat", + "description": "Pattern used to parse values inside timeColumn, parsing is provided by d3, and follows `strftime` implementation", + "type": "string", + "required": false + }, + "timeDisplayFormat": { + "name": "timeDisplayFormat", + "description": "Format used to display temporal value. Can be a number or a date. If a temporal column is a date, use strftime to format it. If it's a number, use [d3 number](https://github.com/d3/d3-format#locale_format) formatting.", + "type": "string", + "required": false + }, + "durationDisplayFormat": { + "name": "durationDisplayFormat", + "description": "Format used to display temporal duration value for brush range. If the temporal column is a date, use strftime to format it. If it's a number, use [d3 number](https://github.com/d3/d3-format#locale_format) formatting.", + "type": "string", + "required": false + }, + "sep": { + "name": "sep", + "description": "Separator for your CSV file.", + "type": "string", + "required": false, + "default": "," + }, + "overviewChannels": { + "name": "overviewChannels", + "description": "Comma-separated list of channel names or indexes displayed in overview.", + "type": "string", + "required": false + }, + "overviewWidth": { + "name": "overviewWidth", + "description": "Default width of overview window in percents", + "type": "string", + "required": false, + "default": "25%" + }, + "fixedScale": { + "name": "fixedScale", + "description": "Whether to scale y-axis to the maximum to fit all the values. If false, current view scales to fit only the displayed values.", + "type": ["true", "false"], + "required": false, + "default": false + } + } + }, + "Channel": { + "name": "Channel", + "description": "Channel tag can be used to label time series data", + "attrs": { + "column": { + "name": "column", + "description": "column name or index", + "type": "string", + "required": true + }, + "legend": { + "name": "legend", + "description": "display name of the channel", + "type": "string", + "required": false + }, + "units": { + "name": "units", + "description": "display units name", + "type": "string", + "required": false + }, + "displayFormat": { + "name": "displayFormat", + "description": "format string for the values, uses d3-format:
\n `[,][.precision][f\\|%]`
\n `,` - group thousands with separator (from locale): `,` (12345.6 -> 12,345.6) `,.2f` (12345.6 -> 12,345.60)
\n `.precision` - precision for `f\\|%` type, significant digits for empty type:
\n `.3f` (12.3456 -> 12.345, 1000 -> 1000.000)
\n `.3` (12.3456 -> 12.3, 1.2345 -> 1.23, 12345 -> 1.23e+4)
\n `f` - treat as float, default precision is .6: `f` (12 -> 12.000000) `.2f` (12 -> 12.00) `.0f` (12.34 -> 12)
\n `%` - treat as percents and format accordingly: `%.0` (0.128 -> 13%) `%.1` (1.2345 -> 123.4%)", + "type": "string", + "required": false + }, + "height": { + "name": "height", + "description": "height of the plot", + "type": "number", + "required": false + }, + "strokeColor": { + "name": "strokeColor", + "description": "plot stroke color, expects hex value", + "type": "string", + "required": false, + "default": "#f48a42" + }, + "strokeWidth": { + "name": "strokeWidth", + "description": "plot stroke width", + "type": "number", + "required": false, + "default": 1 + }, + "markerColor": { + "name": "markerColor", + "description": "plot stroke color, expects hex value", + "type": "string", + "required": false, + "default": "#f48a42" + }, + "markerSize": { + "name": "markerSize", + "description": "plot stroke width", + "type": "number", + "required": false, + "default": 0 + }, + "markerSymbol": { + "name": "markerSymbol", + "description": "plot stroke width", + "type": "number", + "required": false, + "default": "circle" + }, + "timeRange": { + "name": "timeRange", + "description": "data range of x-axis / time axis", + "type": "string", + "required": false + }, + "dataRange": { + "name": "dataRange", + "description": "data range of y-axis / data axis", + "type": "string", + "required": false + }, + "showAxis": { + "name": "showAxis", + "description": "show or bide both axis", + "type": "string", + "required": false + }, + "fixedScale": { + "name": "fixedScale", + "description": "if false current view scales to fit only displayed values; if given overwrites TimeSeries' fixedScale", + "type": ["true", "false"], + "required": false + } + } + }, + "Video": { + "name": "Video", + "description": "Video tag plays a simple video file. Use for video annotation tasks such as classification and transcription.\n\nUse with the following data types: video", + "attrs": { + "name": { + "name": "name", + "description": "Name of the element", + "type": "string", + "required": true + }, + "value": { + "name": "value", + "description": "URL of the video", + "type": "string", + "required": true + }, + "frameRate": { + "name": "frameRate", + "description": "video frame rate per second; default is 24; can use task data like `$fps`", + "type": "number", + "required": false, + "default": 24 + }, + "sync": { + "name": "sync", + "description": "object name to sync with", + "type": "string", + "required": false + }, + "muted": { + "name": "muted", + "description": "muted video", + "type": ["true", "false"], + "required": false, + "default": false + }, + "height": { + "name": "height", + "description": "height of the video player", + "type": "number", + "required": false, + "default": 600 + }, + "timelineHeight": { + "name": "timelineHeight", + "description": "height of the timeline with regions", + "type": "number", + "required": false, + "default": 64 + } + } + }, + "Brush": { + "name": "Brush", + "description": "The `Brush` tag is used for image segmentation tasks where you want to apply a mask or use a brush to draw a region on the image.\n\nUse with the following data types: image.", + "attrs": { + "name": { + "name": "name", + "description": "Name of the element", + "type": "string", + "required": true + }, + "toName": { + "name": "toName", + "description": "Name of the image to label", + "type": "string", + "required": true + }, + "choice": { + "name": "choice", + "description": "Configure whether the data labeler can select one or multiple labels", + "type": ["single", "multiple"], + "required": false, + "default": "single" + }, + "maxUsages": { + "name": "maxUsages", + "description": "Maximum number of times a label can be used per task", + "type": "number", + "required": false + }, + "showInline": { + "name": "showInline", + "description": "Show labels in the same visual line", + "type": ["true", "false"], + "required": false, + "default": true + }, + "smart": { + "name": "smart", + "description": "Show smart tool for interactive pre-annotations", + "type": ["true", "false"], + "required": false + }, + "smartOnly": { + "name": "smartOnly", + "description": "Only show smart tool for interactive pre-annotations", + "type": ["true", "false"], + "required": false + } + } + }, + "BrushLabels": { + "name": "BrushLabels", + "description": "The `BrushLabels` tag for image segmentation tasks is used in the area where you want to apply a mask or use a brush to draw a region on the image.\n\nUse with the following data types: image.", + "attrs": { + "name": { + "name": "name", + "description": "Name of the element", + "type": "string", + "required": true + }, + "toName": { + "name": "toName", + "description": "Name of the image to label", + "type": "string", + "required": true + }, + "choice": { + "name": "choice", + "description": "Configure whether the data labeler can select one or multiple labels", + "type": ["single", "multiple"], + "required": false, + "default": "single" + }, + "maxUsages": { + "name": "maxUsages", + "description": "Maximum number of times a label can be used per task", + "type": "number", + "required": false + }, + "showInline": { + "name": "showInline", + "description": "Show labels in the same visual line", + "type": ["true", "false"], + "required": false, + "default": true + } + } + }, + "Choice": { + "name": "Choice", + "description": "The `Choice` tag represents a single choice for annotations. Use with the `Choices` tag or `Taxonomy` tag to provide specific choice options.", + "attrs": { + "value": { + "name": "value", + "description": "Choice value", + "type": "string", + "required": true + }, + "selected": { + "name": "selected", + "description": "Specify whether to preselect this choice on the labeling interface", + "type": ["true", "false"], + "required": false + }, + "alias": { + "name": "alias", + "description": "Alias for the choice. If used, the alias replaces the choice value in the annotation results. Alias does not display in the interface.", + "type": "string", + "required": false + }, + "style": { + "name": "style", + "description": "CSS style of the checkbox element", + "type": "style", + "required": false + }, + "hotkey": { + "name": "hotkey", + "description": "Hotkey for the selection", + "type": "string", + "required": false + }, + "html": { + "name": "html", + "description": "Can be used to show enriched content, it has higher priority than `value`, however `value` will be used in the exported result (should be properly escaped)", + "type": "string", + "required": false + }, + "hint": { + "name": "hint", + "description": "Hint for choice on hover", + "type": "string", + "required": false + }, + "color": { + "name": "color", + "description": "Color for Taxonomy item", + "type": "string", + "required": false + } + } + }, + "Choices": { + "name": "Choices", + "description": "The `Choices` tag is used to create a group of choices, with radio buttons or checkboxes. It can be used for single or multi-class classification. Also, it is used for advanced classification tasks where annotators can choose one or multiple answers.\n\nChoices can have dynamic value to load labels from task. This task data should contain a list of options to create underlying ``s. All the parameters from options will be transferred to corresponding tags.\n\nThe `Choices` tag can be used with any data types.", + "attrs": { + "name": { + "name": "name", + "description": "Name of the group of choices", + "type": "string", + "required": true + }, + "toName": { + "name": "toName", + "description": "Name of the data item that you want to label", + "type": "string", + "required": true + }, + "choice": { + "name": "choice", + "description": "Single or multi-class classification", + "type": ["single", "single-radio", "multiple"], + "required": false, + "default": "single" + }, + "showInline": { + "name": "showInline", + "description": "Show choices in the same visual line", + "type": ["true", "false"], + "required": false, + "default": false + }, + "required": { + "name": "required", + "description": "Validate whether a choice has been selected", + "type": ["true", "false"], + "required": false, + "default": false + }, + "requiredMessage": { + "name": "requiredMessage", + "description": "Show a message if validation fails", + "type": "string", + "required": false + }, + "visibleWhen": { + "name": "visibleWhen", + "description": "Control visibility of the choices. Can also be used with `when*` attributes below to narrow down visibility", + "type": ["region-selected", "no-region-selected", "choice-selected", "choice-unselected"], + "required": false + }, + "whenTagName": { + "name": "whenTagName", + "description": "Use with visibleWhen. Narrow down visibility by name of the tag. For regions, use the name of the object tag, for choices, use the name of the choices tag", + "type": "string", + "required": false + }, + "whenLabelValue": { + "name": "whenLabelValue", + "description": "Use with visibleWhen=\"region-selected\". Narrow down visibility by label value", + "type": "string", + "required": false + }, + "whenChoiceValue": { + "name": "whenChoiceValue", + "description": "Use with visibleWhen (\"choice-selected\" or \"choice-unselected\") and whenTagName, both are required. Narrow down visibility by choice value", + "type": "string", + "required": false + }, + "perRegion": { + "name": "perRegion", + "description": "Use this tag to select a choice for a specific region instead of the entire task", + "type": ["true", "false"], + "required": false + }, + "perItem": { + "name": "perItem", + "description": "Use this tag to select a choice for a specific item inside the object instead of the whole object", + "type": ["true", "false"], + "required": false + }, + "value": { + "name": "value", + "description": "Task data field containing a list of dynamically loaded choices (see example below)", + "type": "string", + "required": false + }, + "allowNested": { + "name": "allowNested", + "description": "Allow to use `children` field in dynamic choices to nest them. Submitted result will contain array of arrays, every item is a list of values from topmost parent choice down to selected one.", + "type": ["true", "false"], + "required": false + } + } + }, + "DateTime": { + "name": "DateTime", + "description": "The DateTime tag adds date and time selection to the labeling interface. Use this tag to add a date, timestamp, month, or year to an annotation.\n\nUse with the following data types: audio, image, HTML, paragraph, text, time series, video", + "attrs": { + "name": { + "name": "name", + "description": "Name of the element", + "type": "string", + "required": true + }, + "toName": { + "name": "toName", + "description": "Name of the element that you want to label", + "type": "string", + "required": true + }, + "only": { + "name": "only", + "description": "Comma-separated list of parts to display (date, time, month, year)\n date and month/year can't be used together. The date option takes precedence", + "type": "string", + "required": true + }, + "format": { + "name": "format", + "description": "Input/output strftime format for datetime (internally it's always ISO);\n when both date and time are displayed, by default shows ISO with a \"T\" separator;\n when only date is displayed, by default shows ISO date;\n when only time is displayed, by default shows a 24 hour time with leading zero", + "type": "string", + "required": true + }, + "min": { + "name": "min", + "description": "Set a minimum datetime value for only=date in ISO format, or minimum year for only=year", + "type": "string", + "required": false + }, + "max": { + "name": "max", + "description": "Set a maximum datetime value for only=date in ISO format, or maximum year for only=year", + "type": "string", + "required": false + }, + "required": { + "name": "required", + "description": "Whether datetime is required or not", + "type": ["true", "false"], + "required": false, + "default": false + }, + "requiredMessage": { + "name": "requiredMessage", + "description": "Message to show if validation fails", + "type": "string", + "required": false + }, + "perRegion": { + "name": "perRegion", + "description": "Use this option to label regions instead of the whole object", + "type": ["true", "false"], + "required": false + }, + "perItem": { + "name": "perItem", + "description": "Use this option to label items inside the object instead of the whole object", + "type": ["true", "false"], + "required": false + } + } + }, + "Ellipse": { + "name": "Ellipse", + "description": "The `Ellipse` tag is used to add an elliptical bounding box to an image. Use for bounding box image segmentation tasks with ellipses.\n\nUse with the following data types: image.", + "attrs": { + "name": { + "name": "name", + "description": "Name of the element", + "type": "string", + "required": true + }, + "toName": { + "name": "toName", + "description": "Name of the image to label", + "type": "string", + "required": true + }, + "opacity": { + "name": "opacity", + "description": "Opacity of ellipse", + "type": "float", + "required": false, + "default": 0.6 + }, + "fillColor": { + "name": "fillColor", + "description": "Ellipse fill color in hexadecimal", + "type": "string", + "required": false + }, + "strokeColor": { + "name": "strokeColor", + "description": "Stroke color in hexadecimal", + "type": "string", + "required": false, + "default": "#f48a42" + }, + "strokeWidth": { + "name": "strokeWidth", + "description": "Width of the stroke", + "type": "number", + "required": false, + "default": 1 + }, + "canRotate": { + "name": "canRotate", + "description": "Show or hide rotation control", + "type": ["true", "false"], + "required": false, + "default": true + }, + "smart": { + "name": "smart", + "description": "Show smart tool for interactive pre-annotations", + "type": ["true", "false"], + "required": false + }, + "smartOnly": { + "name": "smartOnly", + "description": "Only show smart tool for interactive pre-annotations", + "type": ["true", "false"], + "required": false + } + } + }, + "EllipseLabels": { + "name": "EllipseLabels", + "description": "The `EllipseLabels` tag creates labeled ellipses. Use to apply labels to ellipses for semantic segmentation.\n\nUse with the following data types: image.", + "attrs": { + "name": { + "name": "name", + "description": "Name of the element", + "type": "string", + "required": true + }, + "toName": { + "name": "toName", + "description": "Name of the image to label", + "type": "string", + "required": true + }, + "choice": { + "name": "choice", + "description": "Configure whether you can select one or multiple labels", + "type": ["single", "multiple"], + "required": false, + "default": "single" + }, + "maxUsages": { + "name": "maxUsages", + "description": "Maximum number of times a label can be used per task", + "type": "number", + "required": false + }, + "showInline": { + "name": "showInline", + "description": "Show labels in the same visual line", + "type": ["true", "false"], + "required": false, + "default": true + }, + "opacity": { + "name": "opacity", + "description": "Opacity of ellipse", + "type": "float", + "required": false, + "default": 0.6 + }, + "fillColor": { + "name": "fillColor", + "description": "Ellipse fill color in hexadecimal", + "type": "string", + "required": false + }, + "strokeColor": { + "name": "strokeColor", + "description": "Stroke color in hexadecimal", + "type": "string", + "required": false + }, + "strokeWidth": { + "name": "strokeWidth", + "description": "Width of stroke", + "type": "number", + "required": false, + "default": 1 + }, + "canRotate": { + "name": "canRotate", + "description": "Show or hide rotation option", + "type": ["true", "false"], + "required": false, + "default": true + } + } + }, + "HyperTextLabels": { + "name": "HyperTextLabels", + "description": "The `HyperTextLabels` tag creates labeled hyper text (HTML). Use with the HyperText object tag to annotate HTML text or HTML elements for named entity recognition tasks.\n\nUse with the following data types: HTML.", + "attrs": { + "name": { + "name": "name", + "description": "Name of the element", + "type": "string", + "required": true + }, + "toName": { + "name": "toName", + "description": "Name of the HTML element to label", + "type": "string", + "required": true + }, + "choice": { + "name": "choice", + "description": "Configure if you can select one or multiple labels", + "type": ["single", "multiple"], + "required": false, + "default": "single" + }, + "maxUsages": { + "name": "maxUsages", + "description": "Maximum number of times a label can be used per task", + "type": "number", + "required": false + }, + "showInline": { + "name": "showInline", + "description": "Show labels in the same visual line", + "type": ["true", "false"], + "required": false, + "default": true + } + } + }, + "KeyPoint": { + "name": "KeyPoint", + "description": "The `KeyPoint` tag is used to add a key point to an image without selecting a label. This can be useful when you have only one label to assign to the key point.\n\nUse with the following data types: image.", + "attrs": { + "name": { + "name": "name", + "description": "Name of the element", + "type": "string", + "required": true + }, + "toName": { + "name": "toName", + "description": "Name of the image to label", + "type": "string", + "required": true + }, + "opacity": { + "name": "opacity", + "description": "Opacity of keypoint", + "type": "float", + "required": false, + "default": 0.9 + }, + "fillColor": { + "name": "fillColor", + "description": "Keypoint fill color in hexadecimal", + "type": "string", + "required": false, + "default": "#8bad00" + }, + "strokeWidth": { + "name": "strokeWidth", + "description": "Width of the stroke", + "type": "number", + "required": false, + "default": 1 + }, + "strokeColor": { + "name": "strokeColor", + "description": "Keypoint stroke color in hexadecimal", + "type": "string", + "required": false, + "default": "#8bad00" + }, + "smart": { + "name": "smart", + "description": "Show smart tool for interactive pre-annotations", + "type": ["true", "false"], + "required": false + }, + "smartOnly": { + "name": "smartOnly", + "description": "Only show smart tool for interactive pre-annotations", + "type": ["true", "false"], + "required": false + }, + "snap": { + "name": "snap", + "description": "Snap keypoint to image pixels", + "type": ["pixel", "none"], + "required": false, + "default": "none" + } + } + }, + "KeyPointLabels": { + "name": "KeyPointLabels", + "description": "The `KeyPointLabels` tag creates labeled keypoints. Use to apply labels to identified key points, such as identifying facial features for a facial recognition labeling project.\n\nUse with the following data types: image.", + "attrs": { + "name": { + "name": "name", + "description": "Name of the element", + "type": "string", + "required": true + }, + "toName": { + "name": "toName", + "description": "Name of the image to label", + "type": "string", + "required": true + }, + "choice": { + "name": "choice", + "description": "Configure whether you can select one or multiple labels", + "type": ["single", "multiple"], + "required": false, + "default": "single" + }, + "maxUsages": { + "name": "maxUsages", + "description": "Maximum number of times a label can be used per task", + "type": "number", + "required": false + }, + "showInline": { + "name": "showInline", + "description": "Show labels in the same visual line", + "type": ["true", "false"], + "required": false, + "default": true + }, + "opacity": { + "name": "opacity", + "description": "Opacity of the keypoint", + "type": "float", + "required": false, + "default": 0.9 + }, + "strokeWidth": { + "name": "strokeWidth", + "description": "Width of the stroke", + "type": "number", + "required": false, + "default": 1 + }, + "snap": { + "name": "snap", + "description": "Snap keypoint to image pixels", + "type": ["pixel", "none"], + "required": false, + "default": "none" + } + } + }, + "Label": { + "name": "Label", + "description": "The `Label` tag represents a single label. Use with the `Labels` tag, including `BrushLabels`, `EllipseLabels`, `HyperTextLabels`, `KeyPointLabels`, and other `Labels` tags to specify the value of a specific label.", + "attrs": { + "value": { + "name": "value", + "description": "Value of the label", + "type": "string", + "required": true + }, + "selected": { + "name": "selected", + "description": "Whether to preselect this label", + "type": ["true", "false"], + "required": false, + "default": false + }, + "maxUsages": { + "name": "maxUsages", + "description": "Maximum number of times this label can be used per task", + "type": "number", + "required": false + }, + "hint": { + "name": "hint", + "description": "Hint for label on hover", + "type": "string", + "required": false + }, + "hotkey": { + "name": "hotkey", + "description": "Hotkey to use for the label. Automatically generated if not specified", + "type": "string", + "required": false + }, + "alias": { + "name": "alias", + "description": "Label alias", + "type": "string", + "required": false + }, + "showAlias": { + "name": "showAlias", + "description": "Whether to show alias inside label text", + "type": ["true", "false"], + "required": false, + "default": false + }, + "aliasStyle": { + "name": "aliasStyle", + "description": "CSS style for the alias", + "type": "string", + "required": false, + "default": "opacity:0.6" + }, + "size": { + "name": "size", + "description": "Size of text in the label", + "type": "string", + "required": false, + "default": "medium" + }, + "background": { + "name": "background", + "description": "Background color of an active label in hexadecimal", + "type": "string", + "required": false, + "default": "#36B37E" + }, + "selectedColor": { + "name": "selectedColor", + "description": "Color of text in an active label in hexadecimal", + "type": "string", + "required": false, + "default": "#ffffff" + }, + "granularity": { + "name": "granularity", + "description": "Set control based on symbol or word selection (only for Text)", + "type": ["symbol", "word"], + "required": false + }, + "html": { + "name": "html", + "description": "HTML code is used to display label button instead of raw text provided by `value` (should be properly escaped)", + "type": "string", + "required": false + }, + "category": { + "name": "category", + "description": "Category is used in the export (in label-studio-converter lib) to make an order of labels for YOLO and COCO", + "type": "int", + "required": false + } + } + }, + "Labels": { + "name": "Labels", + "description": "The `Labels` tag provides a set of labels for labeling regions in tasks for machine learning and data science projects. Use the `Labels` tag to create a set of labels that can be assigned to identified region and specify the values of labels to assign to regions.\n\nAll types of Labels can have dynamic value to load labels from task. This task data should contain a list of options to create underlying `
{/* Divider */}
(dragging.current = true)} role="separator" aria-orientation="vertical" diff --git a/web/apps/playground/src/components/PlaygroundPreview.tsx b/web/apps/playground/src/components/PlaygroundPreview.tsx index ddee679c5fdb..cd5851f7e9b2 100644 --- a/web/apps/playground/src/components/PlaygroundPreview.tsx +++ b/web/apps/playground/src/components/PlaygroundPreview.tsx @@ -53,6 +53,7 @@ export const PlaygroundPreview: FC = ({ config, loading, interfaces, settings: { forceBottomPanel: true, + collapsibleBottomPanel: true, }, }); } diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx b/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx index c5323431bc2b..0113bb4f26a0 100644 --- a/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx +++ b/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx @@ -1,4 +1,4 @@ -import { type FC, type MouseEvent as RMouseEvent, useCallback, useMemo, useRef, useState } from "react"; +import { type FC, type MouseEvent as RMouseEvent, useCallback, useMemo, useRef, useState, type ReactNode } from "react"; import { Block, Elem } from "../../../utils/bem"; import { IconChevronLeft, @@ -10,7 +10,7 @@ import { import { useDrag } from "../../../hooks/useDrag"; import { clamp, isDefined } from "../../../utils/utilities"; import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_MIN_HEIGHT, DEFAULT_PANEL_WIDTH, PANEL_HEADER_HEIGHT } from "../constants"; -import { type BaseProps, Side } from "./types"; +import { type BaseProps as OrigBaseProps, Side } from "./types"; import { resizers } from "./utils"; import "./PanelTabsBase.scss"; @@ -18,7 +18,11 @@ const distance = (x1: number, x2: number, y1: number, y2: number) => { return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); }; -export const PanelTabsBase: FC = ({ +interface BasePropsWithChildren extends OrigBaseProps { + children?: ReactNode; +} + +export const PanelTabsBase: FC = ({ name: key, root, width, @@ -50,6 +54,7 @@ export const PanelTabsBase: FC = ({ dragTop, dragBottom, lockPanelContents, + ...props }) => { const headerRef = useRef(); const panelRef = useRef(); @@ -72,6 +77,8 @@ export const PanelTabsBase: FC = ({ const isChildOfGroup = attachedKeys && attachedKeys.includes(key) && attachedKeys[0] !== key; const collapsedHeader = !(collapsed && !isParentOfCollapsedPanel); const tooltipText = visible && !collapsed ? "Collapse" : "Expand"; + const settings = props.currentEntity?.store?.settings || props.currentEntity?.settings; + const [bottomCollapsed, setBottomCollapsed] = useState(false); handlers.current = { onResize, @@ -283,77 +290,96 @@ export const PanelTabsBase: FC = ({ [onVisibilityChange, key, visible], ); - return ( - - - {!locked && collapsedHeader && ( - <> - {isChildOfGroup && visible && ( + return renderPanelTabsBase(); + + function renderPanelTabsBase(): JSX.Element { + return ( + // @ts-expect-error Block is a valid React component + + {/* @ts-expect-error Elem is a valid React component */} + + {!locked && collapsedHeader && ( + <> + {isChildOfGroup && visible && ( + + )} - )} - { - if (collapsed) handleGroupPanelToggle(); - }} - id={key} - mod={{ collapsed }} - name="header" - > - - {!collapsed && } - {!visible && !collapsed && {panelViews.map((view) => view.title).join(" ")}} - - - {(!detached || collapsed) && ( - - {Side.left === alignment ? : } - - )} - {!collapsed && ( - - {visible ? : } - - )} + ref={headerRef} + onClick={() => { + if (collapsed) handleGroupPanelToggle(); + }} + id={key} + mod={{ collapsed }} + name="header" + > + + {!collapsed && } + {!visible && !collapsed && {panelViews.map((view) => view.title).join(" ")}} + + + {(!detached || collapsed) && ( + + {Side.left === alignment ? : } + + )} + {!collapsed && ( + + {visible ? : } + + )} + {settings?.collapsibleBottomPanel && ( + { + e.stopPropagation(); + setBottomCollapsed(v => !v); + }} + data-tooltip={bottomCollapsed ? "Expand Bottom Panel" : "Collapse Bottom Panel"} + > + {bottomCollapsed ? : } + + )} + + + )} + {visible && !collapsed && !bottomCollapsed && ( + + {lockPanelContents && } + {children} - - )} - {visible && !collapsed && ( - - {lockPanelContents && } - {children} + )} + + {visible && !positioning && !locked && ( + + {resizers.map((res) => { + const shouldRender = collapsed + ? false + : ((res === "left" || res === "right") && alignment !== res) || detached; + + return shouldRender ? ( + + ) : null; + })} )} - - {visible && !positioning && !locked && ( - - {resizers.map((res) => { - const shouldRender = collapsed - ? false - : ((res === "left" || res === "right") && alignment !== res) || detached; - - return shouldRender ? ( - - ) : null; - })} - - )} - - ); + + ); + } }; diff --git a/web/libs/editor/src/stores/SettingsStore.js b/web/libs/editor/src/stores/SettingsStore.js index 7b2693dcc492..17fc4ba62716 100644 --- a/web/libs/editor/src/stores/SettingsStore.js +++ b/web/libs/editor/src/stores/SettingsStore.js @@ -41,6 +41,8 @@ const SettingsModel = types forceBottomPanel: types.optional(types.boolean, false), + collapsibleBottomPanel: types.optional(types.boolean, false), + sidePanelMode: types.optional( types.enumeration([SIDEPANEL_MODE_REGIONS, SIDEPANEL_MODE_LABELS]), SIDEPANEL_MODE_REGIONS, From 9414a2ac4968483f5db5a465fdeb8a94eb37bdc6 Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Tue, 6 May 2025 07:20:19 -0500 Subject: [PATCH 14/87] collapse button --- .../SidePanels/TabPanels/PanelTabsBase.tsx | 170 +++++++++--------- .../SidePanels/TabPanels/SideTabsPanels.tsx | 2 +- .../components/SidePanels/TabPanels/Tabs.tsx | 17 +- 3 files changed, 99 insertions(+), 90 deletions(-) diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx b/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx index 0113bb4f26a0..c673b687e97e 100644 --- a/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx +++ b/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx @@ -13,6 +13,7 @@ import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_MIN_HEIGHT, DEFAULT_PANEL_WIDTH, PA import { type BaseProps as OrigBaseProps, Side } from "./types"; import { resizers } from "./utils"; import "./PanelTabsBase.scss"; +import React from "react"; const distance = (x1: number, x2: number, y1: number, y2: number) => { return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); @@ -20,6 +21,7 @@ const distance = (x1: number, x2: number, y1: number, y2: number) => { interface BasePropsWithChildren extends OrigBaseProps { children?: ReactNode; + isBottomPanel?: boolean; } export const PanelTabsBase: FC = ({ @@ -54,6 +56,7 @@ export const PanelTabsBase: FC = ({ dragTop, dragBottom, lockPanelContents, + isBottomPanel, ...props }) => { const headerRef = useRef(); @@ -290,96 +293,89 @@ export const PanelTabsBase: FC = ({ [onVisibilityChange, key, visible], ); - return renderPanelTabsBase(); - - function renderPanelTabsBase(): JSX.Element { - return ( - // @ts-expect-error Block is a valid React component - - {/* @ts-expect-error Elem is a valid React component */} - - {!locked && collapsedHeader && ( - <> - {isChildOfGroup && visible && ( - - )} + return ( + + + {(!locked && collapsedHeader) && ( + <> + {isChildOfGroup && visible && ( { - if (collapsed) handleGroupPanelToggle(); - }} - id={key} - mod={{ collapsed }} - name="header" - > - - {!collapsed && } - {!visible && !collapsed && {panelViews.map((view) => view.title).join(" ")}} - - - {(!detached || collapsed) && ( - - {Side.left === alignment ? : } - - )} - {!collapsed && ( - - {visible ? : } - - )} - {settings?.collapsibleBottomPanel && ( - { - e.stopPropagation(); - setBottomCollapsed(v => !v); - }} - data-tooltip={bottomCollapsed ? "Expand Bottom Panel" : "Collapse Bottom Panel"} - > - {bottomCollapsed ? : } - - )} - + name="grouped-top" + ref={resizeGroup} + mod={{ drag: "grouped-top" === resizing }} + data-resize={"grouped-top"} + /> + )} + { + if (collapsed) handleGroupPanelToggle(); + }} + id={key} + mod={{ collapsed }} + name="header" + > + + {!collapsed && } + {!visible && !collapsed && {panelViews.map((view) => view.title).join(" ")}} + + + {(!detached || collapsed) && ( + + {Side.left === alignment ? : } + + )} + {!collapsed && ( + + {visible ? : } + + )} - - )} - {visible && !collapsed && !bottomCollapsed && ( - - {lockPanelContents && } - {children} - )} - - {visible && !positioning && !locked && ( - - {resizers.map((res) => { - const shouldRender = collapsed - ? false - : ((res === "left" || res === "right") && alignment !== res) || detached; - - return shouldRender ? ( - - ) : null; - })} + + )} + {visible && !collapsed && !bottomCollapsed && ( + + {lockPanelContents && } + {/* Pass collapse/expand state and handlers to Tabs using React.cloneElement */} + {(() => { + const onlyChild = React.Children.only(children); + if (React.isValidElement(onlyChild) && (onlyChild.type as any).displayName === "Tabs") { + return React.cloneElement(onlyChild, { + isBottomPanel: isBottomPanel as boolean, + bottomCollapsed, + setBottomCollapsed, + settings, + } as Partial); + } + return children; + })()} )} - - ); - } + + {visible && !positioning && !locked && ( + + {resizers.map((res) => { + const shouldRender = collapsed + ? false + : ((res === "left" || res === "right") && alignment !== res) || detached; + + return shouldRender ? ( + + ) : null; + })} + + )} + + ); }; diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/SideTabsPanels.tsx b/web/libs/editor/src/components/SidePanels/TabPanels/SideTabsPanels.tsx index 1624248df53c..b3b6a767cffb 100644 --- a/web/libs/editor/src/components/SidePanels/TabPanels/SideTabsPanels.tsx +++ b/web/libs/editor/src/components/SidePanels/TabPanels/SideTabsPanels.tsx @@ -560,7 +560,7 @@ const SideTabsPanelsComponent: FC = ({ {panelsHidden !== true && panelBreakPoint ? ( <> - + diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx b/web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx index 1f68cc0fbe49..a64328382f9c 100644 --- a/web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx +++ b/web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx @@ -1,11 +1,12 @@ import { useRef, useState } from "react"; -import { IconOutlinerDrag } from "@humansignal/ui"; +import { IconOutlinerDrag, IconCollapseSmall, IconExpandSmall } from "@humansignal/ui"; import { useDrag } from "../../../hooks/useDrag"; import { Block, Elem } from "../../../utils/bem"; import { DEFAULT_PANEL_HEIGHT } from "../constants"; import "./Tabs.scss"; import { type BaseProps, Side, type TabProps } from "./types"; import { determineDroppableArea, determineLeftOrRight } from "./utils"; +import { Button } from "libs/editor/src/common/Button/Button"; const classAddedTabs: (Element | undefined)[] = []; @@ -189,7 +190,7 @@ const Tab = ({ ); }; -export const Tabs = (props: BaseProps) => { +export const Tabs = (props: BaseProps & { isBottomPanel?: boolean; bottomCollapsed?: boolean; setBottomCollapsed?: (v: boolean) => void; settings?: any }) => { const ActiveComponent = props.locked ? props.panelViews[props.breakPointActiveTab].component : props.panelViews?.find((view) => view.active)?.component; @@ -229,9 +230,21 @@ export const Tabs = (props: BaseProps) => { ); })} + {props.isBottomPanel && props.settings?.collapsibleBottomPanel && ( + + )} {ActiveComponent && } ); }; + +Tabs.displayName = "Tabs"; From 4003b8ad362084ec48646a1a6745937339447ae7 Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Tue, 6 May 2025 07:26:10 -0500 Subject: [PATCH 15/87] collapse --- .../src/components/SidePanels/TabPanels/PanelTabsBase.tsx | 3 +-- web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx b/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx index c673b687e97e..92c1027beddd 100644 --- a/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx +++ b/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx @@ -344,10 +344,9 @@ export const PanelTabsBase: FC = ({ )} - {visible && !collapsed && !bottomCollapsed && ( + {visible && !collapsed && ( {lockPanelContents && } - {/* Pass collapse/expand state and handlers to Tabs using React.cloneElement */} {(() => { const onlyChild = React.Children.only(children); if (React.isValidElement(onlyChild) && (onlyChild.type as any).displayName === "Tabs") { diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx b/web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx index a64328382f9c..3153f3b3949c 100644 --- a/web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx +++ b/web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx @@ -241,7 +241,9 @@ export const Tabs = (props: BaseProps & { isBottomPanel?: boolean; bottomCollaps )} - {ActiveComponent && } + {!props.bottomCollapsed && ( + {ActiveComponent && } + )} ); From 41e895edfc8a702a8877e01a09ce997bf75da57e Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Tue, 6 May 2025 07:26:55 -0500 Subject: [PATCH 16/87] fix collapse height --- .../components/SidePanels/TabPanels/PanelTabsBase.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx b/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx index 92c1027beddd..eba1dcedd6b4 100644 --- a/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx +++ b/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx @@ -82,6 +82,7 @@ export const PanelTabsBase: FC = ({ const tooltipText = visible && !collapsed ? "Collapse" : "Expand"; const settings = props.currentEntity?.store?.settings || props.currentEntity?.settings; const [bottomCollapsed, setBottomCollapsed] = useState(false); + const TABS_ROW_HEIGHT = 33; // px, should match your CSS handlers.current = { onResize, @@ -96,6 +97,13 @@ export const PanelTabsBase: FC = ({ keyRef.current = key; const style = useMemo(() => { + // If bottom panel and collapsed, only show tabs row + if (isBottomPanel && bottomCollapsed) { + return { + height: `${TABS_ROW_HEIGHT}px`, + zIndex, + }; + } const dynamicStyle = visible ? { height: locked ? DEFAULT_PANEL_HEIGHT : collapsed ? "100%" : (height ?? "100%"), @@ -110,7 +118,7 @@ export const PanelTabsBase: FC = ({ ...dynamicStyle, zIndex, }; - }, [width, height, visible, locked, collapsed, zIndex]); + }, [width, height, visible, locked, collapsed, zIndex, isBottomPanel, bottomCollapsed]); const coordinates = useMemo(() => { return detached && !locked From 9eba7e31bc3a4aa136c8d616e143b7ce18f92661 Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Tue, 6 May 2025 07:32:40 -0500 Subject: [PATCH 17/87] default collapsed --- web/apps/playground/src/components/PlaygroundPreview.tsx | 1 + .../src/components/SidePanels/TabPanels/PanelTabsBase.tsx | 5 ++++- web/libs/editor/src/stores/SettingsStore.js | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/web/apps/playground/src/components/PlaygroundPreview.tsx b/web/apps/playground/src/components/PlaygroundPreview.tsx index cd5851f7e9b2..19fbd9f9c086 100644 --- a/web/apps/playground/src/components/PlaygroundPreview.tsx +++ b/web/apps/playground/src/components/PlaygroundPreview.tsx @@ -54,6 +54,7 @@ export const PlaygroundPreview: FC = ({ config, loading, settings: { forceBottomPanel: true, collapsibleBottomPanel: true, + defaultCollapsedBottomPanel: true, }, }); } diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx b/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx index eba1dcedd6b4..81daa98e283e 100644 --- a/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx +++ b/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx @@ -81,7 +81,10 @@ export const PanelTabsBase: FC = ({ const collapsedHeader = !(collapsed && !isParentOfCollapsedPanel); const tooltipText = visible && !collapsed ? "Collapse" : "Expand"; const settings = props.currentEntity?.store?.settings || props.currentEntity?.settings; - const [bottomCollapsed, setBottomCollapsed] = useState(false); + const [bottomCollapsed, setBottomCollapsed] = useState(() => { + if (isBottomPanel && settings?.defaultCollapsedBottomPanel) return true; + return false; + }); const TABS_ROW_HEIGHT = 33; // px, should match your CSS handlers.current = { diff --git a/web/libs/editor/src/stores/SettingsStore.js b/web/libs/editor/src/stores/SettingsStore.js index 17fc4ba62716..03963ad98b4f 100644 --- a/web/libs/editor/src/stores/SettingsStore.js +++ b/web/libs/editor/src/stores/SettingsStore.js @@ -43,6 +43,8 @@ const SettingsModel = types collapsibleBottomPanel: types.optional(types.boolean, false), + defaultCollapsedBottomPanel: types.optional(types.boolean, false), + sidePanelMode: types.optional( types.enumeration([SIDEPANEL_MODE_REGIONS, SIDEPANEL_MODE_LABELS]), SIDEPANEL_MODE_REGIONS, From b8cae89feb8d0c5f55fe2412e6178eafc5a53a56 Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Tue, 6 May 2025 16:31:32 -0500 Subject: [PATCH 18/87] fix border --- web/apps/playground/src/components/PlaygroundApp.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/apps/playground/src/components/PlaygroundApp.tsx b/web/apps/playground/src/components/PlaygroundApp.tsx index 4ae7a744da95..36b275d3fa83 100644 --- a/web/apps/playground/src/components/PlaygroundApp.tsx +++ b/web/apps/playground/src/components/PlaygroundApp.tsx @@ -80,11 +80,11 @@ export const PlaygroundApp = () => { })} > {/* Minimal top bar */} -
+
LabelStudio Playground
{/* Editor/Preview split */} -
+
{/* Editor Panel */}
From d1e78cf00366fa67a411bbf734a99f2b4b58750f Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Wed, 7 May 2025 07:12:45 -0500 Subject: [PATCH 19/87] panel resize --- .../src/components/PlaygroundApp.tsx | 5 ++ .../components/SidePanels/TabPanels/Tabs.tsx | 61 ++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/web/apps/playground/src/components/PlaygroundApp.tsx b/web/apps/playground/src/components/PlaygroundApp.tsx index 36b275d3fa83..a9ea767d26e1 100644 --- a/web/apps/playground/src/components/PlaygroundApp.tsx +++ b/web/apps/playground/src/components/PlaygroundApp.tsx @@ -73,6 +73,10 @@ export const PlaygroundApp = () => { }; }, []); + const handleDividerDoubleClick = () => { + setEditorWidth(50); // Reset to 50/50 split + }; + return (
{
(dragging.current = true)} + onDoubleClick={handleDividerDoubleClick} role="separator" aria-orientation="vertical" tabIndex={-1} diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx b/web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx index 3153f3b3949c..9de59fa24475 100644 --- a/web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx +++ b/web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useRef, useState, useEffect } from "react"; import { IconOutlinerDrag, IconCollapseSmall, IconExpandSmall } from "@humansignal/ui"; import { useDrag } from "../../../hooks/useDrag"; import { Block, Elem } from "../../../utils/bem"; @@ -195,9 +195,64 @@ export const Tabs = (props: BaseProps & { isBottomPanel?: boolean; bottomCollaps ? props.panelViews[props.breakPointActiveTab].component : props.panelViews?.find((view) => view.active)?.component; + const [panelHeight, setPanelHeight] = useState(300); // Default height in pixels + const dragging = useRef(false); + const startY = useRef(0); + const startHeight = useRef(0); + const DEFAULT_HEIGHT = 300; // Default height in pixels + const MIN_HEIGHT = 100; // Minimum height in pixels + const MAX_HEIGHT = 800; // Maximum height in pixels + + useEffect(() => { + const onMouseMove = (e: MouseEvent) => { + if (!dragging.current) return; + const deltaY = e.clientY - startY.current; + const newHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, startHeight.current - deltaY)); + setPanelHeight(newHeight); + }; + const onMouseUp = () => { + dragging.current = false; + }; + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, []); + + const handleDividerDoubleClick = () => { + setPanelHeight(DEFAULT_HEIGHT); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragging.current = true; + startY.current = e.clientY; + startHeight.current = panelHeight; + }; + return ( <> + {!props.bottomCollapsed && props.isBottomPanel && props.settings?.collapsibleBottomPanel && ( +
+ )} {props.panelViews.map((view, index) => { const { component: Component } = view; @@ -242,7 +297,9 @@ export const Tabs = (props: BaseProps & { isBottomPanel?: boolean; bottomCollaps )} {!props.bottomCollapsed && ( - {ActiveComponent && } + + {ActiveComponent && } + )} From 98af1cd46d7300e7ad1f7aa783ace7dca077fbaa Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Wed, 7 May 2025 07:23:41 -0500 Subject: [PATCH 20/87] fix tw config --- .../src/components/SidePanels/TabPanels/Tabs.tsx | 13 +++---------- web/libs/ui/src/tailwind.config.js | 2 ++ 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx b/web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx index 9de59fa24475..7edc6caf7784 100644 --- a/web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx +++ b/web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx @@ -206,8 +206,8 @@ export const Tabs = (props: BaseProps & { isBottomPanel?: boolean; bottomCollaps useEffect(() => { const onMouseMove = (e: MouseEvent) => { if (!dragging.current) return; - const deltaY = e.clientY - startY.current; - const newHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, startHeight.current - deltaY)); + const deltaY = startY.current - e.clientY; + const newHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, startHeight.current + deltaY)); setPanelHeight(newHeight); }; const onMouseUp = () => { @@ -238,19 +238,12 @@ export const Tabs = (props: BaseProps & { isBottomPanel?: boolean; bottomCollaps {!props.bottomCollapsed && props.isBottomPanel && props.settings?.collapsibleBottomPanel && (
)} diff --git a/web/libs/ui/src/tailwind.config.js b/web/libs/ui/src/tailwind.config.js index 863ed18fb83e..c6f195d1e631 100644 --- a/web/libs/ui/src/tailwind.config.js +++ b/web/libs/ui/src/tailwind.config.js @@ -5,6 +5,8 @@ module.exports = { content: [ "./apps/**/*.{js,jsx,ts,tsx}", "./libs/ui/src/**/*.{js,jsx,ts,tsx}", + "./libs/editor/src/**/*.{js,jsx,ts,tsx}", + "./libs/datamanager/src/**/*.{js,jsx,ts,tsx}", "./libs/core/src/**/*.{js,jsx,ts,tsx}", "./libs/storybook/**/*.{js,jsx,ts,tsx}", ], From 2494677e193d0ce6572e0b07acbaaf089bcfb4eb Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Wed, 7 May 2025 09:42:02 -0500 Subject: [PATCH 21/87] fix bottom panel resizing --- .../src/components/PlaygroundApp.tsx | 1 - .../SidePanels/TabPanels/PanelTabsBase.tsx | 60 ++++++++++++++++++- .../components/SidePanels/TabPanels/Tabs.tsx | 52 +--------------- 3 files changed, 59 insertions(+), 54 deletions(-) diff --git a/web/apps/playground/src/components/PlaygroundApp.tsx b/web/apps/playground/src/components/PlaygroundApp.tsx index a9ea767d26e1..b6aa71197b9a 100644 --- a/web/apps/playground/src/components/PlaygroundApp.tsx +++ b/web/apps/playground/src/components/PlaygroundApp.tsx @@ -10,7 +10,6 @@ import tags from "../utils/schema.json"; import { cnm } from "@humansignal/shad/utils"; import styles from "./PlaygroundApp.module.scss"; - export const PlaygroundApp = () => { const [config, setConfig] = useAtom(configAtom); const [loading, setLoading] = useAtom(loadingAtom); diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx b/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx index 81daa98e283e..51da31c7061e 100644 --- a/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx +++ b/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx @@ -1,4 +1,4 @@ -import { type FC, type MouseEvent as RMouseEvent, useCallback, useMemo, useRef, useState, type ReactNode } from "react"; +import { type FC, type MouseEvent as RMouseEvent, useCallback, useMemo, useRef, useState, type ReactNode, useEffect } from "react"; import { Block, Elem } from "../../../utils/bem"; import { IconChevronLeft, @@ -19,6 +19,10 @@ const distance = (x1: number, x2: number, y1: number, y2: number) => { return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); }; +const TABS_ROW_HEIGHT = 33; // px, should match your CSS +const MIN_HEIGHT = 100; +const MAX_HEIGHT = 800; + interface BasePropsWithChildren extends OrigBaseProps { children?: ReactNode; isBottomPanel?: boolean; @@ -85,7 +89,11 @@ export const PanelTabsBase: FC = ({ if (isBottomPanel && settings?.defaultCollapsedBottomPanel) return true; return false; }); - const TABS_ROW_HEIGHT = 33; // px, should match your CSS + const [panelHeight, setPanelHeight] = useState(DEFAULT_PANEL_HEIGHT); + const dragging = useRef(false); + const startY = useRef(0); + const startHeight = useRef(0); + const collapsibleBottomPanel = settings?.collapsibleBottomPanel ?? false; handlers.current = { onResize, @@ -107,6 +115,12 @@ export const PanelTabsBase: FC = ({ zIndex, }; } + if (isBottomPanel && collapsibleBottomPanel) { + return { + height: `${panelHeight}px`, + zIndex, + }; + } const dynamicStyle = visible ? { height: locked ? DEFAULT_PANEL_HEIGHT : collapsed ? "100%" : (height ?? "100%"), @@ -121,7 +135,7 @@ export const PanelTabsBase: FC = ({ ...dynamicStyle, zIndex, }; - }, [width, height, visible, locked, collapsed, zIndex, isBottomPanel, bottomCollapsed]); + }, [width, height, visible, locked, collapsed, zIndex, isBottomPanel, bottomCollapsed, collapsibleBottomPanel, panelHeight]); const coordinates = useMemo(() => { return detached && !locked @@ -304,8 +318,48 @@ export const PanelTabsBase: FC = ({ [onVisibilityChange, key, visible], ); + useEffect(() => { + const onMouseMove = (e: MouseEvent) => { + if (!dragging.current) return; + const deltaY = startY.current - e.clientY; + const newHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, startHeight.current + deltaY)); + setPanelHeight(newHeight); + }; + const onMouseUp = () => { + dragging.current = false; + }; + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, []); + + const handleDividerDoubleClick = () => { + setPanelHeight(DEFAULT_PANEL_HEIGHT); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragging.current = true; + startY.current = e.clientY; + startHeight.current = panelHeight; + }; + return ( + {isBottomPanel && collapsibleBottomPanel && !bottomCollapsed && ( +
+ )} {(!locked && collapsedHeader) && ( <> diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx b/web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx index 7edc6caf7784..fcb1118e05a2 100644 --- a/web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx +++ b/web/libs/editor/src/components/SidePanels/TabPanels/Tabs.tsx @@ -190,62 +190,14 @@ const Tab = ({ ); }; -export const Tabs = (props: BaseProps & { isBottomPanel?: boolean; bottomCollapsed?: boolean; setBottomCollapsed?: (v: boolean) => void; settings?: any }) => { +export const Tabs = (props: BaseProps & { isBottomPanel?: boolean; bottomCollapsed?: boolean; setBottomCollapsed?: (v: boolean) => void; settings?: any; panelHeight?: number }) => { const ActiveComponent = props.locked ? props.panelViews[props.breakPointActiveTab].component : props.panelViews?.find((view) => view.active)?.component; - const [panelHeight, setPanelHeight] = useState(300); // Default height in pixels - const dragging = useRef(false); - const startY = useRef(0); - const startHeight = useRef(0); - const DEFAULT_HEIGHT = 300; // Default height in pixels - const MIN_HEIGHT = 100; // Minimum height in pixels - const MAX_HEIGHT = 800; // Maximum height in pixels - - useEffect(() => { - const onMouseMove = (e: MouseEvent) => { - if (!dragging.current) return; - const deltaY = startY.current - e.clientY; - const newHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, startHeight.current + deltaY)); - setPanelHeight(newHeight); - }; - const onMouseUp = () => { - dragging.current = false; - }; - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - }; - }, []); - - const handleDividerDoubleClick = () => { - setPanelHeight(DEFAULT_HEIGHT); - }; - - const handleMouseDown = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - dragging.current = true; - startY.current = e.clientY; - startHeight.current = panelHeight; - }; - return ( <> - {!props.bottomCollapsed && props.isBottomPanel && props.settings?.collapsibleBottomPanel && ( -
- )} {props.panelViews.map((view, index) => { const { component: Component } = view; @@ -290,7 +242,7 @@ export const Tabs = (props: BaseProps & { isBottomPanel?: boolean; bottomCollaps )} {!props.bottomCollapsed && ( - + {ActiveComponent && } )} From 4b249df47ded46b2cb3e85d1ff6418859072e0ea Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Wed, 7 May 2025 11:12:35 -0500 Subject: [PATCH 22/87] adding ThemeToggle --- web/apps/playground/src/components/PlaygroundApp.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/apps/playground/src/components/PlaygroundApp.tsx b/web/apps/playground/src/components/PlaygroundApp.tsx index b6aa71197b9a..089b4d508415 100644 --- a/web/apps/playground/src/components/PlaygroundApp.tsx +++ b/web/apps/playground/src/components/PlaygroundApp.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from "react"; import { useAtom } from "jotai"; -import { CodeEditor } from "@humansignal/ui"; +import { CodeEditor, ThemeToggle } from "@humansignal/ui"; import { PlaygroundPreview } from "./PlaygroundPreview"; import { configAtom, loadingAtom, errorAtom, interfacesAtom } from "../atoms/configAtoms"; import { getQueryParams, getInterfacesFromParams } from "../utils/query"; @@ -83,8 +83,9 @@ export const PlaygroundApp = () => { })} > {/* Minimal top bar */} -
+
LabelStudio Playground +
{/* Editor/Preview split */}
From 722aca28effb1c9ab211d94960a679ce9ddeb585 Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Thu, 8 May 2025 08:25:03 -0500 Subject: [PATCH 23/87] improving LSF handling to use React 18 createRoot and improved tear down --- .../src/components/PlaygroundApp.tsx | 112 ++++++++++------- .../src/components/PlaygroundPreview.tsx | 31 +++-- web/libs/editor/src/LabelStudio.tsx | 118 ++++++++++++------ .../editor/src/components/App/Annotation.js | 29 ++++- 4 files changed, 195 insertions(+), 95 deletions(-) diff --git a/web/apps/playground/src/components/PlaygroundApp.tsx b/web/apps/playground/src/components/PlaygroundApp.tsx index 089b4d508415..a456aae70e88 100644 --- a/web/apps/playground/src/components/PlaygroundApp.tsx +++ b/web/apps/playground/src/components/PlaygroundApp.tsx @@ -1,6 +1,6 @@ -import React, { useEffect, useRef, useState } from "react"; -import { useAtom } from "jotai"; -import { CodeEditor, ThemeToggle } from "@humansignal/ui"; +import React, { useEffect, useMemo, useRef, useState, useCallback, memo } from "react"; +import { useAtom, useSetAtom } from "jotai"; +import { CodeEditor, ThemeToggle, Select } from "@humansignal/ui"; import { PlaygroundPreview } from "./PlaygroundPreview"; import { configAtom, loadingAtom, errorAtom, interfacesAtom } from "../atoms/configAtoms"; import { getQueryParams, getInterfacesFromParams } from "../utils/query"; @@ -10,14 +10,67 @@ import tags from "../utils/schema.json"; import { cnm } from "@humansignal/shad/utils"; import styles from "./PlaygroundApp.module.scss"; -export const PlaygroundApp = () => { +const selectOptions = [ + { label: "Light", value: "light" }, + { label: "Dark", value: "dark" }, +]; + +const TopBar = memo(() => { + return ( +
+ LabelStudio Playground +
+ +
+
+ ); +}, () => true); + +const editorExtensions = ["hint", "xml-hint"]; +const editorOptions = { + mode: "xml", + theme: "default", + lineNumbers: true, + extraKeys: { + "'<'": completeAfter, + "' '": completeIfInTag, + "'='": completeIfInTag, + "Ctrl-Space": "autocomplete", + }, + hintOptions: { schemaInfo: tags }, +}; + +const EditorPanel = ({ editorWidth }: { editorWidth: number }) => { const [config, setConfig] = useAtom(configAtom); - const [loading, setLoading] = useAtom(loadingAtom); - const [error, setError] = useAtom(errorAtom); - const [interfaces, setInterfaces] = useAtom(interfacesAtom); + const editorRef = useRef(null); + + return ( +
+
+ setConfig(value)} + border={false} + // @ts-ignore + autoCloseTags + smartIndent + detach + extensions={editorExtensions} + options={editorOptions} + /> +
+
+ ); +}; + +export const PlaygroundApp = () => { + const setConfig = useSetAtom(configAtom); + const setLoading = useSetAtom(loadingAtom); + const setError = useSetAtom(errorAtom); + const setInterfaces = useSetAtom(interfacesAtom); const [editorWidth, setEditorWidth] = useState(50); // percent const dragging = useRef(false); - const editorRef = useRef(null); useEffect(() => { const params = getQueryParams(); @@ -72,9 +125,11 @@ export const PlaygroundApp = () => { }; }, []); - const handleDividerDoubleClick = () => { + const handleDividerDoubleClick = useCallback(() => { setEditorWidth(50); // Reset to 50/50 split - }; + }, [setEditorWidth]); + + const previewPanelStyle = useMemo(() => ({ width: `${100 - editorWidth}%` }), [editorWidth]); return (
{ })} > {/* Minimal top bar */} -
- LabelStudio Playground - -
+ {/* Editor/Preview split */}
{/* Editor Panel */} -
-
- setConfig(value)} - border={false} - // @ts-ignore - autoCloseTags - smartIndent - detach - extensions={["hint", "xml-hint"]} - options={{ - mode: "xml", - theme: "default", - lineNumbers: true, - extraKeys: { - "'<'": completeAfter, - "' '": completeIfInTag, - "'='": completeIfInTag, - "Ctrl-Space": "autocomplete", - }, - hintOptions: { schemaInfo: tags }, - }} - /> -
-
+ {/* Divider */}
{ tabIndex={-1} /> {/* Preview Panel */} -
+
- +
diff --git a/web/apps/playground/src/components/PlaygroundPreview.tsx b/web/apps/playground/src/components/PlaygroundPreview.tsx index 19fbd9f9c086..3644c14396c1 100644 --- a/web/apps/playground/src/components/PlaygroundPreview.tsx +++ b/web/apps/playground/src/components/PlaygroundPreview.tsx @@ -1,16 +1,18 @@ -import { useEffect, useRef } from "react"; +import { memo, useEffect, useRef } from "react"; import { unmountComponentAtNode } from "react-dom"; import type { FC } from "react"; import { generateSampleTaskFromConfig } from "../utils/generateSampleTask"; +import { useAtomValue } from "jotai"; +import { configAtom, errorAtom, loadingAtom, interfacesAtom } from "../atoms/configAtoms"; -interface PlaygroundPreviewProps { - config: string; - loading: boolean; - error: string | null; - interfaces: string[]; -} +type PlaygroundPreviewProps = {}; + +export const PlaygroundPreview: FC = memo(() => { + const config = useAtomValue(configAtom); + const loading = useAtomValue(loadingAtom); + const error = useAtomValue(errorAtom); + const interfaces = useAtomValue(interfacesAtom); -export const PlaygroundPreview: FC = ({ config, loading, error, interfaces }) => { const rootRef = useRef(null); const lsfInstance = useRef(null); const rafId = useRef(null); @@ -20,12 +22,15 @@ export const PlaygroundPreview: FC = ({ config, loading, let dependencies: any; function cleanup() { + if (typeof window !== "undefined" && (window as any).LabelStudio) { + delete (window as any).LabelStudio; + } + if (rootRef.current) { + unmountComponentAtNode(rootRef.current); + } if (lsfInstance.current) { lsfInstance.current.destroy(); lsfInstance.current = null; - if (rootRef.current) { - unmountComponentAtNode(rootRef.current); - } } if (rafId.current !== null) { cancelAnimationFrame(rafId.current); @@ -34,6 +39,7 @@ export const PlaygroundPreview: FC = ({ config, loading, } async function loadLSF() { + console.time("loadLSF"); dependencies = await import("@humansignal/editor"); LabelStudio = dependencies.LabelStudio; if (!LabelStudio || !rootRef.current) return; @@ -57,6 +63,7 @@ export const PlaygroundPreview: FC = ({ config, loading, defaultCollapsedBottomPanel: true, }, }); + console.timeEnd("loadLSF"); } if (!loading && !error && config) { @@ -82,4 +89,4 @@ export const PlaygroundPreview: FC = ({ config, loading, )}
); -}; +}, () => true); diff --git a/web/libs/editor/src/LabelStudio.tsx b/web/libs/editor/src/LabelStudio.tsx index 457264b632b2..9842d2851505 100644 --- a/web/libs/editor/src/LabelStudio.tsx +++ b/web/libs/editor/src/LabelStudio.tsx @@ -1,6 +1,5 @@ import { configure } from "mobx"; -import { destroy } from "mobx-state-tree"; -import { render, unmountComponentAtNode } from "react-dom"; +import { applyAction } from "mobx-state-tree"; import { toCamelCase } from "strman"; import { LabelStudio as LabelStudioReact } from "./Component"; @@ -11,8 +10,6 @@ import { Hotkey } from "./core/Hotkey"; import defaultOptions from "./defaultOptions"; import { destroy as destroySharedStore } from "./mixins/SharedChoiceStore/mixin"; import { EventInvoker } from "./utils/events"; -import { FF_LSDV_4620_3_ML, isFF } from "./utils/feature-flags"; -import { cleanDomAfterReact, findReactKey } from "./utils/reactCleaner"; import { isDefined } from "./utils/utilities"; declare global { @@ -57,6 +54,7 @@ export class LabelStudio { options: Partial; root: Element | string; store: any; + reactRoot: any; destroy: (() => void) | null = () => {}; events = new EventInvoker(); @@ -118,25 +116,17 @@ export class LabelStudio { if (isRendered) { clearRenderedApp(); } - render(, rootElement); + // Create new root for React 18 + this.reactRoot = createRoot(rootElement); + const AppComponent = App as any; + this.reactRoot.render(); }; const clearRenderedApp = () => { - if (!rootElement.childNodes?.length) return; - - const childNodes = [...rootElement.childNodes]; - // cleanDomAfterReact needs this key to be sure that cleaning affects only current react subtree - const reactKey = findReactKey(childNodes[0]); - - unmountComponentAtNode(rootElement); - /* - Unmounting doesn't help with clearing React's fibers - but removing the manually helps - @see https://github.com/facebook/react/pull/20290 (similar problem) - That's maybe not relevant in version 18 - */ - cleanDomAfterReact(childNodes, reactKey); - cleanDomAfterReact([rootElement], reactKey); + if (this.reactRoot) { + this.reactRoot.unmount(); + this.reactRoot = null; + } }; renderApp(); @@ -149,28 +139,80 @@ export class LabelStudio { }); this.destroy = () => { - if (isFF(FF_LSDV_4620_3_ML)) { - clearRenderedApp(); + // Clear any pending timeouts/intervals + if (this.store?.timeouts) { + Object.values(this.store.timeouts).forEach((timeoutId: unknown) => { + if (typeof timeoutId === 'number') { + clearTimeout(timeoutId); + } + }); + } + if (this.store?.intervals) { + Object.values(this.store.intervals).forEach((intervalId: unknown) => { + if (typeof intervalId === 'number') { + clearInterval(intervalId); + } + }); } + + // Remove all event listeners + Object.keys(this.events.events).forEach(eventName => { + this.events.removeAll(eventName); + }); + + // Clear rendered app + clearRenderedApp(); + + // Destroy shared store destroySharedStore(); - if (isFF(FF_LSDV_4620_3_ML)) { - /* - It seems that destroying children separately helps GC to collect garbage - ... - */ - this.store.selfDestroy(); + + // Destroy store and its children using actions + if (this.store) { + try { + // First destroy children to prevent circular references + if (this.store.annotationStore) { + applyAction(this.store, { + name: "destroyAnnotationStore", + path: "/annotationStore", + args: [], + }); + } + if (this.store.relationStore) { + applyAction(this.store, { + name: "destroyRelationStore", + path: "/relationStore", + args: [], + }); + } + if (this.store.settings) { + applyAction(this.store, { + name: "destroySettings", + path: "/settings", + args: [], + }); + } + + // Then destroy the main store + applyAction(this.store, { + name: "destroy", + path: "", + args: [], + }); + } catch (e) { + console.error("Error destroying store:", e); + } } - destroy(this.store); + + // Unbind all hotkeys Hotkey.unbindAll(); - if (isFF(FF_LSDV_4620_3_ML)) { - /* - ... - as well as nulling all these this.store - */ - this.store = null; - this.destroy = null; - LabelStudio.instances.delete(this); - } + + // Clear references + this.store = null; + this.destroy = null; + window.Htx = null; + + // Remove from instances set + LabelStudio.instances.delete(this); }; } diff --git a/web/libs/editor/src/components/App/Annotation.js b/web/libs/editor/src/components/App/Annotation.js index 2cbc4653463a..3f9a020210fc 100644 --- a/web/libs/editor/src/components/App/Annotation.js +++ b/web/libs/editor/src/components/App/Annotation.js @@ -1,14 +1,39 @@ import Tree from "../../core/Tree"; -import { isAlive } from "mobx-state-tree"; +import { isAlive, destroy } from "mobx-state-tree"; import { useLayoutEffect } from "react"; export function Annotation({ annotation, root }) { useLayoutEffect(() => { return () => { if (annotation && isAlive(annotation)) { + // Reset annotation state annotation.resetReady(); + + // Clean up any observers or reactions + if (annotation.disposers) { + annotation.disposers.forEach((disposer) => disposer()); + } + + // Clean up any child regions + if (annotation.regions) { + annotation.regions.forEach((region) => { + if (isAlive(region)) { + destroy(region); + } + }); + } + + // Clean up any relations + if (annotation.relations) { + annotation.relations.forEach((relation) => { + if (isAlive(relation)) { + destroy(relation); + } + }); + } } }; - }, [annotation.pk, annotation.id]); + }, [annotation?.pk, annotation?.id]); + return root ? Tree.renderItem(root, annotation) : null; } From 6a74ccd0b10f389b738c9e6b8f5c23a58e21f3a1 Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Thu, 8 May 2025 08:28:20 -0500 Subject: [PATCH 24/87] fix imports --- web/apps/playground/src/components/PlaygroundApp.tsx | 1 + web/libs/editor/src/LabelStudio.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/web/apps/playground/src/components/PlaygroundApp.tsx b/web/apps/playground/src/components/PlaygroundApp.tsx index a456aae70e88..08c2598775fb 100644 --- a/web/apps/playground/src/components/PlaygroundApp.tsx +++ b/web/apps/playground/src/components/PlaygroundApp.tsx @@ -20,6 +20,7 @@ const TopBar = memo(() => {
LabelStudio Playground
+
@@ -43,10 +38,12 @@ const editorOptions = { hintOptions: { schemaInfo: tags }, }; +const DEFAULT_PANEL_HEIGHT = 300; + const EditorPanel = ({ editorWidth }: { editorWidth: number }) => { const [config, setConfig] = useAtom(configAtom); const editorRef = useRef(null); - const [bottomPanelHeight, setBottomPanelHeight] = useState(180); // Default height for bottom panel + const [bottomPanelHeight, setBottomPanelHeight] = useState(DEFAULT_PANEL_HEIGHT); const [isCollapsed, setIsCollapsed] = useState(false); const minPanelHeight = 120; // Expanded min height const collapsedPanelHeight = 33; // Collapsed height (matches right panel exactly) @@ -77,6 +74,10 @@ const EditorPanel = ({ editorWidth }: { editorWidth: number }) => { document.body.style.cursor = ""; }, []); + const handleDividerDoubleClick = useCallback(() => { + setBottomPanelHeight(DEFAULT_PANEL_HEIGHT); + }, [setBottomPanelHeight]); + React.useEffect(() => { window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); @@ -116,8 +117,9 @@ const EditorPanel = ({ editorWidth }: { editorWidth: number }) => { {/* Divider for resizing (only when not collapsed) */} {!isCollapsed && (
{ {/* Divider */}
(dragging.current = true)} onDoubleClick={handleDividerDoubleClick} role="separator" diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx b/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx index 72b1c2d688ae..88b023cab592 100644 --- a/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx +++ b/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx @@ -353,7 +353,7 @@ export const PanelTabsBase: FC = ({ {isBottomPanel && collapsibleBottomPanel && !bottomCollapsed && (
Date: Mon, 12 May 2025 15:29:33 -0500 Subject: [PATCH 27/87] tabs styles for editor bottom panel to match the playground minimal theme --- .../src/components/PlaygroundApp.module.scss | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/web/apps/playground/src/components/PlaygroundApp.module.scss b/web/apps/playground/src/components/PlaygroundApp.module.scss index f43a7c21cc05..166688e02463 100644 --- a/web/apps/playground/src/components/PlaygroundApp.module.scss +++ b/web/apps/playground/src/components/PlaygroundApp.module.scss @@ -7,6 +7,25 @@ :global(.lsf-tabs-panel__body) { height: 100%; } + + :global(.lsf-panel-tabs__tab) { + color: var(--color-neutral-content-subtler); + } + + :global(.lsf-panel-tabs__tab_active) { + transform: none; + border-width: 0; + color: var(--color-neutral-content); + box-shadow: 1px -1px 0 rgba(var(--color-neutral-shadow-raw) / 4%), -1px -1px 0 rgba(var(--color-neutral-shadow-raw) / 4%); + } + + :global(.lsf-tabs__tabs-row) { + border: none; + } + + :global(.lsf-sidepanels_collapsed .lsf-sidepanels__wrapper .lsf-tabs__contents) { + border: none; + } } body { From d5d09bc0cbc721f4ff8d777f878a07918531a630 Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Mon, 12 May 2025 15:35:37 -0500 Subject: [PATCH 28/87] fix bottom panel height resize --- .../playground/src/components/PlaygroundApp.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/web/apps/playground/src/components/PlaygroundApp.tsx b/web/apps/playground/src/components/PlaygroundApp.tsx index 1239e62cce6a..640c423bae16 100644 --- a/web/apps/playground/src/components/PlaygroundApp.tsx +++ b/web/apps/playground/src/components/PlaygroundApp.tsx @@ -38,15 +38,16 @@ const editorOptions = { hintOptions: { schemaInfo: tags }, }; +const COLLAPSED_PANEL_HEIGHT = 33; const DEFAULT_PANEL_HEIGHT = 300; +const MIN_PANEL_HEIGHT = 100; +const MAX_PANEL_HEIGHT = 800; const EditorPanel = ({ editorWidth }: { editorWidth: number }) => { const [config, setConfig] = useAtom(configAtom); const editorRef = useRef(null); const [bottomPanelHeight, setBottomPanelHeight] = useState(DEFAULT_PANEL_HEIGHT); const [isCollapsed, setIsCollapsed] = useState(false); - const minPanelHeight = 120; // Expanded min height - const collapsedPanelHeight = 33; // Collapsed height (matches right panel exactly) const dragging = useRef(false); const startY = useRef(0); const startHeight = useRef(0); @@ -62,10 +63,8 @@ const EditorPanel = ({ editorWidth }: { editorWidth: number }) => { const handleMouseMove = useCallback((e: MouseEvent) => { if (!dragging.current) return; - let containerHeight = containerRef.current?.offsetHeight || 800; - let maxHeight = Math.max(minPanelHeight, Math.floor(containerHeight * 0.5)); const delta = startY.current - e.clientY; - const newHeight = Math.max(minPanelHeight, Math.min(maxHeight, startHeight.current + delta)); + const newHeight = Math.max(MIN_PANEL_HEIGHT, Math.min(MAX_PANEL_HEIGHT, startHeight.current + delta)); setBottomPanelHeight(newHeight); }, []); @@ -89,12 +88,12 @@ const EditorPanel = ({ editorWidth }: { editorWidth: number }) => { // When collapsing, set height to collapsedPanelHeight React.useEffect(() => { - if (isCollapsed) setBottomPanelHeight(collapsedPanelHeight); + if (isCollapsed) setBottomPanelHeight(COLLAPSED_PANEL_HEIGHT); }, [isCollapsed]); // When expanding, ensure height is at least minPanelHeight React.useEffect(() => { - if (!isCollapsed && bottomPanelHeight < minPanelHeight) setBottomPanelHeight(minPanelHeight); + if (!isCollapsed && bottomPanelHeight < MIN_PANEL_HEIGHT) setBottomPanelHeight(MIN_PANEL_HEIGHT); }, [isCollapsed, bottomPanelHeight]); return ( @@ -126,7 +125,7 @@ const EditorPanel = ({ editorWidth }: { editorWidth: number }) => { /> )} {/* BottomPanel (Input/Output) */} -
+
Date: Mon, 12 May 2025 17:53:54 -0500 Subject: [PATCH 29/87] fix sample generator --- .../src/utils/generateSampleTask.ts | 198 +++++++++++++++--- 1 file changed, 168 insertions(+), 30 deletions(-) diff --git a/web/apps/playground/src/utils/generateSampleTask.ts b/web/apps/playground/src/utils/generateSampleTask.ts index b19d3214382d..009c611c3a45 100644 --- a/web/apps/playground/src/utils/generateSampleTask.ts +++ b/web/apps/playground/src/utils/generateSampleTask.ts @@ -43,42 +43,169 @@ export function generateSampleTaskFromConfig(config: string): { } } - // Find all elements with a value attribute that starts with $ + // Wikimedia Commons public domain sample URLs + const SAMPLE_IMAGE = "https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png"; + const SAMPLE_IMAGE2 = "https://upload.wikimedia.org/wikipedia/commons/3/3a/Cat03.jpg"; + const SAMPLE_AUDIO = + "https://upload.wikimedia.org/wikipedia/commons/9/9d/Bach_-_Cello_Suite_no._1_in_G_major,_BWV_1007_-_I._Pr%C3%A9lude.ogg"; + const SAMPLE_AUDIO2 = "https://upload.wikimedia.org/wikipedia/commons/8/89/Example.ogg"; + const SAMPLE_VIDEO = + "https://upload.wikimedia.org/wikipedia/commons/transcoded/8/88/Big_Buck_Bunny_Trailer_400p.ogv/Big_Buck_Bunny_Trailer_400p.ogv.360p.webm"; + const SAMPLE_VIDEO2 = "https://upload.wikimedia.org/wikipedia/commons/7/75/Big_Buck_Bunny_Trailer_400p.ogv"; + const SAMPLE_PDF = "https://upload.wikimedia.org/wikipedia/commons/3/3f/Fronalpstock_big.pdf"; + const SAMPLE_WEBSITE = "https://www.wikipedia.org/"; + const SAMPLE_CSV = "https://people.sc.fsu.edu/~jburkardt/data/csv/airtravel.csv"; + const SAMPLE_OCR_IMAGE = "https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png"; + + // Find all elements with a value or valueList attribute that starts with $ const data: Record = userData ? { ...userData } : {}; - const valueNodes = Array.from(xml.querySelectorAll("[value]")); + const valueNodes = Array.from(xml.querySelectorAll("[value], [valueList]")); valueNodes.forEach((node) => { - const valueAttr = node.getAttribute("value"); + const valueAttr = node.getAttribute("value") || node.getAttribute("valueList"); if (!valueAttr || !valueAttr.startsWith("$") || valueAttr.length < 2) return; const key = valueAttr.slice(1); if (data[key] !== undefined) return; // already set - // Guess sample value based on tag name or valueList + // Detect valueType="url" or valueList + const valueType = node.getAttribute("valueType") || node.getAttribute("valuetype"); + const onlyUrls = valueType === "url"; + const isValueList = node.hasAttribute("valueList"); const tag = node.tagName.toLowerCase(); + + // Special handling for Paragraphs + if (tag === "paragraphs") { + const nameKey = node.getAttribute("nameKey") || node.getAttribute("namekey") || "author"; + const textKey = node.getAttribute("textKey") || node.getAttribute("textkey") || "text"; + data[key] = [ + { [nameKey]: "Alice", [textKey]: "Sample: Text #1" }, + { [nameKey]: "Bob", [textKey]: "Sample: Text #2" }, + { [nameKey]: "Alice", [textKey]: "Sample: Text #3" }, + { [nameKey]: "Bob", [textKey]: "Sample: Text #4" }, + { [nameKey]: "Alice", [textKey]: "Sample: Text #5" }, + ]; + return; + } + + // Special handling for TimeSeries + if (tag === "timeseries") { + data[key] = [ + { time: 0, value: 1 }, + { time: 1, value: 2 }, + ]; + return; + } + + // Special handling for List + if (tag === "list") { + data[key] = [ + { + id: 1, + title: "Sample: The Amazing World of Opossums", + body: "Opossums are fascinating marsupials native to North America. They have prehensile tails, which help them to climb trees and navigate their surroundings with ease. Additionally, they are known for their unique defense mechanism, called 'playing possum,' where they mimic the appearance and smell of a dead animal to deter predators.", + }, + { + id: 2, + title: "Sample: Opossums: Nature's Pest Control", + body: "Opossums play a crucial role in controlling insect and rodent populations, as they consume a variety of pests like cockroaches, beetles, and mice. This makes them valuable allies for gardeners and homeowners, as they help to maintain a balanced ecosystem and reduce the need for chemical pest control methods.", + }, + { + id: 3, + title: "Sample: Fun Fact: Opossums Are Immune to Snake Venom", + body: "One surprising characteristic of opossums is their natural immunity to snake venom. They have a unique protein in their blood called 'Lethal Toxin-Neutralizing Factor' (LTNF), which neutralizes venom from a variety of snake species, including rattlesnakes and cottonmouths. This allows opossums to prey on snakes without fear of harm, further highlighting their important role in the ecosystem.", + }, + ]; + return; + } + + // Special handling for Table + if (tag === "table") { + data[key] = [{ "Card number": 18799210, "First name": "Sample", "Last name": "Text" }]; + return; + } + + // Special handling for PDF + if (tag === "pdf") { + data[key] = SAMPLE_PDF; + return; + } + + // Special handling for Website/IFrame + if (tag === "website" || tag === "iframe") { + data[key] = SAMPLE_WEBSITE; + return; + } + + // Special handling for CSV + if (tag === "csv") { + data[key] = SAMPLE_CSV; + return; + } + + // Special handling for OCR + if (tag === "ocr") { + data[key] = SAMPLE_OCR_IMAGE; + return; + } + + // Special handling for AudioPlus + if (tag === "audioplus") { + data[key] = SAMPLE_AUDIO2; + return; + } + + // Special handling for longText, corefText, captioning, etc. + if (tag === "longtext") { + data[key] = + "Sample: This is a sample text for long text task. It can be used for text classification, named entity recognition, etc."; + return; + } + if (tag === "coreftext") { + data[key] = "Sample: This is a sample text for coreference resolution and entity linking task."; + return; + } + if (tag === "captioning") { + data[key] = SAMPLE_IMAGE2; + return; + } + + // Special handling for pairText1, pairText2 + if (tag === "pairtext1") { + data[key] = "Sample: Text #1"; + return; + } + if (tag === "pairtext2") { + data[key] = "Sample: Text #2"; + return; + } + + // Special handling for humanMachineDialogue + if (tag === "humanmachinedialogue") { + data[key] = [ + { author: "Human", text: "Sample: Hi, Robot!" }, + { author: "Robot", text: "Sample: Nice to meet you, human! Tell me what you want." }, + { author: "Human", text: "Sample: Order me a pizza from Golden Boy at Green Street " }, + { author: "Robot", text: "Sample: Done. When do you want to get the order?" }, + { author: "Human", text: "Sample: At 3am in the morning, please" }, + ]; + return; + } + + // Main tag-based logic if (tag === "image" || tag === "hyperimage") { - if (node.hasAttribute("valueList")) { - data[key] = [ - "https://htx-pub.s3.amazonaws.com/demo/images/image1.jpg", - "https://htx-pub.s3.amazonaws.com/demo/images/image2.jpg", - ]; + if (isValueList) { + data[key] = [SAMPLE_IMAGE, SAMPLE_IMAGE2]; } else { - data[key] = "https://htx-pub.s3.amazonaws.com/demo/images/image1.jpg"; + data[key] = SAMPLE_IMAGE; } } else if (tag === "audio") { - data[key] = "https://htx-pub.s3.amazonaws.com/demo/audio/sample1.wav"; + data[key] = SAMPLE_AUDIO; } else if (tag === "video") { - data[key] = "https://htx-pub.s3.amazonaws.com/demo/video/sample1.mp4"; + data[key] = SAMPLE_VIDEO; } else if (tag === "text" || tag === "hypertext") { - data[key] = "Sample text for labeling."; - } else if (tag === "paragraphs") { - data[key] = [{ text: "First paragraph." }, { text: "Second paragraph." }]; - } else if (tag === "timeseries") { - data[key] = [ - { time: 0, value: 1 }, - { time: 1, value: 2 }, - ]; + data[key] = "Sample: Your text will go here."; } else if (tag === "choices" || tag.endsWith("labels")) { - data[key] = ["Option 1", "Option 2"]; + data[key] = ["DynamicChoice1", "DynamicChoice2", "DynamicChoice3"]; } else if (tag === "taxonomy") { data[key] = [ { @@ -87,13 +214,6 @@ export function generateSampleTaskFromConfig(config: string): { }, { value: "Category 2" }, ]; - } else if (tag === "table") { - data[key] = [ - { col1: "Row 1, Col 1", col2: "Row 1, Col 2" }, - { col1: "Row 2, Col 1", col2: "Row 2, Col 2" }, - ]; - } else if (tag === "list") { - data[key] = ["Item 1", "Item 2", "Item 3"]; } else if (tag === "html") { data[key] = "Sample HTML content"; } else if (tag === "rating") { @@ -117,7 +237,22 @@ export function generateSampleTaskFromConfig(config: string): { } else if (tag === "repeater") { data[key] = [{ text: "Repeat 1" }, { text: "Repeat 2" }]; } else { - data[key] = `Sample value for ${key}`; + // Patch for valueType="url" + if (onlyUrls) { + if (tag === "text" || tag === "hypertext") { + data[key] = SAMPLE_WEBSITE; + } else if (tag === "image" || tag === "hyperimage") { + data[key] = SAMPLE_IMAGE; + } else if (tag === "audio") { + data[key] = SAMPLE_AUDIO; + } else if (tag === "video") { + data[key] = SAMPLE_VIDEO; + } else { + data[key] = SAMPLE_WEBSITE; + } + } else { + data[key] = `Sample value for ${key}`; + } } }); @@ -132,7 +267,10 @@ export function generateSampleTaskFromConfig(config: string): { if (!valueAttr || !valueAttr.startsWith("$") || valueAttr.length < 2) return; const key = valueAttr.slice(1); if (data[key] === undefined) { - data[key] = [{ value: "Dynamic Label 1" }, { value: "Dynamic Label 2" }]; + data[key] = [ + { value: "DynamicLabel1", background: "#ff0000" }, + { value: "DynamicLabel2", background: "#0000ff" }, + ]; } }); From 1dfa22618286481b34d59b27b2bd012343e2ac29 Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Mon, 12 May 2025 19:48:59 -0500 Subject: [PATCH 30/87] sample data --- web/apps/playground/src/utils/generateSampleTask.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/web/apps/playground/src/utils/generateSampleTask.ts b/web/apps/playground/src/utils/generateSampleTask.ts index 009c611c3a45..f3d819ae6d23 100644 --- a/web/apps/playground/src/utils/generateSampleTask.ts +++ b/web/apps/playground/src/utils/generateSampleTask.ts @@ -48,10 +48,8 @@ export function generateSampleTaskFromConfig(config: string): { const SAMPLE_IMAGE2 = "https://upload.wikimedia.org/wikipedia/commons/3/3a/Cat03.jpg"; const SAMPLE_AUDIO = "https://upload.wikimedia.org/wikipedia/commons/9/9d/Bach_-_Cello_Suite_no._1_in_G_major,_BWV_1007_-_I._Pr%C3%A9lude.ogg"; - const SAMPLE_AUDIO2 = "https://upload.wikimedia.org/wikipedia/commons/8/89/Example.ogg"; const SAMPLE_VIDEO = - "https://upload.wikimedia.org/wikipedia/commons/transcoded/8/88/Big_Buck_Bunny_Trailer_400p.ogv/Big_Buck_Bunny_Trailer_400p.ogv.360p.webm"; - const SAMPLE_VIDEO2 = "https://upload.wikimedia.org/wikipedia/commons/7/75/Big_Buck_Bunny_Trailer_400p.ogv"; + "https://upload.wikimedia.org/wikipedia/commons/transcoded/8/88/Big_Buck_Bunny_alt.webm/Big_Buck_Bunny_alt.webm.360p.vp9.webm"; const SAMPLE_PDF = "https://upload.wikimedia.org/wikipedia/commons/3/3f/Fronalpstock_big.pdf"; const SAMPLE_WEBSITE = "https://www.wikipedia.org/"; const SAMPLE_CSV = "https://people.sc.fsu.edu/~jburkardt/data/csv/airtravel.csv"; @@ -148,12 +146,6 @@ export function generateSampleTaskFromConfig(config: string): { return; } - // Special handling for AudioPlus - if (tag === "audioplus") { - data[key] = SAMPLE_AUDIO2; - return; - } - // Special handling for longText, corefText, captioning, etc. if (tag === "longtext") { data[key] = @@ -198,7 +190,7 @@ export function generateSampleTaskFromConfig(config: string): { } else { data[key] = SAMPLE_IMAGE; } - } else if (tag === "audio") { + } else if (tag === "audio" || tag === "audioplus") { data[key] = SAMPLE_AUDIO; } else if (tag === "video") { data[key] = SAMPLE_VIDEO; From fdb0ef84fb9c22c1767443a66a2d426167cd1e1e Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Mon, 12 May 2025 20:54:22 -0500 Subject: [PATCH 31/87] allow proper sizing of content panel according to resize of bottom panel --- .../src/components/PlaygroundApp.module.scss | 11 +++++++++++ .../SidePanels/TabPanels/PanelTabsBase.tsx | 12 ++++++++++++ .../SidePanels/TabPanels/SideTabsPanels.tsx | 5 +++-- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/web/apps/playground/src/components/PlaygroundApp.module.scss b/web/apps/playground/src/components/PlaygroundApp.module.scss index 166688e02463..a7225869b25c 100644 --- a/web/apps/playground/src/components/PlaygroundApp.module.scss +++ b/web/apps/playground/src/components/PlaygroundApp.module.scss @@ -26,8 +26,19 @@ :global(.lsf-sidepanels_collapsed .lsf-sidepanels__wrapper .lsf-tabs__contents) { border: none; } + + :global(.lsf-sidepanels_collapsed) { + flex: 1; + } + + :global(.lsf-wrapper) { + flex: 1; + display: flex; + flex-direction: column; + } } body { border: 1px solid var(--color-neutral-border); + overflow: hidden; } \ No newline at end of file diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx b/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx index 88b023cab592..48d9cc64d613 100644 --- a/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx +++ b/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.tsx @@ -26,6 +26,7 @@ const MAX_HEIGHT = 800; interface BasePropsWithChildren extends OrigBaseProps { children?: ReactNode; isBottomPanel?: boolean; + contentRef?: React.RefObject; } export const PanelTabsBase: FC = ({ @@ -61,6 +62,7 @@ export const PanelTabsBase: FC = ({ dragBottom, lockPanelContents, isBottomPanel, + contentRef, ...props }) => { const headerRef = useRef(); @@ -138,6 +140,16 @@ export const PanelTabsBase: FC = ({ }; }, [width, height, visible, locked, collapsed, zIndex, isBottomPanel, bottomCollapsed, collapsibleBottomPanel, panelHeight]); + useEffect(() => { + if (contentRef?.current) { + if (isBottomPanel && bottomCollapsed) { + contentRef.current.style.height = `calc(100% - ${TABS_ROW_HEIGHT}px)`; + } else if (isBottomPanel && collapsibleBottomPanel) { + contentRef.current.style.height = `calc(100% - ${panelHeight}px)`; + } + } + }, [panelHeight, isBottomPanel, bottomCollapsed, collapsibleBottomPanel]); + const coordinates = useMemo(() => { return detached && !locked ? { diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/SideTabsPanels.tsx b/web/libs/editor/src/components/SidePanels/TabPanels/SideTabsPanels.tsx index b3b6a767cffb..3f026efc8d64 100644 --- a/web/libs/editor/src/components/SidePanels/TabPanels/SideTabsPanels.tsx +++ b/web/libs/editor/src/components/SidePanels/TabPanels/SideTabsPanels.tsx @@ -76,6 +76,7 @@ const SideTabsPanelsComponent: FC = ({ const localSnap = useRef(snap); const collapsedSideRef = useRef(collapsedSide); const settings = currentEntity?.store?.settings || currentEntity?.settings; + const contentRef = useRef(null); collapsedSideRef.current = collapsedSide; localSnap.current = snap; @@ -554,13 +555,13 @@ const SideTabsPanelsComponent: FC = ({ > {initialized && ( <> - + {children} {panelsHidden !== true && panelBreakPoint ? ( <> - + From 033ec79254841c55830368d9faf7d73664926a22 Mon Sep 17 00:00:00 2001 From: Brandon Martel Date: Mon, 12 May 2025 20:55:09 -0500 Subject: [PATCH 32/87] linting --- .../playground/src/components/BottomPanel.tsx | 12 +- .../src/components/PlaygroundApp.tsx | 42 ++--- .../src/components/PlaygroundPreview.tsx | 161 +++++++++--------- web/apps/playground/src/main.tsx | 1 - web/libs/editor/src/LabelStudio.tsx | 6 +- .../SidePanels/TabPanels/PanelTabsBase.tsx | 26 ++- .../components/SidePanels/TabPanels/Tabs.tsx | 28 ++- 7 files changed, 157 insertions(+), 119 deletions(-) diff --git a/web/apps/playground/src/components/BottomPanel.tsx b/web/apps/playground/src/components/BottomPanel.tsx index f00180d34cc1..ae5b5c8d2ea6 100644 --- a/web/apps/playground/src/components/BottomPanel.tsx +++ b/web/apps/playground/src/components/BottomPanel.tsx @@ -38,13 +38,9 @@ export const BottomPanel = forwardRef(({ isCol style={{ minHeight: HEADER_HEIGHT, maxHeight: HEADER_HEIGHT }} >
-
- Data Input -
+
Data Input
-
- Data Output -
+
Data Output
{/* Floating collapse/expand button */}