diff --git a/.changeset/config.json b/.changeset/config.json index 743a3125b..7cc7da135 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -3,13 +3,7 @@ "changelog": "@changesets/cli/changelog", "commit": false, "fixed": [], - "linked": [ - [ - "react-grab", - "grab", - "@react-grab/cli" - ] - ], + "linked": [["react-grab", "grab", "@react-grab/cli"]], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", diff --git a/AGENTS.md b/AGENTS.md index 26648d8b4..710ab1d27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,9 +31,9 @@ - MUST: Call signals as functions: `count()` not `count`. - MUST: Use functional updates when new state depends on old: `setCount((prev) => prev + 1)`. -- MUST: Keep signals atomic (one per value) — one big state object loses granularity. +- MUST: Keep signals atomic (one per value):one big state object loses granularity. - MUST: Use derived functions `() => count() * 2` for cheap/infrequent derivations. -- MUST: Use `createMemo(() => ...)` for expensive/frequent derivations — caches result. +- MUST: Use `createMemo(() => ...)` for expensive/frequent derivations:caches result. - MUST: Use `createEffect` for side effects only (DOM, localStorage, subscriptions). - MUST: Call `onCleanup(() => ...)` inside effects for subscriptions/intervals/listeners. - MUST: Use path syntax for store updates: `setStore("users", 0, "name", "Jane")`. @@ -44,8 +44,8 @@ - SHOULD: Use `untrack(() => value())` to read without subscribing. - SHOULD: Use `createStore({ ... })` for nested objects with fine-grained reactivity. - SHOULD: Use `produce(draft => { ... })` for complex store mutations. -- NEVER: Derive state via `createEffect(() => setX(y()))` — use memo or derived function. -- NEVER: Place side effects inside `createMemo` — causes infinite loops/crashes. +- NEVER: Derive state via `createEffect(() => setX(y()))`:use memo or derived function. +- NEVER: Place side effects inside `createMemo`:causes infinite loops/crashes. ### Effect Taxonomy @@ -54,10 +54,10 @@ Before writing `createEffect`, classify the work and pick the right primitive: - MUST: Use `createMemo` when the result is pure derived state from other signals/stores. If no external system is touched, it is not an effect. - MUST: Use event handlers and direct action calls when work happens because a user clicked, selected, or navigated. Do not watch a flag/token in an effect to trigger imperative logic. - MUST: Use `onMount`/`onCleanup` for one-time lifecycle setup and teardown (subscriptions, timers, imperative DOM wiring) that should not rerun for reactive changes. -- MUST: Keep `createEffect` single-purpose — one effect, one external bridge. Split mixed-responsibility effects. +- MUST: Keep `createEffect` single-purpose:one effect, one external bridge. Split mixed-responsibility effects. - SHOULD: Use keyed ownership boundaries (keyed ``/``, or keyed `createRoot`) when local state should reset because an identity changed. Do not write a "watch key, clear state" effect. - SHOULD: Normalize state at the write boundary, not via a repair effect that rewrites after the fact. -- NEVER: Use `createEffect` just to copy one store/signal into another — find the single source of truth. +- NEVER: Use `createEffect` just to copy one store/signal into another:find the single source of truth. - NEVER: Use `createEffect` as an event bus (watching a trigger signal to run a command). Call the action directly from the event source. ### Props @@ -67,12 +67,12 @@ Before writing `createEffect`, classify the work and pick the right primitive: - SHOULD: Use `splitProps(props, ["keys"])` to separate local from pass-through props. - SHOULD: Use `mergeProps(defaults, props)` for default values. - SHOULD: Use `children(() => props.children)` only when transforming, otherwise `{props.children}`. -- NEVER: Destructure props `({ title })` — breaks reactivity. +- NEVER: Destructure props `({ title })`:breaks reactivity. ### Control Flow -- MUST: Use `` for object arrays — item is value, index is signal. -- MUST: Use `` for primitives/inputs — item is signal, index is number. +- MUST: Use `` for object arrays:item is value, index is signal. +- MUST: Use `` for primitives/inputs:item is signal, index is number. - MUST: Use `` for async, not ``. - MUST: Access resource states via `data()`, `data.loading`, `data.error`, `data.latest`. - SHOULD: Use `` for conditionals. @@ -80,8 +80,8 @@ Before writing `createEffect`, classify the work and pick the right primitive: - SHOULD: Use `/` for multiple conditions. - SHOULD: Use `createResource(source, fetcher)` for reactive async data. - SHOULD: Use ` ...}>` for render errors. -- NEVER: Use `.map()` in JSX — use `` or ``. -- NEVER: Rely on ErrorBoundary for event handler or setTimeout errors — use try/catch. +- NEVER: Use `.map()` in JSX:use `` or ``. +- NEVER: Rely on ErrorBoundary for event handler or setTimeout errors:use try/catch. ### JSX & DOM @@ -89,7 +89,7 @@ Before writing `createEffect`, classify the work and pick the right primitive: - MUST: Combine static `class="btn"` with reactive `classList={{ active: isActive() }}`. - MUST: Use `onClick` for delegated events; `on:click` for native (element-level). - MUST: Condition inside handler since events are not reactive: `onClick={() => props.onClick?.()}`. -- MUST: Read refs in `onMount` or effects — refs connect after render. +- MUST: Read refs in `onMount` or effects:refs connect after render. - MUST: Call `onCleanup` inside directives for cleanup. - SHOULD: Use `on:click` for `stopPropagation`, capture, passive, or custom events. - SHOULD: Use `style={{ color: color(), "--css-var": value() }}` for inline styles. @@ -120,7 +120,7 @@ This is a pnpm + Turborepo monorepo (19 packages under `packages/`). No external ### Build before test -`pnpm build` must complete before `pnpm test` or `pnpm lint` — Turborepo `dependsOn` enforces this, but be aware that `pnpm test` will rebuild if the build cache is cold. After modifying source files, always rebuild before running tests. +`pnpm build` must complete before `pnpm test` or `pnpm lint`:Turborepo `dependsOn` enforces this, but be aware that `pnpm test` will rebuild if the build cache is cold. After modifying source files, always rebuild before running tests. ### Approved build scripts @@ -136,10 +136,10 @@ See root `package.json` scripts and `CONTRIBUTING.md` for the full list. Quick r - **Install**: `ni` (or `pnpm install`) - **Build**: `nr build` (or `pnpm build`) -- **Dev watch**: `nr dev` (or `pnpm dev`) — watches core packages -- **Test**: `pnpm test` — runs Playwright E2E + Vitest CLI tests -- **Lint**: `pnpm lint` — oxlint on react-grab package -- **Typecheck**: `pnpm typecheck` — tsc on react-grab package -- **Format**: `pnpm format` — oxfmt +- **Dev watch**: `nr dev` (or `pnpm dev`):watches core packages +- **Test**: `pnpm test`:runs Playwright E2E + Vitest CLI tests +- **Lint**: `pnpm lint`:oxlint on react-grab package +- **Typecheck**: `pnpm typecheck`:tsc on react-grab package +- **Format**: `pnpm format`:oxfmt - **CLI dev**: `npm_command=exec node packages/cli/dist/cli.js` - **Test app**: `pnpm --filter @react-grab/e2e-app dev` (port 5175) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b2b16b855..671cf1dc5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,7 +82,7 @@ nr format # Format code with oxfmt - **Use TypeScript interfaces** over types - **Use arrow functions** over function declarations - **Use kebab-case** for file names -- **Use descriptive variable names** — avoid shorthands or 1-2 character names +- **Use descriptive variable names**:avoid shorthands or 1-2 character names - Example: `innerElement` instead of `el` - Example: `didPositionChange` instead of `moved` - **Avoid type casting** (`as`) unless absolutely necessary @@ -113,12 +113,12 @@ git commit -m "feat: add new feature" We use conventional commits: -- `feat:` — New feature -- `fix:` — Bug fix -- `docs:` — Documentation changes -- `chore:` — Maintenance tasks -- `refactor:` — Code refactoring -- `test:` — Test additions or changes +- `feat:`:New feature +- `fix:`:Bug fix +- `docs:`:Documentation changes +- `chore:`:Maintenance tasks +- `refactor:`:Code refactoring +- `test:`:Test additions or changes ### Adding a Changeset diff --git a/README.md b/README.md index 1de0d3160..56495c654 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,118 @@ This copies the element's context (file name, React component, and HTML source c in LoginForm at components/login-form.tsx:46:19 ``` +## Plugins + +Extend React Grab with custom toolbar buttons, context menu actions, lifecycle hooks, and theme overrides via the plugin API. + +```js +import { registerPlugin, unregisterPlugin } from "react-grab"; +``` + +### Toolbar Entries + +Add custom buttons directly to the toolbar. Each entry can be a simple action button or open a dropdown panel: + +```js +registerPlugin({ + name: "my-devtools", + toolbarEntries: [ + { + id: "fps", + icon: '', + tooltip: "FPS Monitor", + // Action-only button (no dropdown), just toggle FPS tracking + onClick: (handle) => { + if (tracking) { + stopTracking(); + handle.setBadge(undefined); + } else { + startTracking((fps) => handle.setBadge(fps)); + } + }, + }, + { + id: "render-monitor", + icon: "🔍", + tooltip: "Render Monitor", + // Dropdown button: onRender receives a raw DOM container + onRender: (container, handle) => { + container.innerHTML = `
+ Renders: 0 + +
`; + + container.querySelector("#clear").addEventListener("click", () => { + handle.setBadge(undefined); + }); + + // Return a cleanup function (called when dropdown closes) + return () => { + /* teardown */ + }; + }, + }, + ], +}); +``` + +The `handle` passed to callbacks provides: + +- `handle.setBadge(value)` / `handle.setIcon(html)` / `handle.setTooltip(text)` to update the button at runtime +- `handle.open()` / `handle.close()` / `handle.toggle()` to control the dropdown +- `handle.api` for full React Grab API access + +### Context Menu Actions + +Add items to the right-click context menu or the toolbar dropdown menu: + +```js +registerPlugin({ + name: "my-plugin", + actions: [ + { + id: "inspect", + label: "Inspect", + shortcut: "I", + onAction: (ctx) => console.dir(ctx.element), + }, + { + id: "toggle-freeze", + label: "Freeze", + target: "toolbar", + isActive: () => isFrozen, + onAction: () => toggleFreeze(), + }, + ], +}); +``` + +### Hooks + +Listen to lifecycle events: + +```js +registerPlugin({ + name: "my-plugin", + hooks: { + onElementSelect: (element) => { + console.log("Selected:", element.tagName); + }, + }, +}); +``` + +In React, register inside a `useEffect` and clean up on unmount: + +```jsx +useEffect(() => { + registerPlugin({ name: "my-plugin" /* ... */ }); + return () => unregisterPlugin("my-plugin"); +}, []); +``` + +See [`packages/react-grab/src/types.ts`](https://github.com/aidenybai/react-grab/blob/main/packages/react-grab/src/types.ts) for the full `Plugin`, `PluginHooks`, `PluginConfig`, `ToolbarEntry`, and `ToolbarEntryHandle` interfaces. + ## Manual Installation If you're using a React framework or build tool, view instructions below: @@ -127,72 +239,6 @@ if (process.env.NODE_ENV === "development") { } ``` -## Plugins - -Use plugins to extend React Grab's built-in UI with context menu actions, toolbar menu items, lifecycle hooks, and theme overrides. Plugins run within React Grab. - -Register a plugin using the `registerPlugin` and `unregisterPlugin` exports: - -```js -import { registerPlugin } from "react-grab"; - -registerPlugin({ - name: "my-plugin", - hooks: { - onElementSelect: (element) => { - console.log("Selected:", element.tagName); - }, - }, -}); -``` - -In React, register inside a `useEffect`: - -```jsx -import { registerPlugin, unregisterPlugin } from "react-grab"; - -useEffect(() => { - registerPlugin({ - name: "my-plugin", - actions: [ - { - id: "my-action", - label: "My Action", - shortcut: "M", - onAction: (context) => { - console.log("Action on:", context.element); - context.hideContextMenu(); - }, - }, - ], - }); - - return () => unregisterPlugin("my-plugin"); -}, []); -``` - -Actions use a `target` field to control where they appear. Omit `target` (or set `"context-menu"`) for the right-click menu, or set `"toolbar"` for the toolbar dropdown: - -```js -actions: [ - { - id: "inspect", - label: "Inspect", - shortcut: "I", - onAction: (ctx) => console.dir(ctx.element), - }, - { - id: "toggle-freeze", - label: "Freeze", - target: "toolbar", - isActive: () => isFrozen, - onAction: () => toggleFreeze(), - }, -]; -``` - -See [`packages/react-grab/src/types.ts`](https://github.com/aidenybai/react-grab/blob/main/packages/react-grab/src/types.ts) for the full `Plugin`, `PluginHooks`, and `PluginConfig` interfaces. - ## Resources & Contributing Back Want to try it out? Check out [our demo](https://react-grab.com). diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 58e81fea0..8557a9bbe 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -4,10 +4,7 @@ import { detectNonInteractive } from "../utils/is-non-interactive.js"; import { detectProject } from "../utils/detect.js"; import { handleError } from "../utils/handle-error.js"; import { highlighter } from "../utils/highlighter.js"; -import { - installMcpServers, - promptMcpInstall, -} from "../utils/install-mcp.js"; +import { installMcpServers, promptMcpInstall } from "../utils/install-mcp.js"; import { logger } from "../utils/logger.js"; import { spinner } from "../utils/spinner.js"; diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index ba0d49a03..d5c2cd383 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -9,9 +9,7 @@ import { applyTransformWithFeedback, installPackagesWithFeedback, } from "../utils/cli-helpers.js"; -import { - promptMcpInstall, -} from "../utils/install-mcp.js"; +import { promptMcpInstall } from "../utils/install-mcp.js"; import { detectProject, findReactProjects, @@ -23,14 +21,10 @@ import { import { printDiff } from "../utils/diff.js"; import { handleError } from "../utils/handle-error.js"; import { highlighter } from "../utils/highlighter.js"; -import { - getPackagesToInstall, -} from "../utils/install.js"; +import { getPackagesToInstall } from "../utils/install.js"; import { logger } from "../utils/logger.js"; import { spinner } from "../utils/spinner.js"; -import { - type AgentIntegration, -} from "../utils/templates.js"; +import { type AgentIntegration } from "../utils/templates.js"; import { previewOptionsTransform, previewPackageJsonTransform, diff --git a/packages/cli/src/utils/detect.ts b/packages/cli/src/utils/detect.ts index d8b2fe1ba..1c2cdb633 100644 --- a/packages/cli/src/utils/detect.ts +++ b/packages/cli/src/utils/detect.ts @@ -417,9 +417,7 @@ export const detectReactGrab = (projectRoot: string): boolean => { return filesToCheck.some(hasReactGrabInFile); }; -const AGENT_PACKAGES = [ - "@react-grab/mcp", -]; +const AGENT_PACKAGES = ["@react-grab/mcp"]; export const detectUnsupportedFramework = ( projectRoot: string, diff --git a/packages/cli/src/utils/install.ts b/packages/cli/src/utils/install.ts index 39bbfd713..44bc90d3e 100644 --- a/packages/cli/src/utils/install.ts +++ b/packages/cli/src/utils/install.ts @@ -44,4 +44,3 @@ export const getPackagesToInstall = ( return packages; }; - diff --git a/packages/cli/src/utils/templates.ts b/packages/cli/src/utils/templates.ts index 8e7214354..d3c0ceef9 100644 --- a/packages/cli/src/utils/templates.ts +++ b/packages/cli/src/utils/templates.ts @@ -55,7 +55,9 @@ export const TANSTACK_EFFECT = `useEffect(() => { } }, []);`; -export const TANSTACK_EFFECT_WITH_AGENT = (_agent: AgentIntegration): string => { +export const TANSTACK_EFFECT_WITH_AGENT = ( + _agent: AgentIntegration, +): string => { return TANSTACK_EFFECT; }; diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts index e8527da37..057b414f3 100644 --- a/packages/cli/test/detect.test.ts +++ b/packages/cli/test/detect.test.ts @@ -340,4 +340,3 @@ describe("detectUnsupportedFramework", () => { expect(detectUnsupportedFramework("/test")).toBe(null); }); }); - diff --git a/packages/cli/test/install.test.ts b/packages/cli/test/install.test.ts index 61950e3bc..38a21358d 100644 --- a/packages/cli/test/install.test.ts +++ b/packages/cli/test/install.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - getPackagesToInstall, -} from "../src/utils/install.js"; +import { getPackagesToInstall } from "../src/utils/install.js"; describe("getPackagesToInstall", () => { it("should return only react-grab when no agent and includeReactGrab is true", () => { diff --git a/packages/cli/test/transform.test.ts b/packages/cli/test/transform.test.ts index ef857c25b..9cd94fb4c 100644 --- a/packages/cli/test/transform.test.ts +++ b/packages/cli/test/transform.test.ts @@ -257,13 +257,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render( ); mockReadFileSync.mockReturnValue(entryContent); - const result = previewTransform( - "/test", - "vite", - "unknown", - "mcp", - false, - ); + const result = previewTransform("/test", "vite", "unknown", "mcp", false); expect(result.success).toBe(true); expect(result.newContent).toContain("react-grab"); diff --git a/packages/grab/README.md b/packages/grab/README.md index 041ec5439..0cb1cca30 100644 --- a/packages/grab/README.md +++ b/packages/grab/README.md @@ -44,6 +44,118 @@ This copies the element's context (file name, React component, and HTML source c in LoginForm at components/login-form.tsx:46:19 ``` +## Plugins + +Extend React Grab with custom toolbar buttons, context menu actions, lifecycle hooks, and theme overrides via the plugin API. + +```js +import { registerPlugin, unregisterPlugin } from "grab"; +``` + +### Toolbar Entries + +Add custom buttons directly to the toolbar. Each entry can be a simple action button or open a dropdown panel: + +```js +registerPlugin({ + name: "my-devtools", + toolbarEntries: [ + { + id: "fps", + icon: '', + tooltip: "FPS Monitor", + // Action-only button (no dropdown), just toggle FPS tracking + onClick: (handle) => { + if (tracking) { + stopTracking(); + handle.setBadge(undefined); + } else { + startTracking((fps) => handle.setBadge(fps)); + } + }, + }, + { + id: "render-monitor", + icon: "🔍", + tooltip: "Render Monitor", + // Dropdown button: onRender receives a raw DOM container + onRender: (container, handle) => { + container.innerHTML = `
+ Renders: 0 + +
`; + + container.querySelector("#clear").addEventListener("click", () => { + handle.setBadge(undefined); + }); + + // Return a cleanup function (called when dropdown closes) + return () => { + /* teardown */ + }; + }, + }, + ], +}); +``` + +The `handle` passed to callbacks provides: + +- `handle.setBadge(value)` / `handle.setIcon(html)` / `handle.setTooltip(text)` to update the button at runtime +- `handle.open()` / `handle.close()` / `handle.toggle()` to control the dropdown +- `handle.api` for full React Grab API access + +### Context Menu Actions + +Add items to the right-click context menu or the toolbar dropdown menu: + +```js +registerPlugin({ + name: "my-plugin", + actions: [ + { + id: "inspect", + label: "Inspect", + shortcut: "I", + onAction: (ctx) => console.dir(ctx.element), + }, + { + id: "toggle-freeze", + label: "Freeze", + target: "toolbar", + isActive: () => isFrozen, + onAction: () => toggleFreeze(), + }, + ], +}); +``` + +### Hooks + +Listen to lifecycle events: + +```js +registerPlugin({ + name: "my-plugin", + hooks: { + onElementSelect: (element) => { + console.log("Selected:", element.tagName); + }, + }, +}); +``` + +In React, register inside a `useEffect` and clean up on unmount: + +```jsx +useEffect(() => { + registerPlugin({ name: "my-plugin" /* ... */ }); + return () => unregisterPlugin("my-plugin"); +}, []); +``` + +See [`packages/react-grab/src/types.ts`](https://github.com/aidenybai/react-grab/blob/main/packages/react-grab/src/types.ts) for the full `Plugin`, `PluginHooks`, `PluginConfig`, `ToolbarEntry`, and `ToolbarEntryHandle` interfaces. + ## Manual Installation If you're using a React framework or build tool, view instructions below: @@ -127,72 +239,6 @@ if (process.env.NODE_ENV === "development") { } ``` -## Plugins - -Use plugins to extend React Grab's built-in UI with context menu actions, toolbar menu items, lifecycle hooks, and theme overrides. Plugins run within React Grab. - -Register a plugin using the `registerPlugin` and `unregisterPlugin` exports: - -```js -import { registerPlugin } from "grab"; - -registerPlugin({ - name: "my-plugin", - hooks: { - onElementSelect: (element) => { - console.log("Selected:", element.tagName); - }, - }, -}); -``` - -In React, register inside a `useEffect`: - -```jsx -import { registerPlugin, unregisterPlugin } from "grab"; - -useEffect(() => { - registerPlugin({ - name: "my-plugin", - actions: [ - { - id: "my-action", - label: "My Action", - shortcut: "M", - onAction: (context) => { - console.log("Action on:", context.element); - context.hideContextMenu(); - }, - }, - ], - }); - - return () => unregisterPlugin("my-plugin"); -}, []); -``` - -Actions use a `target` field to control where they appear. Omit `target` (or set `"context-menu"`) for the right-click menu, or set `"toolbar"` for the toolbar dropdown: - -```js -actions: [ - { - id: "inspect", - label: "Inspect", - shortcut: "I", - onAction: (ctx) => console.dir(ctx.element), - }, - { - id: "toggle-freeze", - label: "Freeze", - target: "toolbar", - isActive: () => isFrozen, - onAction: () => toggleFreeze(), - }, -]; -``` - -See [`packages/react-grab/src/types.ts`](https://github.com/aidenybai/react-grab/blob/main/packages/react-grab/src/types.ts) for the full `Plugin`, `PluginHooks`, and `PluginConfig` interfaces. - ## Resources & Contributing Back Want to try it out? Check out [our demo](https://react-grab.com). diff --git a/packages/gym/app/layout.tsx b/packages/gym/app/layout.tsx index 7d486c0ca..920a5867b 100644 --- a/packages/gym/app/layout.tsx +++ b/packages/gym/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { ThemeProvider } from "@/components/theme-provider"; +import { ToolbarEntriesProvider } from "@/components/toolbar-entries-provider"; import "react-grab"; import "./globals.css"; @@ -35,6 +36,7 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > + {children} diff --git a/packages/gym/app/toolbar-entries/page.tsx b/packages/gym/app/toolbar-entries/page.tsx new file mode 100644 index 000000000..28477e9d7 --- /dev/null +++ b/packages/gym/app/toolbar-entries/page.tsx @@ -0,0 +1,263 @@ +"use client"; + +import { useState, useEffect, useLayoutEffect } from "react"; + +const blockMainThread = (ms: number) => { + const start = performance.now(); + while (performance.now() - start < ms) { + // HACK: intentionally blocking to simulate slow render + } +}; + +const SlowRenderChild = ({ delay }: { delay: number }) => { + blockMainThread(delay); + return ( +
+ Rendered in {delay}ms (blocking) +
+ ); +}; + +const SlowEffectComponent = ({ delay }: { delay: number }) => { + const [count, setCount] = useState(0); + + useEffect(() => { + blockMainThread(delay); + }, [count, delay]); + + return ( +
+ useEffect blocks for {delay}ms + +
+ ); +}; + +const SlowLayoutEffectComponent = ({ delay }: { delay: number }) => { + const [count, setCount] = useState(0); + + useLayoutEffect(() => { + blockMainThread(delay); + }, [count, delay]); + + return ( +
+ useLayoutEffect blocks for {delay}ms + +
+ ); +}; + +const CascadeRenderComponent = () => { + const [count, setCount] = useState(0); + + return ( +
+
+ Cascade render (5 children x 10ms) + +
+
+ {[...Array(5)].map((_, index) => ( + + ))} +
+
+ ); +}; + +const RapidUpdatesComponent = () => { + const [count, setCount] = useState(0); + const [isRunning, setIsRunning] = useState(false); + + useEffect(() => { + if (!isRunning) return; + + let frame = 0; + const maxFrames = 60; + + const tick = () => { + if (frame < maxFrames) { + setCount((previousCount) => previousCount + 1); + blockMainThread(5); + frame++; + requestAnimationFrame(tick); + } else { + setIsRunning(false); + } + }; + + requestAnimationFrame(tick); + }, [isRunning]); + + return ( +
+ Rapid updates (60 frames x 5ms) + +
+ ); +}; + +const ToolbarEntriesPage = () => { + const [showSlowRender, setShowSlowRender] = useState(false); + + return ( +
+

+ Performance Test Zone +

+

+ Intentionally laggy components to exercise the toolbar devtools. Use the{" "} + Render Monitor (pulse icon) and{" "} + FPS Monitor (monitor icon) in the toolbar. +

+ +
+
+ +
+ + {showSlowRender && } + + + + +
+
+ ); +}; + +export default ToolbarEntriesPage; diff --git a/packages/gym/components/agent-playground.tsx b/packages/gym/components/agent-playground.tsx index 5759522c3..a2f25f4d6 100644 --- a/packages/gym/components/agent-playground.tsx +++ b/packages/gym/components/agent-playground.tsx @@ -112,7 +112,6 @@ export const AgentPlaygroundContent = ({ }); }, [loadedProviders, failedProviders, addLog]); - const handleAddProvider = (provider: string) => { const currentProviders = new URLSearchParams(window.location.search).get("provider") ?? ""; diff --git a/packages/gym/components/app-sidebar.tsx b/packages/gym/components/app-sidebar.tsx index 6d193473c..c19812630 100644 --- a/packages/gym/components/app-sidebar.tsx +++ b/packages/gym/components/app-sidebar.tsx @@ -38,6 +38,10 @@ const data = { title: "Login", url: "/login", }, + { + title: "Toolbar Entries", + url: "/toolbar-entries", + }, ], }, { diff --git a/packages/gym/components/toolbar-entries-provider.tsx b/packages/gym/components/toolbar-entries-provider.tsx new file mode 100644 index 000000000..b0079e447 --- /dev/null +++ b/packages/gym/components/toolbar-entries-provider.tsx @@ -0,0 +1,438 @@ +"use client"; + +import { useEffect } from "react"; +import { + registerPlugin, + unregisterPlugin, + type ToolbarEntry, +} from "react-grab"; + +const PLUGIN_NAME = "devtools-toolbar"; + +const ICON_RENDER = ``; +const ICON_FPS = ``; + +interface FiberNode { + child?: FiberNode | null; + sibling?: FiberNode | null; + type?: unknown; + elementType?: unknown; + alternate?: FiberNode | null; + memoizedProps?: unknown; + memoizedState?: unknown; + flags?: number; + stateNode?: unknown; +} + +interface RenderRecord { + componentName: string; + count: number; + lastTimestamp: number; +} + +const createRenderMonitorEntry = (): ToolbarEntry & { dispose: () => void } => { + const renderCounts = new Map(); + let totalRenderCount = 0; + let isMonitoring = false; + + const getComponentName = (fiber: FiberNode): string | null => { + const type = fiber.type ?? fiber.elementType; + if (!type) return null; + if (typeof type === "string") return null; + if (typeof type === "function") + return ( + (type as { displayName?: string }).displayName ?? type.name ?? null + ); + if (typeof type === "object" && type !== null) { + const objectType = type as Record; + if (objectType.displayName) return objectType.displayName as string; + if (objectType.render && typeof objectType.render === "function") { + const renderFunction = objectType.render as { + displayName?: string; + name?: string; + }; + return renderFunction.displayName ?? renderFunction.name ?? null; + } + } + return null; + }; + + const didFiberRender = (fiber: FiberNode): boolean => { + if (!fiber.alternate) return true; + if (fiber.flags && fiber.flags > 0) return true; + return ( + fiber.memoizedProps !== fiber.alternate.memoizedProps || + fiber.memoizedState !== fiber.alternate.memoizedState + ); + }; + + const traverseFiber = (fiber: FiberNode | null | undefined) => { + if (!fiber) return; + const componentName = getComponentName(fiber); + if (componentName && didFiberRender(fiber)) { + const existing = renderCounts.get(componentName); + if (existing) { + existing.count++; + existing.lastTimestamp = performance.now(); + } else { + renderCounts.set(componentName, { + componentName, + count: 1, + lastTimestamp: performance.now(), + }); + } + totalRenderCount++; + } + traverseFiber(fiber.child); + traverseFiber(fiber.sibling); + }; + + const startMonitoring = (handle: { + setBadge: (badge: string | number | undefined) => void; + }) => { + if (isMonitoring) return; + isMonitoring = true; + renderCounts.clear(); + totalRenderCount = 0; + + const hook = (window as unknown as Record) + .__REACT_DEVTOOLS_GLOBAL_HOOK__ as + | { + onCommitFiberRoot?: (...args: unknown[]) => void; + _originalOnCommitFiberRoot?: (...args: unknown[]) => void; + } + | undefined; + + if (!hook) return; + + if (!hook._originalOnCommitFiberRoot) { + hook._originalOnCommitFiberRoot = hook.onCommitFiberRoot; + } + + const originalOnCommit = hook._originalOnCommitFiberRoot; + + hook.onCommitFiberRoot = (...args: unknown[]) => { + if (typeof originalOnCommit === "function") { + originalOnCommit.call(hook, ...args); + } + + if (!isMonitoring) return; + + const fiberRoot = args[1] as { current?: FiberNode } | undefined; + if (fiberRoot?.current) { + traverseFiber(fiberRoot.current); + handle.setBadge(totalRenderCount); + } + }; + }; + + const stopMonitoring = () => { + isMonitoring = false; + const hook = (window as unknown as Record) + .__REACT_DEVTOOLS_GLOBAL_HOOK__ as + | { + onCommitFiberRoot?: unknown; + _originalOnCommitFiberRoot?: (...args: unknown[]) => void; + } + | undefined; + if (hook?._originalOnCommitFiberRoot) { + hook.onCommitFiberRoot = hook._originalOnCommitFiberRoot; + } + }; + + return { + id: "render-monitor", + icon: ICON_RENDER, + tooltip: "Render Monitor", + dispose: stopMonitoring, + onClick: (handle) => { + if (isMonitoring) { + stopMonitoring(); + handle.setBadge(undefined); + handle.setIcon(ICON_RENDER); + } else { + startMonitoring(handle); + handle.setIcon( + ICON_RENDER.replace('stroke="currentColor"', 'stroke="#e53e3e"'), + ); + } + }, + onRender: (container, handle) => { + const renderDropdownContent = () => { + const sorted = [...renderCounts.values()].sort( + (first, second) => second.count - first.count, + ); + const topComponents = sorted.slice(0, 10); + + const rows = topComponents + .map((record) => { + const barWidthPercent = + sorted.length > 0 + ? Math.round((record.count / sorted[0].count) * 100) + : 0; + return ` +
+ ${record.componentName} +
+
+
+ ${record.count} +
`; + }) + .join(""); + + container.innerHTML = ` +
+
+ Render Monitor +
+ + +
+
+
${totalRenderCount} total renders
+ ${topComponents.length > 0 ? rows : '
No renders recorded.
Click Start to begin.
'} +
+ `; + + container + .querySelector("#toggle-btn") + ?.addEventListener("click", () => { + if (isMonitoring) { + stopMonitoring(); + handle.setBadge(undefined); + handle.setIcon(ICON_RENDER); + } else { + startMonitoring(handle); + handle.setIcon( + ICON_RENDER.replace( + 'stroke="currentColor"', + 'stroke="#e53e3e"', + ), + ); + } + renderDropdownContent(); + }); + + container.querySelector("#clear-btn")?.addEventListener("click", () => { + renderCounts.clear(); + totalRenderCount = 0; + handle.setBadge(undefined); + renderDropdownContent(); + }); + }; + + renderDropdownContent(); + + const refreshInterval = setInterval(() => { + if (!isMonitoring) return; + renderDropdownContent(); + }, 1000); + + return () => clearInterval(refreshInterval); + }, + }; +}; + +const createFpsMonitorEntry = (): ToolbarEntry & { dispose: () => void } => { + let isRunning = false; + let animationFrameId: number | null = null; + let lastFrameTimestamp = 0; + let frameTimestamps: number[] = []; + let currentFps = 0; + let minFps = Infinity; + let maxFps = 0; + let longFrameCount = 0; + let fpsHistory: number[] = []; + + const FPS_HISTORY_LENGTH = 60; + const LONG_FRAME_THRESHOLD_MS = 33.33; + + const measureFrame = ( + timestamp: number, + handle: { setBadge: (badge: string | number | undefined) => void }, + ) => { + if (!isRunning) return; + + if (lastFrameTimestamp > 0) { + const frameDurationMs = timestamp - lastFrameTimestamp; + frameTimestamps.push(timestamp); + + if (frameDurationMs > LONG_FRAME_THRESHOLD_MS) { + longFrameCount++; + } + + const oneSecondAgo = timestamp - 1000; + frameTimestamps = frameTimestamps.filter( + (frameTime) => frameTime > oneSecondAgo, + ); + currentFps = frameTimestamps.length; + + if (currentFps > 0) { + minFps = Math.min(minFps, currentFps); + maxFps = Math.max(maxFps, currentFps); + } + + fpsHistory.push(currentFps); + if (fpsHistory.length > FPS_HISTORY_LENGTH) { + fpsHistory = fpsHistory.slice(-FPS_HISTORY_LENGTH); + } + + handle.setBadge(currentFps); + } + + lastFrameTimestamp = timestamp; + animationFrameId = requestAnimationFrame((nextTimestamp) => + measureFrame(nextTimestamp, handle), + ); + }; + + const startMeasuring = (handle: { + setBadge: (badge: string | number | undefined) => void; + }) => { + if (isRunning) return; + isRunning = true; + frameTimestamps = []; + currentFps = 0; + minFps = Infinity; + maxFps = 0; + longFrameCount = 0; + fpsHistory = []; + lastFrameTimestamp = 0; + animationFrameId = requestAnimationFrame((timestamp) => + measureFrame(timestamp, handle), + ); + }; + + const stopMeasuring = () => { + isRunning = false; + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + }; + + return { + id: "fps-monitor", + icon: ICON_FPS, + tooltip: "FPS Monitor", + dispose: stopMeasuring, + onClick: (handle) => { + if (isRunning) { + stopMeasuring(); + handle.setBadge(undefined); + handle.setIcon(ICON_FPS); + } else { + startMeasuring(handle); + handle.setIcon( + ICON_FPS.replace('stroke="currentColor"', 'stroke="#38a169"'), + ); + } + }, + onRender: (container, handle) => { + const renderDropdownContent = () => { + const avgFps = + fpsHistory.length > 0 + ? Math.round( + fpsHistory.reduce((sum, fps) => sum + fps, 0) / + fpsHistory.length, + ) + : 0; + + const sparklineHeight = 32; + const sparklineWidth = 180; + const maxFpsForScale = Math.max(...fpsHistory, 60); + const sparklinePoints = fpsHistory + .map((fps, index) => { + const x = (index / (FPS_HISTORY_LENGTH - 1)) * sparklineWidth; + const y = + sparklineHeight - (fps / maxFpsForScale) * sparklineHeight; + return `${x},${y}`; + }) + .join(" "); + + const fpsColor = + currentFps >= 55 + ? "#38a169" + : currentFps >= 30 + ? "#d69e2e" + : "#e53e3e"; + + container.innerHTML = ` +
+
+ FPS Monitor +
+ +
+
+ ${ + isRunning || fpsHistory.length > 0 + ? ` +
+ ${currentFps} + FPS +
+ + + ${fpsHistory.length > 1 ? `` : ""} + +
+ Min ${minFps === Infinity ? "-" : minFps} + Avg ${avgFps || "-"} + Max ${maxFps || "-"} + Drops ${longFrameCount} +
+ ` + : '
Click Start to begin measuring.
' + } +
+ `; + + container + .querySelector("#toggle-btn") + ?.addEventListener("click", () => { + if (isRunning) { + stopMeasuring(); + handle.setBadge(undefined); + handle.setIcon(ICON_FPS); + } else { + startMeasuring(handle); + handle.setIcon( + ICON_FPS.replace('stroke="currentColor"', 'stroke="#38a169"'), + ); + } + renderDropdownContent(); + }); + }; + + renderDropdownContent(); + + const refreshInterval = setInterval(renderDropdownContent, 500); + + return () => { + clearInterval(refreshInterval); + }; + }, + }; +}; + +export const ToolbarEntriesProvider = () => { + useEffect(() => { + const renderEntry = createRenderMonitorEntry(); + const fpsEntry = createFpsMonitorEntry(); + + registerPlugin({ + name: PLUGIN_NAME, + toolbarEntries: [renderEntry, fpsEntry], + }); + + return () => { + renderEntry.dispose(); + fpsEntry.dispose(); + unregisterPlugin(PLUGIN_NAME); + }; + }, []); + + return null; +}; diff --git a/packages/react-grab/README.md b/packages/react-grab/README.md index 1de0d3160..56495c654 100644 --- a/packages/react-grab/README.md +++ b/packages/react-grab/README.md @@ -44,6 +44,118 @@ This copies the element's context (file name, React component, and HTML source c in LoginForm at components/login-form.tsx:46:19 ``` +## Plugins + +Extend React Grab with custom toolbar buttons, context menu actions, lifecycle hooks, and theme overrides via the plugin API. + +```js +import { registerPlugin, unregisterPlugin } from "react-grab"; +``` + +### Toolbar Entries + +Add custom buttons directly to the toolbar. Each entry can be a simple action button or open a dropdown panel: + +```js +registerPlugin({ + name: "my-devtools", + toolbarEntries: [ + { + id: "fps", + icon: '', + tooltip: "FPS Monitor", + // Action-only button (no dropdown), just toggle FPS tracking + onClick: (handle) => { + if (tracking) { + stopTracking(); + handle.setBadge(undefined); + } else { + startTracking((fps) => handle.setBadge(fps)); + } + }, + }, + { + id: "render-monitor", + icon: "🔍", + tooltip: "Render Monitor", + // Dropdown button: onRender receives a raw DOM container + onRender: (container, handle) => { + container.innerHTML = `
+ Renders: 0 + +
`; + + container.querySelector("#clear").addEventListener("click", () => { + handle.setBadge(undefined); + }); + + // Return a cleanup function (called when dropdown closes) + return () => { + /* teardown */ + }; + }, + }, + ], +}); +``` + +The `handle` passed to callbacks provides: + +- `handle.setBadge(value)` / `handle.setIcon(html)` / `handle.setTooltip(text)` to update the button at runtime +- `handle.open()` / `handle.close()` / `handle.toggle()` to control the dropdown +- `handle.api` for full React Grab API access + +### Context Menu Actions + +Add items to the right-click context menu or the toolbar dropdown menu: + +```js +registerPlugin({ + name: "my-plugin", + actions: [ + { + id: "inspect", + label: "Inspect", + shortcut: "I", + onAction: (ctx) => console.dir(ctx.element), + }, + { + id: "toggle-freeze", + label: "Freeze", + target: "toolbar", + isActive: () => isFrozen, + onAction: () => toggleFreeze(), + }, + ], +}); +``` + +### Hooks + +Listen to lifecycle events: + +```js +registerPlugin({ + name: "my-plugin", + hooks: { + onElementSelect: (element) => { + console.log("Selected:", element.tagName); + }, + }, +}); +``` + +In React, register inside a `useEffect` and clean up on unmount: + +```jsx +useEffect(() => { + registerPlugin({ name: "my-plugin" /* ... */ }); + return () => unregisterPlugin("my-plugin"); +}, []); +``` + +See [`packages/react-grab/src/types.ts`](https://github.com/aidenybai/react-grab/blob/main/packages/react-grab/src/types.ts) for the full `Plugin`, `PluginHooks`, `PluginConfig`, `ToolbarEntry`, and `ToolbarEntryHandle` interfaces. + ## Manual Installation If you're using a React framework or build tool, view instructions below: @@ -127,72 +239,6 @@ if (process.env.NODE_ENV === "development") { } ``` -## Plugins - -Use plugins to extend React Grab's built-in UI with context menu actions, toolbar menu items, lifecycle hooks, and theme overrides. Plugins run within React Grab. - -Register a plugin using the `registerPlugin` and `unregisterPlugin` exports: - -```js -import { registerPlugin } from "react-grab"; - -registerPlugin({ - name: "my-plugin", - hooks: { - onElementSelect: (element) => { - console.log("Selected:", element.tagName); - }, - }, -}); -``` - -In React, register inside a `useEffect`: - -```jsx -import { registerPlugin, unregisterPlugin } from "react-grab"; - -useEffect(() => { - registerPlugin({ - name: "my-plugin", - actions: [ - { - id: "my-action", - label: "My Action", - shortcut: "M", - onAction: (context) => { - console.log("Action on:", context.element); - context.hideContextMenu(); - }, - }, - ], - }); - - return () => unregisterPlugin("my-plugin"); -}, []); -``` - -Actions use a `target` field to control where they appear. Omit `target` (or set `"context-menu"`) for the right-click menu, or set `"toolbar"` for the toolbar dropdown: - -```js -actions: [ - { - id: "inspect", - label: "Inspect", - shortcut: "I", - onAction: (ctx) => console.dir(ctx.element), - }, - { - id: "toggle-freeze", - label: "Freeze", - target: "toolbar", - isActive: () => isFrozen, - onAction: () => toggleFreeze(), - }, -]; -``` - -See [`packages/react-grab/src/types.ts`](https://github.com/aidenybai/react-grab/blob/main/packages/react-grab/src/types.ts) for the full `Plugin`, `PluginHooks`, and `PluginConfig` interfaces. - ## Resources & Contributing Back Want to try it out? Check out [our demo](https://react-grab.com). diff --git a/packages/react-grab/e2e/edge-cases.spec.ts b/packages/react-grab/e2e/edge-cases.spec.ts index 71f5314b6..7b8f4bfdd 100644 --- a/packages/react-grab/e2e/edge-cases.spec.ts +++ b/packages/react-grab/e2e/edge-cases.spec.ts @@ -46,6 +46,7 @@ test.describe("Edge Cases", () => { await reactGrab.waitForSelectionBox(); await reactGrab.removeElement("[data-testid='toggleable-element']"); + await reactGrab.page.waitForTimeout(100); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); diff --git a/packages/react-grab/e2e/fixtures.ts b/packages/react-grab/e2e/fixtures.ts index 70a2b9159..412147aef 100644 --- a/packages/react-grab/e2e/fixtures.ts +++ b/packages/react-grab/e2e/fixtures.ts @@ -1008,9 +1008,7 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { const items = dropdown.querySelectorAll( "[data-react-grab-menu-item]", ); - return Array.from(items).map( - (item) => item.textContent?.trim() ?? "", - ); + return Array.from(items).map((item) => item.textContent?.trim() ?? ""); } return []; }, ATTRIBUTE_NAME); @@ -1682,9 +1680,7 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { id: "comment-action", label: "Edit", shortcut: "Enter", - onAction: (context: { - enterPromptMode?: () => void; - }) => { + onAction: (context: { enterPromptMode?: () => void }) => { context.enterPromptMode?.(); }, }, diff --git a/packages/react-grab/e2e/focus-trap.spec.ts b/packages/react-grab/e2e/focus-trap.spec.ts index 18853e31b..5acd54882 100644 --- a/packages/react-grab/e2e/focus-trap.spec.ts +++ b/packages/react-grab/e2e/focus-trap.spec.ts @@ -230,7 +230,9 @@ test.describe("Focus Trap Resistance", () => { await reactGrab.typeInInput("Test prompt"); await reactGrab.submitInput(); - await expect.poll(() => reactGrab.isPromptModeActive()).toBe(false); + await expect + .poll(() => reactGrab.isPromptModeActive(), { timeout: 5000 }) + .toBe(false); }); test("Escape should dismiss prompt mode despite focus trap", async ({ diff --git a/packages/react-grab/e2e/freeze-animations.spec.ts b/packages/react-grab/e2e/freeze-animations.spec.ts index b3d552793..c98a6a02c 100644 --- a/packages/react-grab/e2e/freeze-animations.spec.ts +++ b/packages/react-grab/e2e/freeze-animations.spec.ts @@ -601,12 +601,12 @@ test.describe("Freeze Animations", () => { expect(tickCountBeforeFreeze).toBeGreaterThan(0); await activateViaApi(page); - await page.waitForTimeout(200); + await page.waitForTimeout(500); const tickCountAtFreeze = await page.evaluate( () => (window as unknown as Record).__RAF_TICK_COUNT__, ); - await page.waitForTimeout(300); + await page.waitForTimeout(500); const tickCountAfterWaiting = await page.evaluate( () => (window as unknown as Record).__RAF_TICK_COUNT__, ); @@ -633,12 +633,12 @@ test.describe("Freeze Animations", () => { await activateViaApi(page); await page.waitForTimeout(200); await deactivateViaApi(page); - await page.waitForTimeout(100); + await page.waitForTimeout(300); const tickCountAfterUnfreeze = await page.evaluate( () => (window as unknown as Record).__RAF_TICK_COUNT__, ); - await page.waitForTimeout(300); + await page.waitForTimeout(500); const tickCountLater = await page.evaluate( () => (window as unknown as Record).__RAF_TICK_COUNT__, ); diff --git a/packages/react-grab/e2e/freeze-updates.spec.ts b/packages/react-grab/e2e/freeze-updates.spec.ts index a32a8ba48..fede344b7 100644 --- a/packages/react-grab/e2e/freeze-updates.spec.ts +++ b/packages/react-grab/e2e/freeze-updates.spec.ts @@ -25,13 +25,13 @@ test.describe("Freeze Updates", () => { ) as HTMLButtonElement; addButton?.click(); }); - await reactGrab.page.waitForTimeout(100); + await reactGrab.page.waitForTimeout(300); const countDuringPromptMode = await getElementCount(); expect(countDuringPromptMode).toBe(initialCount); await reactGrab.pressEscape(); - await reactGrab.page.waitForTimeout(200); + await reactGrab.page.waitForTimeout(500); const countAfterExit = await getElementCount(); expect(countAfterExit).toBe(initialCount); @@ -88,12 +88,18 @@ test.describe("Freeze Updates", () => { await reactGrab.enterPromptMode("[data-testid='dynamic-element-1']"); await reactGrab.pressEscape(); - await reactGrab.page.waitForTimeout(200); + await reactGrab.page.waitForTimeout(500); const countBefore = await getElementCount(); await reactGrab.page.click("[data-testid='add-element-button']"); - await reactGrab.page.waitForTimeout(100); + await reactGrab.page.waitForFunction( + (expected) => + document.querySelectorAll("[data-testid^='dynamic-element-']") + .length === expected, + countBefore + 1, + { timeout: 5000 }, + ); const countAfter = await getElementCount(); expect(countAfter).toBe(countBefore + 1); @@ -208,11 +214,21 @@ test.describe("Freeze Updates", () => { await reactGrab.hoverElement("[data-testid='dynamic-section']"); await reactGrab.waitForSelectionBox(); await reactGrab.deactivate(); + await reactGrab.page.waitForTimeout(300); const countBefore = await getElementCount(); await reactGrab.page.click("[data-testid='add-element-button']"); - await reactGrab.page.waitForTimeout(100); + await reactGrab.page.waitForFunction( + (expected) => { + return ( + document.querySelectorAll("[data-testid^='dynamic-element-']") + .length === expected + ); + }, + countBefore + 1, + { timeout: 5000 }, + ); const countAfter = await getElementCount(); expect(countAfter).toBe(countBefore + 1); @@ -241,7 +257,7 @@ test.describe("Freeze Updates", () => { await reactGrab.enterPromptMode("[data-testid='dynamic-element-1']"); await reactGrab.deactivate(); - await reactGrab.page.waitForTimeout(200); + await reactGrab.page.waitForTimeout(500); const getElementCount = async () => { return reactGrab.page.evaluate(() => { @@ -253,7 +269,13 @@ test.describe("Freeze Updates", () => { const countBefore = await getElementCount(); await reactGrab.page.click("[data-testid='add-element-button']"); - await reactGrab.page.waitForTimeout(100); + await reactGrab.page.waitForFunction( + (expected) => + document.querySelectorAll("[data-testid^='dynamic-element-']") + .length === expected, + countBefore + 1, + { timeout: 5000 }, + ); const countAfter = await getElementCount(); expect(countAfter).toBe(countBefore + 1); @@ -347,7 +369,7 @@ test.describe("Freeze Updates", () => { addButton?.click(); }); await reactGrab.pressEscape(); - await reactGrab.page.waitForTimeout(300); + await reactGrab.page.waitForTimeout(500); const countAfterFirstCycle = await getElementCount(); expect(countAfterFirstCycle).toBe(countBeforeFirstCycle); @@ -360,7 +382,7 @@ test.describe("Freeze Updates", () => { addButton?.click(); }); await reactGrab.pressEscape(); - await reactGrab.page.waitForTimeout(300); + await reactGrab.page.waitForTimeout(500); const countAfterSecondCycle = await getElementCount(); expect(countAfterSecondCycle).toBe(countBeforeFirstCycle); diff --git a/packages/react-grab/e2e/history-items.spec.ts b/packages/react-grab/e2e/history-items.spec.ts index 2e8e69a62..44289e301 100644 --- a/packages/react-grab/e2e/history-items.spec.ts +++ b/packages/react-grab/e2e/history-items.spec.ts @@ -379,13 +379,18 @@ test.describe("Comment Items", () => { .toBe(2); await reactGrab.page.mouse.move(0, 0); - await reactGrab.page.waitForTimeout(200); - const grabbedBoxesAfter = await reactGrab.getGrabbedBoxInfo(); - const remainingHoverBoxes = grabbedBoxesAfter.boxes.filter((box) => - box.id.startsWith("comment-all-hover-"), - ); - expect(remainingHoverBoxes.length).toBe(0); + await expect + .poll( + async () => { + const info = await reactGrab.getGrabbedBoxInfo(); + return info.boxes.filter((box) => + box.id.startsWith("comment-all-hover-"), + ).length; + }, + { timeout: 3000 }, + ) + .toBe(0); }); test("should clear button hover boxes when pinning the dropdown", async ({ diff --git a/packages/react-grab/src/components/renderer.tsx b/packages/react-grab/src/components/renderer.tsx index 4f33cc6be..6c8f71297 100644 --- a/packages/react-grab/src/components/renderer.tsx +++ b/packages/react-grab/src/components/renderer.tsx @@ -15,6 +15,7 @@ import { SelectionLabel } from "./selection-label/index.js"; import { Toolbar } from "./toolbar/index.js"; import { ContextMenu } from "./context-menu.js"; import { ToolbarMenu } from "./toolbar/toolbar-menu.js"; +import { ToolbarEntryContainer } from "./toolbar/toolbar-entry-container.js"; import { CommentsDropdown } from "./comments-dropdown.js"; import { ClearCommentsPrompt } from "./clear-comments-prompt.js"; @@ -217,6 +218,11 @@ export const ReactGrabRenderer: Component = (props) => { onToggleToolbarMenu={props.onToggleToolbarMenu} isToolbarMenuOpen={Boolean(props.toolbarMenuPosition)} isClearPromptOpen={Boolean(props.clearPromptPosition)} + toolbarEntries={props.toolbarEntries} + toolbarEntryOverrides={props.toolbarEntryOverrides} + activeToolbarEntryId={props.activeToolbarEntryId} + onToggleToolbarEntry={props.onToggleToolbarEntry} + isToolbarEntryOpen={Boolean(props.toolbarEntryDropdownPosition)} />
@@ -246,6 +252,17 @@ export const ReactGrabRenderer: Component = (props) => { onCancel={props.onClearCommentsCancel ?? (() => {})} /> + toolbarEntry.id === props.activeToolbarEntryId, + ) ?? null + } + handle={props.activeToolbarEntryHandle ?? null} + onDismiss={props.onToolbarEntryDismiss ?? (() => {})} + /> + void; isToolbarMenuOpen?: boolean; + toolbarEntries?: ToolbarEntry[]; + toolbarEntryOverrides?: Record< + string, + Partial> + >; + activeToolbarEntryId?: string | null; + onToggleToolbarEntry?: (entryId: string) => void; + isToolbarEntryOpen?: boolean; } interface FreezeHandlersOptions { @@ -249,7 +257,8 @@ export const Toolbar: Component = (props) => { !isCollapsed() && !props.isCommentsDropdownOpen && !props.isToolbarMenuOpen && - !props.isClearPromptOpen; + !props.isClearPromptOpen && + !props.isToolbarEntryOpen; const tooltipPosition = (): "top" | "bottom" | "left" | "right" => { const edge = snapEdge(); @@ -601,7 +610,7 @@ export const Toolbar: Component = (props) => { const readExpandableDimension = () => isVerticalEdge ? lastKnownExpandableHeight : lastKnownExpandableWidth; - // HACK: Skip measuring during an active toggle animation — the CSS grid transition is + // HACK: Skip measuring during an active toggle animation. The CSS grid transition is // mid-flight so getBoundingClientRect returns a partial value that contaminates // lastKnownExpandableWidth and causes permanent position drift. if (isCurrentlyEnabled && expandableButtonsRef && !isToggleAnimating()) { @@ -1059,6 +1068,13 @@ export const Toolbar: Component = (props) => { isCommentsPinned={props.isCommentsPinned} disableGridTransitions={isRapidRetoggle()} transformOrigin={getTransformOrigin()} + toolbarEntries={props.toolbarEntries} + toolbarEntryOverrides={props.toolbarEntryOverrides} + activeToolbarEntryId={props.activeToolbarEntryId} + onToolbarEntryClick={(entryId, event) => { + event.stopPropagation(); + props.onToggleToolbarEntry?.(entryId); + }} onAnimationEnd={() => setIsShaking(false)} onCollapseClick={handleToggleCollapse} onExpandableButtonsRef={(element) => { @@ -1299,24 +1315,14 @@ export const Toolbar: Component = (props) => {
- + to fine-tune target - + esc to cancel diff --git a/packages/react-grab/src/components/toolbar/toolbar-content.tsx b/packages/react-grab/src/components/toolbar/toolbar-content.tsx index c4ab12a4a..a57a8bdaf 100644 --- a/packages/react-grab/src/components/toolbar/toolbar-content.tsx +++ b/packages/react-grab/src/components/toolbar/toolbar-content.tsx @@ -1,4 +1,6 @@ +import { For, Show } from "solid-js"; import type { Component, JSX } from "solid-js"; +import type { ToolbarEntry } from "../../types.js"; import { cn } from "../../utils/cn.js"; import { IconSelect } from "../icons/icon-select.jsx"; import { IconChevron } from "../icons/icon-chevron.jsx"; @@ -31,6 +33,13 @@ export interface ToolbarContentProps { toggleButton?: JSX.Element; collapseButton?: JSX.Element; transformOrigin?: string; + toolbarEntries?: ToolbarEntry[]; + toolbarEntryOverrides?: Record< + string, + Partial> + >; + activeToolbarEntryId?: string | null; + onToolbarEntryClick?: (entryId: string, event: MouseEvent) => void; } export const ToolbarContent: Component = (props) => { @@ -216,10 +225,12 @@ export const ToolbarContent: Component = (props) => { "grid relative overflow-visible", gridSizeTransitionClass(), props.isCollapsed - ? (isVertical() - ? "grid-rows-[0fr] pointer-events-none" - : "grid-cols-[0fr] pointer-events-none") - : (isVertical() ? "grid-rows-[1fr]" : "grid-cols-[1fr]"), + ? isVertical() + ? "grid-rows-[0fr] pointer-events-none" + : "grid-cols-[0fr] pointer-events-none" + : isVertical() + ? "grid-rows-[1fr]" + : "grid-cols-[1fr]", )} >
= (props) => { {props.copyAllButton ?? defaultCopyAllButton()}
+ + {(toolbarEntry) => { + const overrides = () => + props.toolbarEntryOverrides?.[toolbarEntry.id]; + const resolvedIcon = () => + overrides()?.icon ?? toolbarEntry.icon; + const resolvedBadge = () => { + const entryOverrides = overrides(); + return entryOverrides && "badge" in entryOverrides + ? entryOverrides.badge + : toolbarEntry.badge; + }; + const resolvedIsVisible = () => + overrides()?.isVisible ?? toolbarEntry.isVisible; + + return ( +
+
+ +
+
+ ); + }} +
{props.toggleButton ?? defaultToggleButton()} diff --git a/packages/react-grab/src/components/toolbar/toolbar-entry-container.tsx b/packages/react-grab/src/components/toolbar/toolbar-entry-container.tsx new file mode 100644 index 000000000..34c46cac2 --- /dev/null +++ b/packages/react-grab/src/components/toolbar/toolbar-entry-container.tsx @@ -0,0 +1,99 @@ +import { Show, onMount, onCleanup, createEffect, on } from "solid-js"; +import type { Component } from "solid-js"; +import type { + DropdownAnchor, + ToolbarEntry, + ToolbarEntryHandle, +} from "../../types.js"; +import { + DROPDOWN_EDGE_TRANSFORM_ORIGIN, + Z_INDEX_LABEL, +} from "../../constants.js"; +import { cn } from "../../utils/cn.js"; +import { suppressMenuEvent } from "../../utils/suppress-menu-event.js"; +import { createAnchoredDropdown } from "../../utils/create-anchored-dropdown.js"; +import { registerOverlayDismiss } from "../../utils/register-overlay-dismiss.js"; + +interface ToolbarEntryContainerProps { + position: DropdownAnchor | null; + entry: ToolbarEntry | null; + handle: ToolbarEntryHandle | null; + onDismiss: () => void; +} + +export const ToolbarEntryContainer: Component = ( + props, +) => { + let containerRef: HTMLDivElement | undefined; + let contentRef: HTMLDivElement | undefined; + + const dropdown = createAnchoredDropdown( + () => containerRef, + () => props.position, + ); + + createEffect( + on( + () => ({ + entry: props.entry, + isAnimatedIn: dropdown.isAnimatedIn(), + }), + ({ entry, isAnimatedIn }) => { + const handle = props.handle; + if (!entry?.onRender || !handle || !isAnimatedIn || !contentRef) return; + contentRef.innerHTML = ""; + const cleanup = entry.onRender(contentRef, handle); + dropdown.measure(); + if (cleanup) { + onCleanup(cleanup); + } + }, + ), + ); + + onMount(() => { + dropdown.measure(); + const unregisterDismiss = registerOverlayDismiss({ + isOpen: () => Boolean(props.position), + onDismiss: props.onDismiss, + }); + onCleanup(() => { + dropdown.clearAnimationHandles(); + unregisterDismiss(); + }); + }); + + return ( + +
+
+
+
+
+ + ); +}; diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index 084c1fb79..0e95c11c8 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -56,6 +56,14 @@ export const OVERLAY_FILL_COLOR_INSPECT = overlayColor(0.04); export const FROZEN_GLOW_COLOR = overlayColor(0.15); export const FROZEN_GLOW_EDGE_PX = 50; +export const PLUGIN_PRIORITY_KEYBOARD = 10; +export const PLUGIN_PRIORITY_NAVIGATION = 20; +export const PLUGIN_PRIORITY_POINTER = 30; +export const PLUGIN_PRIORITY_COPY_PIPELINE = 40; +export const PLUGIN_PRIORITY_MENUS = 50; +export const PLUGIN_PRIORITY_PROMPT = 60; +export const PLUGIN_PRIORITY_TOOLBAR = 80; + export const ARROW_HEIGHT_PX = 8; export const ARROW_MIN_SIZE_PX = 4; export const ARROW_MAX_LABEL_WIDTH_RATIO = 0.2; diff --git a/packages/react-grab/src/core/events.ts b/packages/react-grab/src/core/events.ts index 98f11dcbc..097873ffb 100644 --- a/packages/react-grab/src/core/events.ts +++ b/packages/react-grab/src/core/events.ts @@ -1,4 +1,4 @@ -interface EventListenerManager { +export interface EventListenerManager { signal: AbortSignal; abort: () => void; addWindowListener: ( diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index e6c3f6efd..b1989896a 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -1,170 +1,79 @@ // @ts-expect-error - CSS imported as text via tsup loader import cssText from "../../dist/styles.css"; import { + createSignal, createMemo, createRoot, - createSignal, onCleanup, createEffect, - createResource, on, - batch, } from "solid-js"; import { render } from "solid-js/web"; import { createGrabStore } from "./store.js"; -import { - isKeyboardEventTriggeredByInput, - hasTextSelectionInInput, - hasTextSelectionOnPage, -} from "../utils/is-keyboard-event-triggered-by-input.js"; -import { mountRoot } from "../utils/mount-root.js"; -import { - nativeCancelAnimationFrame, - nativeRequestAnimationFrame, - waitUntilNextFrame, -} from "../utils/native-raf.js"; +import { createNoopApi } from "./noop-api.js"; +import { createEventListenerManager } from "./events.js"; +import { createPluginRegistry } from "./plugin-registry.js"; import { getStackContext, - getNearestComponentName, getComponentDisplayName, checkIsNextProject, } from "./context.js"; import { resolveSource } from "element-source"; -import { createNoopApi } from "./noop-api.js"; -import { createEventListenerManager } from "./events.js"; -import { tryCopyWithFallback } from "./copy.js"; -import { - getElementAtPosition, - getElementsAtPoint, -} from "../utils/get-element-at-position.js"; -import { isValidGrabbableElement } from "../utils/is-valid-grabbable-element.js"; -import { isRootElement } from "../utils/is-root-element.js"; +import { DEFAULT_THEME } from "./theme.js"; +import { logIntro } from "./log-intro.js"; +import { getScriptOptions } from "../utils/get-script-options.js"; +import { mountRoot } from "../utils/mount-root.js"; +import { getElementAtPosition } from "../utils/get-element-at-position.js"; +import { invalidateInteractionCaches } from "../utils/invalidate-interaction-caches.js"; import { isElementConnected } from "../utils/is-element-connected.js"; -import { getElementsInDrag } from "../utils/get-elements-in-drag.js"; -import { getAncestorElements } from "../utils/get-ancestor-elements.js"; +import { isEventFromOverlay } from "../utils/is-event-from-overlay.js"; +import { isRootElement } from "../utils/is-root-element.js"; import { createElementBounds } from "../utils/create-element-bounds.js"; -import { createElementSelector } from "../utils/create-element-selector.js"; -import { getVisibleBoundsCenter } from "../utils/get-visible-bounds-center.js"; -import { invalidateInteractionCaches } from "../utils/invalidate-interaction-caches.js"; -import { normalizeErrorMessage } from "../utils/normalize-error.js"; +import { getElementBoundsCenter } from "../utils/get-element-bounds-center.js"; import { createBoundsFromDragRect, createFlatOverlayBounds, - createPageRectFromBounds, } from "../utils/create-bounds-from-drag-rect.js"; -import { getTagName } from "../utils/get-tag-name.js"; +import { combineBounds } from "../utils/combine-bounds.js"; +import { + nativeCancelAnimationFrame, + nativeRequestAnimationFrame, +} from "../utils/native-raf.js"; +import { loadToolbarState } from "../components/toolbar/state.js"; import { - ARROW_KEYS, - FEEDBACK_DURATION_MS, - FADE_COMPLETE_BUFFER_MS, - KEYDOWN_SPAM_TIMEOUT_MS, - DRAG_THRESHOLD_PX, - ELEMENT_DETECTION_THROTTLE_MS, - PENDING_DETECTION_STALENESS_MS, - COMPONENT_NAME_DEBOUNCE_MS, - DRAG_PREVIEW_DEBOUNCE_MS, - MODIFIER_KEYS, - BLUR_DEACTIVATION_THRESHOLD_MS, - BOUNDS_RECALC_INTERVAL_MS, - INPUT_FOCUS_ACTIVATION_DELAY_MS, - INPUT_TEXT_SELECTION_ACTIVATION_DELAY_MS, DEFAULT_KEY_HOLD_DURATION_MS, DEFAULT_MAX_CONTEXT_LINES, - MIN_HOLD_FOR_ACTIVATION_AFTER_COPY_MS, - ZOOM_DETECTION_THRESHOLD, - ACTION_CYCLE_IDLE_TRIGGER_MS, - WINDOW_REFOCUS_GRACE_PERIOD_MS, - DROPDOWN_HOVER_OPEN_DELAY_MS, - PREVIEW_TEXT_MAX_LENGTH, + BOUNDS_RECALC_INTERVAL_MS, NEXTJS_REVALIDATION_DELAY_MS, - TOOLBAR_DEFAULT_POSITION_RATIO, - DEFAULT_ACTION_ID, + ZOOM_DETECTION_THRESHOLD, } from "../constants.js"; -import { getBoundsCenter } from "../utils/get-bounds-center.js"; -import { getElementBoundsCenter } from "../utils/get-element-bounds-center.js"; -import { getElementCenter } from "../utils/get-element-center.js"; -import { isCLikeKey } from "../utils/is-c-like-key.js"; -import { isTargetKeyCombination } from "../utils/is-target-key-combination.js"; -import { - parseActivationKey, - getModifiersFromActivationKey, -} from "../utils/parse-activation-key.js"; -import { keyMatchesCode } from "../utils/key-matches-code.js"; -import { isEventFromOverlay } from "../utils/is-event-from-overlay.js"; -import { openFile } from "../utils/open-file.js"; -import { combineBounds } from "../utils/combine-bounds.js"; -import { resolveActionEnabled } from "../utils/resolve-action-enabled.js"; import type { - Position, Options, OverlayBounds, - GrabbedBox, ReactGrabAPI, ReactGrabState, - SelectionLabelInstance, - AgentSession, - AgentOptions, - ContextMenuActionContext, - ContextMenuAction, - ActionCycleItem, - ActionCycleState, - ArrowNavigationState, - PerformWithFeedbackOptions, SettableOptions, SourceInfo, Plugin, ToolbarState, - CommentItem, - DropdownAnchor, + SharedPluginApi, + InternalPlugin, + PluginContext, } from "../types.js"; -import { DEFAULT_THEME } from "./theme.js"; -import { createPluginRegistry } from "./plugin-registry.js"; -import { createAgentManager } from "./agent/manager.js"; -import { createArrowNavigator } from "./arrow-navigation.js"; -import { - getRequiredModifiers, - setupKeyboardEventClaimer, -} from "./keyboard-handlers.js"; -import { createAutoScroller, getAutoScrollDirection } from "./auto-scroll.js"; -import { logIntro } from "./log-intro.js"; -import { onIdle } from "../utils/on-idle.js"; -import { getScriptOptions } from "../utils/get-script-options.js"; -import { isEnterCode } from "../utils/is-enter-code.js"; -import { isMac } from "../utils/is-mac.js"; -import { - loadToolbarState, - saveToolbarState, -} from "../components/toolbar/state.js"; + +import { toolbarPlugin } from "./plugins/toolbar-plugin.js"; +import { copyPipelinePlugin } from "./plugins/copy-pipeline.js"; +import { menusPlugin } from "./plugins/menus-plugin.js"; +import { navigationPlugin } from "./plugins/navigation-plugin.js"; +import { pointerPlugin } from "./plugins/pointer-plugin.js"; +import { promptPlugin } from "./plugins/prompt-plugin.js"; +import { keyboardPlugin } from "./plugins/keyboard-plugin.js"; + import { copyPlugin } from "./plugins/copy.js"; import { commentPlugin } from "./plugins/comment.js"; import { openPlugin } from "./plugins/open.js"; import { copyHtmlPlugin } from "./plugins/copy-html.js"; import { copyStylesPlugin } from "./plugins/copy-styles.js"; -import { - freezeAnimations, - freezeAllAnimations, - freezeGlobalAnimations, - unfreezeGlobalAnimations, -} from "../utils/freeze-animations.js"; -import { - freezePseudoStates, - unfreezePseudoStates, -} from "../utils/freeze-pseudo-states.js"; -import { freezeUpdates } from "../utils/freeze-updates.js"; -import { - loadComments, - addCommentItem, - removeCommentItem, - clearComments, - isClearConfirmed, - confirmClear, -} from "../utils/comment-storage.js"; -import { copyContent } from "../utils/copy-content.js"; -import { joinSnippets } from "../utils/join-snippets.js"; -import { generateId } from "../utils/generate-id.js"; -import { logRecoverableError } from "../utils/log-recoverable-error.js"; -import { lockViewportZoom } from "../utils/lock-viewport-zoom.js"; -import { getNearestEdge } from "../utils/get-nearest-edge.js"; const builtInPlugins = [ copyPlugin, @@ -174,37 +83,17 @@ const builtInPlugins = [ openPlugin, ]; -interface CopyWithLabelOptions { - element: Element; - cursorX: number; - selectedElements?: Element[]; - extraPrompt?: string; - shouldDeactivateAfter?: boolean; - onComplete?: () => void; - dragRect?: { - pageX: number; - pageY: number; - width: number; - height: number; - }; -} - -interface BuildActionContextOptions { - element: Element; - filePath: string | undefined; - lineNumber: number | undefined; - tagName: string | undefined; - componentName: string | undefined; - position: Position; - performWithFeedbackOptions?: PerformWithFeedbackOptions; - shouldDeferHideContextMenu: boolean; - onBeforeCopy?: () => void; - onBeforePrompt?: () => void; - customEnterPromptMode?: (agent?: AgentOptions) => void; -} +const internalPlugins: InternalPlugin[] = [ + navigationPlugin, // 20 + pointerPlugin, // 30 + copyPipelinePlugin, // 40 + menusPlugin, // 50 + promptPlugin, // 60 + toolbarPlugin, // 80 + keyboardPlugin, // 90 +]; let hasInited = false; -const toolbarStateChangeCallbacks = new Set<(state: ToolbarState) => void>(); export const init = (rawOptions?: Options): ReactGrabAPI => { if (typeof window === "undefined") { @@ -212,7 +101,6 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { } const scriptOptions = getScriptOptions(); - const initialOptions: Options = { enabled: true, activationMode: "toggle", @@ -227,10 +115,9 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { return createNoopApi(); } hasInited = true; - logIntro(); - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- need to omit enabled from settableOptions to avoid circular dependency + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- need to omit enabled from settableOptions const { enabled: _enabled, ...settableOptions } = initialOptions; return createRoot((dispose) => { @@ -238,12 +125,11 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { let disposeRenderer: (() => void) | undefined; const pluginRegistry = createPluginRegistry(settableOptions); + const eventListenerManager = createEventListenerManager(); const getAgentFromActions = () => { for (const action of pluginRegistry.store.actions) { - if (action.agent?.provider) { - return action.agent; - } + if (action.agent?.provider) return action.agent; } return undefined; }; @@ -256,6 +142,18 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { DEFAULT_KEY_HOLD_DURATION_MS, }); + const shared: SharedPluginApi = {}; + + const [isToolbarSelectHovered, setIsToolbarSelectHovered] = + createSignal(false); + shared.isToolbarSelectHovered = () => isToolbarSelectHovered(); + shared.setIsToolbarSelectHovered = setIsToolbarSelectHovered; + + const [hasDragPreviewBounds, setHasDragPreviewBounds] = + createSignal(false); + shared.hasDragPreviewBounds = () => hasDragPreviewBounds(); + shared.setHasDragPreviewBounds = setHasDragPreviewBounds; + const isHoldingKeys = createMemo(() => store.current.state === "holding"); const isActivated = createMemo(() => store.current.state === "active"); const isFrozenPhase = createMemo( @@ -266,2816 +164,318 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { () => store.current.state === "active" && store.current.phase === "dragging", ); - const didJustDrag = createMemo( - () => - store.current.state === "active" && - store.current.phase === "justDragged", - ); - const isCopying = createMemo(() => store.current.state === "copying"); const didJustCopy = createMemo(() => store.current.state === "justCopied"); + const isCopying = createMemo(() => store.current.state === "copying"); const isPromptMode = createMemo( () => store.current.state === "active" && Boolean(store.current.isPromptMode), ); - const isCommentMode = createMemo( - () => store.pendingCommentMode || isPromptMode(), - ); - const isPendingDismiss = createMemo( - () => - store.current.state === "active" && - Boolean(store.current.isPromptMode) && - Boolean(store.current.isPendingDismiss), - ); - - let unlockViewportZoom: (() => void) | null = null; + const isRendererActive = createMemo(() => isActivated() && !isCopying()); - createEffect( - on(isActivated, (activated, previousActivated) => { - if (activated && !previousActivated) { - freezePseudoStates(); - freezeGlobalAnimations(); - // HACK: Prevent browser from taking over touch gestures - document.body.style.touchAction = "none"; - // HACK: Prevent iOS Safari from auto-zooming on sub-16px inputs - unlockViewportZoom = lockViewportZoom(); - } else if (!activated && previousActivated) { - unfreezePseudoStates(); - unfreezeGlobalAnimations(); - document.body.style.touchAction = ""; - unlockViewportZoom?.(); - unlockViewportZoom = null; - } - }), - ); + const targetElement = createMemo(() => { + void store.viewportVersion; + if (!isRendererActive() || isDragging()) return null; + const element = store.detectedElement; + if (!isElementConnected(element)) return null; + return element; + }); - const savedToolbarState = loadToolbarState(); - const [isEnabled, setIsEnabled] = createSignal( - savedToolbarState?.enabled ?? true, + const effectiveElement = createMemo( + () => store.frozenElement || (isFrozenPhase() ? null : targetElement()), ); - const [toolbarShakeCount, setToolbarShakeCount] = createSignal(0); - const [selectionLabelShakeCount, setSelectionLabelShakeCount] = - createSignal(0); - const [currentToolbarState, setCurrentToolbarState] = - createSignal(savedToolbarState); - const [isToolbarSelectHovered, setIsToolbarSelectHovered] = - createSignal(false); - const [commentItems, setCommentItems] = - createSignal(loadComments()); - const [commentsDropdownPosition, setCommentsDropdownPosition] = - createSignal(null); - const [toolbarMenuPosition, setToolbarMenuPosition] = - createSignal(null); - const [clearPromptPosition, setClearPromptPosition] = - createSignal(null); - let toolbarElement: HTMLDivElement | undefined; - let dropdownTrackingFrameId: number | null = null; - const commentElementMap = new Map(); - const [clockFlashTrigger, setClockFlashTrigger] = createSignal(0); - const [isCommentsHoverOpen, setIsCommentsHoverOpen] = createSignal(false); - let commentsHoverPreviews: { boxId: string; labelId: string | null }[] = []; - - const updateToolbarState = (updates: Partial) => { - const currentState = currentToolbarState() ?? loadToolbarState(); - const newState: ToolbarState = { - edge: currentState?.edge ?? "bottom", - ratio: currentState?.ratio ?? TOOLBAR_DEFAULT_POSITION_RATIO, - collapsed: currentState?.collapsed ?? false, - enabled: currentState?.enabled ?? true, - defaultAction: currentState?.defaultAction ?? DEFAULT_ACTION_ID, - ...updates, - }; - saveToolbarState(newState); - setCurrentToolbarState(newState); - for (const callback of toolbarStateChangeCallbacks) { - callback(newState); - } - return newState; - }; - - const getMappedCommentElements = (commentItemId: string): Element[] => - commentElementMap.get(commentItemId) ?? []; - - const reacquireCommentElements = (commentItem: CommentItem): Element[] => { - const selectors = commentItem.elementSelectors ?? []; - if (selectors.length === 0) return []; - - const reacquiredElements: Element[] = []; - for (const selector of selectors) { - if (!selector) continue; - try { - const reacquiredElement = document.querySelector(selector); - if (isElementConnected(reacquiredElement)) { - reacquiredElements.push(reacquiredElement); - } - // HACK: querySelector can throw on invalid selectors stored from previous sessions - } catch (error) { - logRecoverableError("Invalid stored selector", error); - } - } - return reacquiredElements; - }; - - const getConnectedCommentElements = ( - commentItem: CommentItem, - ): Element[] => { - const mappedElements = getMappedCommentElements(commentItem.id); - const connectedMappedElements = mappedElements.filter((mappedElement) => - isElementConnected(mappedElement), - ); - const areAllMappedElementsConnected = - mappedElements.length > 0 && - connectedMappedElements.length === mappedElements.length; - if (areAllMappedElementsConnected) { - return connectedMappedElements; + const selectionElement = createMemo((): Element | undefined => { + if (store.isTouchMode && isDragging()) { + const detected = store.detectedElement; + if (!detected || isRootElement(detected)) return undefined; + return detected; } + const element = effectiveElement(); + if (!element || isRootElement(element)) return undefined; + return element; + }); - const reacquiredElements = reacquireCommentElements(commentItem); - if (reacquiredElements.length > 0) { - commentElementMap.set(commentItem.id, reacquiredElements); - return reacquiredElements; + const frozenElementsBounds = createMemo((): OverlayBounds[] => { + void store.viewportVersion; + const frozenElements = store.frozenElements; + if (frozenElements.length === 0) return []; + const dragRect = store.frozenDragRect; + if (dragRect && frozenElements.length > 1) { + return [createBoundsFromDragRect(dragRect)]; } + return frozenElements + .filter((element): element is Element => element !== null) + .map((element) => createElementBounds(element)); + }); - return connectedMappedElements; - }; - - const getFirstConnectedCommentElement = ( - commentItem: CommentItem, - ): Element | undefined => getConnectedCommentElements(commentItem)[0]; - - const commentsDisconnectedItemIds = createMemo( - () => { - // HACK: subscribe to dropdown position so connectivity refreshes when dropdown opens - void commentsDropdownPosition(); - const disconnectedIds = new Set(); - for (const item of commentItems()) { - if (getConnectedCommentElements(item).length === 0) { - disconnectedIds.add(item.id); - } - } - return disconnectedIds; - }, - undefined, - { - equals: (prev, next) => { - if (prev.size !== next.size) return false; - for (const id of next) { - if (!prev.has(id)) return false; - } - return true; - }, - }, - ); - - const clearHoldTimer = () => { - if (activationHoldState.timerId !== null) { - clearTimeout(activationHoldState.timerId); - activationHoldState.timerId = null; - } + const isSelectionElementVisible = (): boolean => { + const element = selectionElement(); + if (!element) return false; + if (store.isTouchMode && isDragging()) return isRendererActive(); + return isRendererActive() && !isDragging(); }; - const resetCopyConfirmation = () => { - activationHoldState.copyWaiting = false; - activationHoldState.holdTimerFired = false; - activationHoldState.startTimestamp = null; + const derived = { + isHoldingKeys, + isActivated, + isCopying, + didJustCopy, + isPromptMode, + isDragging, + isFrozenPhase, + isRendererActive, + targetElement, + effectiveElement, + selectionElement, + frozenElementsBounds, }; - createEffect(() => { - if (store.current.state !== "holding") { - clearHoldTimer(); - return; - } - activationHoldState.startTimestamp = Date.now(); - activationHoldState.timerId = window.setTimeout(() => { - activationHoldState.timerId = null; - if (activationHoldState.copyWaiting) { - activationHoldState.holdTimerFired = true; - return; - } - actions.activate(); - }, store.keyHoldDuration); - onCleanup(clearHoldTimer); - }); - - createEffect(() => { - if ( - store.current.state !== "active" || - store.current.phase !== "justDragged" - ) - return; - const timerId = setTimeout(() => { - actions.finishJustDragged(); - }, FEEDBACK_DURATION_MS); - onCleanup(() => clearTimeout(timerId)); - }); + const isSelectionSuppressed = createMemo( + () => + didJustCopy() || + ((shared.isToolbarSelectHovered?.() ?? false) && !isFrozenPhase()), + ); - createEffect(() => { - if (store.current.state !== "justCopied") return; - const timerId = setTimeout(() => { - actions.finishJustCopied(); - }, FEEDBACK_DURATION_MS); - onCleanup(() => clearTimeout(timerId)); + const selectionVisible = createMemo(() => { + if (!pluginRegistry.store.theme.enabled) return false; + if (!pluginRegistry.store.theme.selectionBox.enabled) return false; + if (isSelectionSuppressed()) return false; + if (shared.hasDragPreviewBounds?.() ?? false) return true; + return isSelectionElementVisible(); }); - createEffect( - on(isHoldingKeys, (currentlyHolding, previouslyHolding = false) => { - if (!previouslyHolding || currentlyHolding || !isActivated()) { - return; + const selectionBounds = createMemo((): OverlayBounds | undefined => { + void store.viewportVersion; + const frozenElements = store.frozenElements; + if (frozenElements.length > 0) { + const frozenBounds = frozenElementsBounds(); + if (frozenElements.length === 1) { + return frozenBounds[0]; } - if (pluginRegistry.store.options.activationMode !== "hold") { - actions.setWasActivatedByToggle(true); + const dragRect = store.frozenDragRect; + if (dragRect) { + return frozenBounds[0] ?? createBoundsFromDragRect(dragRect); } - pluginRegistry.hooks.onActivate(); - }), - ); - - const preparePromptMode = ( - element: Element, - positionX: number, - positionY: number, - ) => { - setCopyStartPosition(element, positionX, positionY); - actions.clearInputText(); - }; - - const activatePromptMode = () => { - const element = store.frozenElement || targetElement(); - if (element) { - actions.enterPromptMode( - { x: store.pointer.x, y: store.pointer.y }, - element, - ); + return createFlatOverlayBounds(combineBounds(frozenBounds)); } - }; - - const setCopyStartPosition = ( - element: Element, - positionX: number, - positionY: number, - ) => { - actions.setCopyStart({ x: positionX, y: positionY }, element); + const element = selectionElement(); + if (!element) return undefined; return createElementBounds(element); - }; + }); - const elementDetectionState = { - lastDetectionTimestamp: 0, - pendingDetectionScheduledAt: 0, - latestPointerX: 0, - latestPointerY: 0, - }; - let dragPreviewDebounceTimerId: number | null = null; - const [debouncedDragPointer, setDebouncedDragPointer] = createSignal<{ - x: number; - y: number; - } | null>(null); - const scheduleDragPreviewUpdate = (clientX: number, clientY: number) => { - if (dragPreviewDebounceTimerId !== null) { - clearTimeout(dragPreviewDebounceTimerId); + const cursorPosition = createMemo(() => { + if (isCopying() || isPromptMode()) { + void store.viewportVersion; + const element = store.frozenElement || targetElement(); + if (element) { + const { center } = getElementBoundsCenter(element); + return { + x: center.x + store.copyOffsetFromCenterX, + y: store.copyStart.y, + }; + } + return { x: store.copyStart.x, y: store.copyStart.y }; } - setDebouncedDragPointer(null); - dragPreviewDebounceTimerId = window.setTimeout(() => { - setDebouncedDragPointer({ x: clientX, y: clientY }); - dragPreviewDebounceTimerId = null; - }, DRAG_PREVIEW_DEBOUNCE_MS); - }; - let keydownSpamTimerId: number | null = null; - const activationHoldState = { - timerId: null as number | null, - startTimestamp: null as number | null, - copyWaiting: false, - holdTimerFired: false, - }; - const [isInspectMode, setIsInspectMode] = createSignal(false); - let lastWindowFocusTimestamp = 0; - let isCopyFeedbackCooldownActive = false; - let copyFeedbackCooldownTimerId: number | null = null; + return { x: store.pointer.x, y: store.pointer.y }; + }); - const startCopyFeedbackCooldown = () => { - isCopyFeedbackCooldownActive = true; - if (copyFeedbackCooldownTimerId !== null) { - window.clearTimeout(copyFeedbackCooldownTimerId); - } - copyFeedbackCooldownTimerId = window.setTimeout(() => { - isCopyFeedbackCooldownActive = false; - copyFeedbackCooldownTimerId = null; - }, FEEDBACK_DURATION_MS); + const api: ReactGrabAPI = { + activate: () => { + actions.setPendingCommentMode(false); + if (!isActivated() && (shared.isEnabled?.() ?? true)) { + shared.toggleActivate?.(); + } + }, + deactivate: () => { + if (isActivated() || isCopying()) { + shared.deactivateRenderer?.(); + } + }, + toggle: () => { + if (isActivated()) { + shared.deactivateRenderer?.(); + } else if (shared.isEnabled?.() ?? true) { + shared.toggleActivate?.(); + } + }, + comment: () => shared.handleComment?.(), + isActive: () => isActivated(), + isEnabled: () => shared.isEnabled?.() ?? true, + setEnabled: (enabled: boolean) => { + shared.setEnabled?.(enabled); + }, + getToolbarState: () => loadToolbarState(), + setToolbarState: (state: Partial) => { + shared.updateToolbarState?.(state); + }, + onToolbarStateChange: (callback: (state: ToolbarState) => void) => + shared.subscribeToToolbarStateChanges?.(callback) ?? (() => {}), + dispose: () => { + disposed = true; + hasInited = false; + disposeRenderer?.(); + shared.dismissAllPopups?.(); + eventListenerManager.abort(); + dispose(); + }, + copyElement: async (elements: Element | Element[]): Promise => { + const normalizedElements = Array.isArray(elements) + ? elements + : [elements]; + if (normalizedElements.length === 0) return false; + return (await shared.copyWithFallback?.(normalizedElements)) ?? false; + }, + getSource: async (element: Element): Promise => { + const source = await resolveSource(element); + if (!source) return null; + return { + filePath: source.filePath, + lineNumber: source.lineNumber, + componentName: source.componentName, + }; + }, + getStackContext, + getState: (): ReactGrabState => ({ + isActive: isActivated(), + isDragging: isDragging(), + isCopying: isCopying(), + isPromptMode: isPromptMode(), + isSelectionBoxVisible: Boolean(selectionVisible()), + isDragBoxVisible: Boolean(shared.isDragBoxVisible?.()), + targetElement: targetElement(), + dragBounds: shared.getDragBounds?.() ?? null, + grabbedBoxes: store.grabbedBoxes.map((box) => ({ + id: box.id, + bounds: box.bounds, + createdAt: box.createdAt, + })), + labelInstances: store.labelInstances.map((instance) => ({ + id: instance.id, + status: instance.status, + tagName: instance.tagName, + componentName: instance.componentName, + createdAt: instance.createdAt, + })), + selectionFilePath: store.selectionFilePath, + toolbarState: shared.getCurrentToolbarState?.() ?? loadToolbarState(), + }), + setOptions: (options: SettableOptions) => + pluginRegistry.setOptions(options), + registerPlugin: (plugin: Plugin) => { + pluginRegistry.register(plugin, api); + shared.syncAgentFromRegistry?.(); + }, + unregisterPlugin: (name: string) => { + const activeEntryId = + pluginRegistry.getRendererContributions().activeToolbarEntryId; + if (activeEntryId) { + const pluginEntryIds = pluginRegistry.getPluginToolbarEntryIds(name); + if (pluginEntryIds.includes(activeEntryId as string)) { + shared.closeToolbarEntry?.(); + } + } + pluginRegistry.unregister(name); + shared.syncAgentFromRegistry?.(); + }, + getPlugins: () => pluginRegistry.getPluginNames(), + getDisplayName: getComponentDisplayName, + toggleToolbarEntry: (entryId: string) => + shared.toggleToolbarEntry?.(entryId), + closeToolbarEntry: () => shared.closeToolbarEntry?.(), }; - const clearCopyFeedbackCooldown = () => { - if (copyFeedbackCooldownTimerId !== null) { - window.clearTimeout(copyFeedbackCooldownTimerId); - copyFeedbackCooldownTimerId = null; - } - isCopyFeedbackCooldownActive = false; + const createPluginContext = (plugin: InternalPlugin): PluginContext => { + const priority = plugin.priority ?? 50; + return { + store, + actions, + derived, + registry: { + store: pluginRegistry.store, + hooks: pluginRegistry.hooks, + updateToolbarEntry: pluginRegistry.updateToolbarEntry, + setOptions: pluginRegistry.setOptions, + }, + api, + events: eventListenerManager, + shared, + onKeyDown: (handler) => + pluginRegistry.addInterceptor( + "keydown", + priority, + handler as (event: never) => boolean, + ), + onKeyUp: (handler) => + pluginRegistry.addInterceptor( + "keyup", + priority, + handler as (event: never) => boolean, + ), + onPointerDown: (handler) => + pluginRegistry.addInterceptor( + "pointerdown", + priority, + handler as (event: never) => boolean, + ), + onPointerMove: (handler) => + pluginRegistry.addInterceptor( + "pointermove", + priority, + handler as (event: never) => boolean, + ), + onPointerUp: (handler) => + pluginRegistry.addInterceptor( + "pointerup", + priority, + handler as (event: never) => boolean, + ), + onContextMenu: (handler) => + pluginRegistry.addInterceptor( + "contextmenu", + priority, + handler as (event: never) => boolean, + ), + provide: (key, accessor) => + pluginRegistry.provideRendererProp(key, accessor as () => unknown), + }; }; - let actionCycleIdleTimeoutId: number | null = null; - let selectionSourceRequestVersion = 0; - let componentNameRequestVersion = 0; - let componentNameDebounceTimerId: number | null = null; - let keyboardSelectedElement: Element | null = null; - let isPendingContextMenuSelect = false; - let pendingDefaultActionId: string | null = null; - const [ - debouncedElementForComponentName, - setDebouncedElementForComponentName, - ] = createSignal(null); - const [resolvedComponentName, setResolvedComponentName] = createSignal< - string | undefined - >(undefined); - const [actionCycleItems, setActionCycleItems] = createSignal< - ActionCycleItem[] - >([]); - const [actionCycleActiveIndex, setActionCycleActiveIndex] = createSignal< - number | null - >(null); - const [arrowNavigationElements, setArrowNavigationElements] = createSignal< - Element[] - >([]); - const [arrowNavigationActiveIndex, setArrowNavigationActiveIndex] = - createSignal(0); + const internalCleanups: Array<() => void> = []; + for (const plugin of internalPlugins) { + const ctx = createPluginContext(plugin); + const cleanup = plugin.setup(ctx); + if (cleanup) internalCleanups.push(cleanup); + } - const arrowNavigator = createArrowNavigator( - isValidGrabbableElement, - createElementBounds, + pluginRegistry.provideRendererProp("selectionVisible", () => + selectionVisible(), ); - - const autoScroller = createAutoScroller( - () => store.pointer, - () => isDragging(), + pluginRegistry.provideRendererProp("selectionBounds", () => + selectionBounds(), ); - - const isRendererActive = createMemo(() => isActivated() && !isCopying()); - - const grabbedBoxTimeouts = new Map(); - - const showTemporaryGrabbedBox = ( - bounds: OverlayBounds, - element: Element, - ) => { - const boxId = generateId("grabbed"); - const createdAt = Date.now(); - const newBox: GrabbedBox = { id: boxId, bounds, createdAt, element }; - - actions.addGrabbedBox(newBox); - pluginRegistry.hooks.onGrabbedBox(bounds, element); - - const timeoutId = window.setTimeout(() => { - grabbedBoxTimeouts.delete(boxId); - actions.removeGrabbedBox(boxId); - }, FEEDBACK_DURATION_MS); - grabbedBoxTimeouts.set(boxId, timeoutId); - }; - - const notifyElementsSelected = async ( - elements: Element[], - ): Promise => { - const elementsPayload = await Promise.all( - elements.map(async (element) => { - const source = await resolveSource(element); - let componentName = source?.componentName ?? null; - const filePath = source?.filePath; - const lineNumber = source?.lineNumber ?? undefined; - const columnNumber = source?.columnNumber ?? undefined; - - if (!componentName) { - componentName = getComponentDisplayName(element); - } - - const textContent = - element instanceof HTMLElement - ? element.innerText?.slice(0, PREVIEW_TEXT_MAX_LENGTH) - : undefined; - - return { - tagName: getTagName(element), - id: element.id || undefined, - className: element.getAttribute("class") || undefined, - textContent, - componentName: componentName ?? undefined, - filePath, - lineNumber, - columnNumber, - }; - }), - ); - - window.dispatchEvent( - new CustomEvent("react-grab:element-selected", { - detail: { - elements: elementsPayload, - }, - }), - ); - }; - - const labelFadeTimeouts = new Map(); - - const cancelLabelFade = (instanceId: string) => { - const existingTimeout = labelFadeTimeouts.get(instanceId); - if (existingTimeout !== undefined) { - window.clearTimeout(existingTimeout); - labelFadeTimeouts.delete(instanceId); - } - }; - - const cancelAllLabelFades = () => { - for (const timeoutId of labelFadeTimeouts.values()) { - window.clearTimeout(timeoutId); - } - labelFadeTimeouts.clear(); - }; - - const scheduleLabelFade = (instanceId: string) => { - cancelLabelFade(instanceId); - - const timeoutId = window.setTimeout(() => { - labelFadeTimeouts.delete(instanceId); - actions.updateLabelInstance(instanceId, "fading"); - setTimeout(() => { - labelFadeTimeouts.delete(instanceId); - actions.removeLabelInstance(instanceId); - }, FADE_COMPLETE_BUFFER_MS); - }, FEEDBACK_DURATION_MS); - - labelFadeTimeouts.set(instanceId, timeoutId); - }; - - const handleLabelInstanceHoverChange = ( - instanceId: string, - isHovered: boolean, - ) => { - if (isHovered) { - cancelLabelFade(instanceId); - } else { - const instance = store.labelInstances.find( - (labelInstance) => labelInstance.id === instanceId, - ); - if (instance && instance.status === "copied") { - scheduleLabelFade(instanceId); - } - } - }; - - const createLabelInstance = ( - bounds: OverlayBounds, - tagName: string, - componentName: string | undefined, - status: SelectionLabelInstance["status"], - options?: { - element?: Element; - mouseX?: number; - elements?: Element[]; - boundsMultiple?: OverlayBounds[]; - hideArrow?: boolean; - }, - ): string => { - actions.clearLabelInstances(); - cancelAllLabelFades(); - const instanceId = generateId("label"); - const boundsCenterX = bounds.x + bounds.width / 2; - const boundsHalfWidth = bounds.width / 2; - const mouseX = options?.mouseX; - const mouseXOffset = - mouseX !== undefined ? mouseX - boundsCenterX : undefined; - - const instance: SelectionLabelInstance = { - id: instanceId, - bounds, - boundsMultiple: options?.boundsMultiple, - tagName, - componentName, - status, - createdAt: Date.now(), - element: options?.element, - elements: options?.elements, - mouseX, - mouseXOffsetFromCenter: mouseXOffset, - mouseXOffsetRatio: - mouseXOffset !== undefined && boundsHalfWidth > 0 - ? mouseXOffset / boundsHalfWidth - : undefined, - hideArrow: options?.hideArrow, - }; - actions.addLabelInstance(instance); - return instanceId; - }; - - const clearAllLabels = () => { - cancelAllLabelFades(); - actions.clearLabelInstances(); - }; - - const updateLabelAfterCopy = ( - labelInstanceId: string, - didSucceed: boolean, - errorMessage?: string, - ) => { - if (didSucceed) { - actions.updateLabelInstance(labelInstanceId, "copied"); - } else { - actions.updateLabelInstance( - labelInstanceId, - "error", - errorMessage || "Unknown error", - ); - } - scheduleLabelFade(labelInstanceId); - }; - - const executeCopyOperation = async ( - clipboardOperation: () => Promise, - labelInstanceId: string | null, - copiedElement?: Element, - shouldDeactivateAfter?: boolean, - ) => { - clearCopyFeedbackCooldown(); - if (store.current.state !== "copying") { - actions.startCopy(); - } - - let didSucceed = false; - let errorMessage: string | undefined; - - try { - await clipboardOperation(); - didSucceed = true; - } catch (error) { - errorMessage = normalizeErrorMessage(error, "Action failed"); - } - - if (labelInstanceId) { - updateLabelAfterCopy(labelInstanceId, didSucceed, errorMessage); - } - - if (store.current.state !== "copying") return; - - if (didSucceed) { - actions.completeCopy(copiedElement); - } - - if (shouldDeactivateAfter) { - deactivateRenderer(); - } else if (didSucceed) { - actions.activate(); - startCopyFeedbackCooldown(); - } else { - actions.unfreeze(); - } - }; - - const handleCopySuccessWithComments = (options: { - copiedElements: Element[]; - content: string; - extraPrompt: string | undefined; - elementName: string | undefined; - tagName: string | null; - componentName: string | null; - }) => { - const { - copiedElements, - content, - extraPrompt, - elementName, - tagName, - componentName, - } = options; - pluginRegistry.hooks.onCopySuccess(copiedElements, content); - - if (!extraPrompt) return; - - const hasCopiedElements = copiedElements.length > 0; - - if (hasCopiedElements) { - const currentItems = commentItems(); - for (const [ - existingItemId, - mappedElements, - ] of commentElementMap.entries()) { - const isSameSelection = - mappedElements.length === copiedElements.length && - mappedElements.every( - (mappedElement, index) => mappedElement === copiedElements[index], - ); - if (!isSameSelection) continue; - const existingItem = currentItems.find( - (item) => item.id === existingItemId, - ); - if (!existingItem) continue; - - if (existingItem.commentText === extraPrompt) { - removeCommentItem(existingItemId); - commentElementMap.delete(existingItemId); - break; - } - } - } - - const elementSelectors = copiedElements.map((copiedElement, index) => - createElementSelector(copiedElement, index === 0), - ); - - const updatedCommentItems = addCommentItem({ - content, - elementName: elementName ?? "element", - tagName: tagName ?? "div", - componentName: componentName ?? undefined, - elementsCount: copiedElements.length, - previewBounds: copiedElements.map((copiedElement) => - createElementBounds(copiedElement), - ), - elementSelectors, - commentText: extraPrompt, - timestamp: Date.now(), - }); - setCommentItems(updatedCommentItems); - setClockFlashTrigger((previous) => previous + 1); - const newestCommentItem = updatedCommentItems[0]; - if (newestCommentItem && hasCopiedElements) { - commentElementMap.set(newestCommentItem.id, [...copiedElements]); - } - - const currentItemIds = new Set( - updatedCommentItems.map((item) => item.id), - ); - for (const mapItemId of commentElementMap.keys()) { - if (!currentItemIds.has(mapItemId)) { - commentElementMap.delete(mapItemId); - } - } - }; - - const copyWithFallback = ( - elements: Element[], - extraPrompt?: string, - resolvedComponentName?: string, - ) => { - const firstElement = elements[0]; - const componentName = - resolvedComponentName ?? - (firstElement ? getComponentDisplayName(firstElement) : null); - const tagName = firstElement ? getTagName(firstElement) : null; - const elementName = componentName ?? tagName ?? undefined; - - return tryCopyWithFallback( - { - maxContextLines: pluginRegistry.store.options.maxContextLines, - getContent: pluginRegistry.store.options.getContent, - componentName: elementName, - }, - { - onBeforeCopy: pluginRegistry.hooks.onBeforeCopy, - transformSnippet: pluginRegistry.hooks.transformSnippet, - transformCopyContent: pluginRegistry.hooks.transformCopyContent, - onAfterCopy: pluginRegistry.hooks.onAfterCopy, - onCopySuccess: (copiedElements: Element[], content: string) => { - handleCopySuccessWithComments({ - copiedElements, - content, - extraPrompt, - elementName, - tagName, - componentName, - }); - }, - onCopyError: pluginRegistry.hooks.onCopyError, - }, - elements, - extraPrompt, - ); - }; - - const copyElementsToClipboard = async ( - targetElements: Element[], - extraPrompt?: string, - resolvedComponentName?: string, - ): Promise => { - if (targetElements.length === 0) return; - - const unhandledElements: Element[] = []; - const pendingResults: Promise[] = []; - for (const element of targetElements) { - const { wasIntercepted, pendingResult } = - pluginRegistry.hooks.onElementSelect(element); - if (!wasIntercepted) { - unhandledElements.push(element); - } - if (pendingResult) { - pendingResults.push(pendingResult); - } - if (pluginRegistry.store.theme.grabbedBoxes.enabled) { - showTemporaryGrabbedBox(createElementBounds(element), element); - } - } - await waitUntilNextFrame(); - if (unhandledElements.length > 0) { - await copyWithFallback( - unhandledElements, - extraPrompt, - resolvedComponentName, - ); - } else if (pendingResults.length > 0) { - const results = await Promise.all(pendingResults); - if (!results.every(Boolean)) { - throw new Error("Failed to copy"); - } - } - void notifyElementsSelected(targetElements); - }; - - const performCopyWithLabel = (options: CopyWithLabelOptions) => { - const { - element, - cursorX, - selectedElements, - extraPrompt, - shouldDeactivateAfter, - onComplete, - dragRect: passedDragRect, - } = options; - - const allTargetElements = selectedElements ?? [element]; - const dragRect = passedDragRect ?? store.frozenDragRect; - const isMultiSelect = allTargetElements.length > 1; - - const selectionBounds = - dragRect && isMultiSelect - ? createBoundsFromDragRect(dragRect) - : createFlatOverlayBounds(createElementBounds(element)); - - const labelCursorX = isMultiSelect - ? selectionBounds.x + selectionBounds.width / 2 - : cursorX; - - const tagName = getTagName(element); - clearCopyFeedbackCooldown(); - actions.startCopy(); - - const labelInstanceId = tagName - ? createLabelInstance(selectionBounds, tagName, undefined, "copying", { - element, - mouseX: labelCursorX, - elements: selectedElements, - }) - : null; - - void getNearestComponentName(element) - .then(async (componentName) => { - await executeCopyOperation( - () => - copyElementsToClipboard( - allTargetElements, - extraPrompt, - componentName ?? undefined, - ), - labelInstanceId, - element, - shouldDeactivateAfter, - ); - onComplete?.(); - }) - .catch((error) => { - logRecoverableError("Copy operation failed", error); - if (labelInstanceId) { - updateLabelAfterCopy( - labelInstanceId, - false, - normalizeErrorMessage(error, "Action failed"), - ); - } - if (store.current.state === "copying") { - actions.unfreeze(); - } - }); - }; - - const targetElement = createMemo(() => { - void store.viewportVersion; - if (!isRendererActive() || isDragging()) return null; - const element = store.detectedElement; - if (!isElementConnected(element)) return null; - return element; - }); - - const effectiveElement = createMemo( - () => store.frozenElement || (isFrozenPhase() ? null : targetElement()), - ); - - createEffect(() => { - const element = store.detectedElement; - if (!element) return; - - const intervalId = setInterval(() => { - if (!isElementConnected(element)) { - actions.setDetectedElement(null); - } - }, BOUNDS_RECALC_INTERVAL_MS); - - onCleanup(() => clearInterval(intervalId)); - }); - - createEffect( - on(effectiveElement, (element) => { - if (componentNameDebounceTimerId !== null) { - clearTimeout(componentNameDebounceTimerId); - componentNameDebounceTimerId = null; - } - - if (!element) { - setDebouncedElementForComponentName(null); - return; - } - - componentNameDebounceTimerId = window.setTimeout(() => { - componentNameDebounceTimerId = null; - setDebouncedElementForComponentName(element); - }, COMPONENT_NAME_DEBOUNCE_MS); - }), - ); - - onCleanup(() => { - if (componentNameDebounceTimerId !== null) { - clearTimeout(componentNameDebounceTimerId); - componentNameDebounceTimerId = null; - } - }); - - createEffect(() => { - const elements = store.frozenElements; - const cleanup = freezeAnimations(elements); - onCleanup(cleanup); - }); - - createEffect( - on(isActivated, (activated) => { - if (!activated) return; - if (!pluginRegistry.store.options.freezeReactUpdates) return; - const unfreezeUpdates = freezeUpdates(); - onCleanup(unfreezeUpdates); - }), - ); - - // HACK: In touch mode during drag, effectiveElement() is null so we use detectedElement - const getSelectionElement = (): Element | undefined => { - if (store.isTouchMode && isDragging()) { - const detected = store.detectedElement; - if (!detected || isRootElement(detected)) return undefined; - return detected; - } - const element = effectiveElement(); - if (!element || isRootElement(element)) return undefined; - return element; - }; - - const selectionElement = createMemo(() => getSelectionElement()); - - const isSelectionElementVisible = (): boolean => { - const element = selectionElement(); - if (!element) return false; - if (store.isTouchMode && isDragging()) { - return isRendererActive(); - } - return isRendererActive() && !isDragging(); - }; - - const frozenElementsBounds = createMemo((): OverlayBounds[] => { - void store.viewportVersion; - - const frozenElements = store.frozenElements; - if (frozenElements.length === 0) return []; - - const dragRect = store.frozenDragRect; - if (dragRect && frozenElements.length > 1) { - return [createBoundsFromDragRect(dragRect)]; - } - - return frozenElements - .filter((element): element is Element => element !== null) - .map((element) => createElementBounds(element)); - }); - - const selectionBounds = createMemo((): OverlayBounds | undefined => { - void store.viewportVersion; - - const frozenElements = store.frozenElements; - if (frozenElements.length > 0) { - const frozenBounds = frozenElementsBounds(); - if (frozenElements.length === 1) { - const firstBounds = frozenBounds[0]; - if (firstBounds) return firstBounds; - } - const dragRect = store.frozenDragRect; - if (dragRect) { - const dragBounds = frozenBounds[0]; - return dragBounds ?? createBoundsFromDragRect(dragRect); - } - return createFlatOverlayBounds(combineBounds(frozenBounds)); - } - - const element = selectionElement(); - if (!element) return undefined; - return createElementBounds(element); - }); - - const toPageCoordinates = (clientX: number, clientY: number) => ({ - pageX: clientX + window.scrollX, - pageY: clientY + window.scrollY, - }); - - const calculateDragDistance = (endX: number, endY: number) => { - const { pageX: endPageX, pageY: endPageY } = toPageCoordinates( - endX, - endY, - ); - - return { - x: Math.abs(endPageX - store.dragStart.x), - y: Math.abs(endPageY - store.dragStart.y), - }; - }; - - const isDraggingBeyondThreshold = createMemo(() => { - if (!isDragging()) return false; - - const dragDistance = calculateDragDistance( - store.pointer.x, - store.pointer.y, - ); - - return ( - dragDistance.x > DRAG_THRESHOLD_PX || dragDistance.y > DRAG_THRESHOLD_PX - ); - }); - - const calculateDragRectangle = (endX: number, endY: number) => { - const { pageX: endPageX, pageY: endPageY } = toPageCoordinates( - endX, - endY, - ); - - const dragPageX = Math.min(store.dragStart.x, endPageX); - const dragPageY = Math.min(store.dragStart.y, endPageY); - const dragWidth = Math.abs(endPageX - store.dragStart.x); - const dragHeight = Math.abs(endPageY - store.dragStart.y); - - return { - x: dragPageX - window.scrollX, - y: dragPageY - window.scrollY, - width: dragWidth, - height: dragHeight, - }; - }; - - const dragBounds = createMemo((): OverlayBounds | undefined => { - void store.viewportVersion; - - if (!isDraggingBeyondThreshold()) return undefined; - - const drag = calculateDragRectangle(store.pointer.x, store.pointer.y); - - return { - borderRadius: "0px", - height: drag.height, - transform: "none", - width: drag.width, - x: drag.x, - y: drag.y, - }; - }); - - const dragPreviewBounds = createMemo((): OverlayBounds[] => { - void store.viewportVersion; - - if (!isDraggingBeyondThreshold()) return []; - - const pointer = debouncedDragPointer(); - if (!pointer) return []; - - const drag = calculateDragRectangle(pointer.x, pointer.y); - const elements = getElementsInDrag(drag, isValidGrabbableElement); - const previewElements = - elements.length > 0 - ? elements - : getElementsInDrag(drag, isValidGrabbableElement, false); - - return previewElements.map((element) => createElementBounds(element)); - }); - - const selectionBoundsMultiple = createMemo((): OverlayBounds[] => { - const previewBounds = dragPreviewBounds(); - if (previewBounds.length > 0) { - return previewBounds; - } - return frozenElementsBounds(); - }); - - const inspectBounds = createMemo((): OverlayBounds[] => { - if (!isInspectMode()) return []; - - const element = effectiveElement(); - if (!element) return []; - - void store.viewportVersion; - - return [...getAncestorElements(element), element].map((ancestor) => - createElementBounds(ancestor), - ); - }); - - const cursorPosition = createMemo(() => { - if (isCopying() || isPromptMode()) { - void store.viewportVersion; - const element = store.frozenElement || targetElement(); - if (element) { - const { center } = getElementBoundsCenter(element); - return { - x: center.x + store.copyOffsetFromCenterX, - y: store.copyStart.y, - }; - } - return { - x: store.copyStart.x, - y: store.copyStart.y, - }; - } - return { - x: store.pointer.x, - y: store.pointer.y, - }; - }); - - createEffect( - on( - () => [targetElement(), store.lastGrabbedElement] as const, - ([currentElement, lastElement]) => { - if (lastElement && currentElement && lastElement !== currentElement) { - actions.setLastGrabbed(null); - } - if (currentElement) { - pluginRegistry.hooks.onElementHover(currentElement); - } - }, - ), - ); - - createEffect( - on( - () => targetElement(), - (element) => { - const currentVersion = ++selectionSourceRequestVersion; - - const clearSource = () => { - if (selectionSourceRequestVersion === currentVersion) { - actions.setSelectionSource(null, null); - } - }; - - if (!element) { - clearSource(); - return; - } - - resolveSource(element) - .then((source) => { - if (selectionSourceRequestVersion !== currentVersion) return; - if (!source) { - clearSource(); - return; - } - actions.setSelectionSource(source.filePath, source.lineNumber); - }) - .catch(() => { - if (selectionSourceRequestVersion === currentVersion) { - actions.setSelectionSource(null, null); - } - }); - }, - ), - ); - - createEffect( - on( - () => store.viewportVersion, - () => agentManager._internal.updateBoundsOnViewportChange(), - ), - ); - - const publicGrabbedBoxes = createMemo(() => - store.grabbedBoxes.map((box) => ({ - id: box.id, - bounds: box.bounds, - createdAt: box.createdAt, - })), - ); - - const publicLabelInstances = createMemo(() => - store.labelInstances.map((instance) => ({ - id: instance.id, - status: instance.status, - tagName: instance.tagName, - componentName: instance.componentName, - createdAt: instance.createdAt, - })), - ); - - const derivedStateForHook = createMemo(() => { - const active = isActivated(); - const dragging = isDragging(); - const copying = isCopying(); - const inputMode = isPromptMode(); - const target = targetElement(); - const drag = dragBounds(); - const themeEnabled = pluginRegistry.store.theme.enabled; - const selectionBoxEnabled = - pluginRegistry.store.theme.selectionBox.enabled; - const dragBoxEnabled = pluginRegistry.store.theme.dragBox.enabled; - const draggingBeyondThreshold = isDraggingBeyondThreshold(); - const effectiveTarget = effectiveElement(); - const justCopied = didJustCopy(); - - const isSelectionBoxVisible = Boolean( - themeEnabled && - selectionBoxEnabled && - active && - !copying && - !justCopied && - !dragging && - effectiveTarget != null, - ); - const isDragBoxVisible = Boolean( - themeEnabled && - dragBoxEnabled && - active && - !copying && - draggingBeyondThreshold, - ); - - return { - isActive: active, - isDragging: dragging, - isCopying: copying, - isPromptMode: inputMode, - isSelectionBoxVisible, - isDragBoxVisible, - targetElement: target, - dragBounds: drag - ? { x: drag.x, y: drag.y, width: drag.width, height: drag.height } - : null, - grabbedBoxes: [...publicGrabbedBoxes()], - labelInstances: [...publicLabelInstances()], - selectionFilePath: store.selectionFilePath, - toolbarState: currentToolbarState(), - }; - }); - - createEffect( - on(derivedStateForHook, (state) => { - pluginRegistry.hooks.onStateChange(state); - }), - ); - - createEffect( - on( - () => - [ - isPromptMode(), - store.pointer.x, - store.pointer.y, - targetElement(), - ] as const, - ([inputMode, x, y, target]) => { - pluginRegistry.hooks.onPromptModeChange(inputMode, { - x, - y, - targetElement: target, - }); - }, - ), - ); - - createEffect( - on( - () => [selectionVisible(), selectionBounds(), targetElement()] as const, - ([visible, bounds, element]) => { - pluginRegistry.hooks.onSelectionBox( - Boolean(visible), - bounds ?? null, - element, - ); - }, - ), - ); - - createEffect( - on( - () => [dragVisible(), dragBounds()] as const, - ([visible, bounds]) => { - pluginRegistry.hooks.onDragBox(Boolean(visible), bounds ?? null); - }, - ), - ); - - createEffect( - on( - () => - [ - labelVisible(), - labelVariant(), - cursorPosition(), - targetElement(), - store.selectionFilePath, - store.selectionLineNumber, - ] as const, - ([visible, variant, position, element, filePath, lineNumber]) => { - pluginRegistry.hooks.onElementLabel(Boolean(visible), variant, { - x: position.x, - y: position.y, - content: "", - element: element ?? undefined, - tagName: element ? getTagName(element) || undefined : undefined, - filePath: filePath ?? undefined, - lineNumber: lineNumber ?? undefined, - }); - }, - ), - ); - - let cursorStyleElement: HTMLStyleElement | null = null; - - const setCursorOverride = (cursor: string | null) => { - if (cursor) { - if (!cursorStyleElement) { - cursorStyleElement = document.createElement("style"); - cursorStyleElement.setAttribute("data-react-grab-cursor", ""); - document.head.appendChild(cursorStyleElement); - } - cursorStyleElement.textContent = `* { cursor: ${cursor} !important; }`; - } else if (cursorStyleElement) { - cursorStyleElement.remove(); - cursorStyleElement = null; - } - }; - - createEffect( - on( - () => [isActivated(), isCopying(), isPromptMode()] as const, - ([activated, copying, promptMode]) => { - if (copying) { - setCursorOverride("progress"); - } else if (activated && !promptMode) { - setCursorOverride("crosshair"); - } else { - setCursorOverride(null); - } - }, - ), - ); - - const activateRenderer = () => { - const wasInHoldingState = isHoldingKeys(); - actions.activate(); - // HACK: Only call onActivate if we weren't in holding state. - // When coming from holding state, the reactive effect (previouslyHoldingKeys transition) - // will handle calling onActivate to avoid duplicate invocations. - if (!wasInHoldingState) { - pluginRegistry.hooks.onActivate(); - } - }; - - const deactivateRenderer = () => { - const wasDragging = isDragging(); - const previousFocused = store.previouslyFocusedElement; - actions.deactivate(); - setIsInspectMode(false); - clearArrowNavigation(); - keyboardSelectedElement = null; - isPendingContextMenuSelect = false; - if (wasDragging) { - document.body.style.userSelect = ""; - } - if (keydownSpamTimerId) window.clearTimeout(keydownSpamTimerId); - autoScroller.stop(); - if ( - previousFocused instanceof HTMLElement && - isElementConnected(previousFocused) - ) { - previousFocused.focus(); - } - pluginRegistry.hooks.onDeactivate(); - }; - - const forceDeactivateAll = () => { - if (isHoldingKeys()) { - actions.releaseHold(); - } - if (isActivated()) { - deactivateRenderer(); - } - clearCopyFeedbackCooldown(); - }; - - const toggleActivate = () => { - actions.setWasActivatedByToggle(true); - activateRenderer(); - }; - - const restoreInputFromSession = ( - session: AgentSession, - elements: Element[], - agent?: AgentOptions, - ) => { - const element = elements[0]; - if (isElementConnected(element)) { - const rect = element.getBoundingClientRect(); - const centerY = rect.top + rect.height / 2; - - actions.setPointer({ x: session.position.x, y: centerY }); - actions.setFrozenElements(elements); - actions.setInputText(session.context.prompt); - actions.setWasActivatedByToggle(true); - - if (agent) { - actions.setSelectedAgent(agent); - } - - if (!isActivated()) { - activateRenderer(); - } - } - }; - - const wrapAgentWithCallbacks = (agent: AgentOptions): AgentOptions => { - return { - ...agent, - onAbort: (session: AgentSession, elements: Element[]) => { - agent.onAbort?.(session, elements); - restoreInputFromSession(session, elements, agent); - }, - onUndo: (session: AgentSession, elements: Element[]) => { - agent.onUndo?.(session, elements); - restoreInputFromSession(session, elements, agent); - }, - }; - }; - - const getAgentOptionsWithCallbacks = () => { - const agent = getAgentFromActions(); - if (!agent) return undefined; - return wrapAgentWithCallbacks(agent); - }; - - const agentManager = createAgentManager(getAgentOptionsWithCallbacks(), { - transformAgentContext: pluginRegistry.hooks.transformAgentContext, - }); - - const handleInputSubmit = () => { - actions.clearLastCopied(); - const frozenElements = [...store.frozenElements]; - const element = store.frozenElement || targetElement(); - const prompt = isPromptMode() ? store.inputText.trim() : ""; - - if (!element) { - deactivateRenderer(); - return; - } - - const elements = frozenElements.length > 0 ? frozenElements : [element]; - - const currentSelectionBounds = elements.map((selectedElement) => - createElementBounds(selectedElement), - ); - const firstBounds = currentSelectionBounds[0]; - const { x: currentX, y: currentY } = getBoundsCenter(firstBounds); - const labelPositionX = currentX + store.copyOffsetFromCenterX; - - if ((store.selectedAgent || store.hasAgentProvider) && prompt) { - const currentReplySessionId = store.replySessionId; - const selectedAgent = store.selectedAgent; - - deactivateRenderer(); - - actions.clearReplySessionId(); - actions.setSelectedAgent(null); - - void agentManager.session.start({ - elements, - prompt, - position: { x: labelPositionX, y: currentY }, - selectionBounds: currentSelectionBounds, - sessionId: currentReplySessionId ?? undefined, - agent: selectedAgent - ? wrapAgentWithCallbacks(selectedAgent) - : undefined, - }); - - return; - } - - actions.setPointer({ x: currentX, y: currentY }); - actions.exitPromptMode(); - actions.clearInputText(); - actions.clearReplySessionId(); - - performCopyWithLabel({ - element, - cursorX: labelPositionX, - selectedElements: elements, - extraPrompt: prompt || undefined, - shouldDeactivateAfter: true, - }); - }; - - const handleInputCancel = () => { - actions.clearLastCopied(); - if (!isPromptMode()) return; - - if (isPendingDismiss()) { - actions.clearInputText(); - actions.clearReplySessionId(); - deactivateRenderer(); - return; - } - - actions.setPendingDismiss(true); - setSelectionLabelShakeCount((count) => count + 1); - }; - - const handleConfirmDismiss = () => { - actions.clearInputText(); - actions.clearReplySessionId(); - deactivateRenderer(); - }; - - const handleCancelDismiss = () => { - actions.setPendingDismiss(false); - }; - - const handleAgentAbort = (sessionId: string, confirmed: boolean) => { - actions.setPendingAbortSessionId(null); - if (confirmed) { - agentManager.session.abort(sessionId); - } - }; - - const handleToggleExpand = () => { - const element = store.frozenElement || targetElement(); - if (element) { - preparePromptMode(element, store.pointer.x, store.pointer.y); - } - activatePromptMode(); - }; - - const handleFollowUpSubmit = (sessionId: string, prompt: string) => { - const session = agentManager.sessions().get(sessionId); - const elements = agentManager.session.getElements(sessionId); - const sessionBounds = session?.selectionBounds ?? []; - const firstBounds = sessionBounds[0]; - if (session && elements.length > 0 && firstBounds) { - const positionX = session.position.x; - const followUpSessionId = session.context.sessionId ?? sessionId; - - agentManager.session.dismiss(sessionId); - - void agentManager.session.start({ - elements, - prompt, - position: { - x: positionX, - y: firstBounds.y + firstBounds.height / 2, - }, - selectionBounds: sessionBounds, - sessionId: followUpSessionId, - }); - } - }; - - const handleAcknowledgeError = (sessionId: string) => { - const prompt = agentManager.session.acknowledgeError(sessionId); - if (prompt) { - actions.setInputText(prompt); - } - }; - - const handleToggleActive = () => { - if (isActivated()) { - deactivateRenderer(); - } else if (isEnabled()) { - const defaultActionId = - currentToolbarState()?.defaultAction ?? DEFAULT_ACTION_ID; - if (defaultActionId === DEFAULT_ACTION_ID) { - actions.setPendingCommentMode(true); - } else { - pendingDefaultActionId = defaultActionId; - isPendingContextMenuSelect = true; - } - toggleActivate(); - } - }; - - const enterCommentModeForElement = ( - element: Element, - positionX: number, - positionY: number, - ) => { - actions.setPendingCommentMode(false); - actions.clearInputText(); - actions.enterPromptMode({ x: positionX, y: positionY }, element); - }; - - const openContextMenu = (element: Element, position: Position) => { - actions.showContextMenu(position, element); - clearArrowNavigation(); - dismissAllPopups(); - pluginRegistry.hooks.onContextMenu(element, position); - }; - - const runPendingDefaultAction = (element: Element, position: Position) => { - const actionId = pendingDefaultActionId; - pendingDefaultActionId = null; - if (!actionId) return; - - const action = pluginRegistry.store.actions.find( - (registeredAction) => registeredAction.id === actionId, - ); - if (!action) { - handleSetDefaultAction(DEFAULT_ACTION_ID); - openContextMenu(element, position); - return; - } - - const elementBounds = createElementBounds(element); - const context = buildActionContext({ - element, - filePath: store.selectionFilePath ?? undefined, - lineNumber: store.selectionLineNumber ?? undefined, - tagName: getTagName(element) || undefined, - componentName: resolvedComponentName(), - position, - shouldDeferHideContextMenu: false, - performWithFeedbackOptions: { - fallbackBounds: elementBounds, - fallbackSelectionBounds: [elementBounds], - position, - }, - }); - action.onAction(context); - }; - - const handleComment = () => { - if (!isEnabled()) return; - - const isAlreadyInCommentMode = isActivated() && isCommentMode(); - if (isAlreadyInCommentMode) { - deactivateRenderer(); - return; - } - - actions.setPendingCommentMode(true); - if (!isActivated()) { - toggleActivate(); - } - }; - - const handleToggleEnabled = () => { - const newEnabled = !isEnabled(); - setIsEnabled(newEnabled); - updateToolbarState({ enabled: newEnabled }); - if (!newEnabled) { - forceDeactivateAll(); - dismissAllPopups(); - } - }; - - const handlePointerMove = (clientX: number, clientY: number) => { - if ( - !isEnabled() || - isPromptMode() || - isFrozenPhase() || - store.contextMenuPosition !== null - ) - return; - - actions.setPointer({ x: clientX, y: clientY }); - - elementDetectionState.latestPointerX = clientX; - elementDetectionState.latestPointerY = clientY; - - const now = performance.now(); - const isDetectionPending = - elementDetectionState.pendingDetectionScheduledAt > 0 && - now - elementDetectionState.pendingDetectionScheduledAt < - PENDING_DETECTION_STALENESS_MS; - if ( - now - elementDetectionState.lastDetectionTimestamp >= - ELEMENT_DETECTION_THROTTLE_MS && - !isDetectionPending - ) { - elementDetectionState.lastDetectionTimestamp = now; - elementDetectionState.pendingDetectionScheduledAt = now; - onIdle(() => { - const candidate = getElementAtPosition( - elementDetectionState.latestPointerX, - elementDetectionState.latestPointerY, - ); - if (candidate !== store.detectedElement) { - actions.setDetectedElement(candidate); - } - elementDetectionState.pendingDetectionScheduledAt = 0; - }); - } - - if (isDragging()) { - scheduleDragPreviewUpdate(clientX, clientY); - - const direction = getAutoScrollDirection(clientX, clientY); - const isNearEdge = - direction.top || - direction.bottom || - direction.left || - direction.right; - - if (isNearEdge && !autoScroller.isActive()) { - autoScroller.start(); - } else if (!isNearEdge && autoScroller.isActive()) { - autoScroller.stop(); - } - } - }; - - const handlePointerDown = (clientX: number, clientY: number) => { - if (!isRendererActive() || isCopying()) return false; - - actions.startDrag({ x: clientX, y: clientY }); - actions.setPointer({ x: clientX, y: clientY }); - document.body.style.userSelect = "none"; - - scheduleDragPreviewUpdate(clientX, clientY); - - pluginRegistry.hooks.onDragStart( - clientX + window.scrollX, - clientY + window.scrollY, - ); - - return true; - }; - - const handleDragSelection = ( - dragSelectionRect: ReturnType, - hasModifierKeyHeld: boolean, - ) => { - const elements = getElementsInDrag( - dragSelectionRect, - isValidGrabbableElement, - ); - const selectedElements = - elements.length > 0 - ? elements - : getElementsInDrag( - dragSelectionRect, - isValidGrabbableElement, - false, - ); - - if (selectedElements.length === 0) return; - - freezeAllAnimations(selectedElements); - - pluginRegistry.hooks.onDragEnd(selectedElements, dragSelectionRect); - const firstElement = selectedElements[0]; - const center = getElementCenter(firstElement); - - actions.setPointer(center); - actions.setFrozenElements(selectedElements); - const dragRect = createPageRectFromBounds(dragSelectionRect); - actions.setFrozenDragRect(dragRect); - actions.freeze(); - actions.setLastGrabbed(firstElement); - - if (store.pendingCommentMode) { - enterCommentModeForElement(firstElement, center.x, center.y); - return; - } - - if (isPendingContextMenuSelect) { - isPendingContextMenuSelect = false; - if (pendingDefaultActionId) { - runPendingDefaultAction(firstElement, center); - } else { - openContextMenu(firstElement, center); - } - return; - } - - const shouldDeactivateAfter = - store.wasActivatedByToggle && !hasModifierKeyHeld; - - performCopyWithLabel({ - element: firstElement, - cursorX: center.x, - selectedElements, - shouldDeactivateAfter, - dragRect, - }); - }; - - const handleSingleClick = ( - clientX: number, - clientY: number, - hasModifierKeyHeld: boolean, - ) => { - const validFrozenElement = isElementConnected(store.frozenElement) - ? store.frozenElement - : null; - - const validKeyboardSelectedElement = isElementConnected( - keyboardSelectedElement, - ) - ? keyboardSelectedElement - : null; - - const element = - validFrozenElement ?? - validKeyboardSelectedElement ?? - getElementAtPosition(clientX, clientY) ?? - (isElementConnected(store.detectedElement) - ? store.detectedElement - : null); - if (!element) return; - - const didSelectViaKeyboard = - !validFrozenElement && validKeyboardSelectedElement === element; - - let positionX: number; - let positionY: number; - - if (validFrozenElement) { - positionX = store.pointer.x; - positionY = store.pointer.y; - } else if (didSelectViaKeyboard) { - const elementCenter = getElementCenter(element); - positionX = elementCenter.x; - positionY = elementCenter.y; - } else { - positionX = clientX; - positionY = clientY; - } - - keyboardSelectedElement = null; - - if (store.pendingCommentMode) { - enterCommentModeForElement(element, positionX, positionY); - return; - } - - if (isPendingContextMenuSelect) { - isPendingContextMenuSelect = false; - const { wasIntercepted } = - pluginRegistry.hooks.onElementSelect(element); - if (wasIntercepted) return; - - freezeAllAnimations([element]); - actions.setFrozenElement(element); - const position = { x: positionX, y: positionY }; - actions.setPointer(position); - actions.freeze(); - if (pendingDefaultActionId) { - runPendingDefaultAction(element, position); - } else { - openContextMenu(element, position); - } - return; - } - - const shouldDeactivateAfter = - store.wasActivatedByToggle && !hasModifierKeyHeld; - - actions.setLastGrabbed(element); - - performCopyWithLabel({ - element, - cursorX: positionX, - shouldDeactivateAfter, - }); - }; - - const cancelActiveDrag = () => { - if (!isDragging()) return; - actions.cancelDrag(); - autoScroller.stop(); - document.body.style.userSelect = ""; - }; - - const handlePointerUp = ( - clientX: number, - clientY: number, - hasModifierKeyHeld: boolean, - ) => { - if (!isDragging()) return; - - if (dragPreviewDebounceTimerId !== null) { - clearTimeout(dragPreviewDebounceTimerId); - dragPreviewDebounceTimerId = null; - } - setDebouncedDragPointer(null); - - const dragDistance = calculateDragDistance(clientX, clientY); - const wasDragGesture = - dragDistance.x > DRAG_THRESHOLD_PX || - dragDistance.y > DRAG_THRESHOLD_PX; - - // HACK: Calculate drag rectangle BEFORE ending drag, because endDrag resets dragStart - const dragSelectionRect = wasDragGesture - ? calculateDragRectangle(clientX, clientY) - : null; - - if (wasDragGesture) { - actions.endDrag(); - } else { - actions.cancelDrag(); - } - autoScroller.stop(); - document.body.style.userSelect = ""; - - if (dragSelectionRect) { - handleDragSelection(dragSelectionRect, hasModifierKeyHeld); - } else { - handleSingleClick(clientX, clientY, hasModifierKeyHeld); - } - }; - - const eventListenerManager = createEventListenerManager(); - - const keyboardClaimer = setupKeyboardEventClaimer(); - - const blockEnterIfNeeded = (event: KeyboardEvent) => { - let originalKey: string; - try { - originalKey = keyboardClaimer.originalKeyDescriptor?.get - ? keyboardClaimer.originalKeyDescriptor.get.call(event) - : event.key; - } catch { - return false; - } - const isEnterKey = originalKey === "Enter" || isEnterCode(event.code); - const isOverlayActive = isActivated() || isHoldingKeys(); - const shouldBlockEnter = - isEnterKey && - isOverlayActive && - !isPromptMode() && - !store.wasActivatedByToggle && - clearPromptPosition() === null; - - if (shouldBlockEnter) { - keyboardClaimer.claimedEvents.add(event); - event.preventDefault(); - event.stopImmediatePropagation(); - return true; - } - return false; - }; - - eventListenerManager.addDocumentListener("keydown", blockEnterIfNeeded, { - capture: true, - }); - eventListenerManager.addDocumentListener("keyup", blockEnterIfNeeded, { - capture: true, - }); - eventListenerManager.addDocumentListener("keypress", blockEnterIfNeeded, { - capture: true, - }); - - const handleUndoRedoKeys = (event: KeyboardEvent): boolean => { - const isUndoOrRedo = - event.code === "KeyZ" && (event.metaKey || event.ctrlKey); - - if (!isUndoOrRedo) return false; - - const hasActiveConfirmation = Array.from( - agentManager.sessions().values(), - ).some((session) => !session.isStreaming && !session.error); - - if (hasActiveConfirmation) return false; - - const isRedo = event.shiftKey; - - if (isRedo && agentManager.canRedo()) { - event.preventDefault(); - event.stopPropagation(); - agentManager.history.redo(); - return true; - } else if (!isRedo && agentManager.canUndo()) { - event.preventDefault(); - event.stopPropagation(); - agentManager.history.undo(); - return true; - } - - return false; - }; - - const clearArrowNavigation = () => { - setArrowNavigationElements([]); - setArrowNavigationActiveIndex(0); - arrowNavigator.clearHistory(); - }; - - const selectAndFocusElement = (element: Element) => { - actions.setFrozenElement(element); - actions.freeze(); - keyboardSelectedElement = element; - - const { center } = getElementBoundsCenter(element); - actions.setPointer(center); - - if (store.contextMenuPosition !== null) { - actions.showContextMenu(center, element); - } - }; - - const openArrowNavigationMenu = (anchorElement: Element) => { - const bounds = createElementBounds(anchorElement); - const probePoint = getVisibleBoundsCenter(bounds); - const elementsAtPoint = getElementsAtPoint(probePoint.x, probePoint.y) - .filter(isValidGrabbableElement) - .reverse(); - - setArrowNavigationElements(elementsAtPoint); - setArrowNavigationActiveIndex( - Math.max(0, elementsAtPoint.indexOf(anchorElement)), - ); - }; - - const handleArrowNavigationSelect = (index: number) => { - const targetElement = arrowNavigationElements()[index]; - if (!targetElement) return; - - setArrowNavigationActiveIndex(index); - arrowNavigator.clearHistory(); - selectAndFocusElement(targetElement); - }; - - const handleArrowNavigation = (event: KeyboardEvent): boolean => { - if (!isActivated() || isPromptMode()) return false; - if (!ARROW_KEYS.has(event.key)) return false; - - let currentElement = effectiveElement(); - const isInitialSelection = !currentElement; - - if (!currentElement) { - currentElement = getElementAtPosition( - window.innerWidth / 2, - window.innerHeight / 2, - ); - } - - if (!currentElement) return false; - - const isVertical = event.key === "ArrowUp" || event.key === "ArrowDown"; - - if (!isVertical) { - clearArrowNavigation(); - const nextElement = arrowNavigator.findNext(event.key, currentElement); - if (!nextElement && !isInitialSelection) return false; - event.preventDefault(); - event.stopPropagation(); - selectAndFocusElement(nextElement ?? currentElement); - return true; - } - - if (arrowNavigationElements().length === 0) { - openArrowNavigationMenu(currentElement); - } - - const nextElement = arrowNavigator.findNext(event.key, currentElement); - const elementToSelect = nextElement ?? currentElement; - - event.preventDefault(); - event.stopPropagation(); - selectAndFocusElement(elementToSelect); - - const newIndex = arrowNavigationElements().indexOf(elementToSelect); - if (newIndex !== -1) { - setArrowNavigationActiveIndex(newIndex); - } else { - openArrowNavigationMenu(elementToSelect); - } - - return true; - }; - - const handleEnterKeyActivation = (event: KeyboardEvent): boolean => { - if (!isEnterCode(event.code)) return false; - if (isKeyboardEventTriggeredByInput(event)) return false; - - const copiedElement = store.lastCopiedElement; - const canActivateFromCopied = - !isHoldingKeys() && - !isPromptMode() && - !isActivated() && - copiedElement && - isElementConnected(copiedElement) && - !store.labelInstances.some( - (instance) => - instance.status === "copied" || instance.status === "fading", - ); - - if (canActivateFromCopied) { - event.preventDefault(); - event.stopImmediatePropagation(); - - const center = getElementCenter(copiedElement); - - actions.setPointer(center); - preparePromptMode(copiedElement, center.x, center.y); - actions.setFrozenElement(copiedElement); - actions.clearLastCopied(); - - activatePromptMode(); - if (!isActivated()) { - activateRenderer(); - } - return true; - } - - const canActivateFromHolding = isHoldingKeys() && !isPromptMode(); - - if (canActivateFromHolding) { - event.preventDefault(); - event.stopImmediatePropagation(); - - const element = store.frozenElement || targetElement(); - if (element) { - preparePromptMode(element, store.pointer.x, store.pointer.y); - } - - actions.setPointer({ x: store.pointer.x, y: store.pointer.y }); - if (element) { - actions.setFrozenElement(element); - } - activatePromptMode(); - - if (keydownSpamTimerId !== null) { - window.clearTimeout(keydownSpamTimerId); - keydownSpamTimerId = null; - } - - if (!isActivated()) { - activateRenderer(); - } - - return true; - } - - return false; - }; - - const handleOpenFileShortcut = (event: KeyboardEvent): boolean => { - if (event.key?.toLowerCase() !== "o" || isPromptMode()) return false; - if (!isActivated() || !(event.metaKey || event.ctrlKey)) return false; - - const filePath = store.selectionFilePath; - const lineNumber = store.selectionLineNumber; - if (!filePath) return false; - - event.preventDefault(); - event.stopPropagation(); - - const wasHandled = pluginRegistry.hooks.onOpenFile( - filePath, - lineNumber ?? undefined, - ); - if (!wasHandled) { - openFile( - filePath, - lineNumber ?? undefined, - pluginRegistry.hooks.transformOpenFileUrl, - ); - } - return true; - }; - - const clearActionCycleIdleTimeout = () => { - if (actionCycleIdleTimeoutId !== null) { - window.clearTimeout(actionCycleIdleTimeoutId); - actionCycleIdleTimeoutId = null; - } - }; - - const resetActionCycle = () => { - clearActionCycleIdleTimeout(); - setActionCycleItems([]); - setActionCycleActiveIndex(null); - }; - - const canCycleActions = createMemo(() => { - const element = selectionElement(); - return ( - Boolean(element) && - isRendererActive() && - !isPromptMode() && - !isDragging() && - store.contextMenuPosition === null - ); - }); - - const activationBaseKey = createMemo(() => { - const { key } = getModifiersFromActivationKey( - pluginRegistry.store.options.activationKey, - ); - return (key ?? "c").toUpperCase(); - }); - - const actionCycleState = createMemo(() => ({ - items: actionCycleItems(), - activeIndex: actionCycleActiveIndex(), - isVisible: - actionCycleActiveIndex() !== null && - actionCycleItems().length > 0 && - !isCommentMode(), - })); - - const arrowNavigationItems = createMemo(() => - arrowNavigationElements().map((element) => ({ - tagName: getTagName(element) || "element", - componentName: getComponentDisplayName(element) ?? undefined, - })), - ); - - const arrowNavigationState = createMemo(() => ({ - items: arrowNavigationItems(), - activeIndex: arrowNavigationActiveIndex(), - isVisible: arrowNavigationElements().length > 0, - })); - - const inspectAncestorElements = createMemo((): Element[] => { - if (!isInspectMode()) return []; - const element = effectiveElement(); - if (!element) return []; - return [...getAncestorElements(element).reverse(), element]; - }); - - const inspectNavigationItems = createMemo(() => - inspectAncestorElements().map((element) => ({ - tagName: getTagName(element) || "element", - componentName: getComponentDisplayName(element) ?? undefined, - })), - ); - - const [inspectActiveIndex, setInspectActiveIndex] = createSignal(-1); - - createEffect( - on(inspectAncestorElements, (elements) => { - setInspectActiveIndex(elements.length - 1); - }), - ); - - const inspectNavigationState = createMemo(() => { - const elements = inspectAncestorElements(); - return { - items: inspectNavigationItems(), - activeIndex: inspectActiveIndex(), - isVisible: isInspectMode() && elements.length > 0, - }; - }); - - const handleInspectSelect = (index: number) => { - setInspectActiveIndex(index); - }; - - createEffect( - on(selectionElement, () => { - resetActionCycle(); - }), - ); - - createEffect( - on(canCycleActions, (isEnabled) => { - if (!isEnabled) { - resetActionCycle(); - } - }), - ); - - const getActionById = (actionId: string): ContextMenuAction | undefined => - pluginRegistry.store.actions.find((action) => action.id === actionId); - - const getActionCycleContext = (): ContextMenuActionContext | undefined => { - const element = selectionElement(); - if (!element) return undefined; - - const fallbackBounds = selectionBounds(); - - return buildActionContext({ - element, - filePath: store.selectionFilePath ?? undefined, - lineNumber: store.selectionLineNumber ?? undefined, - tagName: getTagName(element) || undefined, - componentName: resolvedComponentName(), - position: store.pointer, - performWithFeedbackOptions: { - fallbackBounds, - fallbackSelectionBounds: fallbackBounds ? [fallbackBounds] : [], - }, - shouldDeferHideContextMenu: false, - onBeforePrompt: resetActionCycle, - }); - }; - - const availableActionCycleItems = createMemo((): ActionCycleItem[] => { - if (!selectionElement()) return []; - - const cycleItems: ActionCycleItem[] = []; - for (const action of pluginRegistry.store.actions) { - const isStaticallyDisabled = - typeof action.enabled === "boolean" && !action.enabled; - if (isStaticallyDisabled) continue; - const hasNonMatchingShortcut = - action.shortcut && - action.shortcut.toUpperCase() !== activationBaseKey(); - if (hasNonMatchingShortcut) continue; - cycleItems.push({ - id: action.id, - label: action.label, - shortcut: action.shortcut, - }); - } - return cycleItems; - }); - - const scheduleActionCycleActivation = () => { - clearActionCycleIdleTimeout(); - actionCycleIdleTimeoutId = window.setTimeout(() => { - actionCycleIdleTimeoutId = null; - const activeIndex = actionCycleActiveIndex(); - const items = actionCycleItems(); - if (activeIndex === null || items.length === 0) return; - const selectedItem = items[activeIndex]; - if (!selectedItem) return; - const action = getActionById(selectedItem.id); - if (!action) { - resetActionCycle(); - return; - } - const context = getActionCycleContext(); - if (!context || !resolveActionEnabled(action, context)) { - resetActionCycle(); - return; - } - resetActionCycle(); - const result = action.onAction(context); - if (result instanceof Promise) { - void result; - } - }, ACTION_CYCLE_IDLE_TRIGGER_MS); - }; - - const advanceActionCycle = (): boolean => { - if (!canCycleActions()) return false; - const cycleItems = availableActionCycleItems(); - if (cycleItems.length === 0) return false; - - setActionCycleItems(cycleItems); - - const currentIndex = actionCycleActiveIndex(); - const isCurrentIndexValid = - currentIndex !== null && currentIndex < cycleItems.length; - const nextIndex = isCurrentIndexValid - ? (currentIndex + 1) % cycleItems.length - : 0; - - setActionCycleActiveIndex(nextIndex); - scheduleActionCycleActivation(); - return true; - }; - - const handleActionCycleKey = (event: KeyboardEvent): boolean => { - if (!keyMatchesCode(activationBaseKey(), event.code)) return false; - if (event.altKey || event.repeat) return false; - if (isKeyboardEventTriggeredByInput(event)) return false; - if (!advanceActionCycle()) return false; - - event.preventDefault(); - event.stopPropagation(); - if (event.metaKey || event.ctrlKey) { - event.stopImmediatePropagation(); - } - return true; - }; - - const handleActivationKeys = (event: KeyboardEvent): void => { - if ( - !pluginRegistry.store.options.allowActivationInsideInput && - isKeyboardEventTriggeredByInput(event) - ) { - return; - } - - if (!isTargetKeyCombination(event, pluginRegistry.store.options)) { - if ( - (event.metaKey || event.ctrlKey) && - !MODIFIER_KEYS.includes(event.key) && - !isEnterCode(event.code) - ) { - if (isActivated() && !store.wasActivatedByToggle) { - deactivateRenderer(); - } else if (isHoldingKeys()) { - clearHoldTimer(); - resetCopyConfirmation(); - actions.releaseHold(); - } - } - if (!isEnterCode(event.code) || !isHoldingKeys()) { - return; - } - } - - if ((isActivated() || isHoldingKeys()) && !isPromptMode()) { - event.preventDefault(); - if (isEnterCode(event.code)) { - event.stopImmediatePropagation(); - } - } - - if (isActivated()) { - if ( - store.wasActivatedByToggle && - pluginRegistry.store.options.activationMode !== "hold" - ) - return; - if (event.repeat) return; - - if (keydownSpamTimerId !== null) { - window.clearTimeout(keydownSpamTimerId); - } - keydownSpamTimerId = window.setTimeout(() => { - deactivateRenderer(); - }, KEYDOWN_SPAM_TIMEOUT_MS); - return; - } - - if (isHoldingKeys() && event.repeat) { - if (activationHoldState.copyWaiting) { - const shouldActivate = activationHoldState.holdTimerFired; - resetCopyConfirmation(); - if (shouldActivate) { - actions.activate(); - } - } - return; - } - - if (isCopying() || didJustCopy()) return; - - if (!isHoldingKeys()) { - const keyHoldDuration = - pluginRegistry.store.options.keyHoldDuration ?? - DEFAULT_KEY_HOLD_DURATION_MS; - - let activationDuration = keyHoldDuration; - if (isKeyboardEventTriggeredByInput(event)) { - if (hasTextSelectionInInput(event)) { - activationDuration += INPUT_TEXT_SELECTION_ACTIVATION_DELAY_MS; - } else { - activationDuration += INPUT_FOCUS_ACTIVATION_DELAY_MS; - } - } else if (hasTextSelectionOnPage()) { - activationDuration += INPUT_TEXT_SELECTION_ACTIVATION_DELAY_MS; - } - resetCopyConfirmation(); - actions.startHold(activationDuration); - } - }; - - eventListenerManager.addWindowListener( - "keydown", - (event: KeyboardEvent) => { - blockEnterIfNeeded(event); - - if (event.key === "Shift" && !event.repeat && isActivated()) { - setIsInspectMode(true); - if (isFrozenPhase()) { - actions.unfreeze(); - clearArrowNavigation(); - } - } - - if (!isEnabled()) { - if ( - isTargetKeyCombination(event, pluginRegistry.store.options) && - !event.repeat - ) { - setToolbarShakeCount((count) => count + 1); - } - return; - } - - if (handleUndoRedoKeys(event)) return; - - const isEnterToActivateInput = - isEnterCode(event.code) && isHoldingKeys() && !isPromptMode(); - - const isFromReactGrabInput = isEventFromOverlay( - event, - "data-react-grab-input", - ); - if ( - isPromptMode() && - isTargetKeyCombination(event, pluginRegistry.store.options) && - !event.repeat && - !isFromReactGrabInput - ) { - event.preventDefault(); - event.stopPropagation(); - handleInputCancel(); - return; - } - - if (event.key === "Escape" && clearPromptPosition() !== null) { - return; - } - - if (event.key === "Escape" && commentsDropdownPosition() !== null) { - dismissCommentsDropdown(); - return; - } - - if (event.key === "Escape" && toolbarMenuPosition() !== null) { - dismissToolbarMenu(); - return; - } - - const isFromOverlay = - isEventFromOverlay(event, "data-react-grab-ignore-events") && - !isEnterToActivateInput; - - if (isPromptMode() || isFromOverlay) { - if (event.key === "Escape") { - if (store.pendingAbortSessionId) { - event.preventDefault(); - event.stopPropagation(); - actions.setPendingAbortSessionId(null); - } else if (isPromptMode()) { - handleInputCancel(); - } else if (store.wasActivatedByToggle) { - deactivateRenderer(); - } - } - - if (isFromOverlay && ARROW_KEYS.has(event.key)) { - if (handleArrowNavigation(event)) return; - } - - return; - } - - if (event.key === "Escape") { - if (store.pendingAbortSessionId) { - event.preventDefault(); - event.stopPropagation(); - actions.setPendingAbortSessionId(null); - return; - } - - if (agentManager.isProcessing()) { - return; - } - - if (isHoldingKeys() || store.wasActivatedByToggle) { - deactivateRenderer(); - return; - } - } - - const didWindowJustRegainFocus = - Date.now() - lastWindowFocusTimestamp < - WINDOW_REFOCUS_GRACE_PERIOD_MS; - - if (!didWindowJustRegainFocus && handleActionCycleKey(event)) return; - if (handleArrowNavigation(event)) return; - if (handleEnterKeyActivation(event)) return; - if (handleOpenFileShortcut(event)) return; - - if (!didWindowJustRegainFocus) { - handleActivationKeys(event); - } - }, - { capture: true }, - ); - - eventListenerManager.addWindowListener( - "keyup", - (event: KeyboardEvent) => { - if (blockEnterIfNeeded(event)) return; - - if (event.key === "Shift") { - setIsInspectMode(false); - } - - const requiredModifiers = getRequiredModifiers( - pluginRegistry.store.options, - ); - const isReleasingModifier = - requiredModifiers.metaKey || requiredModifiers.ctrlKey - ? isMac() - ? !event.metaKey - : !event.ctrlKey - : (requiredModifiers.shiftKey && !event.shiftKey) || - (requiredModifiers.altKey && !event.altKey); - - const isReleasingActivationKey = pluginRegistry.store.options - .activationKey - ? typeof pluginRegistry.store.options.activationKey === "function" - ? pluginRegistry.store.options.activationKey(event) - : parseActivationKey(pluginRegistry.store.options.activationKey)( - event, - ) - : isCLikeKey(event.key, event.code); - - if (didJustCopy() || isCopyFeedbackCooldownActive) { - if (isReleasingActivationKey || isReleasingModifier) { - clearCopyFeedbackCooldown(); - deactivateRenderer(); - } - return; - } - - if (!isHoldingKeys() && !isActivated()) return; - if (isPromptMode()) return; - - const hasCustomShortcut = Boolean( - pluginRegistry.store.options.activationKey, - ); - - const isHoldMode = - pluginRegistry.store.options.activationMode === "hold"; - - if (isActivated()) { - const hasContextMenu = store.contextMenuPosition !== null; - if (isReleasingModifier) { - if ( - store.wasActivatedByToggle && - pluginRegistry.store.options.activationMode !== "hold" - ) - return; - if (hasContextMenu) return; - deactivateRenderer(); - } else if (isHoldMode && isReleasingActivationKey) { - if (keydownSpamTimerId !== null) { - window.clearTimeout(keydownSpamTimerId); - keydownSpamTimerId = null; - } - if (hasContextMenu) return; - deactivateRenderer(); - } else if ( - !hasCustomShortcut && - isReleasingActivationKey && - keydownSpamTimerId !== null - ) { - window.clearTimeout(keydownSpamTimerId); - keydownSpamTimerId = null; - } - return; - } - - if (isReleasingActivationKey || isReleasingModifier) { - if ( - store.wasActivatedByToggle && - pluginRegistry.store.options.activationMode !== "hold" - ) - return; - - const shouldRelease = - isHoldingKeys() || - (activationHoldState.holdTimerFired && isReleasingModifier); - - if (shouldRelease) { - clearHoldTimer(); - const elapsedSinceHoldStart = activationHoldState.startTimestamp - ? Date.now() - activationHoldState.startTimestamp - : 0; - const heldLongEnoughForActivation = - elapsedSinceHoldStart >= MIN_HOLD_FOR_ACTIVATION_AFTER_COPY_MS; - const shouldActivateAfterCopy = - activationHoldState.holdTimerFired && - heldLongEnoughForActivation && - (pluginRegistry.store.options.allowActivationInsideInput || - !isKeyboardEventTriggeredByInput(event)); - resetCopyConfirmation(); - if (shouldActivateAfterCopy) { - actions.activate(); - } else { - actions.releaseHold(); - } - } else { - deactivateRenderer(); - } - } - }, - { capture: true }, - ); - - eventListenerManager.addDocumentListener("copy", () => { - if (isHoldingKeys()) { - activationHoldState.copyWaiting = true; - } - }); - - eventListenerManager.addWindowListener("keypress", blockEnterIfNeeded, { - capture: true, - }); - - eventListenerManager.addWindowListener( - "pointermove", - (event: PointerEvent) => { - if (!event.isPrimary) return; - const isTouchPointer = event.pointerType === "touch"; - actions.setTouchMode(isTouchPointer); - if (isEventFromOverlay(event, "data-react-grab-ignore-events")) return; - if (store.contextMenuPosition !== null) return; - if (isTouchPointer && !isHoldingKeys() && !isActivated()) return; - const isActiveState = isTouchPointer ? isHoldingKeys() : isActivated(); - if (isActiveState && !isPromptMode() && isFrozenPhase()) { - actions.unfreeze(); - clearArrowNavigation(); - } - handlePointerMove(event.clientX, event.clientY); - }, - { passive: true }, + pluginRegistry.provideRendererProp( + "selectionElementsCount", + () => store.frozenElements.length, ); - - eventListenerManager.addWindowListener( - "pointerdown", - (event: PointerEvent) => { - if (event.button !== 0) return; - if (!event.isPrimary) return; - actions.setTouchMode(event.pointerType === "touch"); - if (isEventFromOverlay(event, "data-react-grab-ignore-events")) return; - if (store.contextMenuPosition !== null) return; - if (toolbarMenuPosition() !== null) return; - - if (isPromptMode()) { - const bounds = selectionBounds(); - const isClickOnSelection = - bounds && - event.clientX >= bounds.x && - event.clientX <= bounds.x + bounds.width && - event.clientY >= bounds.y && - event.clientY <= bounds.y + bounds.height; - - if (isClickOnSelection) { - void handleInputSubmit(); - } else { - handleInputCancel(); - } - return; - } - - const didHandle = handlePointerDown(event.clientX, event.clientY); - if (didHandle) { - document.documentElement.setPointerCapture(event.pointerId); - event.preventDefault(); - event.stopImmediatePropagation(); - } - }, - { capture: true }, - ); - - eventListenerManager.addWindowListener( - "pointerup", - (event: PointerEvent) => { - if (event.button !== 0) return; - if (!event.isPrimary) return; - if (isEventFromOverlay(event, "data-react-grab-ignore-events")) return; - if (store.contextMenuPosition !== null) return; - const isActive = isRendererActive() || isCopying() || isDragging(); - const hasModifierKeyHeld = event.metaKey || event.ctrlKey; - handlePointerUp(event.clientX, event.clientY, hasModifierKeyHeld); - if (isActive) { - event.preventDefault(); - event.stopImmediatePropagation(); - } - }, - { capture: true }, - ); - - eventListenerManager.addWindowListener( - "contextmenu", - (event: MouseEvent) => { - if (!isRendererActive() || isCopying() || isPromptMode()) return; - - const isFromOverlay = isEventFromOverlay( - event, - "data-react-grab-ignore-events", - ); - if (isFromOverlay && arrowNavigationElements().length > 0) { - clearArrowNavigation(); - } else if (isFromOverlay) { - return; - } - - if (store.contextMenuPosition !== null) { - event.preventDefault(); - return; - } - - event.preventDefault(); - event.stopPropagation(); - - const element = getElementAtPosition(event.clientX, event.clientY); - if (!element) return; - - const existingFrozenElements = store.frozenElements; - const isClickedElementAlreadyFrozen = - existingFrozenElements.length > 1 && - existingFrozenElements.includes(element); - - if (isClickedElementAlreadyFrozen) { - freezeAllAnimations(existingFrozenElements); - } else { - freezeAllAnimations([element]); - actions.setFrozenElement(element); - } - - const position = { x: event.clientX, y: event.clientY }; - actions.setPointer(position); - actions.freeze(); - openContextMenu(element, position); - }, - { capture: true }, - ); - - eventListenerManager.addWindowListener( - "pointercancel", - (event: PointerEvent) => { - if (!event.isPrimary) return; - cancelActiveDrag(); - }, - ); - - eventListenerManager.addWindowListener( - "click", - (event: MouseEvent) => { - if (isEventFromOverlay(event, "data-react-grab-ignore-events")) return; - if (store.contextMenuPosition !== null) return; - - if (isRendererActive() || isCopying() || didJustDrag()) { - event.preventDefault(); - event.stopImmediatePropagation(); - - if (store.wasActivatedByToggle && !isCopying() && !isPromptMode()) { - if (!isHoldingKeys()) { - deactivateRenderer(); - } else { - actions.setWasActivatedByToggle(false); - } - } - } - }, - { capture: true }, + pluginRegistry.provideRendererProp("mouseX", () => + store.frozenElements.length > 1 ? undefined : cursorPosition().x, ); + pluginRegistry.provideRendererProp("selectionLabelStatus", () => "idle"); - eventListenerManager.addDocumentListener("visibilitychange", () => { - if (document.hidden) { - actions.clearGrabbedBoxes(); - const storeActivationTimestamp = store.activationTimestamp; - if ( - isActivated() && - !isPromptMode() && - storeActivationTimestamp !== null && - Date.now() - storeActivationTimestamp > BLUR_DEACTIVATION_THRESHOLD_MS - ) { - deactivateRenderer(); - } - } - }); - - eventListenerManager.addWindowListener("blur", () => { - cancelActiveDrag(); - if (isHoldingKeys()) { - clearHoldTimer(); - actions.releaseHold(); - resetCopyConfirmation(); - } - }); - - eventListenerManager.addWindowListener("focus", () => { - lastWindowFocusTimestamp = Date.now(); + createEffect(() => { + const element = store.detectedElement; + if (!element) return; + const intervalId = setInterval(() => { + if (!isElementConnected(element)) actions.setDetectedElement(null); + }, BOUNDS_RECALC_INTERVAL_MS); + onCleanup(() => clearInterval(intervalId)); }); - eventListenerManager.addWindowListener( - "focusin", - (event: FocusEvent) => { - if (isEventFromOverlay(event, "data-react-grab")) { - event.stopPropagation(); - } - }, - { capture: true }, - ); - const redetectElementUnderPointer = () => { if (store.isTouchMode && !isHoldingKeys() && !isActivated()) return; + const enabled = shared.isEnabled?.() ?? true; if ( - isEnabled() && + enabled && !isPromptMode() && !isFrozenPhase() && !isDragging() && @@ -3094,7 +494,6 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { invalidateInteractionCaches(); redetectElementUnderPointer(); actions.incrementViewportVersion(); - agentManager._internal.updateBoundsOnViewportChange(); actions.updateContextMenuPosition(); }; @@ -3106,12 +505,12 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { let previousViewportHeight = window.innerHeight; eventListenerManager.addWindowListener("resize", () => { - const currentViewportWidth = window.innerWidth; - const currentViewportHeight = window.innerHeight; + const currentWidth = window.innerWidth; + const currentHeight = window.innerHeight; if (previousViewportWidth > 0 && previousViewportHeight > 0) { - const scaleX = currentViewportWidth / previousViewportWidth; - const scaleY = currentViewportHeight / previousViewportHeight; + const scaleX = currentWidth / previousViewportWidth; + const scaleY = currentHeight / previousViewportHeight; const isUniformScale = Math.abs(scaleX - scaleY) < ZOOM_DETECTION_THRESHOLD; const hasScaleChanged = Math.abs(scaleX - 1) > ZOOM_DETECTION_THRESHOLD; @@ -3124,9 +523,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { } } - previousViewportWidth = currentViewportWidth; - previousViewportHeight = currentViewportHeight; - + previousViewportWidth = currentWidth; + previousViewportHeight = currentHeight; handleViewportChange(); }); @@ -3146,11 +544,9 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const scheduleBoundsSync = () => { if (viewportChangeFrameId !== null) return; - viewportChangeFrameId = nativeRequestAnimationFrame(() => { viewportChangeFrameId = null; actions.incrementViewportVersion(); - agentManager._internal.updateBoundsOnViewportChange(); }); }; @@ -3161,1053 +557,222 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { isCopying() || store.labelInstances.length > 0 || store.grabbedBoxes.length > 0 || - agentManager.sessions().size > 0); + (shared.getAgentSessionCount?.() ?? 0) > 0); if (shouldRunInterval) { if (boundsRecalcIntervalId !== null) return; - - boundsRecalcIntervalId = window.setInterval(() => { - scheduleBoundsSync(); - }, BOUNDS_RECALC_INTERVAL_MS); - return; - } - - if (boundsRecalcIntervalId !== null) { - window.clearInterval(boundsRecalcIntervalId); - boundsRecalcIntervalId = null; - } - - if (viewportChangeFrameId !== null) { - nativeCancelAnimationFrame(viewportChangeFrameId); - viewportChangeFrameId = null; - } - }); - - onCleanup(() => { - if (boundsRecalcIntervalId !== null) { - window.clearInterval(boundsRecalcIntervalId); - } - if (viewportChangeFrameId !== null) { - nativeCancelAnimationFrame(viewportChangeFrameId); - } - }); - - eventListenerManager.addDocumentListener( - "copy", - (event: ClipboardEvent) => { - if ( - isPromptMode() || - isEventFromOverlay(event, "data-react-grab-ignore-events") - ) { - return; - } - if (isRendererActive() || isCopying()) { - event.preventDefault(); - } - }, - { capture: true }, - ); - - onCleanup(() => { - eventListenerManager.abort(); - if (dragPreviewDebounceTimerId !== null) { - window.clearTimeout(dragPreviewDebounceTimerId); - } - if (keydownSpamTimerId) window.clearTimeout(keydownSpamTimerId); - clearCopyFeedbackCooldown(); - if (actionCycleIdleTimeoutId) { - window.clearTimeout(actionCycleIdleTimeoutId); - } - if (dropdownTrackingFrameId !== null) { - nativeCancelAnimationFrame(dropdownTrackingFrameId); - } - grabbedBoxTimeouts.forEach((timeoutId) => window.clearTimeout(timeoutId)); - grabbedBoxTimeouts.clear(); - cancelAllLabelFades(); - autoScroller.stop(); - document.body.style.userSelect = ""; - document.body.style.touchAction = ""; - unlockViewportZoom?.(); - unlockViewportZoom = null; - setCursorOverride(null); - keyboardClaimer.restore(); - }); - - const resolvedCssText = typeof cssText === "string" ? cssText : ""; - const rendererRoot = mountRoot(resolvedCssText); - - const isThemeEnabled = createMemo(() => pluginRegistry.store.theme.enabled); - const isSelectionBoxThemeEnabled = createMemo( - () => pluginRegistry.store.theme.selectionBox.enabled, - ); - const isElementLabelThemeEnabled = createMemo( - () => pluginRegistry.store.theme.elementLabel.enabled, - ); - const isDragBoxThemeEnabled = createMemo( - () => pluginRegistry.store.theme.dragBox.enabled, - ); - const isSelectionSuppressed = createMemo( - () => didJustCopy() || (isToolbarSelectHovered() && !isFrozenPhase()), - ); - const hasDragPreviewBounds = createMemo( - () => dragPreviewBounds().length > 0, - ); - - const selectionVisible = createMemo(() => { - if (!isThemeEnabled()) return false; - if (!isSelectionBoxThemeEnabled()) return false; - if (isSelectionSuppressed()) return false; - if (hasDragPreviewBounds()) return true; - return isSelectionElementVisible(); - }); - - const selectionTagName = createMemo(() => { - const element = selectionElement(); - if (!element) return undefined; - return getTagName(element) || undefined; - }); - - createEffect( - on( - () => debouncedElementForComponentName(), - (element) => { - const currentVersion = ++componentNameRequestVersion; - - if (!element) { - setResolvedComponentName(undefined); - return; - } - - getNearestComponentName(element) - .then((name) => { - if (componentNameRequestVersion !== currentVersion) return; - setResolvedComponentName(name ?? undefined); - }) - .catch(() => { - if (componentNameRequestVersion !== currentVersion) return; - setResolvedComponentName(undefined); - }); - }, - ), - ); - - const selectionLabelVisible = createMemo(() => { - if (store.contextMenuPosition !== null) return false; - if (!isElementLabelThemeEnabled()) return false; - if (isSelectionSuppressed()) return false; - - return isSelectionElementVisible(); - }); - - const labelInstanceCache = new Map(); - - const recomputeLabelInstance = ( - instance: SelectionLabelInstance, - ): SelectionLabelInstance => { - const hasMultipleElements = - instance.elements && instance.elements.length > 1; - const instanceElement = instance.element; - const canRecalculateBounds = - !hasMultipleElements && - instanceElement && - document.body.contains(instanceElement); - const newBounds = canRecalculateBounds - ? createElementBounds(instanceElement) - : instance.bounds; - - const previousInstance = labelInstanceCache.get(instance.id); - const boundsUnchanged = - previousInstance && - previousInstance.bounds.x === newBounds.x && - previousInstance.bounds.y === newBounds.y && - previousInstance.bounds.width === newBounds.width && - previousInstance.bounds.height === newBounds.height; - if ( - previousInstance && - previousInstance.status === instance.status && - previousInstance.errorMessage === instance.errorMessage && - boundsUnchanged - ) { - return previousInstance; - } - const newBoundsCenterX = newBounds.x + newBounds.width / 2; - const newBoundsHalfWidth = newBounds.width / 2; - let newMouseX: number; - if (instance.mouseXOffsetRatio !== undefined && newBoundsHalfWidth > 0) { - newMouseX = - newBoundsCenterX + instance.mouseXOffsetRatio * newBoundsHalfWidth; - } else if (instance.mouseXOffsetFromCenter !== undefined) { - newMouseX = newBoundsCenterX + instance.mouseXOffsetFromCenter; - } else { - newMouseX = instance.mouseX ?? newBoundsCenterX; - } - const newCached = { ...instance, bounds: newBounds, mouseX: newMouseX }; - labelInstanceCache.set(instance.id, newCached); - return newCached; - }; - - const computedLabelInstances = createMemo(() => { - if (!isThemeEnabled()) return []; - if (!pluginRegistry.store.theme.grabbedBoxes.enabled) return []; - void store.viewportVersion; - const currentIds = new Set( - store.labelInstances.map((instance) => instance.id), - ); - for (const cachedId of labelInstanceCache.keys()) { - if (!currentIds.has(cachedId)) { - labelInstanceCache.delete(cachedId); - } - } - return store.labelInstances.map(recomputeLabelInstance); - }); - - const computedGrabbedBoxes = createMemo(() => { - if (!isThemeEnabled()) return []; - if (!pluginRegistry.store.theme.grabbedBoxes.enabled) return []; - void store.viewportVersion; - return store.grabbedBoxes.map((box) => { - if (!box.element || !document.body.contains(box.element)) { - return box; - } - return { - ...box, - bounds: createElementBounds(box.element), - }; - }); - }); - - const dragVisible = createMemo( - () => - isThemeEnabled() && - isDragBoxThemeEnabled() && - isRendererActive() && - isDraggingBeyondThreshold(), - ); - - const labelVariant = createMemo(() => - isCopying() ? "processing" : "hover", - ); - - const labelVisible = createMemo(() => { - if (!isThemeEnabled()) return false; - const themeEnabled = isElementLabelThemeEnabled(); - const inPromptMode = isPromptMode(); - const copying = isCopying(); - const rendererActive = isRendererActive(); - const dragging = isDragging(); - const hasElement = Boolean(effectiveElement()); - const toolbarSelectHovered = isToolbarSelectHovered(); - const frozen = isFrozenPhase(); - - if (!themeEnabled) return false; - if (inPromptMode) return false; - if (toolbarSelectHovered && !frozen) return false; - if (copying) return true; - return rendererActive && !dragging && hasElement; - }); - - const contextMenuBounds = createMemo((): OverlayBounds | null => { - void store.viewportVersion; - const element = store.contextMenuElement; - if (!element) return null; - return createElementBounds(element); - }); - - const contextMenuPosition = createMemo(() => { - void store.viewportVersion; - return store.contextMenuPosition; - }); - - const contextMenuTagName = createMemo(() => { - const element = store.contextMenuElement; - if (!element) return undefined; - const frozenCount = store.frozenElements.length; - if (frozenCount > 1) { - return `${frozenCount} elements`; - } - return getTagName(element) || undefined; - }); - - const [contextMenuComponentName] = createResource( - () => ({ - element: store.contextMenuElement, - frozenCount: store.frozenElements.length, - }), - async ({ element, frozenCount }) => { - if (!element) return undefined; - if (frozenCount > 1) return undefined; - const name = await getNearestComponentName(element); - return name ?? undefined; - }, - ); - - const [contextMenuFilePath] = createResource( - () => store.contextMenuElement, - async (element) => { - if (!element) return null; - return resolveSource(element); - }, - ); - - const createPerformWithFeedback = ( - element: Element, - elements: Element[], - tagName: string | undefined, - componentName: string | undefined, - options?: PerformWithFeedbackOptions, - ) => { - return async (action: () => Promise): Promise => { - const fallbackBounds = options?.fallbackBounds ?? null; - const fallbackSelectionBounds = options?.fallbackSelectionBounds ?? []; - const position = - options?.position ?? store.contextMenuPosition ?? store.pointer; - const frozenBounds = frozenElementsBounds(); - const singleElementBounds = contextMenuBounds() ?? fallbackBounds; - const hasMultipleElements = elements.length > 1; - - const labelBounds = hasMultipleElements - ? createFlatOverlayBounds(combineBounds(frozenBounds)) - : singleElementBounds; - - const shouldDeactivateAfter = store.wasActivatedByToggle; - let selectionBoundsForLabel: OverlayBounds[]; - if (hasMultipleElements) { - selectionBoundsForLabel = frozenBounds; - } else if (singleElementBounds) { - selectionBoundsForLabel = [singleElementBounds]; - } else { - selectionBoundsForLabel = fallbackSelectionBounds; - } - - actions.hideContextMenu(); - - if (labelBounds) { - const labelCursorX = hasMultipleElements - ? labelBounds.x + labelBounds.width / 2 - : position.x; - - const labelInstanceId = createLabelInstance( - labelBounds, - tagName || "element", - componentName, - "copying", - { - element, - mouseX: labelCursorX, - elements: hasMultipleElements ? elements : undefined, - boundsMultiple: selectionBoundsForLabel, - }, - ); - - let didSucceed = false; - let errorMessage: string | undefined; - - try { - didSucceed = await action(); - if (!didSucceed) { - errorMessage = "Failed to copy"; - } - } catch (error) { - errorMessage = normalizeErrorMessage(error, "Action failed"); - } - - updateLabelAfterCopy(labelInstanceId, didSucceed, errorMessage); - } else { - // HACK: Fire-and-forget when no label bounds to display feedback on - try { - await action(); - } catch (error) { - logRecoverableError("Action failed without feedback bounds", error); - } - } - - if (shouldDeactivateAfter) { - deactivateRenderer(); - } else { - actions.unfreeze(); - } - }; - }; - - // HACK: Defer hiding context menu until after click event propagates fully - const deferHideContextMenu = () => { - setTimeout(() => { - actions.hideContextMenu(); - }, 0); - }; - - const buildActionContext = ( - options: BuildActionContextOptions, - ): ContextMenuActionContext => { - const { - element, - filePath, - lineNumber, - tagName, - componentName, - position, - performWithFeedbackOptions, - shouldDeferHideContextMenu, - onBeforeCopy, - onBeforePrompt, - customEnterPromptMode, - } = options; - - const elements = - store.frozenElements.length > 0 ? store.frozenElements : [element]; - - const hideContextMenuAction = shouldDeferHideContextMenu - ? deferHideContextMenu - : actions.hideContextMenu; - - const copyAction = () => { - onBeforeCopy?.(); - performCopyWithLabel({ - element, - cursorX: position.x, - selectedElements: elements.length > 1 ? elements : undefined, - shouldDeactivateAfter: store.wasActivatedByToggle, - }); - hideContextMenuAction(); - }; - - const defaultEnterPromptMode = (agent?: AgentOptions) => { - if (agent) { - actions.setSelectedAgent(agent); - } - clearAllLabels(); - onBeforePrompt?.(); - preparePromptMode(element, position.x, position.y); - actions.setPointer({ x: position.x, y: position.y }); - actions.setFrozenElement(element); - activatePromptMode(); - if (!isActivated()) { - activateRenderer(); - } - hideContextMenuAction(); - }; - - const context: ContextMenuActionContext = { - element, - elements, - filePath, - lineNumber, - componentName, - tagName, - enterPromptMode: customEnterPromptMode ?? defaultEnterPromptMode, - copy: copyAction, - hooks: { - transformHtmlContent: pluginRegistry.hooks.transformHtmlContent, - onOpenFile: pluginRegistry.hooks.onOpenFile, - transformOpenFileUrl: pluginRegistry.hooks.transformOpenFileUrl, - }, - performWithFeedback: createPerformWithFeedback( - element, - elements, - tagName, - componentName, - performWithFeedbackOptions, - ), - hideContextMenu: hideContextMenuAction, - cleanup: () => { - if (store.wasActivatedByToggle) { - deactivateRenderer(); - } else { - actions.unfreeze(); - } - }, - }; - - const transformedContext = - pluginRegistry.hooks.transformActionContext(context); - return { ...context, ...transformedContext }; - }; - - const contextMenuActionContext = createMemo( - (): ContextMenuActionContext | undefined => { - const element = store.contextMenuElement; - if (!element) return undefined; - const fileInfo = contextMenuFilePath(); - const position = store.contextMenuPosition ?? store.pointer; - - return buildActionContext({ - element, - filePath: fileInfo?.filePath, - lineNumber: fileInfo?.lineNumber ?? undefined, - tagName: contextMenuTagName(), - componentName: contextMenuComponentName(), - position, - shouldDeferHideContextMenu: true, - onBeforeCopy: () => { - keyboardSelectedElement = null; - }, - customEnterPromptMode: (agent?: AgentOptions) => { - if (agent) { - actions.setSelectedAgent(agent); - } - clearAllLabels(); - actions.clearInputText(); - actions.enterPromptMode(position, element); - deferHideContextMenu(); - }, - }); - }, - ); - - const handleContextMenuDismiss = () => { - setTimeout(() => { - actions.hideContextMenu(); - deactivateRenderer(); - }, 0); - }; - - const clearCommentsHoverPreviews = () => { - for (const { boxId, labelId } of commentsHoverPreviews) { - actions.removeGrabbedBox(boxId); - if (labelId) { - actions.removeLabelInstance(labelId); - } - } - commentsHoverPreviews = []; - }; - - const addCommentItemPreview = ( - item: CommentItem, - previewBounds: OverlayBounds[], - previewElements: Element[], - idPrefix: string, - ) => { - if (previewBounds.length === 0) return; - - for (const [index, bounds] of previewBounds.entries()) { - const previewElement = previewElements[index]; - const boxId = `${idPrefix}-${item.id}-${index}`; - // HACK: createdAt=0 is falsy, which skips the auto-fade logic in the overlay canvas animation loop - actions.addGrabbedBox({ - id: boxId, - bounds, - createdAt: 0, - element: previewElement, - }); - - let labelId: string | null = null; - if (index === 0) { - labelId = `${idPrefix}-label-${item.id}`; - actions.addLabelInstance({ - id: labelId, - bounds, - tagName: item.tagName, - componentName: item.componentName, - elementsCount: item.elementsCount, - status: "idle", - isPromptMode: Boolean(item.commentText), - inputValue: item.commentText ?? undefined, - createdAt: 0, - element: previewElement, - mouseX: bounds.x + bounds.width / 2, - }); - } - - commentsHoverPreviews.push({ boxId, labelId }); - } - }; - - const showCommentItemPreview = ( - item: CommentItem, - idPrefix: string, - ): void => { - const connectedElements = getConnectedCommentElements(item); - const previewBounds = connectedElements.map((element) => - createElementBounds(element), - ); - addCommentItemPreview(item, previewBounds, connectedElements, idPrefix); - }; - - const stopTrackingDropdownPosition = () => { - if (dropdownTrackingFrameId !== null) { - nativeCancelAnimationFrame(dropdownTrackingFrameId); - dropdownTrackingFrameId = null; - } - }; - - const startTrackingDropdownPosition = (computePosition: () => void) => { - stopTrackingDropdownPosition(); - const updatePosition = () => { - computePosition(); - dropdownTrackingFrameId = nativeRequestAnimationFrame(updatePosition); - }; - updatePosition(); - }; - - const computeDropdownAnchor = (): DropdownAnchor | null => { - if (!toolbarElement) return null; - const toolbarRect = toolbarElement.getBoundingClientRect(); - const edge = getNearestEdge(toolbarRect); - - if (edge === "left" || edge === "right") { - return { - x: edge === "left" ? toolbarRect.right : toolbarRect.left, - y: toolbarRect.top + toolbarRect.height / 2, - edge, - toolbarWidth: toolbarRect.width, - }; - } - - return { - x: toolbarRect.left + toolbarRect.width / 2, - y: edge === "top" ? toolbarRect.bottom : toolbarRect.top, - edge, - toolbarWidth: toolbarRect.width, - }; - }; - - const openTrackedDropdown = ( - setPosition: (anchor: DropdownAnchor) => void, - ) => { - startTrackingDropdownPosition(() => { - const anchor = computeDropdownAnchor(); - if (anchor) setPosition(anchor); - }); - }; - - const dismissCommentsDropdown = () => { - cancelCommentsHoverOpenTimeout(); - cancelCommentsHoverCloseTimeout(); - stopTrackingDropdownPosition(); - clearCommentsHoverPreviews(); - setCommentsDropdownPosition(null); - setIsCommentsHoverOpen(false); - }; - - const dismissToolbarMenu = () => { - stopTrackingDropdownPosition(); - setToolbarMenuPosition(null); - }; - - const openCommentsDropdown = () => { - actions.hideContextMenu(); - dismissToolbarMenu(); - dismissClearPrompt(); - setCommentItems(loadComments()); - openTrackedDropdown(setCommentsDropdownPosition); - }; - - let commentsHoverOpenTimeoutId: ReturnType | null = null; - let commentsHoverCloseTimeoutId: ReturnType | null = - null; - - const cancelCommentsHoverOpenTimeout = () => { - if (commentsHoverOpenTimeoutId !== null) { - clearTimeout(commentsHoverOpenTimeoutId); - commentsHoverOpenTimeoutId = null; - } - }; - - const cancelCommentsHoverCloseTimeout = () => { - if (commentsHoverCloseTimeoutId !== null) { - clearTimeout(commentsHoverCloseTimeoutId); - commentsHoverCloseTimeoutId = null; - } - }; - - const scheduleCommentsHoverClose = () => { - commentsHoverCloseTimeoutId = setTimeout(() => { - commentsHoverCloseTimeoutId = null; - dismissCommentsDropdown(); - }, DROPDOWN_HOVER_OPEN_DELAY_MS); - }; - - const showClearPrompt = () => { - dismissCommentsDropdown(); - dismissToolbarMenu(); - openTrackedDropdown(setClearPromptPosition); - }; - - const dismissClearPrompt = () => { - stopTrackingDropdownPosition(); - setClearPromptPosition(null); - }; - - const dismissAllPopups = () => { - dismissCommentsDropdown(); - dismissToolbarMenu(); - dismissClearPrompt(); - }; - - const handleToggleToolbarMenu = () => { - if (toolbarMenuPosition() !== null) { - dismissToolbarMenu(); - } else { - actions.hideContextMenu(); - dismissCommentsDropdown(); - dismissClearPrompt(); - openTrackedDropdown(setToolbarMenuPosition); - } - }; - - const handleSetDefaultAction = (actionId: string) => { - updateToolbarState({ defaultAction: actionId }); - }; - - const handleToggleComments = () => { - cancelCommentsHoverOpenTimeout(); - cancelCommentsHoverCloseTimeout(); - const isCurrentlyOpen = commentsDropdownPosition() !== null; - if (isCurrentlyOpen) { - if (isCommentsHoverOpen()) { - clearCommentsHoverPreviews(); - setIsCommentsHoverOpen(false); - } else { - dismissCommentsDropdown(); - } - } else { - clearCommentsHoverPreviews(); - openCommentsDropdown(); - } - }; - - const copyCommentItemContent = (item: CommentItem) => { - copyContent(item.content, { - tagName: item.tagName, - componentName: item.componentName ?? item.elementName, - commentText: item.commentText, - }); - const element = getFirstConnectedCommentElement(item); - if (!element) return; - - clearAllLabels(); - - // HACK: defer to next frame so idle preview label clears visually before "copied" appears - nativeRequestAnimationFrame(() => { - if (!isElementConnected(element)) return; - const bounds = createElementBounds(element); - const labelId = createLabelInstance( - bounds, - item.tagName, - item.componentName, - "copied", - { element, mouseX: bounds.x + bounds.width / 2 }, + boundsRecalcIntervalId = window.setInterval( + () => scheduleBoundsSync(), + BOUNDS_RECALC_INTERVAL_MS, ); - if (labelId) scheduleLabelFade(labelId); - }); - }; - - const handleCommentItemSelect = (item: CommentItem) => { - clearCommentsHoverPreviews(); - if (isPromptMode()) { - actions.exitPromptMode(); - actions.clearInputText(); + return; } - const element = getFirstConnectedCommentElement(item); - if (item.commentText && element) { - const { center } = getElementBoundsCenter(element); - actions.enterPromptMode(center, element); - actions.setInputText(item.commentText); - } else { - copyCommentItemContent(item); + if (boundsRecalcIntervalId !== null) { + window.clearInterval(boundsRecalcIntervalId); + boundsRecalcIntervalId = null; } - }; - - const handleCommentsCopyAll = () => { - clearCommentsHoverPreviews(); - const currentCommentItems = commentItems(); - if (currentCommentItems.length === 0) return; - - const combinedContent = joinSnippets( - currentCommentItems.map((commentItem) => commentItem.content), - ); - - const firstItem = currentCommentItems[0]; - copyContent(combinedContent, { - componentName: firstItem.componentName ?? firstItem.tagName, - entries: currentCommentItems.map((commentItem) => ({ - tagName: commentItem.tagName, - componentName: commentItem.componentName ?? commentItem.elementName, - content: commentItem.content, - commentText: commentItem.commentText, - })), - }); - - if (isClearConfirmed()) { - handleCommentsClear(); - } else { - showClearPrompt(); + if (viewportChangeFrameId !== null) { + nativeCancelAnimationFrame(viewportChangeFrameId); + viewportChangeFrameId = null; } + }); - clearAllLabels(); - - // HACK: defer to next frame so idle preview labels clear visually before "copied" appears - nativeRequestAnimationFrame(() => { - batch(() => { - for (const commentItem of currentCommentItems) { - const connectedElements = getConnectedCommentElements(commentItem); - for (const element of connectedElements) { - const bounds = createElementBounds(element); - const labelId = generateId("label"); - - actions.addLabelInstance({ - id: labelId, - bounds, - tagName: commentItem.tagName, - componentName: commentItem.componentName, - status: "copied", - createdAt: Date.now(), - element, - mouseX: bounds.x + bounds.width / 2, - }); - scheduleLabelFade(labelId); - } - } - }); - }); - }; + onCleanup(() => { + if (boundsRecalcIntervalId !== null) + window.clearInterval(boundsRecalcIntervalId); + if (viewportChangeFrameId !== null) + nativeCancelAnimationFrame(viewportChangeFrameId); + }); - const handleCommentItemHover = (commentItemId: string | null) => { - clearCommentsHoverPreviews(); - if (!commentItemId) return; + eventListenerManager.addDocumentListener( + "copy", + (event: ClipboardEvent) => { + if ( + isPromptMode() || + isEventFromOverlay(event, "data-react-grab-ignore-events") + ) + return; + if (isRendererActive() || isCopying()) event.preventDefault(); + }, + { capture: true }, + ); - const item = commentItems().find( - (innerItem) => innerItem.id === commentItemId, - ); - if (!item) return; - showCommentItemPreview(item, "comment-hover"); - }; + eventListenerManager.addWindowListener( + "keydown", + (event: KeyboardEvent) => { + pluginRegistry.dispatchInterceptor("keydown", event); + }, + { capture: true }, + ); + eventListenerManager.addWindowListener( + "keyup", + (event: KeyboardEvent) => { + pluginRegistry.dispatchInterceptor("keyup", event); + }, + { capture: true }, + ); + eventListenerManager.addWindowListener( + "pointermove", + (event: PointerEvent) => { + pluginRegistry.dispatchInterceptor("pointermove", event); + }, + { capture: true }, + ); + eventListenerManager.addWindowListener( + "pointerdown", + (event: PointerEvent) => { + pluginRegistry.dispatchInterceptor("pointerdown", event); + }, + { capture: true }, + ); + eventListenerManager.addWindowListener( + "pointerup", + (event: PointerEvent) => { + pluginRegistry.dispatchInterceptor("pointerup", event); + }, + { capture: true }, + ); + eventListenerManager.addWindowListener( + "contextmenu", + (event: MouseEvent) => { + pluginRegistry.dispatchInterceptor("contextmenu", event); + }, + { capture: true }, + ); - const handleCommentsButtonHover = (isHovered: boolean) => { - cancelCommentsHoverOpenTimeout(); - clearCommentsHoverPreviews(); - if (isHovered) { - cancelCommentsHoverCloseTimeout(); - if ( - commentsDropdownPosition() === null && - clearPromptPosition() === null - ) { - showAllCommentItemPreviews(); - commentsHoverOpenTimeoutId = setTimeout(() => { - commentsHoverOpenTimeoutId = null; - setIsCommentsHoverOpen(true); - openCommentsDropdown(); - }, DROPDOWN_HOVER_OPEN_DELAY_MS); + eventListenerManager.addWindowListener( + "focusin", + (event: FocusEvent) => { + if (isEventFromOverlay(event, "data-react-grab")) { + event.stopPropagation(); } - } else if (isCommentsHoverOpen()) { - scheduleCommentsHoverClose(); - } - }; + }, + { capture: true }, + ); - const handleCommentsDropdownHover = (isHovered: boolean) => { - if (isHovered) { - cancelCommentsHoverCloseTimeout(); - } else if (isCommentsHoverOpen()) { - scheduleCommentsHoverClose(); - } - }; + const publicGrabbedBoxes = createMemo(() => + store.grabbedBoxes.map((box) => ({ + id: box.id, + bounds: box.bounds, + createdAt: box.createdAt, + })), + ); + const publicLabelInstances = createMemo(() => + store.labelInstances.map((i) => ({ + id: i.id, + status: i.status, + tagName: i.tagName, + componentName: i.componentName, + createdAt: i.createdAt, + })), + ); - const handleCommentsCopyAllHover = (isHovered: boolean) => { - clearCommentsHoverPreviews(); - if (isHovered) { - cancelCommentsHoverCloseTimeout(); - showAllCommentItemPreviews(); - } else if (isCommentsHoverOpen()) { - scheduleCommentsHoverClose(); - } - }; + const derivedStateForHook = createMemo( + (): ReactGrabState => ({ + isActive: isActivated(), + isDragging: isDragging(), + isCopying: isCopying(), + isPromptMode: isPromptMode(), + isSelectionBoxVisible: Boolean(selectionVisible()), + isDragBoxVisible: Boolean(shared.isDragBoxVisible?.()), + targetElement: targetElement(), + dragBounds: shared.getDragBounds?.() ?? null, + grabbedBoxes: [...publicGrabbedBoxes()], + labelInstances: [...publicLabelInstances()], + selectionFilePath: store.selectionFilePath, + toolbarState: shared.getCurrentToolbarState?.() ?? null, + }), + ); - const showAllCommentItemPreviews = () => { - for (const item of commentItems()) { - showCommentItemPreview(item, "comment-all-hover"); - } - }; + createEffect( + on(derivedStateForHook, (state) => { + pluginRegistry.hooks.onStateChange(state); + }), + ); - const handleCommentsClear = () => { - commentElementMap.clear(); - const updatedCommentItems = clearComments(); - setCommentItems(updatedCommentItems); - dismissCommentsDropdown(); - }; + createEffect( + on( + () => + [ + isPromptMode(), + store.pointer.x, + store.pointer.y, + targetElement(), + ] as const, + ([inputMode, x, y, target]) => { + pluginRegistry.hooks.onPromptModeChange(inputMode, { + x, + y, + targetElement: target, + }); + }, + ), + ); - const handleShowContextMenuInstance = (instanceId: string) => { - const instance = store.labelInstances.find( - (labelInstance) => labelInstance.id === instanceId, - ); - if (!instance?.element) return; - if (!isElementConnected(instance.element)) return; + createEffect( + on( + () => [targetElement(), store.lastGrabbedElement] as const, + ([currentElement, lastElement]) => { + if (lastElement && currentElement && lastElement !== currentElement) { + actions.setLastGrabbed(null); + } + if (currentElement) { + pluginRegistry.hooks.onElementHover(currentElement); + } + }, + ), + ); - const contextMenuElement = instance.element; - const { center } = getElementBoundsCenter(contextMenuElement); - const position = { - x: instance.mouseX ?? center.x, - y: center.y, - }; + createEffect( + on( + () => [selectionVisible(), selectionBounds(), targetElement()] as const, + ([visible, bounds, element]) => { + pluginRegistry.hooks.onSelectionBox( + Boolean(visible), + bounds ?? null, + element, + ); + }, + ), + ); - const elementsToFreeze = - instance.elements && instance.elements.length > 0 - ? instance.elements.filter((element) => isElementConnected(element)) - : [contextMenuElement]; + createEffect( + on( + () => { + const contributions = pluginRegistry.getRendererContributions(); + return [contributions.dragVisible, contributions.dragBounds] as const; + }, + ([visible, bounds]) => { + pluginRegistry.hooks.onDragBox( + Boolean(visible), + (bounds as OverlayBounds) ?? null, + ); + }, + ), + ); - // HACK: Defer context menu display to avoid event interference - setTimeout(() => { - if (!isActivated()) { - actions.setWasActivatedByToggle(true); - activateRenderer(); - } - actions.setPointer(position); - actions.setFrozenElements(elementsToFreeze); - const hasMultipleElements = elementsToFreeze.length > 1; - if (hasMultipleElements && instance.bounds) { - actions.setFrozenDragRect(createPageRectFromBounds(instance.bounds)); - } - actions.freeze(); - actions.showContextMenu(position, contextMenuElement); - }, 0); - }; + onCleanup(() => { + eventListenerManager.abort(); + for (const cleanup of internalCleanups) cleanup(); + }); + + const resolvedCssText = typeof cssText === "string" ? cssText : ""; + const rendererRoot = mountRoot(resolvedCssText); createEffect(() => { const hue = pluginRegistry.store.theme.hue; - if (hue !== 0) { - rendererRoot.style.filter = `hue-rotate(${hue}deg)`; - } else { - rendererRoot.style.filter = ""; - } + rendererRoot.style.filter = hue !== 0 ? `hue-rotate(${hue}deg)` : ""; }); if (pluginRegistry.store.theme.enabled) { - // HACK: Dynamically imported to avoid solid-js/web's delegateEvents() running - // at module evaluation time, which crashes during SSR (window is not defined). void import("../components/renderer.js") .then(({ ReactGrabRenderer }) => { if (disposed) return; disposeRenderer = render(() => { return ( 0 || - dragPreviewBounds().length > 0 - } - inspectVisible={isInspectMode() && inspectBounds().length > 0} - inspectBounds={inspectBounds()} - selectionElementsCount={store.frozenElements.length} - selectionFilePath={store.selectionFilePath ?? undefined} - selectionLineNumber={store.selectionLineNumber ?? undefined} - selectionTagName={selectionTagName()} - selectionComponentName={resolvedComponentName()} - selectionLabelVisible={selectionLabelVisible()} - selectionLabelStatus="idle" - selectionActionCycleState={actionCycleState()} - selectionArrowNavigationState={arrowNavigationState()} - onArrowNavigationSelect={handleArrowNavigationSelect} - inspectNavigationState={inspectNavigationState()} - onInspectSelect={handleInspectSelect} - labelInstances={computedLabelInstances()} - dragVisible={dragVisible()} - dragBounds={dragBounds()} - grabbedBoxes={computedGrabbedBoxes()} - mouseX={ - store.frozenElements.length > 1 - ? undefined - : cursorPosition().x - } - isFrozen={ - isFrozenPhase() || isActivated() || isToolbarSelectHovered() - } - inputValue={store.inputText} - isPromptMode={isPromptMode()} - hasAgent={store.hasAgentProvider} - agentSessions={agentManager.sessions()} - supportsUndo={store.supportsUndo} - supportsFollowUp={store.supportsFollowUp} - dismissButtonText={store.dismissButtonText} - onDismissSession={agentManager.session.dismiss} - onUndoSession={agentManager.session.undo} - onFollowUpSubmitSession={handleFollowUpSubmit} - onAcknowledgeSessionError={handleAcknowledgeError} - onRetrySession={agentManager.session.retry} - onShowContextMenuInstance={handleShowContextMenuInstance} - onLabelInstanceHoverChange={handleLabelInstanceHoverChange} - onInputChange={actions.setInputText} - onInputSubmit={() => void handleInputSubmit()} - onToggleExpand={handleToggleExpand} - isPendingDismiss={isPendingDismiss()} - selectionLabelShakeCount={selectionLabelShakeCount()} - onConfirmDismiss={handleConfirmDismiss} - onCancelDismiss={handleCancelDismiss} - pendingAbortSessionId={store.pendingAbortSessionId} - onRequestAbortSession={(sessionId) => - actions.setPendingAbortSessionId(sessionId) - } - onAbortSession={handleAgentAbort} - toolbarVisible={pluginRegistry.store.theme.toolbar.enabled} - isActive={isActivated()} - onToggleActive={handleToggleActive} - enabled={isEnabled()} - onToggleEnabled={handleToggleEnabled} - shakeCount={toolbarShakeCount()} - onToolbarStateChange={(state) => { - setCurrentToolbarState(state); - toolbarStateChangeCallbacks.forEach((callback) => - callback(state), - ); - }} - onSubscribeToToolbarStateChanges={(callback) => { - toolbarStateChangeCallbacks.add(callback); - return () => { - toolbarStateChangeCallbacks.delete(callback); - }; - }} - onToolbarSelectHoverChange={setIsToolbarSelectHovered} - onToolbarRef={(element) => { - toolbarElement = element; - }} - contextMenuPosition={contextMenuPosition()} - contextMenuBounds={contextMenuBounds()} - contextMenuTagName={contextMenuTagName()} - contextMenuComponentName={contextMenuComponentName()} - contextMenuHasFilePath={Boolean( - contextMenuFilePath()?.filePath, - )} - actions={pluginRegistry.store.actions} - actionContext={contextMenuActionContext()} - onContextMenuDismiss={handleContextMenuDismiss} - onContextMenuHide={deferHideContextMenu} - commentItems={commentItems()} - commentsDisconnectedItemIds={commentsDisconnectedItemIds()} - commentItemCount={commentItems().length} - clockFlashTrigger={clockFlashTrigger()} - commentsDropdownPosition={commentsDropdownPosition()} - isCommentsPinned={ - commentsDropdownPosition() !== null && !isCommentsHoverOpen() - } - onToggleComments={handleToggleComments} - onCopyAll={handleCommentsCopyAll} - onCopyAllHover={handleCommentsCopyAllHover} - onCommentsButtonHover={handleCommentsButtonHover} - onCommentItemSelect={handleCommentItemSelect} - onCommentItemHover={handleCommentItemHover} - onCommentsCopyAll={handleCommentsCopyAll} - onCommentsCopyAllHover={handleCommentsCopyAllHover} - onCommentsClear={handleCommentsClear} - onCommentsDismiss={dismissCommentsDropdown} - onCommentsDropdownHover={handleCommentsDropdownHover} - toolbarMenuPosition={toolbarMenuPosition()} - toolbarMenuActions={pluginRegistry.store.actions.filter( - (action) => action.showInToolbarMenu === true, - )} - defaultActionId={ - currentToolbarState()?.defaultAction ?? DEFAULT_ACTION_ID - } - onSetDefaultAction={handleSetDefaultAction} - onToggleToolbarMenu={handleToggleToolbarMenu} - onToolbarMenuDismiss={dismissToolbarMenu} - clearPromptPosition={clearPromptPosition()} - onClearCommentsConfirm={() => { - confirmClear(); - dismissClearPrompt(); - handleCommentsClear(); - }} - onClearCommentsCancel={dismissClearPrompt} + {...pluginRegistry.getRendererContributions()} /> ); }, rendererRoot); @@ -4217,182 +782,14 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }); } - if (store.hasAgentProvider) { - agentManager.session.tryResume(); - } - - const copyElementAPI = async ( - elements: Element | Element[], - ): Promise => { - const elementsArray = Array.isArray(elements) ? elements : [elements]; - if (elementsArray.length === 0) return false; - return await copyWithFallback(elementsArray); - }; - - const syncAgentFromRegistry = () => { - const agentOpts = getAgentOptionsWithCallbacks(); - if (agentOpts) { - agentManager._internal.setOptions(agentOpts); - } - const hasProvider = Boolean(agentOpts?.provider); - actions.setHasAgentProvider(hasProvider); - if (hasProvider && agentOpts?.provider) { - const capturedProvider = agentOpts.provider; - actions.setAgentCapabilities({ - supportsUndo: Boolean(capturedProvider.undo), - supportsFollowUp: Boolean(capturedProvider.supportsFollowUp), - dismissButtonText: capturedProvider.dismissButtonText, - isAgentConnected: false, - }); - - if (capturedProvider.checkConnection) { - capturedProvider - .checkConnection() - .then((isConnected) => { - const currentAgentOpts = getAgentOptionsWithCallbacks(); - if (currentAgentOpts?.provider !== capturedProvider) { - return; - } - actions.setAgentCapabilities({ - supportsUndo: Boolean(capturedProvider.undo), - supportsFollowUp: Boolean(capturedProvider.supportsFollowUp), - dismissButtonText: capturedProvider.dismissButtonText, - isAgentConnected: isConnected, - }); - }) - .catch((error) => { - logRecoverableError("Agent connection check failed", error); - }); - } - - agentManager.session.tryResume(); - } else { - actions.setAgentCapabilities({ - supportsUndo: false, - supportsFollowUp: false, - dismissButtonText: undefined, - isAgentConnected: false, - }); - } - }; - - const api: ReactGrabAPI = { - activate: () => { - actions.setPendingCommentMode(false); - if (!isActivated() && isEnabled()) { - toggleActivate(); - } - }, - deactivate: () => { - if (isActivated() || isCopying()) { - deactivateRenderer(); - } - }, - toggle: () => { - if (isActivated()) { - deactivateRenderer(); - } else if (isEnabled()) { - toggleActivate(); - } - }, - comment: handleComment, - isActive: () => isActivated(), - isEnabled: () => isEnabled(), - setEnabled: (enabled: boolean) => { - if (enabled === isEnabled()) return; - setIsEnabled(enabled); - if (!enabled) { - forceDeactivateAll(); - } - }, - getToolbarState: () => loadToolbarState(), - setToolbarState: (state: Partial) => { - const currentState = loadToolbarState(); - const newState: ToolbarState = { - edge: state.edge ?? currentState?.edge ?? "bottom", - ratio: - state.ratio ?? - currentState?.ratio ?? - TOOLBAR_DEFAULT_POSITION_RATIO, - collapsed: state.collapsed ?? currentState?.collapsed ?? false, - enabled: state.enabled ?? currentState?.enabled ?? true, - defaultAction: - state.defaultAction ?? - currentState?.defaultAction ?? - DEFAULT_ACTION_ID, - }; - saveToolbarState(newState); - setCurrentToolbarState(newState); - if (state.enabled !== undefined && state.enabled !== isEnabled()) { - setIsEnabled(state.enabled); - } - toolbarStateChangeCallbacks.forEach((callback) => callback(newState)); - }, - onToolbarStateChange: (callback: (state: ToolbarState) => void) => { - toolbarStateChangeCallbacks.add(callback); - return () => { - toolbarStateChangeCallbacks.delete(callback); - }; - }, - dispose: () => { - disposed = true; - hasInited = false; - disposeRenderer?.(); - cancelCommentsHoverOpenTimeout(); - cancelCommentsHoverCloseTimeout(); - stopTrackingDropdownPosition(); - toolbarStateChangeCallbacks.clear(); - dispose(); - }, - copyElement: copyElementAPI, - getSource: async (element: Element): Promise => { - const source = await resolveSource(element); - if (!source) return null; - return { - filePath: source.filePath, - lineNumber: source.lineNumber, - componentName: source.componentName, - }; - }, - getStackContext, - getState: (): ReactGrabState => ({ - isActive: isActivated(), - isDragging: isDragging(), - isCopying: isCopying(), - isPromptMode: isPromptMode(), - isSelectionBoxVisible: Boolean(selectionVisible()), - isDragBoxVisible: Boolean(dragVisible()), - targetElement: targetElement(), - dragBounds: dragBounds() ?? null, - grabbedBoxes: [...publicGrabbedBoxes()], - labelInstances: [...publicLabelInstances()], - selectionFilePath: store.selectionFilePath, - toolbarState: currentToolbarState(), - }), - setOptions: (newOptions: SettableOptions) => { - pluginRegistry.setOptions(newOptions); - }, - registerPlugin: (plugin: Plugin) => { - pluginRegistry.register(plugin, api); - syncAgentFromRegistry(); - }, - unregisterPlugin: (name: string) => { - pluginRegistry.unregister(name); - syncAgentFromRegistry(); - }, - getPlugins: () => pluginRegistry.getPluginNames(), - getDisplayName: getComponentDisplayName, - }; - for (const plugin of builtInPlugins) { pluginRegistry.register(plugin, api); } + shared.syncAgentFromRegistry?.(); + // HACK: Force revalidation of Next.js project detection - // since it's cached in the browser and not updated when the project is changed - setTimeout(() => { - checkIsNextProject(true); - }, NEXTJS_REVALIDATION_DELAY_MS); + setTimeout(() => checkIsNextProject(true), NEXTJS_REVALIDATION_DELAY_MS); return api; }); @@ -4420,6 +817,8 @@ export type { Plugin, PluginConfig, PluginHooks, + ToolbarEntry, + ToolbarEntryHandle, } from "../types.js"; export { generateSnippet } from "../utils/generate-snippet.js"; diff --git a/packages/react-grab/src/core/noop-api.ts b/packages/react-grab/src/core/noop-api.ts index 2c0d8bd54..8ba371a3e 100644 --- a/packages/react-grab/src/core/noop-api.ts +++ b/packages/react-grab/src/core/noop-api.ts @@ -36,4 +36,6 @@ export const createNoopApi = (): ReactGrabAPI => ({ unregisterPlugin: NOOP, getPlugins: () => [], getDisplayName: () => null, + toggleToolbarEntry: NOOP, + closeToolbarEntry: NOOP, }); diff --git a/packages/react-grab/src/core/plugin-registry.ts b/packages/react-grab/src/core/plugin-registry.ts index 94e99faa8..f1fa87210 100644 --- a/packages/react-grab/src/core/plugin-registry.ts +++ b/packages/react-grab/src/core/plugin-registry.ts @@ -1,4 +1,4 @@ -import { createStore } from "solid-js/store"; +import { createStore, reconcile } from "solid-js/store"; import type { Position, Plugin, @@ -6,6 +6,7 @@ import type { PluginHooks, Theme, ContextMenuAction, + ToolbarEntry, ReactGrabAPI, ReactGrabState, PromptModeContext, @@ -54,6 +55,11 @@ interface PluginStoreState { theme: Required; options: OptionsState; actions: ContextMenuAction[]; + toolbarEntries: ToolbarEntry[]; + toolbarEntryOverrides: Record< + string, + Partial> + >; } type HookName = keyof PluginHooks; @@ -66,12 +72,15 @@ const createPluginRegistry = (initialOptions: SettableOptions = {}) => { theme: DEFAULT_THEME, options: { ...DEFAULT_OPTIONS, ...initialOptions }, actions: [], + toolbarEntries: [], + toolbarEntryOverrides: {}, }); const recomputeStore = () => { let mergedTheme: Required = DEFAULT_THEME; let mergedOptions: OptionsState = { ...DEFAULT_OPTIONS, ...initialOptions }; const allContextMenuActions: ContextMenuAction[] = []; + const allToolbarEntries: ToolbarEntry[] = []; for (const { config } of plugins.values()) { if (config.theme) { @@ -87,6 +96,12 @@ const createPluginRegistry = (initialOptions: SettableOptions = {}) => { allContextMenuActions.push(action); } } + + if (config.toolbarEntries) { + for (const toolbarEntry of config.toolbarEntries) { + allToolbarEntries.push(toolbarEntry); + } + } } mergedOptions = { ...mergedOptions, ...directOptionOverrides }; @@ -94,6 +109,7 @@ const createPluginRegistry = (initialOptions: SettableOptions = {}) => { setStore("theme", mergedTheme); setStore("options", mergedOptions); setStore("actions", allContextMenuActions); + setStore("toolbarEntries", allToolbarEntries); }; const setOption = ( @@ -142,6 +158,13 @@ const createPluginRegistry = (initialOptions: SettableOptions = {}) => { config.actions = [...plugin.actions, ...(config.actions ?? [])]; } + if (plugin.toolbarEntries) { + config.toolbarEntries = [ + ...plugin.toolbarEntries, + ...(config.toolbarEntries ?? []), + ]; + } + if (plugin.hooks) { config.hooks = config.hooks ? { ...plugin.hooks, ...config.hooks } @@ -167,6 +190,20 @@ const createPluginRegistry = (initialOptions: SettableOptions = {}) => { registered.config.cleanup(); } + const removedEntryIds = new Set( + (registered.config.toolbarEntries ?? []).map( + (toolbarEntry) => toolbarEntry.id, + ), + ); + if (removedEntryIds.size > 0) { + const filteredOverrides = Object.fromEntries( + Object.entries(store.toolbarEntryOverrides).filter( + ([entryId]) => !removedEntryIds.has(entryId), + ), + ); + setStore("toolbarEntryOverrides", reconcile(filteredOverrides)); + } + plugins.delete(name); recomputeStore(); }; @@ -175,6 +212,14 @@ const createPluginRegistry = (initialOptions: SettableOptions = {}) => { return Array.from(plugins.keys()); }; + const getPluginToolbarEntryIds = (name: string): string[] => { + const registered = plugins.get(name); + if (!registered) return []; + return (registered.config.toolbarEntries ?? []).map( + (toolbarEntry) => toolbarEntry.id, + ); + }; + const callHook = ( hookName: K, ...args: Parameters> @@ -332,14 +377,89 @@ const createPluginRegistry = (initialOptions: SettableOptions = {}) => { callHookReduce("transformSnippet", snippet, element), }; + const updateToolbarEntry = ( + entryId: string, + updates: Partial< + Pick + >, + ) => { + setStore("toolbarEntryOverrides", entryId, (prev) => ({ + ...prev, + ...updates, + })); + }; + + // Interceptor chain: priority-ordered handlers for each event type + type InterceptorEntry = { + priority: number; + handler: (event: never) => boolean; + }; + const interceptors = { + keydown: [] as InterceptorEntry[], + keyup: [] as InterceptorEntry[], + pointerdown: [] as InterceptorEntry[], + pointermove: [] as InterceptorEntry[], + pointerup: [] as InterceptorEntry[], + contextmenu: [] as InterceptorEntry[], + }; + + const addInterceptor = ( + eventType: keyof typeof interceptors, + priority: number, + handler: (event: never) => boolean, + ) => { + const chain = interceptors[eventType]; + chain.push({ priority, handler }); + chain.sort((first, second) => first.priority - second.priority); + }; + + const dispatchInterceptor = ( + eventType: keyof typeof interceptors, + event: E, + ): boolean => { + for (const { handler } of interceptors[eventType]) { + if ((handler as (event: E) => boolean)(event)) return true; + } + return false; + }; + + // Renderer prop contributions: plugins register reactive accessors + const rendererContributions = new Map unknown>(); + + const provideRendererProp = (key: string, accessor: () => unknown) => { + rendererContributions.set(key, accessor); + }; + + const getRendererContributions = (): Record => { + const result: Record = {}; + for (const [key, accessor] of rendererContributions) { + Object.defineProperty(result, key, { + get: accessor, + enumerable: true, + configurable: true, + }); + } + return result; + }; + return { register, unregister, getPluginNames, + getPluginToolbarEntryIds, setOptions, + updateToolbarEntry, store, hooks, + addInterceptor, + dispatchInterceptor, + provideRendererProp, + getRendererContributions, }; }; export { createPluginRegistry }; +export type { OptionsState, PluginStoreState }; + +type PluginRegistryReturn = ReturnType; +export type PluginRegistryHooks = PluginRegistryReturn["hooks"]; diff --git a/packages/react-grab/src/core/plugins/copy-pipeline.ts b/packages/react-grab/src/core/plugins/copy-pipeline.ts new file mode 100644 index 000000000..856bac851 --- /dev/null +++ b/packages/react-grab/src/core/plugins/copy-pipeline.ts @@ -0,0 +1,745 @@ +import { createMemo, createEffect, on, onCleanup } from "solid-js"; +import type { + InternalPlugin, + SelectionLabelInstance, + OverlayBounds, + GrabbedBox, + CopyWithLabelOptions, + PerformWithFeedbackOptions, + Position, +} from "../../types.js"; +import { tryCopyWithFallback } from "../copy.js"; +import { + getNearestComponentName, + getComponentDisplayName, +} from "../context.js"; +import { resolveSource } from "element-source"; +import { createElementBounds } from "../../utils/create-element-bounds.js"; +import { generateId } from "../../utils/generate-id.js"; +import { normalizeErrorMessage } from "../../utils/normalize-error.js"; +import { logRecoverableError } from "../../utils/log-recoverable-error.js"; +import { isEventFromOverlay } from "../../utils/is-event-from-overlay.js"; +import { isElementConnected } from "../../utils/is-element-connected.js"; +import { getTagName } from "../../utils/get-tag-name.js"; +import { getElementBoundsCenter } from "../../utils/get-element-bounds-center.js"; +import { waitUntilNextFrame } from "../../utils/native-raf.js"; +import { combineBounds } from "../../utils/combine-bounds.js"; +import { + createBoundsFromDragRect, + createFlatOverlayBounds, + createPageRectFromBounds, +} from "../../utils/create-bounds-from-drag-rect.js"; +import { + FEEDBACK_DURATION_MS, + PREVIEW_TEXT_MAX_LENGTH, + PLUGIN_PRIORITY_COPY_PIPELINE, +} from "../../constants.js"; +import { createLabelFadeManager } from "../../utils/label-fade-manager.js"; + +export const copyPipelinePlugin: InternalPlugin = { + name: "copy-pipeline", + priority: PLUGIN_PRIORITY_COPY_PIPELINE, + setup: (ctx) => { + const { store, actions, registry, events, derived } = ctx; + const { + isActivated, + isCopying, + isPromptMode, + isRendererActive, + frozenElementsBounds, + } = derived; + + let isCopyFeedbackCooldownActive = false; + let copyFeedbackCooldownTimerId: number | null = null; + + const startCopyFeedbackCooldown = () => { + isCopyFeedbackCooldownActive = true; + if (copyFeedbackCooldownTimerId !== null) { + window.clearTimeout(copyFeedbackCooldownTimerId); + } + copyFeedbackCooldownTimerId = window.setTimeout(() => { + isCopyFeedbackCooldownActive = false; + copyFeedbackCooldownTimerId = null; + }, FEEDBACK_DURATION_MS); + }; + + const clearCopyFeedbackCooldown = () => { + if (copyFeedbackCooldownTimerId !== null) { + window.clearTimeout(copyFeedbackCooldownTimerId); + copyFeedbackCooldownTimerId = null; + } + isCopyFeedbackCooldownActive = false; + }; + + createEffect(() => { + if (store.current.state !== "justCopied") return; + const timerId = setTimeout(() => { + actions.finishJustCopied(); + }, FEEDBACK_DURATION_MS); + onCleanup(() => clearTimeout(timerId)); + }); + + let cursorStyleElement: HTMLStyleElement | null = null; + + const setCursorOverride = (cursor: string | null) => { + if (cursor) { + if (!cursorStyleElement) { + cursorStyleElement = document.createElement("style"); + cursorStyleElement.setAttribute("data-react-grab-cursor", ""); + document.head.appendChild(cursorStyleElement); + } + cursorStyleElement.textContent = `* { cursor: ${cursor} !important; }`; + } else if (cursorStyleElement) { + cursorStyleElement.remove(); + cursorStyleElement = null; + } + }; + + createEffect( + on( + () => [isActivated(), isCopying(), isPromptMode()] as const, + ([activated, copying, promptMode]) => { + if (copying) { + setCursorOverride("progress"); + } else if (activated && !promptMode) { + setCursorOverride("crosshair"); + } else { + setCursorOverride(null); + } + }, + ), + ); + + const grabbedBoxTimeouts = new Map(); + + const showTemporaryGrabbedBox = ( + bounds: OverlayBounds, + element: Element, + ) => { + const boxId = generateId("grabbed"); + const createdAt = Date.now(); + const newBox: GrabbedBox = { id: boxId, bounds, createdAt, element }; + + actions.addGrabbedBox(newBox); + registry.hooks.onGrabbedBox(bounds, element); + + const timeoutId = window.setTimeout(() => { + grabbedBoxTimeouts.delete(boxId); + actions.removeGrabbedBox(boxId); + }, FEEDBACK_DURATION_MS); + grabbedBoxTimeouts.set(boxId, timeoutId); + }; + + const labelFade = createLabelFadeManager(actions); + + const handleLabelInstanceHoverChange = ( + instanceId: string, + isHovered: boolean, + ) => { + if (isHovered) { + labelFade.cancel(instanceId); + } else { + const instance = store.labelInstances.find( + (labelInstance) => labelInstance.id === instanceId, + ); + if (instance && instance.status === "copied") { + labelFade.schedule(instanceId); + } + } + }; + + const createLabelInstance = ( + bounds: OverlayBounds, + tagName: string, + componentName: string | undefined, + status: SelectionLabelInstance["status"], + options?: { + element?: Element; + mouseX?: number; + elements?: Element[]; + boundsMultiple?: OverlayBounds[]; + hideArrow?: boolean; + }, + ): string => { + actions.clearLabelInstances(); + labelFade.cancelAll(); + const instanceId = generateId("label"); + const boundsCenterX = bounds.x + bounds.width / 2; + const boundsHalfWidth = bounds.width / 2; + const mouseX = options?.mouseX; + const mouseXOffset = + mouseX !== undefined ? mouseX - boundsCenterX : undefined; + + const instance: SelectionLabelInstance = { + id: instanceId, + bounds, + boundsMultiple: options?.boundsMultiple, + tagName, + componentName, + status, + createdAt: Date.now(), + element: options?.element, + elements: options?.elements, + mouseX, + mouseXOffsetFromCenter: mouseXOffset, + mouseXOffsetRatio: + mouseXOffset !== undefined && boundsHalfWidth > 0 + ? mouseXOffset / boundsHalfWidth + : undefined, + hideArrow: options?.hideArrow, + }; + actions.addLabelInstance(instance); + return instanceId; + }; + + const clearAllLabels = () => { + labelFade.cancelAll(); + actions.clearLabelInstances(); + }; + + const updateLabelAfterCopy = ( + labelInstanceId: string, + didSucceed: boolean, + errorMessage?: string, + ) => { + if (didSucceed) { + actions.updateLabelInstance(labelInstanceId, "copied"); + } else { + actions.updateLabelInstance( + labelInstanceId, + "error", + errorMessage || "Unknown error", + ); + } + labelFade.schedule(labelInstanceId); + }; + + const notifyElementsSelected = async ( + elements: Element[], + ): Promise => { + const elementsPayload = await Promise.all( + elements.map(async (element) => { + const source = await resolveSource(element); + let componentName = source?.componentName ?? null; + const filePath = source?.filePath; + const lineNumber = source?.lineNumber ?? undefined; + const columnNumber = source?.columnNumber ?? undefined; + + if (!componentName) { + componentName = getComponentDisplayName(element); + } + + const textContent = + element instanceof HTMLElement + ? element.innerText?.slice(0, PREVIEW_TEXT_MAX_LENGTH) + : undefined; + + return { + tagName: getTagName(element), + id: element.id || undefined, + className: element.getAttribute("class") || undefined, + textContent, + componentName: componentName ?? undefined, + filePath, + lineNumber, + columnNumber, + }; + }), + ); + + window.dispatchEvent( + new CustomEvent("react-grab:element-selected", { + detail: { + elements: elementsPayload, + }, + }), + ); + }; + + const executeCopyOperation = async ( + clipboardOperation: () => Promise, + labelInstanceId: string | null, + copiedElement?: Element, + shouldDeactivateAfter?: boolean, + ) => { + clearCopyFeedbackCooldown(); + if (store.current.state !== "copying") { + actions.startCopy(); + } + + let didSucceed = false; + let errorMessage: string | undefined; + + try { + await clipboardOperation(); + didSucceed = true; + } catch (error) { + errorMessage = normalizeErrorMessage(error, "Action failed"); + } + + if (labelInstanceId) { + updateLabelAfterCopy(labelInstanceId, didSucceed, errorMessage); + } + + if (store.current.state !== "copying") return; + + if (didSucceed) { + actions.completeCopy(copiedElement); + } + + if (shouldDeactivateAfter) { + ctx.shared.deactivateRenderer?.(); + } else if (didSucceed) { + actions.activate(); + startCopyFeedbackCooldown(); + } else { + actions.unfreeze(); + } + }; + + const copyWithFallback = ( + elements: Element[], + extraPrompt?: string, + resolvedComponentName?: string, + ) => { + const firstElement = elements[0]; + const componentName = + resolvedComponentName ?? + (firstElement ? getComponentDisplayName(firstElement) : null); + const tagName = firstElement ? getTagName(firstElement) : null; + const elementName = componentName ?? tagName ?? undefined; + + return tryCopyWithFallback( + { + maxContextLines: registry.store.options.maxContextLines, + getContent: registry.store.options.getContent, + componentName: elementName, + }, + { + onBeforeCopy: registry.hooks.onBeforeCopy, + transformSnippet: registry.hooks.transformSnippet, + transformCopyContent: registry.hooks.transformCopyContent, + onAfterCopy: registry.hooks.onAfterCopy, + onCopySuccess: (copiedElements: Element[], content: string) => { + registry.hooks.onCopySuccess(copiedElements, content); + ctx.shared.handleCopySuccessWithComments?.( + copiedElements, + extraPrompt, + ); + }, + onCopyError: registry.hooks.onCopyError, + }, + elements, + extraPrompt, + ); + }; + + const copyElementsToClipboard = async ( + targetElements: Element[], + extraPrompt?: string, + resolvedComponentName?: string, + ): Promise => { + if (targetElements.length === 0) return; + + const unhandledElements: Element[] = []; + const pendingResults: Promise[] = []; + for (const element of targetElements) { + const { wasIntercepted, pendingResult } = + registry.hooks.onElementSelect(element); + if (!wasIntercepted) { + unhandledElements.push(element); + } + if (pendingResult) { + pendingResults.push(pendingResult); + } + if (registry.store.theme.grabbedBoxes.enabled) { + showTemporaryGrabbedBox(createElementBounds(element), element); + } + } + await waitUntilNextFrame(); + if (unhandledElements.length > 0) { + await copyWithFallback( + unhandledElements, + extraPrompt, + resolvedComponentName, + ); + } else if (pendingResults.length > 0) { + const results = await Promise.all(pendingResults); + if (!results.every(Boolean)) { + throw new Error("Failed to copy"); + } + } + void notifyElementsSelected(targetElements); + }; + + const performCopyWithLabel = (options: CopyWithLabelOptions) => { + const { + element, + cursorX, + selectedElements, + extraPrompt, + shouldDeactivateAfter, + onComplete, + dragRect: passedDragRect, + } = options; + + const allTargetElements = selectedElements ?? [element]; + const dragRect = passedDragRect ?? store.frozenDragRect; + const isMultiSelect = allTargetElements.length > 1; + + const selectionBounds = + dragRect && isMultiSelect + ? createBoundsFromDragRect(dragRect) + : createFlatOverlayBounds(createElementBounds(element)); + + const labelCursorX = isMultiSelect + ? selectionBounds.x + selectionBounds.width / 2 + : cursorX; + + const tagName = getTagName(element); + clearCopyFeedbackCooldown(); + actions.startCopy(); + + const labelInstanceId = tagName + ? createLabelInstance(selectionBounds, tagName, undefined, "copying", { + element, + mouseX: labelCursorX, + elements: selectedElements, + }) + : null; + + void getNearestComponentName(element) + .then(async (componentName) => { + await executeCopyOperation( + () => + copyElementsToClipboard( + allTargetElements, + extraPrompt, + componentName ?? undefined, + ), + labelInstanceId, + element, + shouldDeactivateAfter, + ); + onComplete?.(); + }) + .catch((error) => { + logRecoverableError("Copy operation failed", error); + if (labelInstanceId) { + updateLabelAfterCopy( + labelInstanceId, + false, + normalizeErrorMessage(error, "Action failed"), + ); + } + if (store.current.state === "copying") { + actions.unfreeze(); + } + }); + }; + + const labelInstanceCache = new Map(); + + const recomputeLabelInstance = ( + instance: SelectionLabelInstance, + ): SelectionLabelInstance => { + const hasMultipleElements = + instance.elements && instance.elements.length > 1; + const instanceElement = instance.element; + const canRecalculateBounds = + !hasMultipleElements && + instanceElement && + document.body.contains(instanceElement); + const newBounds = canRecalculateBounds + ? createElementBounds(instanceElement) + : instance.bounds; + + const previousInstance = labelInstanceCache.get(instance.id); + const boundsUnchanged = + previousInstance && + previousInstance.bounds.x === newBounds.x && + previousInstance.bounds.y === newBounds.y && + previousInstance.bounds.width === newBounds.width && + previousInstance.bounds.height === newBounds.height; + if ( + previousInstance && + previousInstance.status === instance.status && + previousInstance.errorMessage === instance.errorMessage && + boundsUnchanged + ) { + return previousInstance; + } + const newBoundsCenterX = newBounds.x + newBounds.width / 2; + const newBoundsHalfWidth = newBounds.width / 2; + let newMouseX: number; + if (instance.mouseXOffsetRatio !== undefined && newBoundsHalfWidth > 0) { + newMouseX = + newBoundsCenterX + instance.mouseXOffsetRatio * newBoundsHalfWidth; + } else if (instance.mouseXOffsetFromCenter !== undefined) { + newMouseX = newBoundsCenterX + instance.mouseXOffsetFromCenter; + } else { + newMouseX = instance.mouseX ?? newBoundsCenterX; + } + const newCached = { ...instance, bounds: newBounds, mouseX: newMouseX }; + labelInstanceCache.set(instance.id, newCached); + return newCached; + }; + + const computedLabelInstances = createMemo(() => { + if (!registry.store.theme.enabled) return []; + if (!registry.store.theme.grabbedBoxes.enabled) return []; + void store.viewportVersion; + const currentIds = new Set( + store.labelInstances.map((instance) => instance.id), + ); + for (const cachedId of labelInstanceCache.keys()) { + if (!currentIds.has(cachedId)) { + labelInstanceCache.delete(cachedId); + } + } + return store.labelInstances.map(recomputeLabelInstance); + }); + + const computedGrabbedBoxes = createMemo(() => { + if (!registry.store.theme.enabled) return []; + if (!registry.store.theme.grabbedBoxes.enabled) return []; + void store.viewportVersion; + return store.grabbedBoxes.map((box) => { + if (!box.element || !document.body.contains(box.element)) { + return box; + } + return { + ...box, + bounds: createElementBounds(box.element), + }; + }); + }); + + const contextMenuBounds = createMemo((): OverlayBounds | null => { + void store.viewportVersion; + const element = store.contextMenuElement; + if (!element) return null; + return createElementBounds(element); + }); + + const createPerformWithFeedback = ( + element: Element, + elements: Element[], + tagName: string | undefined, + componentName: string | undefined, + options?: PerformWithFeedbackOptions, + ) => { + return async (action: () => Promise): Promise => { + const fallbackBounds = options?.fallbackBounds ?? null; + const fallbackSelectionBounds = options?.fallbackSelectionBounds ?? []; + const position = + options?.position ?? store.contextMenuPosition ?? store.pointer; + const frozenBounds = frozenElementsBounds(); + const singleElementBounds = contextMenuBounds() ?? fallbackBounds; + const hasMultipleElements = elements.length > 1; + + const labelBounds = hasMultipleElements + ? createFlatOverlayBounds(combineBounds(frozenBounds)) + : singleElementBounds; + + const shouldDeactivateAfter = store.wasActivatedByToggle; + let selectionBoundsForLabel: OverlayBounds[]; + if (hasMultipleElements) { + selectionBoundsForLabel = frozenBounds; + } else if (singleElementBounds) { + selectionBoundsForLabel = [singleElementBounds]; + } else { + selectionBoundsForLabel = fallbackSelectionBounds; + } + + actions.hideContextMenu(); + + if (labelBounds) { + const labelCursorX = hasMultipleElements + ? labelBounds.x + labelBounds.width / 2 + : position.x; + + const labelInstanceId = createLabelInstance( + labelBounds, + tagName || "element", + componentName, + "copying", + { + element, + mouseX: labelCursorX, + elements: hasMultipleElements ? elements : undefined, + boundsMultiple: selectionBoundsForLabel, + }, + ); + + let didSucceed = false; + let errorMessage: string | undefined; + + try { + didSucceed = await action(); + if (!didSucceed) { + errorMessage = "Failed to copy"; + } + } catch (error) { + errorMessage = normalizeErrorMessage(error, "Action failed"); + } + + updateLabelAfterCopy(labelInstanceId, didSucceed, errorMessage); + } else { + // HACK: Fire-and-forget when no label bounds to display feedback on + try { + await action(); + } catch (error) { + logRecoverableError("Action failed without feedback bounds", error); + } + } + + if (shouldDeactivateAfter) { + ctx.shared.deactivateRenderer?.(); + } else { + actions.unfreeze(); + } + }; + }; + + const handleShowContextMenuInstance = (instanceId: string) => { + const instance = store.labelInstances.find( + (labelInstance) => labelInstance.id === instanceId, + ); + if (!instance?.element) return; + if (!isElementConnected(instance.element)) return; + + const contextMenuElement = instance.element; + const { center } = getElementBoundsCenter(contextMenuElement); + const position = { + x: instance.mouseX ?? center.x, + y: center.y, + }; + + const elementsToFreeze = + instance.elements && instance.elements.length > 0 + ? instance.elements.filter((element) => isElementConnected(element)) + : [contextMenuElement]; + + // HACK: Defer context menu display to avoid event interference + setTimeout(() => { + if (!isActivated()) { + actions.setWasActivatedByToggle(true); + ctx.shared.activateRenderer?.(); + } + actions.setPointer(position); + actions.setFrozenElements(elementsToFreeze); + const hasMultipleElements = elementsToFreeze.length > 1; + if (hasMultipleElements && instance.bounds) { + actions.setFrozenDragRect(createPageRectFromBounds(instance.bounds)); + } + actions.freeze(); + actions.showContextMenu(position, contextMenuElement); + }, 0); + }; + + events.addDocumentListener( + "copy", + (event: ClipboardEvent) => { + if ( + isPromptMode() || + isEventFromOverlay(event, "data-react-grab-ignore-events") + ) { + return; + } + if (isRendererActive() || isCopying()) { + event.preventDefault(); + } + }, + { capture: true }, + ); + + ctx.shared.performCopyWithLabel = performCopyWithLabel; + ctx.shared.createLabelInstance = ( + element: Element, + tagName: string, + componentName: string | undefined, + cursorX: number, + options?: { + elements?: Element[]; + boundsMultiple?: OverlayBounds[]; + extraPrompt?: string; + hideArrow?: boolean; + }, + ) => { + const bounds = createElementBounds(element); + return createLabelInstance(bounds, tagName, componentName, "copying", { + element, + mouseX: cursorX, + elements: options?.elements, + boundsMultiple: options?.boundsMultiple, + hideArrow: options?.hideArrow, + }); + }; + ctx.shared.updateLabelAfterCopy = updateLabelAfterCopy; + ctx.shared.clearAllLabels = clearAllLabels; + ctx.shared.showTemporaryGrabbedBox = (element: Element) => { + showTemporaryGrabbedBox(createElementBounds(element), element); + }; + ctx.shared.createPerformWithFeedback = ( + options?: PerformWithFeedbackOptions, + ) => { + const element = store.frozenElement ?? store.detectedElement; + if (!element) { + return async () => {}; + } + const elements = + store.frozenElements.length > 0 ? store.frozenElements : [element]; + const tagName = getTagName(element) || undefined; + const componentName = getComponentDisplayName(element) ?? undefined; + return createPerformWithFeedback( + element, + elements, + tagName, + componentName, + options, + ); + }; + ctx.shared.isCopyFeedbackCooldownActive = () => + isCopyFeedbackCooldownActive; + ctx.shared.clearCopyFeedbackCooldown = clearCopyFeedbackCooldown; + ctx.shared.isRendererActive = () => isRendererActive(); + ctx.shared.copyWithFallback = (elements: Element[], extraPrompt?: string) => + copyWithFallback(elements, extraPrompt); + ctx.shared.setCopyStartPosition = ( + position: Position, + element: Element, + ) => { + actions.setCopyStart(position, element); + }; + + ctx.provide("grabbedBoxes", () => computedGrabbedBoxes()); + ctx.provide("labelInstances", () => computedLabelInstances()); + ctx.provide( + "onShowContextMenuInstance", + () => handleShowContextMenuInstance, + ); + ctx.provide( + "onLabelInstanceHoverChange", + () => handleLabelInstanceHoverChange, + ); + + return () => { + clearCopyFeedbackCooldown(); + + for (const timeoutId of grabbedBoxTimeouts.values()) { + window.clearTimeout(timeoutId); + } + grabbedBoxTimeouts.clear(); + + labelFade.cancelAll(); + + if (cursorStyleElement) { + cursorStyleElement.remove(); + cursorStyleElement = null; + } + + labelInstanceCache.clear(); + }; + }, +}; diff --git a/packages/react-grab/src/core/plugins/keyboard-plugin.ts b/packages/react-grab/src/core/plugins/keyboard-plugin.ts new file mode 100644 index 000000000..b26339542 --- /dev/null +++ b/packages/react-grab/src/core/plugins/keyboard-plugin.ts @@ -0,0 +1,552 @@ +import { createEffect, onCleanup } from "solid-js"; +import type { InternalPlugin } from "../../types.js"; +import { + isKeyboardEventTriggeredByInput, + hasTextSelectionInInput, + hasTextSelectionOnPage, +} from "../../utils/is-keyboard-event-triggered-by-input.js"; +import { isTargetKeyCombination } from "../../utils/is-target-key-combination.js"; +import { parseActivationKey } from "../../utils/parse-activation-key.js"; +import { isEventFromOverlay } from "../../utils/is-event-from-overlay.js"; +import { isElementConnected } from "../../utils/is-element-connected.js"; +import { isEnterCode } from "../../utils/is-enter-code.js"; +import { isCLikeKey } from "../../utils/is-c-like-key.js"; +import { isMac } from "../../utils/is-mac.js"; +import { openFile } from "../../utils/open-file.js"; +import { getElementCenter } from "../../utils/get-element-center.js"; +import { + setupKeyboardEventClaimer, + getRequiredModifiers, +} from "../keyboard-handlers.js"; +import { + MODIFIER_KEYS, + KEYDOWN_SPAM_TIMEOUT_MS, + DEFAULT_KEY_HOLD_DURATION_MS, + INPUT_FOCUS_ACTIVATION_DELAY_MS, + INPUT_TEXT_SELECTION_ACTIVATION_DELAY_MS, + MIN_HOLD_FOR_ACTIVATION_AFTER_COPY_MS, + WINDOW_REFOCUS_GRACE_PERIOD_MS, + BLUR_DEACTIVATION_THRESHOLD_MS, + PLUGIN_PRIORITY_KEYBOARD, +} from "../../constants.js"; + +export const keyboardPlugin: InternalPlugin = { + name: "keyboard", + priority: PLUGIN_PRIORITY_KEYBOARD, + setup: (ctx) => { + const { store, actions, events, registry, derived } = ctx; + const { + isHoldingKeys, + isActivated, + isCopying, + didJustCopy, + isPromptMode, + targetElement, + } = derived; + + const activationHoldState = { + timerId: null as number | null, + startTimestamp: null as number | null, + copyWaiting: false, + holdTimerFired: false, + }; + + let keydownSpamTimerId: number | null = null; + let lastWindowFocusTimestamp = 0; + + const clearHoldTimer = () => { + if (activationHoldState.timerId !== null) { + clearTimeout(activationHoldState.timerId); + activationHoldState.timerId = null; + } + }; + + const resetCopyConfirmation = () => { + activationHoldState.copyWaiting = false; + activationHoldState.holdTimerFired = false; + activationHoldState.startTimestamp = null; + }; + + createEffect(() => { + if (store.current.state !== "holding") { + clearHoldTimer(); + return; + } + activationHoldState.startTimestamp = Date.now(); + activationHoldState.timerId = window.setTimeout(() => { + activationHoldState.timerId = null; + if (activationHoldState.copyWaiting) { + activationHoldState.holdTimerFired = true; + return; + } + if (registry.store.options.activationMode !== "hold") { + actions.setWasActivatedByToggle(true); + } + actions.activate(); + }, store.keyHoldDuration); + onCleanup(clearHoldTimer); + }); + + const keyboardClaimer = setupKeyboardEventClaimer(); + + const blockEnterIfNeeded = (event: KeyboardEvent) => { + let originalKey: string; + try { + originalKey = keyboardClaimer.originalKeyDescriptor?.get + ? keyboardClaimer.originalKeyDescriptor.get.call(event) + : event.key; + } catch { + return false; + } + const isEnterKey = originalKey === "Enter" || isEnterCode(event.code); + const isOverlayActive = isActivated() || isHoldingKeys(); + const shouldBlockEnter = + isEnterKey && + isOverlayActive && + !isPromptMode() && + !store.wasActivatedByToggle; + + if (shouldBlockEnter) { + keyboardClaimer.claimedEvents.add(event); + event.preventDefault(); + event.stopImmediatePropagation(); + return true; + } + return false; + }; + + events.addDocumentListener("keydown", blockEnterIfNeeded, { + capture: true, + }); + events.addDocumentListener("keyup", blockEnterIfNeeded, { + capture: true, + }); + events.addDocumentListener("keypress", blockEnterIfNeeded, { + capture: true, + }); + + const handleEnterKeyActivation = (event: KeyboardEvent): boolean => { + if (!isEnterCode(event.code)) return false; + if (isKeyboardEventTriggeredByInput(event)) return false; + + const copiedElement = store.lastCopiedElement; + const canActivateFromCopied = + !isHoldingKeys() && + !isPromptMode() && + !isActivated() && + copiedElement && + isElementConnected(copiedElement) && + !store.labelInstances.some( + (instance) => + instance.status === "copied" || instance.status === "fading", + ); + + if (canActivateFromCopied) { + event.preventDefault(); + event.stopImmediatePropagation(); + + const center = getElementCenter(copiedElement); + + actions.setPointer(center); + ctx.shared.setCopyStartPosition?.(center, copiedElement); + actions.clearInputText(); + actions.setFrozenElement(copiedElement); + actions.clearLastCopied(); + + ctx.shared.activatePromptMode?.(); + if (!isActivated()) { + ctx.shared.activateRenderer?.(); + } + return true; + } + + const canActivateFromHolding = isHoldingKeys() && !isPromptMode(); + + if (canActivateFromHolding) { + event.preventDefault(); + event.stopImmediatePropagation(); + + const element = store.frozenElement || targetElement(); + if (element) { + ctx.shared.setCopyStartPosition?.( + { x: store.pointer.x, y: store.pointer.y }, + element, + ); + actions.clearInputText(); + } + + actions.setPointer({ x: store.pointer.x, y: store.pointer.y }); + if (element) { + actions.setFrozenElement(element); + } + ctx.shared.activatePromptMode?.(); + + if (keydownSpamTimerId !== null) { + window.clearTimeout(keydownSpamTimerId); + keydownSpamTimerId = null; + } + + if (!isActivated()) { + ctx.shared.activateRenderer?.(); + } + + return true; + } + + return false; + }; + + const handleOpenFileShortcut = (event: KeyboardEvent): boolean => { + if (event.key?.toLowerCase() !== "o" || isPromptMode()) return false; + if (!isActivated() || !(event.metaKey || event.ctrlKey)) return false; + + const filePath = store.selectionFilePath; + const lineNumber = store.selectionLineNumber; + if (!filePath) return false; + + event.preventDefault(); + event.stopPropagation(); + + const wasHandled = registry.hooks.onOpenFile( + filePath, + lineNumber ?? undefined, + ); + if (!wasHandled) { + openFile( + filePath, + lineNumber ?? undefined, + registry.hooks.transformOpenFileUrl, + ); + } + return true; + }; + + const handleActivationKeys = (event: KeyboardEvent): void => { + if ( + !registry.store.options.allowActivationInsideInput && + isKeyboardEventTriggeredByInput(event) + ) { + return; + } + + if (!isTargetKeyCombination(event, registry.store.options)) { + if ( + (event.metaKey || event.ctrlKey) && + !MODIFIER_KEYS.includes(event.key) && + !isEnterCode(event.code) + ) { + if (isActivated() && !store.wasActivatedByToggle) { + ctx.shared.deactivateRenderer?.(); + } else if (isHoldingKeys()) { + clearHoldTimer(); + resetCopyConfirmation(); + actions.releaseHold(); + } + } + if (!isEnterCode(event.code) || !isHoldingKeys()) { + return; + } + } + + if ((isActivated() || isHoldingKeys()) && !isPromptMode()) { + event.preventDefault(); + if (isEnterCode(event.code)) { + event.stopImmediatePropagation(); + } + } + + if (isActivated()) { + if ( + store.wasActivatedByToggle && + registry.store.options.activationMode !== "hold" + ) + return; + if (event.repeat) return; + + if (keydownSpamTimerId !== null) { + window.clearTimeout(keydownSpamTimerId); + } + keydownSpamTimerId = window.setTimeout(() => { + ctx.shared.deactivateRenderer?.(); + }, KEYDOWN_SPAM_TIMEOUT_MS); + return; + } + + if (isHoldingKeys() && event.repeat) { + if (activationHoldState.copyWaiting) { + const shouldActivate = activationHoldState.holdTimerFired; + resetCopyConfirmation(); + if (shouldActivate) { + actions.activate(); + } + } + return; + } + + if (isCopying() || didJustCopy()) return; + + if (!isHoldingKeys()) { + const keyHoldDuration = + registry.store.options.keyHoldDuration ?? + DEFAULT_KEY_HOLD_DURATION_MS; + + let activationDuration = keyHoldDuration; + if (isKeyboardEventTriggeredByInput(event)) { + if (hasTextSelectionInInput(event)) { + activationDuration += INPUT_TEXT_SELECTION_ACTIVATION_DELAY_MS; + } else { + activationDuration += INPUT_FOCUS_ACTIVATION_DELAY_MS; + } + } else if (hasTextSelectionOnPage()) { + activationDuration += INPUT_TEXT_SELECTION_ACTIVATION_DELAY_MS; + } + resetCopyConfirmation(); + actions.startHold(activationDuration); + } + }; + + ctx.onKeyDown((event) => { + blockEnterIfNeeded(event); + + if (!ctx.shared.isEnabled?.()) { + if ( + isTargetKeyCombination(event, registry.store.options) && + !event.repeat + ) { + ctx.shared.shakeToolbar?.(); + } + return true; + } + + const isEnterToActivateInput = + isEnterCode(event.code) && isHoldingKeys() && !isPromptMode(); + + const isFromReactGrabInput = isEventFromOverlay( + event, + "data-react-grab-input", + ); + if ( + isPromptMode() && + isTargetKeyCombination(event, registry.store.options) && + !event.repeat && + !isFromReactGrabInput + ) { + event.preventDefault(); + event.stopPropagation(); + ctx.shared.handleInputCancel?.(); + return true; + } + + const isFromOverlay = + isEventFromOverlay(event, "data-react-grab-ignore-events") && + !isEnterToActivateInput; + + if (event.key === "Escape") { + if (store.pendingAbortSessionId) { + event.preventDefault(); + event.stopPropagation(); + actions.setPendingAbortSessionId(null); + return true; + } + + if (isPromptMode() || isFromOverlay) { + if (isPromptMode()) { + ctx.shared.handleInputCancel?.(); + } else if (store.wasActivatedByToggle) { + ctx.shared.deactivateRenderer?.(); + } + return true; + } + + if (ctx.shared.isAgentProcessing?.()) { + return true; + } + + if (isHoldingKeys() || store.wasActivatedByToggle) { + ctx.shared.deactivateRenderer?.(); + return true; + } + } + + if (isPromptMode() || isFromOverlay) { + return false; + } + + const didWindowJustRegainFocus = + Date.now() - lastWindowFocusTimestamp < WINDOW_REFOCUS_GRACE_PERIOD_MS; + + if (handleEnterKeyActivation(event)) return true; + if (handleOpenFileShortcut(event)) return true; + + if (!didWindowJustRegainFocus) { + handleActivationKeys(event); + } + + return false; + }); + + ctx.onKeyUp((event) => { + if (blockEnterIfNeeded(event)) return true; + + const requiredModifiers = getRequiredModifiers(registry.store.options); + const isReleasingModifier = + requiredModifiers.metaKey || requiredModifiers.ctrlKey + ? isMac() + ? !event.metaKey + : !event.ctrlKey + : (requiredModifiers.shiftKey && !event.shiftKey) || + (requiredModifiers.altKey && !event.altKey); + + const isReleasingActivationKey = registry.store.options.activationKey + ? typeof registry.store.options.activationKey === "function" + ? registry.store.options.activationKey(event) + : parseActivationKey(registry.store.options.activationKey)(event) + : isCLikeKey(event.key, event.code); + + if (didJustCopy() || ctx.shared.isCopyFeedbackCooldownActive?.()) { + if (isReleasingActivationKey || isReleasingModifier) { + ctx.shared.clearCopyFeedbackCooldown?.(); + ctx.shared.deactivateRenderer?.(); + } + return true; + } + + if (!isHoldingKeys() && !isActivated()) return false; + if (isPromptMode()) return false; + + const hasCustomShortcut = Boolean(registry.store.options.activationKey); + + const isHoldMode = registry.store.options.activationMode === "hold"; + + if (isActivated()) { + const hasContextMenu = store.contextMenuPosition !== null; + if (isReleasingModifier) { + if ( + store.wasActivatedByToggle && + registry.store.options.activationMode !== "hold" + ) + return false; + if (hasContextMenu) return false; + ctx.shared.deactivateRenderer?.(); + } else if (isHoldMode && isReleasingActivationKey) { + if (keydownSpamTimerId !== null) { + window.clearTimeout(keydownSpamTimerId); + keydownSpamTimerId = null; + } + if (hasContextMenu) return false; + ctx.shared.deactivateRenderer?.(); + } else if ( + !hasCustomShortcut && + isReleasingActivationKey && + keydownSpamTimerId !== null + ) { + window.clearTimeout(keydownSpamTimerId); + keydownSpamTimerId = null; + } + return true; + } + + if (isReleasingActivationKey || isReleasingModifier) { + if ( + store.wasActivatedByToggle && + registry.store.options.activationMode !== "hold" + ) + return false; + + const shouldRelease = + isHoldingKeys() || + (activationHoldState.holdTimerFired && isReleasingModifier); + + if (shouldRelease) { + clearHoldTimer(); + const elapsedSinceHoldStart = activationHoldState.startTimestamp + ? Date.now() - activationHoldState.startTimestamp + : 0; + const heldLongEnoughForActivation = + elapsedSinceHoldStart >= MIN_HOLD_FOR_ACTIVATION_AFTER_COPY_MS; + const shouldActivateAfterCopy = + activationHoldState.holdTimerFired && + heldLongEnoughForActivation && + (registry.store.options.allowActivationInsideInput || + !isKeyboardEventTriggeredByInput(event)); + resetCopyConfirmation(); + if (shouldActivateAfterCopy) { + if (registry.store.options.activationMode !== "hold") { + actions.setWasActivatedByToggle(true); + } + actions.activate(); + } else { + actions.releaseHold(); + } + } else { + ctx.shared.deactivateRenderer?.(); + } + return true; + } + + return false; + }); + + events.addDocumentListener("copy", () => { + if (isHoldingKeys()) { + activationHoldState.copyWaiting = true; + } + }); + + events.addWindowListener("keypress", blockEnterIfNeeded, { + capture: true, + }); + + events.addWindowListener("blur", () => { + ctx.shared.cancelActiveDrag?.(); + if (isHoldingKeys()) { + clearHoldTimer(); + actions.releaseHold(); + resetCopyConfirmation(); + } + }); + + events.addWindowListener("focus", () => { + lastWindowFocusTimestamp = Date.now(); + }); + + events.addDocumentListener("visibilitychange", () => { + if (document.hidden) { + actions.clearGrabbedBoxes(); + const storeActivationTimestamp = store.activationTimestamp; + if ( + isActivated() && + !isPromptMode() && + storeActivationTimestamp !== null && + Date.now() - storeActivationTimestamp > BLUR_DEACTIVATION_THRESHOLD_MS + ) { + ctx.shared.deactivateRenderer?.(); + } + } + }); + + events.addWindowListener( + "focusin", + (event: FocusEvent) => { + if (isEventFromOverlay(event, "data-react-grab")) { + event.stopPropagation(); + } + }, + { capture: true }, + ); + + ctx.shared.clearHoldTimer = clearHoldTimer; + ctx.shared.resetCopyConfirmation = resetCopyConfirmation; + ctx.shared.isHoldingKeys = () => isHoldingKeys(); + + return () => { + clearHoldTimer(); + if (keydownSpamTimerId !== null) { + window.clearTimeout(keydownSpamTimerId); + keydownSpamTimerId = null; + } + keyboardClaimer.restore(); + resetCopyConfirmation(); + }; + }, +}; diff --git a/packages/react-grab/src/core/plugins/menus-plugin.ts b/packages/react-grab/src/core/plugins/menus-plugin.ts new file mode 100644 index 000000000..f17d15030 --- /dev/null +++ b/packages/react-grab/src/core/plugins/menus-plugin.ts @@ -0,0 +1,630 @@ +import { + createSignal, + createMemo, + createEffect, + createResource, + on, +} from "solid-js"; +import { resolveSource } from "element-source"; +import type { + InternalPlugin, + ActionCycleItem, + ActionCycleState, + ContextMenuAction, + ContextMenuActionContext, + BuildActionContextOptions, + OverlayBounds, + PerformWithFeedbackOptions, + SelectionLabelInstance, + AgentOptions, +} from "../../types.js"; +import { + getNearestComponentName, + getComponentDisplayName, +} from "../context.js"; +import { getTagName } from "../../utils/get-tag-name.js"; +import { createElementBounds } from "../../utils/create-element-bounds.js"; +import { combineBounds } from "../../utils/combine-bounds.js"; +import { + createBoundsFromDragRect, + createFlatOverlayBounds, +} from "../../utils/create-bounds-from-drag-rect.js"; +import { normalizeErrorMessage } from "../../utils/normalize-error.js"; +import { logRecoverableError } from "../../utils/log-recoverable-error.js"; +import { resolveActionEnabled } from "../../utils/resolve-action-enabled.js"; +import { keyMatchesCode } from "../../utils/key-matches-code.js"; +import { isKeyboardEventTriggeredByInput } from "../../utils/is-keyboard-event-triggered-by-input.js"; +import { getModifiersFromActivationKey } from "../../utils/parse-activation-key.js"; +import { generateId } from "../../utils/generate-id.js"; +import { + ACTION_CYCLE_IDLE_TRIGGER_MS, + PLUGIN_PRIORITY_MENUS, +} from "../../constants.js"; +import { createLabelFadeManager } from "../../utils/label-fade-manager.js"; + +export const menusPlugin: InternalPlugin = { + name: "menus", + priority: PLUGIN_PRIORITY_MENUS, + setup: (ctx) => { + const { store, actions, registry, shared, derived } = ctx; + const { + isActivated, + isPromptMode, + isDragging, + isRendererActive, + selectionElement, + frozenElementsBounds, + } = derived; + + const isCommentMode = createMemo( + () => store.pendingCommentMode || isPromptMode(), + ); + + const selectionBounds = createMemo((): OverlayBounds | undefined => { + void store.viewportVersion; + + const frozenElements = store.frozenElements; + if (frozenElements.length > 0) { + const frozen = frozenElementsBounds(); + if (frozenElements.length === 1) { + const firstBounds = frozen[0]; + if (firstBounds) return firstBounds; + } + const dragRect = store.frozenDragRect; + if (dragRect) { + const dragBounds = frozen[0]; + return dragBounds ?? createBoundsFromDragRect(dragRect); + } + return createFlatOverlayBounds(combineBounds(frozen)); + } + + const element = selectionElement(); + if (!element) return undefined; + return createElementBounds(element); + }); + + let actionCycleIdleTimeoutId: number | null = null; + + const [actionCycleItems, setActionCycleItems] = createSignal< + ActionCycleItem[] + >([]); + const [actionCycleActiveIndex, setActionCycleActiveIndex] = createSignal< + number | null + >(null); + + const labelFade = createLabelFadeManager(actions); + + const createLabelInstanceWithBounds = ( + bounds: OverlayBounds, + tagName: string, + componentName: string | undefined, + status: SelectionLabelInstance["status"], + options?: { + element?: Element; + mouseX?: number; + elements?: Element[]; + boundsMultiple?: OverlayBounds[]; + hideArrow?: boolean; + }, + ): string => { + actions.clearLabelInstances(); + labelFade.cancelAll(); + const instanceId = generateId("label"); + const boundsCenterX = bounds.x + bounds.width / 2; + const boundsHalfWidth = bounds.width / 2; + const mouseX = options?.mouseX; + const mouseXOffset = + mouseX !== undefined ? mouseX - boundsCenterX : undefined; + + const instance: SelectionLabelInstance = { + id: instanceId, + bounds, + boundsMultiple: options?.boundsMultiple, + tagName, + componentName, + status, + createdAt: Date.now(), + element: options?.element, + elements: options?.elements, + mouseX, + mouseXOffsetFromCenter: mouseXOffset, + mouseXOffsetRatio: + mouseXOffset !== undefined && boundsHalfWidth > 0 + ? mouseXOffset / boundsHalfWidth + : undefined, + hideArrow: options?.hideArrow, + }; + actions.addLabelInstance(instance); + return instanceId; + }; + + const updateLabelAfterCopy = ( + labelInstanceId: string, + didSucceed: boolean, + errorMessage?: string, + ) => { + if (didSucceed) { + actions.updateLabelInstance(labelInstanceId, "copied"); + } else { + actions.updateLabelInstance( + labelInstanceId, + "error", + errorMessage || "Unknown error", + ); + } + labelFade.schedule(labelInstanceId); + }; + + const clearActionCycleIdleTimeout = () => { + if (actionCycleIdleTimeoutId !== null) { + window.clearTimeout(actionCycleIdleTimeoutId); + actionCycleIdleTimeoutId = null; + } + }; + + const resetActionCycle = () => { + clearActionCycleIdleTimeout(); + setActionCycleItems([]); + setActionCycleActiveIndex(null); + }; + + const canCycleActions = createMemo(() => { + const element = selectionElement(); + return ( + Boolean(element) && + isRendererActive() && + !isPromptMode() && + !isDragging() && + store.contextMenuPosition === null + ); + }); + + const activationBaseKey = createMemo(() => { + const { key } = getModifiersFromActivationKey( + registry.store.options.activationKey, + ); + return (key ?? "c").toUpperCase(); + }); + + const actionCycleState = createMemo(() => ({ + items: actionCycleItems(), + activeIndex: actionCycleActiveIndex(), + isVisible: + actionCycleActiveIndex() !== null && + actionCycleItems().length > 0 && + !isCommentMode(), + })); + + createEffect( + on(selectionElement, () => { + resetActionCycle(); + }), + ); + + createEffect( + on(canCycleActions, (enabled) => { + if (!enabled) { + resetActionCycle(); + } + }), + ); + + const getActionById = (actionId: string): ContextMenuAction | undefined => + registry.store.actions.find((action) => action.id === actionId); + + const getActionCycleContext = (): ContextMenuActionContext | undefined => { + const element = selectionElement(); + if (!element) return undefined; + + const fallbackBounds = selectionBounds(); + + return buildActionContext({ + element, + filePath: store.selectionFilePath ?? undefined, + lineNumber: store.selectionLineNumber ?? undefined, + tagName: getTagName(element) || undefined, + componentName: getComponentDisplayName(element) ?? undefined, + position: store.pointer, + performWithFeedbackOptions: { + fallbackBounds, + fallbackSelectionBounds: fallbackBounds ? [fallbackBounds] : [], + }, + shouldDeferHideContextMenu: false, + onBeforePrompt: resetActionCycle, + }); + }; + + const availableActionCycleItems = createMemo((): ActionCycleItem[] => { + if (!selectionElement()) return []; + + const cycleItems: ActionCycleItem[] = []; + for (const action of registry.store.actions) { + const isStaticallyDisabled = + typeof action.enabled === "boolean" && !action.enabled; + if (isStaticallyDisabled) continue; + const hasNonMatchingShortcut = + action.shortcut && + action.shortcut.toUpperCase() !== activationBaseKey(); + if (hasNonMatchingShortcut) continue; + cycleItems.push({ + id: action.id, + label: action.label, + shortcut: action.shortcut, + }); + } + return cycleItems; + }); + + const scheduleActionCycleActivation = () => { + clearActionCycleIdleTimeout(); + actionCycleIdleTimeoutId = window.setTimeout(() => { + actionCycleIdleTimeoutId = null; + const activeIndex = actionCycleActiveIndex(); + const items = actionCycleItems(); + if (activeIndex === null || items.length === 0) return; + const selectedItem = items[activeIndex]; + if (!selectedItem) return; + const action = getActionById(selectedItem.id); + if (!action) { + resetActionCycle(); + return; + } + const context = getActionCycleContext(); + if (!context || !resolveActionEnabled(action, context)) { + resetActionCycle(); + return; + } + resetActionCycle(); + const result = action.onAction(context); + if (result instanceof Promise) { + void result; + } + }, ACTION_CYCLE_IDLE_TRIGGER_MS); + }; + + const advanceActionCycle = (): boolean => { + if (!canCycleActions()) return false; + const cycleItems = availableActionCycleItems(); + if (cycleItems.length === 0) return false; + + setActionCycleItems(cycleItems); + + const currentIndex = actionCycleActiveIndex(); + const isCurrentIndexValid = + currentIndex !== null && currentIndex < cycleItems.length; + const nextIndex = isCurrentIndexValid + ? (currentIndex + 1) % cycleItems.length + : 0; + + setActionCycleActiveIndex(nextIndex); + scheduleActionCycleActivation(); + return true; + }; + + const handleActionCycleKey = (event: KeyboardEvent): boolean => { + if (!keyMatchesCode(activationBaseKey(), event.code)) return false; + if (event.altKey || event.repeat) return false; + if (isKeyboardEventTriggeredByInput(event)) return false; + if (!advanceActionCycle()) return false; + + event.preventDefault(); + event.stopPropagation(); + if (event.metaKey || event.ctrlKey) { + event.stopImmediatePropagation(); + } + return true; + }; + + const openContextMenu = ( + element: Element, + position: { x: number; y: number }, + ) => { + actions.showContextMenu(position, element); + shared.clearArrowNavigation?.(); + shared.dismissAllPopups?.(); + registry.hooks.onContextMenu(element, position); + }; + + const contextMenuBounds = createMemo((): OverlayBounds | null => { + void store.viewportVersion; + const element = store.contextMenuElement; + if (!element) return null; + return createElementBounds(element); + }); + + const contextMenuPosition = createMemo(() => { + void store.viewportVersion; + return store.contextMenuPosition; + }); + + const contextMenuTagName = createMemo(() => { + const element = store.contextMenuElement; + if (!element) return undefined; + const frozenCount = store.frozenElements.length; + if (frozenCount > 1) { + return `${frozenCount} elements`; + } + return getTagName(element) || undefined; + }); + + const [contextMenuComponentName] = createResource( + () => ({ + element: store.contextMenuElement, + frozenCount: store.frozenElements.length, + }), + async ({ element, frozenCount }) => { + if (!element) return undefined; + if (frozenCount > 1) return undefined; + const name = await getNearestComponentName(element); + return name ?? undefined; + }, + ); + + const [contextMenuFilePath] = createResource( + () => store.contextMenuElement, + async (element) => { + if (!element) return null; + return resolveSource(element); + }, + ); + + const createPerformWithFeedback = ( + element: Element, + elements: Element[], + tagName: string | undefined, + componentName: string | undefined, + options?: PerformWithFeedbackOptions, + ) => { + return async (action: () => Promise): Promise => { + const fallbackBounds = options?.fallbackBounds ?? null; + const fallbackSelectionBounds = options?.fallbackSelectionBounds ?? []; + const position = + options?.position ?? store.contextMenuPosition ?? store.pointer; + const frozenBounds = frozenElementsBounds(); + const singleElementBounds = contextMenuBounds() ?? fallbackBounds; + const hasMultipleElements = elements.length > 1; + + const labelBounds = hasMultipleElements + ? createFlatOverlayBounds(combineBounds(frozenBounds)) + : singleElementBounds; + + const shouldDeactivateAfter = store.wasActivatedByToggle; + let selectionBoundsForLabel: OverlayBounds[]; + if (hasMultipleElements) { + selectionBoundsForLabel = frozenBounds; + } else if (singleElementBounds) { + selectionBoundsForLabel = [singleElementBounds]; + } else { + selectionBoundsForLabel = fallbackSelectionBounds; + } + + actions.hideContextMenu(); + + if (labelBounds) { + const labelCursorX = hasMultipleElements + ? labelBounds.x + labelBounds.width / 2 + : position.x; + + const labelInstanceId = createLabelInstanceWithBounds( + labelBounds, + tagName || "element", + componentName, + "copying", + { + element, + mouseX: labelCursorX, + elements: hasMultipleElements ? elements : undefined, + boundsMultiple: selectionBoundsForLabel, + }, + ); + + let didSucceed = false; + let errorMessage: string | undefined; + + try { + didSucceed = await action(); + if (!didSucceed) { + errorMessage = "Failed to copy"; + } + } catch (error) { + errorMessage = normalizeErrorMessage(error, "Action failed"); + } + + updateLabelAfterCopy(labelInstanceId, didSucceed, errorMessage); + } else { + // HACK: Fire-and-forget when no label bounds to display feedback on + try { + await action(); + } catch (error) { + logRecoverableError("Action failed without feedback bounds", error); + } + } + + if (shouldDeactivateAfter) { + shared.deactivateRenderer?.(); + } else { + actions.unfreeze(); + } + }; + }; + + // HACK: Defer hiding context menu until after click event propagates fully + const deferHideContextMenu = () => { + setTimeout(() => { + actions.hideContextMenu(); + }, 0); + }; + + const buildActionContext = ( + options: BuildActionContextOptions, + ): ContextMenuActionContext => { + const { + element, + filePath, + lineNumber, + tagName, + componentName, + position, + performWithFeedbackOptions, + shouldDeferHideContextMenu, + onBeforeCopy, + onBeforePrompt, + customEnterPromptMode, + } = options; + + const elements = + store.frozenElements.length > 0 ? store.frozenElements : [element]; + + const hideContextMenuAction = shouldDeferHideContextMenu + ? deferHideContextMenu + : actions.hideContextMenu; + + const copyAction = () => { + onBeforeCopy?.(); + shared.performCopyWithLabel?.({ + element, + cursorX: position.x, + selectedElements: elements.length > 1 ? elements : undefined, + shouldDeactivateAfter: store.wasActivatedByToggle, + }); + hideContextMenuAction(); + }; + + const defaultEnterPromptMode = (agent?: AgentOptions) => { + if (agent) { + actions.setSelectedAgent(agent); + } + shared.clearAllLabels?.(); + onBeforePrompt?.(); + shared.preparePromptMode?.(position, element); + actions.setPointer({ x: position.x, y: position.y }); + actions.setFrozenElement(element); + shared.activatePromptMode?.(); + if (!isActivated()) { + shared.activateRenderer?.(); + } + hideContextMenuAction(); + }; + + const context: ContextMenuActionContext = { + element, + elements, + filePath, + lineNumber, + componentName, + tagName, + enterPromptMode: customEnterPromptMode ?? defaultEnterPromptMode, + copy: copyAction, + hooks: { + transformHtmlContent: registry.hooks.transformHtmlContent, + onOpenFile: registry.hooks.onOpenFile, + transformOpenFileUrl: registry.hooks.transformOpenFileUrl, + }, + performWithFeedback: createPerformWithFeedback( + element, + elements, + tagName, + componentName, + performWithFeedbackOptions, + ), + hideContextMenu: hideContextMenuAction, + cleanup: () => { + if (store.wasActivatedByToggle) { + shared.deactivateRenderer?.(); + } else { + actions.unfreeze(); + } + }, + }; + + const transformedContext = registry.hooks.transformActionContext(context); + return { ...context, ...transformedContext }; + }; + + const contextMenuActionContext = createMemo( + (): ContextMenuActionContext | undefined => { + const element = store.contextMenuElement; + if (!element) return undefined; + const fileInfo = contextMenuFilePath(); + const position = store.contextMenuPosition ?? store.pointer; + + return buildActionContext({ + element, + filePath: fileInfo?.filePath, + lineNumber: fileInfo?.lineNumber ?? undefined, + tagName: contextMenuTagName(), + componentName: contextMenuComponentName(), + position, + shouldDeferHideContextMenu: true, + onBeforeCopy: () => { + // Side effect: keyboard-selected element is cleared by the + // navigation plugin when the context menu triggers a copy. + }, + customEnterPromptMode: (agent?: AgentOptions) => { + if (agent) { + actions.setSelectedAgent(agent); + } + shared.clearAllLabels?.(); + actions.clearInputText(); + actions.enterPromptMode(position, element); + deferHideContextMenu(); + }, + }); + }, + ); + + const handleContextMenuDismiss = () => { + setTimeout(() => { + actions.hideContextMenu(); + shared.deactivateRenderer?.(); + }, 0); + }; + + shared.openContextMenu = (position, element) => { + openContextMenu(element, position); + }; + shared.buildActionContext = buildActionContext; + shared.createPerformWithFeedback = ( + options?: PerformWithFeedbackOptions, + ) => { + const element = store.contextMenuElement ?? selectionElement(); + if (!element) { + return async () => {}; + } + const elements = + store.frozenElements.length > 0 ? store.frozenElements : [element]; + const tagName = getTagName(element) || undefined; + const componentName = getComponentDisplayName(element) ?? undefined; + return createPerformWithFeedback( + element, + elements, + tagName, + componentName, + options, + ); + }; + + ctx.onKeyDown(handleActionCycleKey); + + ctx.provide("contextMenuPosition", () => contextMenuPosition()); + ctx.provide("contextMenuBounds", () => contextMenuBounds()); + ctx.provide("contextMenuTagName", () => contextMenuTagName()); + ctx.provide("contextMenuComponentName", () => contextMenuComponentName()); + ctx.provide("contextMenuHasFilePath", () => + Boolean(contextMenuFilePath()?.filePath), + ); + ctx.provide("actions", () => registry.store.actions); + ctx.provide("actionContext", () => contextMenuActionContext()); + ctx.provide("onContextMenuDismiss", () => handleContextMenuDismiss); + ctx.provide("onContextMenuHide", () => deferHideContextMenu); + ctx.provide("selectionActionCycleState", () => actionCycleState()); + + return () => { + clearActionCycleIdleTimeout(); + labelFade.cancelAll(); + shared.openContextMenu = undefined; + shared.buildActionContext = undefined; + shared.createPerformWithFeedback = undefined; + }; + }, +}; diff --git a/packages/react-grab/src/core/plugins/navigation-plugin.ts b/packages/react-grab/src/core/plugins/navigation-plugin.ts new file mode 100644 index 000000000..1225fc66c --- /dev/null +++ b/packages/react-grab/src/core/plugins/navigation-plugin.ts @@ -0,0 +1,398 @@ +import { + createSignal, + createMemo, + createEffect, + on, + onCleanup, +} from "solid-js"; +import { resolveSource } from "element-source"; +import type { InternalPlugin, ArrowNavigationState } from "../../types.js"; +import { + ARROW_KEYS, + COMPONENT_NAME_DEBOUNCE_MS, + PLUGIN_PRIORITY_NAVIGATION, +} from "../../constants.js"; +import { createArrowNavigator } from "../arrow-navigation.js"; +import { + getNearestComponentName, + getComponentDisplayName, +} from "../context.js"; +import { isValidGrabbableElement } from "../../utils/is-valid-grabbable-element.js"; +import { createElementBounds } from "../../utils/create-element-bounds.js"; +import { getElementBoundsCenter } from "../../utils/get-element-bounds-center.js"; +import { getVisibleBoundsCenter } from "../../utils/get-visible-bounds-center.js"; +import { + getElementAtPosition, + getElementsAtPoint, +} from "../../utils/get-element-at-position.js"; +import { getAncestorElements } from "../../utils/get-ancestor-elements.js"; +import { getTagName } from "../../utils/get-tag-name.js"; + +export const navigationPlugin: InternalPlugin = { + name: "navigation", + priority: PLUGIN_PRIORITY_NAVIGATION, + setup: (ctx) => { + const { store, actions, derived } = ctx; + const { + isActivated, + isFrozenPhase, + isDragging, + isRendererActive, + isPromptMode, + targetElement, + effectiveElement, + selectionElement, + } = derived; + + const isSelectionElementVisible = (): boolean => { + const element = selectionElement(); + if (!element) return false; + if (store.isTouchMode && isDragging()) { + return isRendererActive(); + } + return isRendererActive() && !isDragging(); + }; + + let componentNameDebounceTimerId: number | null = null; + let componentNameRequestVersion = 0; + let selectionSourceRequestVersion = 0; + + const [ + debouncedElementForComponentName, + setDebouncedElementForComponentName, + ] = createSignal(null); + const [resolvedComponentName, setResolvedComponentName] = createSignal< + string | undefined + >(undefined); + + const [arrowNavigationElements, setArrowNavigationElements] = createSignal< + Element[] + >([]); + const [arrowNavigationActiveIndex, setArrowNavigationActiveIndex] = + createSignal(0); + + const arrowNavigator = createArrowNavigator( + isValidGrabbableElement, + createElementBounds, + ); + + const [isInspectMode, setIsInspectMode] = createSignal(false); + const [inspectActiveIndex, setInspectActiveIndex] = createSignal(-1); + + createEffect( + on(effectiveElement, (element) => { + if (componentNameDebounceTimerId !== null) { + clearTimeout(componentNameDebounceTimerId); + componentNameDebounceTimerId = null; + } + + if (!element) { + setDebouncedElementForComponentName(null); + return; + } + + componentNameDebounceTimerId = window.setTimeout(() => { + componentNameDebounceTimerId = null; + setDebouncedElementForComponentName(element); + }, COMPONENT_NAME_DEBOUNCE_MS); + }), + ); + + onCleanup(() => { + if (componentNameDebounceTimerId !== null) { + clearTimeout(componentNameDebounceTimerId); + componentNameDebounceTimerId = null; + } + }); + + createEffect( + on( + () => debouncedElementForComponentName(), + (element) => { + const currentVersion = ++componentNameRequestVersion; + + if (!element) { + setResolvedComponentName(undefined); + return; + } + + getNearestComponentName(element) + .then((name) => { + if (componentNameRequestVersion !== currentVersion) return; + setResolvedComponentName(name ?? undefined); + }) + .catch(() => { + if (componentNameRequestVersion !== currentVersion) return; + setResolvedComponentName(undefined); + }); + }, + ), + ); + + createEffect( + on( + () => targetElement(), + (element) => { + const currentVersion = ++selectionSourceRequestVersion; + + const clearSource = () => { + if (selectionSourceRequestVersion === currentVersion) { + actions.setSelectionSource(null, null); + } + }; + + if (!element) { + clearSource(); + return; + } + + resolveSource(element) + .then((source) => { + if (selectionSourceRequestVersion !== currentVersion) return; + if (!source) { + clearSource(); + return; + } + actions.setSelectionSource(source.filePath, source.lineNumber); + }) + .catch(() => { + if (selectionSourceRequestVersion === currentVersion) { + actions.setSelectionSource(null, null); + } + }); + }, + ), + ); + + const selectionTagName = createMemo(() => { + const element = selectionElement(); + if (!element) return undefined; + return getTagName(element) || undefined; + }); + + const isElementLabelThemeEnabled = createMemo( + () => ctx.registry.store.theme.elementLabel.enabled, + ); + + const isSelectionSuppressed = createMemo( + () => + derived.didJustCopy() || + ((ctx.shared.isToolbarSelectHovered?.() ?? false) && + !isFrozenPhase()), + ); + + const selectionLabelVisible = createMemo(() => { + if (store.contextMenuPosition !== null) return false; + if (!isElementLabelThemeEnabled()) return false; + if (isSelectionSuppressed()) return false; + return isSelectionElementVisible(); + }); + + const inspectBounds = createMemo(() => { + if (!isInspectMode()) return []; + + const element = effectiveElement(); + if (!element) return []; + + void store.viewportVersion; + + return [...getAncestorElements(element), element].map((ancestor) => + createElementBounds(ancestor), + ); + }); + + const arrowNavigationItems = createMemo(() => + arrowNavigationElements().map((element) => ({ + tagName: getTagName(element) || "element", + componentName: getComponentDisplayName(element) ?? undefined, + })), + ); + + const arrowNavigationState = createMemo(() => ({ + items: arrowNavigationItems(), + activeIndex: arrowNavigationActiveIndex(), + isVisible: arrowNavigationElements().length > 0, + })); + + const inspectAncestorElements = createMemo((): Element[] => { + if (!isInspectMode()) return []; + const element = effectiveElement(); + if (!element) return []; + return [...getAncestorElements(element).reverse(), element]; + }); + + const inspectNavigationItems = createMemo(() => + inspectAncestorElements().map((element) => ({ + tagName: getTagName(element) || "element", + componentName: getComponentDisplayName(element) ?? undefined, + })), + ); + + createEffect( + on(inspectAncestorElements, (elements) => { + setInspectActiveIndex(elements.length - 1); + }), + ); + + const inspectNavigationState = createMemo(() => { + const elements = inspectAncestorElements(); + return { + items: inspectNavigationItems(), + activeIndex: inspectActiveIndex(), + isVisible: isInspectMode() && elements.length > 0, + }; + }); + + const clearArrowNavigation = () => { + setArrowNavigationElements([]); + setArrowNavigationActiveIndex(0); + arrowNavigator.clearHistory(); + }; + + const selectAndFocusElement = (element: Element) => { + actions.setFrozenElement(element); + actions.freeze(); + + const { center } = getElementBoundsCenter(element); + actions.setPointer(center); + + if (store.contextMenuPosition !== null) { + actions.showContextMenu(center, element); + } + }; + + const openArrowNavigationMenu = (anchorElement: Element) => { + const bounds = createElementBounds(anchorElement); + const probePoint = getVisibleBoundsCenter(bounds); + const elementsAtPoint = getElementsAtPoint(probePoint.x, probePoint.y) + .filter(isValidGrabbableElement) + .reverse(); + + setArrowNavigationElements(elementsAtPoint); + setArrowNavigationActiveIndex( + Math.max(0, elementsAtPoint.indexOf(anchorElement)), + ); + }; + + const handleArrowNavigationSelect = (index: number) => { + const selectedElement = arrowNavigationElements()[index]; + if (!selectedElement) return; + + setArrowNavigationActiveIndex(index); + arrowNavigator.clearHistory(); + selectAndFocusElement(selectedElement); + }; + + const handleArrowNavigation = (event: KeyboardEvent): boolean => { + if (!isActivated() || isPromptMode()) return false; + if (!ARROW_KEYS.has(event.key)) return false; + + let currentElement = effectiveElement(); + const isInitialSelection = !currentElement; + + if (!currentElement) { + currentElement = getElementAtPosition( + window.innerWidth / 2, + window.innerHeight / 2, + ); + } + + if (!currentElement) return false; + + const isVertical = event.key === "ArrowUp" || event.key === "ArrowDown"; + + if (!isVertical) { + clearArrowNavigation(); + const nextElement = arrowNavigator.findNext(event.key, currentElement); + if (!nextElement && !isInitialSelection) return false; + event.preventDefault(); + event.stopPropagation(); + selectAndFocusElement(nextElement ?? currentElement); + return true; + } + + if (arrowNavigationElements().length === 0) { + openArrowNavigationMenu(currentElement); + } + + const nextElement = arrowNavigator.findNext(event.key, currentElement); + const elementToSelect = nextElement ?? currentElement; + + event.preventDefault(); + event.stopPropagation(); + selectAndFocusElement(elementToSelect); + + const newIndex = arrowNavigationElements().indexOf(elementToSelect); + if (newIndex !== -1) { + setArrowNavigationActiveIndex(newIndex); + } else { + openArrowNavigationMenu(elementToSelect); + } + + return true; + }; + + const handleInspectSelect = (index: number) => { + setInspectActiveIndex(index); + }; + + ctx.shared.clearArrowNavigation = clearArrowNavigation; + ctx.shared.hasArrowNavigation = () => arrowNavigationElements().length > 0; + + ctx.provide("selectionArrowNavigationState", () => arrowNavigationState()); + ctx.provide("onArrowNavigationSelect", () => handleArrowNavigationSelect); + ctx.provide("inspectNavigationState", () => inspectNavigationState()); + ctx.provide("onInspectSelect", () => handleInspectSelect); + ctx.provide( + "inspectVisible", + () => isInspectMode() && inspectBounds().length > 0, + ); + ctx.provide("inspectBounds", () => inspectBounds()); + ctx.provide("selectionLabelVisible", () => selectionLabelVisible()); + ctx.provide("selectionTagName", () => selectionTagName()); + ctx.provide("selectionComponentName", () => resolvedComponentName()); + ctx.provide( + "selectionFilePath", + () => store.selectionFilePath ?? undefined, + ); + ctx.provide( + "selectionLineNumber", + () => store.selectionLineNumber ?? undefined, + ); + + ctx.onKeyDown((event) => { + if (event.key === "Shift" && !event.repeat && isActivated()) { + setIsInspectMode(true); + if (isFrozenPhase()) { + actions.unfreeze(); + clearArrowNavigation(); + } + return true; + } + + if (ARROW_KEYS.has(event.key)) { + return handleArrowNavigation(event); + } + + return false; + }); + + ctx.onKeyUp((event) => { + if (event.key === "Shift") { + setIsInspectMode(false); + return true; + } + return false; + }); + + return () => { + if (componentNameDebounceTimerId !== null) { + clearTimeout(componentNameDebounceTimerId); + componentNameDebounceTimerId = null; + } + + ctx.shared.clearArrowNavigation = undefined; + ctx.shared.hasArrowNavigation = undefined; + }; + }, +}; diff --git a/packages/react-grab/src/core/plugins/pointer-plugin.ts b/packages/react-grab/src/core/plugins/pointer-plugin.ts new file mode 100644 index 000000000..0b1aea9b0 --- /dev/null +++ b/packages/react-grab/src/core/plugins/pointer-plugin.ts @@ -0,0 +1,702 @@ +import { createSignal, createMemo, createEffect, onCleanup } from "solid-js"; +import type { InternalPlugin, OverlayBounds, Position } from "../../types.js"; +import { + DRAG_THRESHOLD_PX, + ELEMENT_DETECTION_THROTTLE_MS, + PENDING_DETECTION_STALENESS_MS, + DRAG_PREVIEW_DEBOUNCE_MS, + FEEDBACK_DURATION_MS, + DEFAULT_ACTION_ID, + PLUGIN_PRIORITY_POINTER, +} from "../../constants.js"; +import { createAutoScroller, getAutoScrollDirection } from "../auto-scroll.js"; +import { getElementAtPosition } from "../../utils/get-element-at-position.js"; +import { getElementsInDrag } from "../../utils/get-elements-in-drag.js"; +import { isValidGrabbableElement } from "../../utils/is-valid-grabbable-element.js"; +import { isEventFromOverlay } from "../../utils/is-event-from-overlay.js"; +import { createElementBounds } from "../../utils/create-element-bounds.js"; +import { + createBoundsFromDragRect, + createPageRectFromBounds, + createFlatOverlayBounds, +} from "../../utils/create-bounds-from-drag-rect.js"; +import { freezeAllAnimations } from "../../utils/freeze-animations.js"; +import { getElementCenter } from "../../utils/get-element-center.js"; +import { isElementConnected } from "../../utils/is-element-connected.js"; +import { onIdle } from "../../utils/on-idle.js"; +import { getTagName } from "../../utils/get-tag-name.js"; +import { combineBounds } from "../../utils/combine-bounds.js"; + +export const pointerPlugin: InternalPlugin = { + name: "pointer", + priority: PLUGIN_PRIORITY_POINTER, + setup: (ctx) => { + const { store, actions, registry, derived } = ctx; + + const { + isHoldingKeys, + isActivated, + isFrozenPhase, + isDragging, + isCopying, + isPromptMode, + isRendererActive, + selectionElement, + frozenElementsBounds, + } = derived; + + const didJustDrag = createMemo( + () => + store.current.state === "active" && + store.current.phase === "justDragged", + ); + + const selectionBounds = createMemo((): OverlayBounds | undefined => { + void store.viewportVersion; + + const frozenElements = store.frozenElements; + if (frozenElements.length > 0) { + const frozenBounds = frozenElementsBounds(); + if (frozenElements.length === 1) { + const firstBounds = frozenBounds[0]; + if (firstBounds) return firstBounds; + } + const dragRect = store.frozenDragRect; + if (dragRect) { + const firstFrozenBounds = frozenBounds[0]; + return firstFrozenBounds ?? createBoundsFromDragRect(dragRect); + } + return createFlatOverlayBounds(combineBounds(frozenBounds)); + } + + const element = selectionElement(); + if (!element) return undefined; + return createElementBounds(element); + }); + + const elementDetectionState = { + lastDetectionTimestamp: 0, + pendingDetectionScheduledAt: 0, + latestPointerX: 0, + latestPointerY: 0, + }; + + let dragPreviewDebounceTimerId: number | null = null; + const [debouncedDragPointer, setDebouncedDragPointer] = createSignal<{ + x: number; + y: number; + } | null>(null); + + const scheduleDragPreviewUpdate = (clientX: number, clientY: number) => { + if (dragPreviewDebounceTimerId !== null) { + clearTimeout(dragPreviewDebounceTimerId); + } + setDebouncedDragPointer(null); + dragPreviewDebounceTimerId = window.setTimeout(() => { + setDebouncedDragPointer({ x: clientX, y: clientY }); + dragPreviewDebounceTimerId = null; + }, DRAG_PREVIEW_DEBOUNCE_MS); + }; + + const autoScroller = createAutoScroller( + () => store.pointer, + () => isDragging(), + ); + + let isPendingContextMenuSelect = false; + let pendingDefaultActionId: string | null = null; + + let keyboardSelectedElement: Element | null = null; + + const toPageCoordinates = (clientX: number, clientY: number) => ({ + pageX: clientX + window.scrollX, + pageY: clientY + window.scrollY, + }); + + const calculateDragDistance = (endX: number, endY: number) => { + const { pageX: endPageX, pageY: endPageY } = toPageCoordinates( + endX, + endY, + ); + + return { + x: Math.abs(endPageX - store.dragStart.x), + y: Math.abs(endPageY - store.dragStart.y), + }; + }; + + const isDraggingBeyondThreshold = createMemo(() => { + if (!isDragging()) return false; + + const dragDistance = calculateDragDistance( + store.pointer.x, + store.pointer.y, + ); + + return ( + dragDistance.x > DRAG_THRESHOLD_PX || dragDistance.y > DRAG_THRESHOLD_PX + ); + }); + + const calculateDragRectangle = (endX: number, endY: number) => { + const { pageX: endPageX, pageY: endPageY } = toPageCoordinates( + endX, + endY, + ); + + const dragPageX = Math.min(store.dragStart.x, endPageX); + const dragPageY = Math.min(store.dragStart.y, endPageY); + const dragWidth = Math.abs(endPageX - store.dragStart.x); + const dragHeight = Math.abs(endPageY - store.dragStart.y); + + return { + x: dragPageX - window.scrollX, + y: dragPageY - window.scrollY, + width: dragWidth, + height: dragHeight, + }; + }; + + const dragBounds = createMemo((): OverlayBounds | undefined => { + void store.viewportVersion; + + if (!isDraggingBeyondThreshold()) return undefined; + + const drag = calculateDragRectangle(store.pointer.x, store.pointer.y); + + return { + borderRadius: "0px", + height: drag.height, + transform: "none", + width: drag.width, + x: drag.x, + y: drag.y, + }; + }); + + const dragPreviewBounds = createMemo((): OverlayBounds[] => { + void store.viewportVersion; + + if (!isDraggingBeyondThreshold()) return []; + + const pointer = debouncedDragPointer(); + if (!pointer) return []; + + const drag = calculateDragRectangle(pointer.x, pointer.y); + const elements = getElementsInDrag(drag, isValidGrabbableElement); + const previewElements = + elements.length > 0 + ? elements + : getElementsInDrag(drag, isValidGrabbableElement, false); + + return previewElements.map((element) => createElementBounds(element)); + }); + + const selectionBoundsMultiple = createMemo((): OverlayBounds[] => { + const previewBounds = dragPreviewBounds(); + if (previewBounds.length > 0) { + return previewBounds; + } + return frozenElementsBounds(); + }); + + const dragVisible = createMemo( + () => + registry.store.theme.enabled && + registry.store.theme.dragBox.enabled && + isRendererActive() && + isDraggingBeyondThreshold(), + ); + + createEffect(() => { + if ( + store.current.state !== "active" || + store.current.phase !== "justDragged" + ) + return; + const timerId = setTimeout(() => { + actions.finishJustDragged(); + }, FEEDBACK_DURATION_MS); + onCleanup(() => clearTimeout(timerId)); + }); + + const openContextMenu = (element: Element, position: Position) => { + actions.showContextMenu(position, element); + ctx.shared.clearArrowNavigation?.(); + ctx.shared.dismissAllPopups?.(); + registry.hooks.onContextMenu(element, position); + }; + + const enterCommentModeForElement = ( + element: Element, + positionX: number, + positionY: number, + ) => { + actions.setPendingCommentMode(false); + actions.clearInputText(); + actions.enterPromptMode({ x: positionX, y: positionY }, element); + }; + + const runPendingDefaultAction = (element: Element, position: Position) => { + const actionId = pendingDefaultActionId; + pendingDefaultActionId = null; + if (!actionId) return; + + const action = registry.store.actions.find( + (registeredAction) => registeredAction.id === actionId, + ); + if (!action) { + ctx.shared.handleSetDefaultAction?.(DEFAULT_ACTION_ID); + openContextMenu(element, position); + return; + } + + const elementBounds = createElementBounds(element); + if (ctx.shared.buildActionContext) { + const context = ctx.shared.buildActionContext({ + element, + filePath: store.selectionFilePath ?? undefined, + lineNumber: store.selectionLineNumber ?? undefined, + tagName: getTagName(element) || undefined, + componentName: undefined, + position, + shouldDeferHideContextMenu: false, + performWithFeedbackOptions: { + fallbackBounds: elementBounds, + fallbackSelectionBounds: [elementBounds], + position, + }, + }); + action.onAction(context); + } + }; + + const handlePointerMove = (clientX: number, clientY: number) => { + if ( + isPromptMode() || + isFrozenPhase() || + store.contextMenuPosition !== null + ) + return; + + actions.setPointer({ x: clientX, y: clientY }); + + elementDetectionState.latestPointerX = clientX; + elementDetectionState.latestPointerY = clientY; + + const now = performance.now(); + const isDetectionPending = + elementDetectionState.pendingDetectionScheduledAt > 0 && + now - elementDetectionState.pendingDetectionScheduledAt < + PENDING_DETECTION_STALENESS_MS; + if ( + now - elementDetectionState.lastDetectionTimestamp >= + ELEMENT_DETECTION_THROTTLE_MS && + !isDetectionPending + ) { + elementDetectionState.lastDetectionTimestamp = now; + elementDetectionState.pendingDetectionScheduledAt = now; + onIdle(() => { + const candidate = getElementAtPosition( + elementDetectionState.latestPointerX, + elementDetectionState.latestPointerY, + ); + if (candidate !== store.detectedElement) { + actions.setDetectedElement(candidate); + } + elementDetectionState.pendingDetectionScheduledAt = 0; + }); + } + + if (isDragging()) { + scheduleDragPreviewUpdate(clientX, clientY); + + const direction = getAutoScrollDirection(clientX, clientY); + const isNearEdge = + direction.top || + direction.bottom || + direction.left || + direction.right; + + if (isNearEdge && !autoScroller.isActive()) { + autoScroller.start(); + } else if (!isNearEdge && autoScroller.isActive()) { + autoScroller.stop(); + } + } + }; + + const handlePointerDown = (clientX: number, clientY: number) => { + if (!isRendererActive() || isCopying()) return false; + + actions.startDrag({ x: clientX, y: clientY }); + actions.setPointer({ x: clientX, y: clientY }); + document.body.style.userSelect = "none"; + + scheduleDragPreviewUpdate(clientX, clientY); + + registry.hooks.onDragStart( + clientX + window.scrollX, + clientY + window.scrollY, + ); + + return true; + }; + + const handleDragSelection = ( + dragSelectionRect: ReturnType, + hasModifierKeyHeld: boolean, + ) => { + const elements = getElementsInDrag( + dragSelectionRect, + isValidGrabbableElement, + ); + const selectedElements = + elements.length > 0 + ? elements + : getElementsInDrag( + dragSelectionRect, + isValidGrabbableElement, + false, + ); + + if (selectedElements.length === 0) return; + + freezeAllAnimations(selectedElements); + + registry.hooks.onDragEnd(selectedElements, dragSelectionRect); + const firstElement = selectedElements[0]; + const center = getElementCenter(firstElement); + + actions.setPointer(center); + actions.setFrozenElements(selectedElements); + const dragRect = createPageRectFromBounds(dragSelectionRect); + actions.setFrozenDragRect(dragRect); + actions.freeze(); + actions.setLastGrabbed(firstElement); + + if (store.pendingCommentMode) { + enterCommentModeForElement(firstElement, center.x, center.y); + return; + } + + if (isPendingContextMenuSelect) { + isPendingContextMenuSelect = false; + if (pendingDefaultActionId) { + runPendingDefaultAction(firstElement, center); + } else { + openContextMenu(firstElement, center); + } + return; + } + + const shouldDeactivateAfter = + store.wasActivatedByToggle && !hasModifierKeyHeld; + + ctx.shared.performCopyWithLabel?.({ + element: firstElement, + cursorX: center.x, + selectedElements, + shouldDeactivateAfter, + dragRect, + }); + }; + + const handleSingleClick = ( + clientX: number, + clientY: number, + hasModifierKeyHeld: boolean, + ) => { + const validFrozenElement = isElementConnected(store.frozenElement) + ? store.frozenElement + : null; + + const validKeyboardSelectedElement = isElementConnected( + keyboardSelectedElement, + ) + ? keyboardSelectedElement + : null; + + const element = + validFrozenElement ?? + validKeyboardSelectedElement ?? + getElementAtPosition(clientX, clientY) ?? + (isElementConnected(store.detectedElement) + ? store.detectedElement + : null); + if (!element) return; + + const didSelectViaKeyboard = + !validFrozenElement && validKeyboardSelectedElement === element; + + let positionX: number; + let positionY: number; + + if (validFrozenElement) { + positionX = store.pointer.x; + positionY = store.pointer.y; + } else if (didSelectViaKeyboard) { + const elementCenter = getElementCenter(element); + positionX = elementCenter.x; + positionY = elementCenter.y; + } else { + positionX = clientX; + positionY = clientY; + } + + keyboardSelectedElement = null; + + if (store.pendingCommentMode) { + enterCommentModeForElement(element, positionX, positionY); + return; + } + + if (isPendingContextMenuSelect) { + isPendingContextMenuSelect = false; + const { wasIntercepted } = registry.hooks.onElementSelect(element); + if (wasIntercepted) return; + + freezeAllAnimations([element]); + actions.setFrozenElement(element); + const position = { x: positionX, y: positionY }; + actions.setPointer(position); + actions.freeze(); + if (pendingDefaultActionId) { + runPendingDefaultAction(element, position); + } else { + openContextMenu(element, position); + } + return; + } + + const shouldDeactivateAfter = + store.wasActivatedByToggle && !hasModifierKeyHeld; + + actions.setLastGrabbed(element); + + ctx.shared.performCopyWithLabel?.({ + element, + cursorX: positionX, + shouldDeactivateAfter, + }); + }; + + const cancelActiveDrag = () => { + if (!isDragging()) return; + actions.cancelDrag(); + autoScroller.stop(); + document.body.style.userSelect = ""; + }; + + const handlePointerUp = ( + clientX: number, + clientY: number, + hasModifierKeyHeld: boolean, + ) => { + if (!isDragging()) return; + + if (dragPreviewDebounceTimerId !== null) { + clearTimeout(dragPreviewDebounceTimerId); + dragPreviewDebounceTimerId = null; + } + setDebouncedDragPointer(null); + + const dragDistance = calculateDragDistance(clientX, clientY); + const wasDragGesture = + dragDistance.x > DRAG_THRESHOLD_PX || + dragDistance.y > DRAG_THRESHOLD_PX; + + // HACK: Calculate drag rectangle BEFORE ending drag, because endDrag resets dragStart + const dragSelectionRect = wasDragGesture + ? calculateDragRectangle(clientX, clientY) + : null; + + if (wasDragGesture) { + actions.endDrag(); + } else { + actions.cancelDrag(); + } + autoScroller.stop(); + document.body.style.userSelect = ""; + + if (dragSelectionRect) { + handleDragSelection(dragSelectionRect, hasModifierKeyHeld); + } else { + handleSingleClick(clientX, clientY, hasModifierKeyHeld); + } + }; + + ctx.onPointerMove((event: PointerEvent) => { + if (!event.isPrimary) return false; + const isTouchPointer = event.pointerType === "touch"; + actions.setTouchMode(isTouchPointer); + if (isEventFromOverlay(event, "data-react-grab-ignore-events")) + return false; + if (store.contextMenuPosition !== null) return false; + if (isTouchPointer && !isHoldingKeys() && !isActivated()) return false; + const isActiveState = isTouchPointer ? isHoldingKeys() : isActivated(); + if (isActiveState && !isPromptMode() && isFrozenPhase()) { + actions.unfreeze(); + ctx.shared.clearArrowNavigation?.(); + } + handlePointerMove(event.clientX, event.clientY); + return false; + }); + + ctx.onPointerDown((event: PointerEvent) => { + if (event.button !== 0) return false; + if (!event.isPrimary) return false; + actions.setTouchMode(event.pointerType === "touch"); + if (isEventFromOverlay(event, "data-react-grab-ignore-events")) + return false; + if (store.contextMenuPosition !== null) return false; + + if (isPromptMode()) { + const bounds = selectionBounds(); + const isClickOnSelection = + bounds && + event.clientX >= bounds.x && + event.clientX <= bounds.x + bounds.width && + event.clientY >= bounds.y && + event.clientY <= bounds.y + bounds.height; + + if (isClickOnSelection) { + ctx.shared.handleInputSubmit?.(); + } else { + ctx.shared.handleInputCancel?.(); + } + return true; + } + + const didHandle = handlePointerDown(event.clientX, event.clientY); + if (didHandle) { + document.documentElement.setPointerCapture(event.pointerId); + event.preventDefault(); + event.stopImmediatePropagation(); + return true; + } + + return false; + }); + + ctx.onPointerUp((event: PointerEvent) => { + if (event.button !== 0) return false; + if (!event.isPrimary) return false; + if (isEventFromOverlay(event, "data-react-grab-ignore-events")) + return false; + if (store.contextMenuPosition !== null) return false; + const isActive = isRendererActive() || isCopying() || isDragging(); + const hasModifierKeyHeld = event.metaKey || event.ctrlKey; + handlePointerUp(event.clientX, event.clientY, hasModifierKeyHeld); + if (isActive) { + event.preventDefault(); + event.stopImmediatePropagation(); + return true; + } + return false; + }); + + ctx.onContextMenu((event: MouseEvent) => { + if (!isRendererActive() || isCopying() || isPromptMode()) return false; + + const isFromOverlay = isEventFromOverlay( + event, + "data-react-grab-ignore-events", + ); + if (isFromOverlay && ctx.shared.hasArrowNavigation?.()) { + ctx.shared.clearArrowNavigation?.(); + } else if (isFromOverlay) { + return false; + } + + if (store.contextMenuPosition !== null) { + event.preventDefault(); + return true; + } + + event.preventDefault(); + event.stopPropagation(); + + const element = getElementAtPosition(event.clientX, event.clientY); + if (!element) return true; + + const existingFrozenElements = store.frozenElements; + const isClickedElementAlreadyFrozen = + existingFrozenElements.length > 1 && + existingFrozenElements.includes(element); + + if (isClickedElementAlreadyFrozen) { + freezeAllAnimations(existingFrozenElements); + } else { + freezeAllAnimations([element]); + actions.setFrozenElement(element); + } + + const position = { x: event.clientX, y: event.clientY }; + actions.setPointer(position); + actions.freeze(); + openContextMenu(element, position); + return true; + }); + + ctx.events.addWindowListener("pointercancel", (event: PointerEvent) => { + if (!event.isPrimary) return; + cancelActiveDrag(); + }); + + ctx.events.addWindowListener( + "click", + (event: MouseEvent) => { + if (isEventFromOverlay(event, "data-react-grab-ignore-events")) return; + if (store.contextMenuPosition !== null) return; + + if (isRendererActive() || isCopying() || didJustDrag()) { + event.preventDefault(); + event.stopImmediatePropagation(); + + if (store.wasActivatedByToggle && !isCopying() && !isPromptMode()) { + if (!isHoldingKeys()) { + ctx.shared.deactivateRenderer?.(); + } else { + actions.setWasActivatedByToggle(false); + } + } + } + }, + { capture: true }, + ); + + ctx.shared.openContextMenu = (position: Position, element: Element) => { + openContextMenu(element, position); + }; + createEffect(() => { + ctx.shared.setHasDragPreviewBounds?.(dragPreviewBounds().length > 0); + }); + ctx.shared.cancelActiveDrag = () => cancelActiveDrag(); + ctx.shared.getDragBounds = () => dragBounds() ?? null; + ctx.shared.isDragBoxVisible = () => Boolean(dragVisible()); + + ctx.provide("dragVisible", () => dragVisible()); + ctx.provide("dragBounds", () => dragBounds()); + ctx.provide("selectionBoundsMultiple", () => selectionBoundsMultiple()); + ctx.provide( + "selectionShouldSnap", + () => store.frozenElements.length > 0 || dragPreviewBounds().length > 0, + ); + + return () => { + if (dragPreviewDebounceTimerId !== null) { + clearTimeout(dragPreviewDebounceTimerId); + dragPreviewDebounceTimerId = null; + } + autoScroller.stop(); + keyboardSelectedElement = null; + isPendingContextMenuSelect = false; + pendingDefaultActionId = null; + document.body.style.userSelect = ""; + ctx.shared.openContextMenu = undefined; + ctx.shared.setHasDragPreviewBounds?.(false); + ctx.shared.cancelActiveDrag = undefined; + }; + }, +}; diff --git a/packages/react-grab/src/core/plugins/prompt-plugin.ts b/packages/react-grab/src/core/plugins/prompt-plugin.ts new file mode 100644 index 000000000..57cea4bcb --- /dev/null +++ b/packages/react-grab/src/core/plugins/prompt-plugin.ts @@ -0,0 +1,937 @@ +import { createSignal, createMemo, createEffect, on, batch } from "solid-js"; +import type { + InternalPlugin, + AgentSession, + AgentOptions, + CommentItem, + DropdownAnchor, + OverlayBounds, + Position, +} from "../../types.js"; +import { createAgentManager } from "../agent/manager.js"; +import { + loadComments, + addCommentItem, + removeCommentItem, + clearComments, + isClearConfirmed, + confirmClear, +} from "../../utils/comment-storage.js"; +import { copyContent } from "../../utils/copy-content.js"; +import { joinSnippets } from "../../utils/join-snippets.js"; +import { createElementBounds } from "../../utils/create-element-bounds.js"; +import { createElementSelector } from "../../utils/create-element-selector.js"; +import { isElementConnected } from "../../utils/is-element-connected.js"; +import { getElementBoundsCenter } from "../../utils/get-element-bounds-center.js"; +import { getBoundsCenter } from "../../utils/get-bounds-center.js"; +import { generateId } from "../../utils/generate-id.js"; +import { logRecoverableError } from "../../utils/log-recoverable-error.js"; +import { nativeRequestAnimationFrame } from "../../utils/native-raf.js"; +import { + DROPDOWN_HOVER_OPEN_DELAY_MS, + PLUGIN_PRIORITY_PROMPT, +} from "../../constants.js"; + +export const promptPlugin: InternalPlugin = { + name: "prompt", + priority: PLUGIN_PRIORITY_PROMPT, + setup: (ctx) => { + const { store, actions, registry, shared, derived } = ctx; + const { isActivated, isPromptMode, targetElement } = derived; + + const isCommentMode = createMemo( + () => store.pendingCommentMode || isPromptMode(), + ); + const isPendingDismiss = createMemo( + () => + store.current.state === "active" && + Boolean(store.current.isPromptMode) && + Boolean(store.current.isPendingDismiss), + ); + + // selectionLabelShakeCount signal + const [selectionLabelShakeCount, setSelectionLabelShakeCount] = + createSignal(0); + + const [commentItems, setCommentItems] = + createSignal(loadComments()); + const [commentsDropdownPosition, setCommentsDropdownPosition] = + createSignal(null); + const [clearPromptPosition, setClearPromptPosition] = + createSignal(null); + const [clockFlashTrigger, setClockFlashTrigger] = createSignal(0); + const [isCommentsHoverOpen, setIsCommentsHoverOpen] = createSignal(false); + let commentsHoverPreviews: { boxId: string; labelId: string | null }[] = []; + const commentElementMap = new Map(); + + const getMappedCommentElements = (commentItemId: string): Element[] => + commentElementMap.get(commentItemId) ?? []; + + const reacquireCommentElements = (commentItem: CommentItem): Element[] => { + const selectors = commentItem.elementSelectors ?? []; + if (selectors.length === 0) return []; + + const reacquiredElements: Element[] = []; + for (const selector of selectors) { + if (!selector) continue; + try { + const reacquiredElement = document.querySelector(selector); + if (isElementConnected(reacquiredElement)) { + reacquiredElements.push(reacquiredElement); + } + // HACK: querySelector can throw on invalid selectors stored from previous sessions + } catch (error) { + logRecoverableError("Invalid stored selector", error); + } + } + return reacquiredElements; + }; + + const getConnectedCommentElements = ( + commentItem: CommentItem, + ): Element[] => { + const mappedElements = getMappedCommentElements(commentItem.id); + const connectedMappedElements = mappedElements.filter((mappedElement) => + isElementConnected(mappedElement), + ); + const areAllMappedElementsConnected = + mappedElements.length > 0 && + connectedMappedElements.length === mappedElements.length; + + if (areAllMappedElementsConnected) { + return connectedMappedElements; + } + + const reacquiredElements = reacquireCommentElements(commentItem); + if (reacquiredElements.length > 0) { + commentElementMap.set(commentItem.id, reacquiredElements); + return reacquiredElements; + } + + return connectedMappedElements; + }; + + const getFirstConnectedCommentElement = ( + commentItem: CommentItem, + ): Element | undefined => getConnectedCommentElements(commentItem)[0]; + + const commentsDisconnectedItemIds = createMemo( + () => { + // HACK: subscribe to dropdown position so connectivity refreshes when dropdown opens + void commentsDropdownPosition(); + const disconnectedIds = new Set(); + for (const item of commentItems()) { + if (getConnectedCommentElements(item).length === 0) { + disconnectedIds.add(item.id); + } + } + return disconnectedIds; + }, + undefined, + { + equals: (prev, next) => { + if (prev.size !== next.size) return false; + for (const id of next) { + if (!prev.has(id)) return false; + } + return true; + }, + }, + ); + + const setCopyStartPosition = ( + element: Element, + positionX: number, + positionY: number, + ) => { + actions.setCopyStart({ x: positionX, y: positionY }, element); + return createElementBounds(element); + }; + + const preparePromptMode = ( + element: Element, + positionX: number, + positionY: number, + ) => { + setCopyStartPosition(element, positionX, positionY); + actions.clearInputText(); + }; + + const activatePromptMode = () => { + const element = store.frozenElement || targetElement(); + if (element) { + actions.enterPromptMode( + { x: store.pointer.x, y: store.pointer.y }, + element, + ); + } + }; + + const enterCommentModeForElement = ( + element: Element, + positionX: number, + positionY: number, + ) => { + actions.setPendingCommentMode(false); + actions.clearInputText(); + actions.enterPromptMode({ x: positionX, y: positionY }, element); + }; + + const handleCopySuccessWithComments = ( + copiedElements: Element[], + extraPrompt: string | undefined, + ) => { + if (!extraPrompt) return; + + const hasCopiedElements = copiedElements.length > 0; + + if (hasCopiedElements) { + const currentItems = commentItems(); + for (const [ + existingItemId, + mappedElements, + ] of commentElementMap.entries()) { + const isSameSelection = + mappedElements.length === copiedElements.length && + mappedElements.every( + (mappedElement, index) => mappedElement === copiedElements[index], + ); + if (!isSameSelection) continue; + const existingItem = currentItems.find( + (item) => item.id === existingItemId, + ); + if (!existingItem) continue; + + if (existingItem.commentText === extraPrompt) { + removeCommentItem(existingItemId); + commentElementMap.delete(existingItemId); + break; + } + } + } + + const elementSelectors = copiedElements.map((copiedElement, index) => + createElementSelector(copiedElement, index === 0), + ); + + const firstElement = copiedElements[0]; + const tagName = firstElement + ? (firstElement.tagName?.toLowerCase() ?? "div") + : "div"; + + const updatedCommentItems = addCommentItem({ + content: "", + elementName: "element", + tagName, + componentName: undefined, + elementsCount: copiedElements.length, + previewBounds: copiedElements.map((copiedElement) => + createElementBounds(copiedElement), + ), + elementSelectors, + commentText: extraPrompt, + timestamp: Date.now(), + }); + setCommentItems(updatedCommentItems); + setClockFlashTrigger((previous) => previous + 1); + const newestCommentItem = updatedCommentItems[0]; + if (newestCommentItem && hasCopiedElements) { + commentElementMap.set(newestCommentItem.id, [...copiedElements]); + } + + const currentItemIds = new Set( + updatedCommentItems.map((item) => item.id), + ); + for (const mapItemId of commentElementMap.keys()) { + if (!currentItemIds.has(mapItemId)) { + commentElementMap.delete(mapItemId); + } + } + }; + + const getAgentFromActions = () => { + for (const action of registry.store.actions) { + if (action.agent?.provider) { + return action.agent; + } + } + return undefined; + }; + + const restoreInputFromSession = ( + session: AgentSession, + elements: Element[], + agent?: AgentOptions, + ) => { + const element = elements[0]; + if (isElementConnected(element)) { + const rect = element.getBoundingClientRect(); + const centerY = rect.top + rect.height / 2; + + actions.setPointer({ x: session.position.x, y: centerY }); + actions.setFrozenElements(elements); + actions.setInputText(session.context.prompt); + actions.setWasActivatedByToggle(true); + + if (agent) { + actions.setSelectedAgent(agent); + } + + if (!isActivated()) { + shared.activateRenderer?.(); + } + } + }; + + const wrapAgentWithCallbacks = (agent: AgentOptions): AgentOptions => { + return { + ...agent, + onAbort: (session: AgentSession, elements: Element[]) => { + agent.onAbort?.(session, elements); + restoreInputFromSession(session, elements, agent); + }, + onUndo: (session: AgentSession, elements: Element[]) => { + agent.onUndo?.(session, elements); + restoreInputFromSession(session, elements, agent); + }, + }; + }; + + const getAgentOptionsWithCallbacks = () => { + const agent = getAgentFromActions(); + if (!agent) return undefined; + return wrapAgentWithCallbacks(agent); + }; + + const agentManager = createAgentManager(getAgentOptionsWithCallbacks(), { + transformAgentContext: registry.hooks.transformAgentContext, + }); + + const handleInputSubmit = () => { + actions.clearLastCopied(); + const frozenElements = [...store.frozenElements]; + const element = store.frozenElement || targetElement(); + const prompt = isPromptMode() ? store.inputText.trim() : ""; + + if (!element) { + shared.deactivateRenderer?.(); + return; + } + + const elements = frozenElements.length > 0 ? frozenElements : [element]; + + const currentSelectionBounds = elements.map((selectedElement) => + createElementBounds(selectedElement), + ); + const firstBounds = currentSelectionBounds[0]; + const { x: currentX, y: currentY } = getBoundsCenter(firstBounds); + const labelPositionX = currentX + store.copyOffsetFromCenterX; + + if ((store.selectedAgent || store.hasAgentProvider) && prompt) { + const currentReplySessionId = store.replySessionId; + const selectedAgent = store.selectedAgent; + + shared.deactivateRenderer?.(); + + actions.clearReplySessionId(); + actions.setSelectedAgent(null); + + void agentManager.session.start({ + elements, + prompt, + position: { x: labelPositionX, y: currentY }, + selectionBounds: currentSelectionBounds, + sessionId: currentReplySessionId ?? undefined, + agent: selectedAgent + ? wrapAgentWithCallbacks(selectedAgent) + : undefined, + }); + + return; + } + + actions.setPointer({ x: currentX, y: currentY }); + actions.exitPromptMode(); + actions.clearInputText(); + actions.clearReplySessionId(); + + shared.performCopyWithLabel?.({ + element, + cursorX: labelPositionX, + selectedElements: elements, + extraPrompt: prompt || undefined, + shouldDeactivateAfter: true, + }); + }; + + const handleInputCancel = () => { + actions.clearLastCopied(); + if (!isPromptMode()) return; + + if (isPendingDismiss()) { + actions.clearInputText(); + actions.clearReplySessionId(); + shared.deactivateRenderer?.(); + return; + } + + actions.setPendingDismiss(true); + setSelectionLabelShakeCount((count) => count + 1); + }; + + const handleConfirmDismiss = () => { + actions.clearInputText(); + actions.clearReplySessionId(); + shared.deactivateRenderer?.(); + }; + + const handleCancelDismiss = () => { + actions.setPendingDismiss(false); + }; + + const handleAgentAbort = (sessionId: string, confirmed: boolean) => { + actions.setPendingAbortSessionId(null); + if (confirmed) { + agentManager.session.abort(sessionId); + } + }; + + const handleToggleExpand = () => { + const element = store.frozenElement || targetElement(); + if (element) { + preparePromptMode(element, store.pointer.x, store.pointer.y); + } + activatePromptMode(); + }; + + const handleFollowUpSubmit = (sessionId: string, prompt: string) => { + const session = agentManager.sessions().get(sessionId); + const elements = agentManager.session.getElements(sessionId); + const sessionBounds = session?.selectionBounds ?? []; + const firstBounds = sessionBounds[0]; + if (session && elements.length > 0 && firstBounds) { + const positionX = session.position.x; + const followUpSessionId = session.context.sessionId ?? sessionId; + + agentManager.session.dismiss(sessionId); + + void agentManager.session.start({ + elements, + prompt, + position: { + x: positionX, + y: firstBounds.y + firstBounds.height / 2, + }, + selectionBounds: sessionBounds, + sessionId: followUpSessionId, + }); + } + }; + + const handleAcknowledgeError = (sessionId: string) => { + const prompt = agentManager.session.acknowledgeError(sessionId); + if (prompt) { + actions.setInputText(prompt); + } + }; + + const handleComment = () => { + const isAlreadyInCommentMode = isActivated() && isCommentMode(); + if (isAlreadyInCommentMode) { + shared.deactivateRenderer?.(); + return; + } + + actions.setPendingCommentMode(true); + if (!isActivated()) { + shared.toggleActivate?.(); + } + }; + + const handleUndoRedoKeys = (event: KeyboardEvent): boolean => { + const isUndoOrRedo = + event.code === "KeyZ" && (event.metaKey || event.ctrlKey); + + if (!isUndoOrRedo) return false; + + const hasActiveConfirmation = Array.from( + agentManager.sessions().values(), + ).some((session) => !session.isStreaming && !session.error); + + if (hasActiveConfirmation) return false; + + const isRedo = event.shiftKey; + + if (isRedo && agentManager.canRedo()) { + event.preventDefault(); + event.stopPropagation(); + agentManager.history.redo(); + return true; + } else if (!isRedo && agentManager.canUndo()) { + event.preventDefault(); + event.stopPropagation(); + agentManager.history.undo(); + return true; + } + + return false; + }; + + createEffect( + on( + () => store.viewportVersion, + () => agentManager._internal.updateBoundsOnViewportChange(), + ), + ); + + createEffect( + on( + () => + [ + isPromptMode(), + store.pointer.x, + store.pointer.y, + targetElement(), + ] as const, + ([inputMode, x, y, target]) => { + registry.hooks.onPromptModeChange(inputMode, { + x, + y, + targetElement: target, + }); + }, + ), + ); + + const clearCommentsHoverPreviews = () => { + for (const { boxId, labelId } of commentsHoverPreviews) { + actions.removeGrabbedBox(boxId); + if (labelId) { + actions.removeLabelInstance(labelId); + } + } + commentsHoverPreviews = []; + }; + + const addCommentItemPreview = ( + item: CommentItem, + previewBounds: OverlayBounds[], + previewElements: Element[], + idPrefix: string, + ) => { + if (previewBounds.length === 0) return; + + for (const [index, bounds] of previewBounds.entries()) { + const previewElement = previewElements[index]; + const boxId = `${idPrefix}-${item.id}-${index}`; + // HACK: createdAt=0 is falsy, which skips the auto-fade logic in the overlay canvas animation loop + actions.addGrabbedBox({ + id: boxId, + bounds, + createdAt: 0, + element: previewElement, + }); + + let labelId: string | null = null; + if (index === 0) { + labelId = `${idPrefix}-label-${item.id}`; + actions.addLabelInstance({ + id: labelId, + bounds, + tagName: item.tagName, + componentName: item.componentName, + elementsCount: item.elementsCount, + status: "idle", + isPromptMode: Boolean(item.commentText), + inputValue: item.commentText ?? undefined, + createdAt: 0, + element: previewElement, + mouseX: bounds.x + bounds.width / 2, + }); + } + + commentsHoverPreviews.push({ boxId, labelId }); + } + }; + + const showCommentItemPreview = ( + item: CommentItem, + idPrefix: string, + ): void => { + const connectedElements = getConnectedCommentElements(item); + const previewBounds = connectedElements.map((element) => + createElementBounds(element), + ); + addCommentItemPreview(item, previewBounds, connectedElements, idPrefix); + }; + + let commentsHoverOpenTimeoutId: ReturnType | null = null; + let commentsHoverCloseTimeoutId: ReturnType | null = + null; + + const cancelCommentsHoverOpenTimeout = () => { + if (commentsHoverOpenTimeoutId !== null) { + clearTimeout(commentsHoverOpenTimeoutId); + commentsHoverOpenTimeoutId = null; + } + }; + + const cancelCommentsHoverCloseTimeout = () => { + if (commentsHoverCloseTimeoutId !== null) { + clearTimeout(commentsHoverCloseTimeoutId); + commentsHoverCloseTimeoutId = null; + } + }; + + const scheduleCommentsHoverClose = () => { + commentsHoverCloseTimeoutId = setTimeout(() => { + commentsHoverCloseTimeoutId = null; + dismissCommentsDropdown(); + }, DROPDOWN_HOVER_OPEN_DELAY_MS); + }; + + const openCommentsDropdown = () => { + actions.hideContextMenu(); + shared.dismissToolbarPopups?.(); + dismissClearPrompt(); + setCommentItems(loadComments()); + shared.openTrackedDropdown?.(setCommentsDropdownPosition); + }; + + const dismissCommentsDropdown = () => { + cancelCommentsHoverOpenTimeout(); + cancelCommentsHoverCloseTimeout(); + shared.stopTrackingDropdownPosition?.(); + clearCommentsHoverPreviews(); + setCommentsDropdownPosition(null); + setIsCommentsHoverOpen(false); + }; + + const showClearPrompt = () => { + dismissCommentsDropdown(); + shared.dismissToolbarPopups?.(); + shared.openTrackedDropdown?.(setClearPromptPosition); + }; + + const dismissClearPrompt = () => { + shared.stopTrackingDropdownPosition?.(); + setClearPromptPosition(null); + }; + + const handleToggleComments = () => { + cancelCommentsHoverOpenTimeout(); + cancelCommentsHoverCloseTimeout(); + const isCurrentlyOpen = commentsDropdownPosition() !== null; + if (isCurrentlyOpen) { + if (isCommentsHoverOpen()) { + clearCommentsHoverPreviews(); + setIsCommentsHoverOpen(false); + } else { + dismissCommentsDropdown(); + } + } else { + clearCommentsHoverPreviews(); + openCommentsDropdown(); + } + }; + + const copyCommentItemContent = (item: CommentItem) => { + copyContent(item.content, { + tagName: item.tagName, + componentName: item.componentName ?? item.elementName, + commentText: item.commentText, + }); + const element = getFirstConnectedCommentElement(item); + if (!element) return; + + shared.clearAllLabels?.(); + + // HACK: defer to next frame so idle preview label clears visually before "copied" appears + nativeRequestAnimationFrame(() => { + if (!isElementConnected(element)) return; + const bounds = createElementBounds(element); + const labelId = shared.createLabelInstance?.( + element, + item.tagName, + item.componentName, + bounds.x + bounds.width / 2, + ); + if (labelId) { + shared.updateLabelAfterCopy?.(labelId, true); + } + }); + }; + + const handleCommentItemSelect = (item: CommentItem) => { + clearCommentsHoverPreviews(); + if (isPromptMode()) { + actions.exitPromptMode(); + actions.clearInputText(); + } + const element = getFirstConnectedCommentElement(item); + + if (item.commentText && element) { + const { center } = getElementBoundsCenter(element); + actions.enterPromptMode(center, element); + actions.setInputText(item.commentText); + } else { + copyCommentItemContent(item); + } + }; + + const handleCommentsCopyAll = () => { + clearCommentsHoverPreviews(); + const currentCommentItems = commentItems(); + if (currentCommentItems.length === 0) return; + + const combinedContent = joinSnippets( + currentCommentItems.map((commentItem) => commentItem.content), + ); + + const firstItem = currentCommentItems[0]; + copyContent(combinedContent, { + componentName: firstItem.componentName ?? firstItem.tagName, + entries: currentCommentItems.map((commentItem) => ({ + tagName: commentItem.tagName, + componentName: commentItem.componentName ?? commentItem.elementName, + content: commentItem.content, + commentText: commentItem.commentText, + })), + }); + + if (isClearConfirmed()) { + handleCommentsClear(); + } else { + showClearPrompt(); + } + + shared.clearAllLabels?.(); + + // HACK: defer to next frame so idle preview labels clear visually before "copied" appears + nativeRequestAnimationFrame(() => { + batch(() => { + for (const commentItem of currentCommentItems) { + const connectedElements = getConnectedCommentElements(commentItem); + for (const element of connectedElements) { + const bounds = createElementBounds(element); + const labelId = generateId("label"); + + actions.addLabelInstance({ + id: labelId, + bounds, + tagName: commentItem.tagName, + componentName: commentItem.componentName, + status: "copied", + createdAt: Date.now(), + element, + mouseX: bounds.x + bounds.width / 2, + }); + // Schedule fade via shared (labels will auto-fade via copy-pipeline) + shared.updateLabelAfterCopy?.(labelId, true); + } + } + }); + }); + }; + + const handleCommentItemHover = (commentItemId: string | null) => { + clearCommentsHoverPreviews(); + if (!commentItemId) return; + + const item = commentItems().find( + (innerItem) => innerItem.id === commentItemId, + ); + if (!item) return; + showCommentItemPreview(item, "comment-hover"); + }; + + const handleCommentsButtonHover = (isHovered: boolean) => { + cancelCommentsHoverOpenTimeout(); + clearCommentsHoverPreviews(); + if (isHovered) { + cancelCommentsHoverCloseTimeout(); + if ( + commentsDropdownPosition() === null && + clearPromptPosition() === null + ) { + showAllCommentItemPreviews(); + commentsHoverOpenTimeoutId = setTimeout(() => { + commentsHoverOpenTimeoutId = null; + setIsCommentsHoverOpen(true); + openCommentsDropdown(); + }, DROPDOWN_HOVER_OPEN_DELAY_MS); + } + } else if (isCommentsHoverOpen()) { + scheduleCommentsHoverClose(); + } + }; + + const handleCommentsDropdownHover = (isHovered: boolean) => { + if (isHovered) { + cancelCommentsHoverCloseTimeout(); + } else if (isCommentsHoverOpen()) { + scheduleCommentsHoverClose(); + } + }; + + const handleCommentsCopyAllHover = (isHovered: boolean) => { + clearCommentsHoverPreviews(); + if (isHovered) { + cancelCommentsHoverCloseTimeout(); + showAllCommentItemPreviews(); + } else if (isCommentsHoverOpen()) { + scheduleCommentsHoverClose(); + } + }; + + const showAllCommentItemPreviews = () => { + for (const item of commentItems()) { + showCommentItemPreview(item, "comment-all-hover"); + } + }; + + const handleCommentsClear = () => { + commentElementMap.clear(); + const updatedCommentItems = clearComments(); + setCommentItems(updatedCommentItems); + dismissCommentsDropdown(); + }; + + ctx.onKeyDown(handleUndoRedoKeys); + + shared.preparePromptMode = (position: Position, element: Element) => { + preparePromptMode(element, position.x, position.y); + }; + shared.activatePromptMode = activatePromptMode; + shared.handleComment = handleComment; + shared.enterCommentModeForElement = (element: Element) => { + const { center } = getElementBoundsCenter(element); + enterCommentModeForElement(element, center.x, center.y); + }; + shared.handleInputSubmit = () => void handleInputSubmit(); + shared.handleInputCancel = handleInputCancel; + shared.handleCopySuccessWithComments = handleCopySuccessWithComments; + shared.setCopyStartPosition = (position: Position, element: Element) => { + setCopyStartPosition(element, position.x, position.y); + }; + shared.getAgentSessionCount = () => agentManager.sessions().size; + shared.syncAgentFromRegistry = () => { + const agentOpts = getAgentOptionsWithCallbacks(); + if (agentOpts) { + agentManager._internal.setOptions(agentOpts); + } + const hasProvider = Boolean(agentOpts?.provider); + actions.setHasAgentProvider(hasProvider); + if (hasProvider && agentOpts?.provider) { + const capturedProvider = agentOpts.provider; + actions.setAgentCapabilities({ + supportsUndo: Boolean(capturedProvider.undo), + supportsFollowUp: Boolean(capturedProvider.supportsFollowUp), + dismissButtonText: capturedProvider.dismissButtonText, + isAgentConnected: false, + }); + if (capturedProvider.checkConnection) { + capturedProvider + .checkConnection() + .then((isConnected) => { + const currentAgentOpts = getAgentOptionsWithCallbacks(); + if (currentAgentOpts?.provider !== capturedProvider) return; + actions.setAgentCapabilities({ + supportsUndo: Boolean(capturedProvider.undo), + supportsFollowUp: Boolean(capturedProvider.supportsFollowUp), + dismissButtonText: capturedProvider.dismissButtonText, + isAgentConnected: isConnected, + }); + }) + .catch((error) => { + logRecoverableError("Agent connection check failed", error); + }); + } + agentManager.session.tryResume(); + } else { + actions.setAgentCapabilities({ + supportsUndo: false, + supportsFollowUp: false, + dismissButtonText: undefined, + isAgentConnected: false, + }); + } + }; + + // Chain into dismissAllPopups so our popups are also dismissed + const previousDismissAllPopups = shared.dismissAllPopups; + shared.dismissAllPopups = () => { + previousDismissAllPopups?.(); + dismissCommentsDropdown(); + dismissClearPrompt(); + }; + + ctx.provide("inputValue", () => store.inputText); + ctx.provide("isPromptMode", () => isPromptMode()); + ctx.provide("replyToPrompt", () => { + const replySessionId = store.replySessionId; + if (!replySessionId) return undefined; + const session = agentManager.sessions().get(replySessionId); + return session?.context?.prompt; + }); + ctx.provide("hasAgent", () => store.hasAgentProvider); + ctx.provide("agentSessions", () => agentManager.sessions()); + ctx.provide("supportsUndo", () => store.supportsUndo); + ctx.provide("supportsFollowUp", () => store.supportsFollowUp); + ctx.provide("dismissButtonText", () => store.dismissButtonText); + ctx.provide("onDismissSession", () => agentManager.session.dismiss); + ctx.provide("onUndoSession", () => agentManager.session.undo); + ctx.provide("onFollowUpSubmitSession", () => handleFollowUpSubmit); + ctx.provide("onAcknowledgeSessionError", () => handleAcknowledgeError); + ctx.provide("onRetrySession", () => agentManager.session.retry); + ctx.provide( + "onRequestAbortSession", + () => (sessionId: string) => actions.setPendingAbortSessionId(sessionId), + ); + ctx.provide("onAbortSession", () => handleAgentAbort); + ctx.provide("pendingAbortSessionId", () => store.pendingAbortSessionId); + ctx.provide("onInputChange", () => actions.setInputText); + ctx.provide("onInputSubmit", () => () => void handleInputSubmit()); + ctx.provide("onToggleExpand", () => handleToggleExpand); + ctx.provide("isPendingDismiss", () => isPendingDismiss()); + ctx.provide("selectionLabelShakeCount", () => selectionLabelShakeCount()); + ctx.provide("onConfirmDismiss", () => handleConfirmDismiss); + ctx.provide("onCancelDismiss", () => handleCancelDismiss); + ctx.provide("commentItems", () => commentItems()); + ctx.provide("commentsDisconnectedItemIds", () => + commentsDisconnectedItemIds(), + ); + ctx.provide("commentItemCount", () => commentItems().length); + ctx.provide("clockFlashTrigger", () => clockFlashTrigger()); + ctx.provide("commentsDropdownPosition", () => commentsDropdownPosition()); + ctx.provide( + "isCommentsPinned", + () => commentsDropdownPosition() !== null && !isCommentsHoverOpen(), + ); + ctx.provide("onToggleComments", () => handleToggleComments); + ctx.provide("onCopyAll", () => handleCommentsCopyAll); + ctx.provide("onCopyAllHover", () => handleCommentsCopyAllHover); + ctx.provide("onCommentsButtonHover", () => handleCommentsButtonHover); + ctx.provide("onCommentItemSelect", () => handleCommentItemSelect); + ctx.provide("onCommentItemHover", () => handleCommentItemHover); + ctx.provide("onCommentsCopyAll", () => handleCommentsCopyAll); + ctx.provide("onCommentsCopyAllHover", () => handleCommentsCopyAllHover); + ctx.provide("onCommentsClear", () => handleCommentsClear); + ctx.provide("onCommentsDismiss", () => dismissCommentsDropdown); + ctx.provide("onCommentsDropdownHover", () => handleCommentsDropdownHover); + ctx.provide("clearPromptPosition", () => clearPromptPosition()); + ctx.provide("onClearCommentsConfirm", () => () => { + confirmClear(); + dismissClearPrompt(); + handleCommentsClear(); + }); + ctx.provide("onClearCommentsCancel", () => dismissClearPrompt); + + return () => { + cancelCommentsHoverOpenTimeout(); + cancelCommentsHoverCloseTimeout(); + clearCommentsHoverPreviews(); + commentElementMap.clear(); + }; + }, +}; diff --git a/packages/react-grab/src/core/plugins/toolbar-plugin.ts b/packages/react-grab/src/core/plugins/toolbar-plugin.ts new file mode 100644 index 000000000..f38f34f63 --- /dev/null +++ b/packages/react-grab/src/core/plugins/toolbar-plugin.ts @@ -0,0 +1,421 @@ +import { createSignal, createEffect, on, onCleanup } from "solid-js"; +import type { + InternalPlugin, + ToolbarState, + ToolbarEntryHandle, + DropdownAnchor, +} from "../../types.js"; +import { + loadToolbarState, + saveToolbarState, +} from "../../components/toolbar/state.js"; +import { + TOOLBAR_DEFAULT_POSITION_RATIO, + DEFAULT_ACTION_ID, + PLUGIN_PRIORITY_TOOLBAR, +} from "../../constants.js"; +import { + freezePseudoStates, + unfreezePseudoStates, +} from "../../utils/freeze-pseudo-states.js"; +import { + freezeGlobalAnimations, + unfreezeGlobalAnimations, + freezeAnimations, +} from "../../utils/freeze-animations.js"; +import { freezeUpdates } from "../../utils/freeze-updates.js"; +import { lockViewportZoom } from "../../utils/lock-viewport-zoom.js"; +import { getNearestEdge } from "../../utils/get-nearest-edge.js"; +import { + nativeCancelAnimationFrame, + nativeRequestAnimationFrame, +} from "../../utils/native-raf.js"; +import { isElementConnected } from "../../utils/is-element-connected.js"; + +const toolbarStateChangeCallbacks = new Set<(state: ToolbarState) => void>(); + +export const toolbarPlugin: InternalPlugin = { + name: "toolbar", + priority: PLUGIN_PRIORITY_TOOLBAR, + setup: (ctx) => { + const { store, actions, registry, api, derived } = ctx; + const { + isHoldingKeys, + isActivated, + isFrozenPhase, + isDragging, + isRendererActive, + } = derived; + + // Toolbar signals + const savedToolbarState = loadToolbarState(); + const [isEnabled, setIsEnabled] = createSignal( + savedToolbarState?.enabled ?? true, + ); + const [toolbarShakeCount, setToolbarShakeCount] = createSignal(0); + const [currentToolbarState, setCurrentToolbarState] = + createSignal(savedToolbarState); + const isToolbarSelectHovered = () => + ctx.shared.isToolbarSelectHovered?.() ?? false; + const setIsToolbarSelectHovered = (value: boolean) => + ctx.shared.setIsToolbarSelectHovered?.(value); + const [toolbarMenuPosition, setToolbarMenuPosition] = + createSignal(null); + const [activeToolbarEntryId, setActiveToolbarEntryId] = createSignal< + string | null + >(null); + const [toolbarEntryDropdownPosition, setToolbarEntryDropdownPosition] = + createSignal(null); + + let toolbarElement: HTMLDivElement | undefined; + let dropdownTrackingFrameId: number | null = null; + let unlockViewportZoom: (() => void) | null = null; + + const updateToolbarState = (updates: Partial) => { + const currentState = currentToolbarState() ?? loadToolbarState(); + const newState: ToolbarState = { + edge: currentState?.edge ?? "bottom", + ratio: currentState?.ratio ?? TOOLBAR_DEFAULT_POSITION_RATIO, + collapsed: currentState?.collapsed ?? false, + enabled: currentState?.enabled ?? true, + defaultAction: currentState?.defaultAction ?? DEFAULT_ACTION_ID, + ...updates, + }; + saveToolbarState(newState); + setCurrentToolbarState(newState); + for (const callback of toolbarStateChangeCallbacks) { + callback(newState); + } + return newState; + }; + + createEffect( + on(isActivated, (activated, previousActivated) => { + if (activated && !previousActivated) { + freezePseudoStates(); + freezeGlobalAnimations(); + // HACK: Prevent browser from taking over touch gestures + document.body.style.touchAction = "none"; + // HACK: Prevent iOS Safari from auto-zooming on sub-16px inputs + unlockViewportZoom = lockViewportZoom(); + } else if (!activated && previousActivated) { + unfreezePseudoStates(); + unfreezeGlobalAnimations(); + document.body.style.touchAction = ""; + unlockViewportZoom?.(); + unlockViewportZoom = null; + } + }), + ); + + createEffect(() => { + const elements = store.frozenElements; + const cleanup = freezeAnimations(elements); + onCleanup(cleanup); + }); + + createEffect( + on(isActivated, (activated) => { + if (!activated) return; + if (!registry.store.options.freezeReactUpdates) return; + const unfreezeUpdates = freezeUpdates(); + onCleanup(unfreezeUpdates); + }), + ); + + const stopTrackingDropdownPosition = () => { + if (dropdownTrackingFrameId !== null) { + nativeCancelAnimationFrame(dropdownTrackingFrameId); + dropdownTrackingFrameId = null; + } + }; + + const startTrackingDropdownPosition = (computePosition: () => void) => { + stopTrackingDropdownPosition(); + const trackFrame = () => { + computePosition(); + dropdownTrackingFrameId = nativeRequestAnimationFrame(trackFrame); + }; + trackFrame(); + }; + + const computeDropdownAnchor = (): DropdownAnchor | null => { + if (!toolbarElement) return null; + const toolbarRect = toolbarElement.getBoundingClientRect(); + const edge = getNearestEdge(toolbarRect); + + if (edge === "left" || edge === "right") { + return { + x: edge === "left" ? toolbarRect.right : toolbarRect.left, + y: toolbarRect.top + toolbarRect.height / 2, + edge, + toolbarWidth: toolbarRect.width, + }; + } + + return { + x: toolbarRect.left + toolbarRect.width / 2, + y: edge === "top" ? toolbarRect.bottom : toolbarRect.top, + edge, + toolbarWidth: toolbarRect.width, + }; + }; + + const openTrackedDropdown = ( + setPosition: (anchor: DropdownAnchor) => void, + ) => { + startTrackingDropdownPosition(() => { + const anchor = computeDropdownAnchor(); + if (anchor) setPosition(anchor); + }); + }; + + const dismissToolbarMenu = () => { + stopTrackingDropdownPosition(); + setToolbarMenuPosition(null); + }; + + const getToolbarEntryHandle = (entryId: string): ToolbarEntryHandle => ({ + api, + isOpen: () => activeToolbarEntryId() === entryId, + open: () => { + if (activeToolbarEntryId() !== entryId) + handleToggleToolbarEntry(entryId); + }, + close: () => { + if (activeToolbarEntryId() === entryId) dismissToolbarEntry(); + }, + toggle: () => handleToggleToolbarEntry(entryId), + setIcon: (icon) => registry.updateToolbarEntry(entryId, { icon }), + setTooltip: (tooltip) => + registry.updateToolbarEntry(entryId, { tooltip }), + setBadge: (badge) => registry.updateToolbarEntry(entryId, { badge }), + setVisible: (isVisible) => + registry.updateToolbarEntry(entryId, { isVisible }), + }); + + const activeToolbarEntryHandle = (): ToolbarEntryHandle | null => { + const activeEntryId = activeToolbarEntryId(); + return activeEntryId ? getToolbarEntryHandle(activeEntryId) : null; + }; + + const dismissToolbarEntry = () => { + stopTrackingDropdownPosition(); + setToolbarEntryDropdownPosition(null); + setActiveToolbarEntryId(null); + }; + + const handleToggleToolbarEntry = (entryId: string) => { + const entry = registry.store.toolbarEntries.find( + (toolbarEntry) => toolbarEntry.id === entryId, + ); + if (!entry) return; + + const handle = getToolbarEntryHandle(entryId); + + if (!entry.onRender) { + entry.onClick?.(handle); + return; + } + + if (activeToolbarEntryId() === entryId) { + dismissToolbarEntry(); + } else { + dismissToolbarEntry(); + actions.hideContextMenu(); + ctx.shared.dismissAllPopups?.(); + entry.onClick?.(handle); + setActiveToolbarEntryId(entryId); + openTrackedDropdown(setToolbarEntryDropdownPosition); + } + }; + + const handleToggleToolbarMenu = () => { + if (toolbarMenuPosition() !== null) { + dismissToolbarMenu(); + } else { + actions.hideContextMenu(); + ctx.shared.dismissAllPopups?.(); + openTrackedDropdown(setToolbarMenuPosition); + } + }; + + const handleSetDefaultAction = (actionId: string) => { + updateToolbarState({ defaultAction: actionId }); + }; + + const dismissToolbarPopups = () => { + dismissToolbarMenu(); + dismissToolbarEntry(); + }; + + const activateRenderer = () => { + const wasInHoldingState = isHoldingKeys(); + actions.activate(); + // HACK: Only call onActivate if we weren't in holding state. + // When coming from holding state, the reactive effect (previouslyHoldingKeys transition) + // will handle calling onActivate to avoid duplicate invocations. + if (!wasInHoldingState) { + registry.hooks.onActivate(); + } + }; + + const deactivateRenderer = () => { + const wasDragging = isDragging(); + const previousFocused = store.previouslyFocusedElement; + actions.deactivate(); + // Delegate subsystem cleanup to shared functions from other plugins + ctx.shared.clearArrowNavigation?.(); + if (wasDragging) { + document.body.style.userSelect = ""; + } + if ( + previousFocused instanceof HTMLElement && + isElementConnected(previousFocused) + ) { + previousFocused.focus(); + } + registry.hooks.onDeactivate(); + }; + + const forceDeactivateAll = () => { + if (isHoldingKeys()) { + actions.releaseHold(); + } + if (isActivated()) { + deactivateRenderer(); + } + }; + + const toggleActivate = () => { + actions.setWasActivatedByToggle(true); + activateRenderer(); + }; + + const handleToggleActive = () => { + if (isActivated()) { + deactivateRenderer(); + } else if (isEnabled()) { + const defaultActionId = + currentToolbarState()?.defaultAction ?? DEFAULT_ACTION_ID; + if (defaultActionId === DEFAULT_ACTION_ID) { + actions.setPendingCommentMode(true); + } + toggleActivate(); + } + }; + + const handleToggleEnabled = () => { + const newEnabled = !isEnabled(); + setIsEnabled(newEnabled); + updateToolbarState({ enabled: newEnabled }); + if (!newEnabled) { + forceDeactivateAll(); + ctx.shared.dismissAllPopups?.(); + } + }; + + ctx.shared.activateRenderer = activateRenderer; + ctx.shared.deactivateRenderer = deactivateRenderer; + ctx.shared.toggleActivate = toggleActivate; + ctx.shared.forceDeactivateAll = forceDeactivateAll; + ctx.shared.handleToggleEnabled = handleToggleEnabled; + ctx.shared.handleSetDefaultAction = handleSetDefaultAction; + ctx.shared.updateToolbarState = updateToolbarState; + ctx.shared.isRendererActive = () => isRendererActive(); + ctx.shared.getCurrentToolbarState = () => currentToolbarState(); + ctx.shared.subscribeToToolbarStateChanges = ( + callback: (state: ToolbarState) => void, + ) => { + toolbarStateChangeCallbacks.add(callback); + return () => { + toolbarStateChangeCallbacks.delete(callback); + }; + }; + ctx.shared.shakeToolbar = () => setToolbarShakeCount((count) => count + 1); + ctx.shared.toggleToolbarEntry = (entryId: string) => + handleToggleToolbarEntry(entryId); + ctx.shared.closeToolbarEntry = () => dismissToolbarEntry(); + ctx.shared.setEnabled = (enabled: boolean) => { + if (enabled === isEnabled()) return; + setIsEnabled(enabled); + updateToolbarState({ enabled }); + if (!enabled) { + forceDeactivateAll(); + ctx.shared.dismissAllPopups?.(); + } + }; + ctx.shared.isEnabled = () => isEnabled(); + ctx.shared.openTrackedDropdown = openTrackedDropdown; + ctx.shared.stopTrackingDropdownPosition = stopTrackingDropdownPosition; + ctx.shared.dismissToolbarPopups = dismissToolbarPopups; + + // Chain into dismissAllPopups so other plugins' popups are also dismissed + const previousDismissAllPopups = ctx.shared.dismissAllPopups; + ctx.shared.dismissAllPopups = () => { + previousDismissAllPopups?.(); + dismissToolbarPopups(); + }; + + ctx.provide("toolbarVisible", () => registry.store.theme.toolbar.enabled); + ctx.provide("isActive", () => isActivated()); + ctx.provide("enabled", () => isEnabled()); + ctx.provide("onToggleActive", () => handleToggleActive); + ctx.provide("onToggleEnabled", () => handleToggleEnabled); + ctx.provide("shakeCount", () => toolbarShakeCount()); + ctx.provide("onToolbarStateChange", () => (state: ToolbarState) => { + setCurrentToolbarState(state); + toolbarStateChangeCallbacks.forEach((callback) => callback(state)); + }); + ctx.provide( + "onSubscribeToToolbarStateChanges", + () => (callback: (state: ToolbarState) => void) => { + toolbarStateChangeCallbacks.add(callback); + return () => { + toolbarStateChangeCallbacks.delete(callback); + }; + }, + ); + ctx.provide("onToolbarSelectHoverChange", () => setIsToolbarSelectHovered); + ctx.provide("onToolbarRef", () => (element: HTMLDivElement) => { + toolbarElement = element; + }); + ctx.provide("toolbarMenuPosition", () => toolbarMenuPosition()); + ctx.provide("toolbarMenuActions", () => + registry.store.actions.filter( + (action) => action.showInToolbarMenu === true, + ), + ); + ctx.provide( + "defaultActionId", + () => currentToolbarState()?.defaultAction ?? DEFAULT_ACTION_ID, + ); + ctx.provide("onSetDefaultAction", () => handleSetDefaultAction); + ctx.provide("onToggleToolbarMenu", () => handleToggleToolbarMenu); + ctx.provide("onToolbarMenuDismiss", () => dismissToolbarMenu); + ctx.provide("toolbarEntries", () => registry.store.toolbarEntries); + ctx.provide( + "toolbarEntryOverrides", + () => registry.store.toolbarEntryOverrides, + ); + ctx.provide("activeToolbarEntryId", () => activeToolbarEntryId()); + ctx.provide("activeToolbarEntryHandle", () => activeToolbarEntryHandle()); + ctx.provide("toolbarEntryDropdownPosition", () => + toolbarEntryDropdownPosition(), + ); + ctx.provide("onToggleToolbarEntry", () => handleToggleToolbarEntry); + ctx.provide("onToolbarEntryDismiss", () => dismissToolbarEntry); + ctx.provide( + "isFrozen", + () => isFrozenPhase() || isActivated() || isToolbarSelectHovered(), + ); + + return () => { + stopTrackingDropdownPosition(); + unlockViewportZoom?.(); + unlockViewportZoom = null; + document.body.style.touchAction = ""; + toolbarStateChangeCallbacks.clear(); + }; + }, +}; diff --git a/packages/react-grab/src/core/store.ts b/packages/react-grab/src/core/store.ts index 12a2e5c40..21caf0377 100644 --- a/packages/react-grab/src/core/store.ts +++ b/packages/react-grab/src/core/store.ts @@ -669,3 +669,4 @@ const createGrabStore = (input: GrabStoreInput) => { }; export { createGrabStore }; +export type { GrabStore, GrabActions, GrabState, GrabPhase }; diff --git a/packages/react-grab/src/index.ts b/packages/react-grab/src/index.ts index 1923baaaa..ab8ac3d9a 100644 --- a/packages/react-grab/src/index.ts +++ b/packages/react-grab/src/index.ts @@ -39,6 +39,8 @@ export type { Plugin, PluginConfig, PluginHooks, + ToolbarEntry, + ToolbarEntryHandle, } from "./types.js"; import { init } from "./core/index.js"; diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index 3150d2e78..2442c2f04 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -318,19 +318,243 @@ export interface PluginHooks { ) => string | Promise; } +export interface ToolbarEntry { + id: string; + /** + * HTML content for the button icon. Can be: + * - An emoji string: "🔍" + * - SVG markup: "..." + * - Any HTML: "X" + * Rendered via innerHTML inside the button. + */ + icon: string; + tooltip?: string; + badge?: string | number; + isVisible?: boolean; + /** + * Called on button click. If onRender is NOT defined, + * this is a simple action button (no dropdown). + * If onRender IS defined, onClick fires before the dropdown opens. + */ + onClick?: (handle: ToolbarEntryHandle) => void; + /** + * Called when the dropdown container opens. + * Receives a raw HTMLElement to render into (framework-agnostic). + * Return an optional cleanup function called when the dropdown closes. + */ + onRender?: ( + container: HTMLElement, + handle: ToolbarEntryHandle, + ) => (() => void) | void; +} + +export interface ToolbarEntryHandle { + api: ReactGrabAPI; + isOpen: () => boolean; + open: () => void; + close: () => void; + toggle: () => void; + setIcon: (icon: string) => void; + setTooltip: (tooltip: string) => void; + setBadge: (badge: string | number | undefined) => void; + setVisible: (isVisible: boolean) => void; +} + export interface PluginConfig { theme?: DeepPartial; options?: SettableOptions; actions?: ContextMenuAction[]; + toolbarEntries?: ToolbarEntry[]; hooks?: PluginHooks; cleanup?: () => void; } +export interface CopyWithLabelOptions { + element: Element; + cursorX: number; + selectedElements?: Element[]; + extraPrompt?: string; + shouldDeactivateAfter?: boolean; + onComplete?: () => void; + dragRect?: { + pageX: number; + pageY: number; + width: number; + height: number; + }; +} + +export interface BuildActionContextOptions { + element: Element; + filePath: string | undefined; + lineNumber: number | undefined; + tagName: string | undefined; + componentName: string | undefined; + position: Position; + performWithFeedbackOptions?: PerformWithFeedbackOptions; + shouldDeferHideContextMenu: boolean; + onBeforeCopy?: () => void; + onBeforePrompt?: () => void; + customEnterPromptMode?: (agent?: AgentOptions) => void; +} + +/** + * Shared mutable bag for cross-plugin function registration. + * Plugins write their functions during setup() and read others at runtime. + * By the time any event fires, all plugins have finished setup. + */ +export interface SharedPluginApi { + performCopyWithLabel?: (options: CopyWithLabelOptions) => void; + openContextMenu?: (position: Position, element: Element) => void; + activateRenderer?: () => void; + deactivateRenderer?: () => void; + toggleActivate?: () => void; + forceDeactivateAll?: () => void; + preparePromptMode?: (position: Position, element: Element) => void; + activatePromptMode?: () => void; + handleComment?: () => void; + enterCommentModeForElement?: (element: Element) => void; + buildActionContext?: ( + options: BuildActionContextOptions, + ) => ContextMenuActionContext; + createPerformWithFeedback?: ( + options?: PerformWithFeedbackOptions, + ) => (action: () => Promise) => Promise; + dismissAllPopups?: () => void; + dismissToolbarPopups?: () => void; + clearArrowNavigation?: () => void; + hasArrowNavigation?: () => boolean; + showTemporaryGrabbedBox?: (element: Element) => void; + createLabelInstance?: ( + element: Element, + tagName: string, + componentName: string | undefined, + cursorX: number, + options?: { + elements?: Element[]; + boundsMultiple?: OverlayBounds[]; + extraPrompt?: string; + hideArrow?: boolean; + }, + ) => string; + updateLabelAfterCopy?: ( + labelId: string, + didSucceed: boolean, + errorMessage?: string, + ) => void; + clearAllLabels?: () => void; + handleCopySuccessWithComments?: ( + elements: Element[], + commentText: string | undefined, + ) => void; + handleToggleEnabled?: () => void; + handleSetDefaultAction?: (actionId: string) => void; + updateToolbarState?: (updates: Partial) => void; + setCopyStartPosition?: (position: Position, element: Element) => void; + isRendererActive?: () => boolean; + isEnabled?: () => boolean; + shakeToolbar?: () => void; + handleInputSubmit?: () => void; + handleInputCancel?: () => void; + isAgentProcessing?: () => boolean; + isCopyFeedbackCooldownActive?: () => boolean; + clearCopyFeedbackCooldown?: () => void; + cancelActiveDrag?: () => void; + getDragBounds?: () => OverlayBounds | null; + isDragBoxVisible?: () => boolean; + clearHoldTimer?: () => void; + resetCopyConfirmation?: () => void; + isHoldingKeys?: () => boolean; + // Kernel-facing accessors + isToolbarSelectHovered?: () => boolean; + setIsToolbarSelectHovered?: (value: boolean) => void; + hasDragPreviewBounds?: () => boolean; + setHasDragPreviewBounds?: (value: boolean) => void; + openTrackedDropdown?: ( + setPosition: (anchor: DropdownAnchor) => void, + ) => void; + stopTrackingDropdownPosition?: () => void; + getCurrentToolbarState?: () => ToolbarState | null; + getAgentSessionCount?: () => number; + syncAgentFromRegistry?: () => void; + subscribeToToolbarStateChanges?: ( + callback: (state: ToolbarState) => void, + ) => () => void; + copyWithFallback?: ( + elements: Element[], + extraPrompt?: string, + ) => Promise; + toggleToolbarEntry?: (entryId: string) => void; + closeToolbarEntry?: () => void; + setEnabled?: (enabled: boolean) => void; +} + +export interface DerivedState { + isHoldingKeys: () => boolean; + isActivated: () => boolean; + isCopying: () => boolean; + didJustCopy: () => boolean; + isPromptMode: () => boolean; + isDragging: () => boolean; + isFrozenPhase: () => boolean; + isRendererActive: () => boolean; + targetElement: () => Element | null; + effectiveElement: () => Element | null; + selectionElement: () => Element | undefined; + frozenElementsBounds: () => OverlayBounds[]; +} + +export interface PluginContext { + store: import("./core/store.js").GrabStore; + actions: import("./core/store.js").GrabActions; + derived: DerivedState; + registry: { + store: { + theme: Required; + options: import("./core/plugin-registry.js").OptionsState; + actions: ContextMenuAction[]; + toolbarEntries: ToolbarEntry[]; + toolbarEntryOverrides: Record< + string, + Partial> + >; + }; + hooks: import("./core/plugin-registry.js").PluginRegistryHooks; + updateToolbarEntry: ( + entryId: string, + updates: Partial< + Pick + >, + ) => void; + setOptions: (options: SettableOptions) => void; + }; + api: ReactGrabAPI; + events: import("./core/events.js").EventListenerManager; + shared: SharedPluginApi; + onKeyDown: (handler: (event: KeyboardEvent) => boolean) => void; + onKeyUp: (handler: (event: KeyboardEvent) => boolean) => void; + onPointerDown: (handler: (event: PointerEvent) => boolean) => void; + onPointerMove: (handler: (event: PointerEvent) => boolean) => void; + onPointerUp: (handler: (event: PointerEvent) => boolean) => void; + onContextMenu: (handler: (event: MouseEvent) => boolean) => void; + provide: ( + key: K, + accessor: () => ReactGrabRendererProps[K], + ) => void; +} + +export interface InternalPlugin { + name: string; + priority?: number; + setup: (ctx: PluginContext) => (() => void) | void; +} + export interface Plugin { name: string; theme?: DeepPartial; options?: SettableOptions; actions?: ContextMenuAction[]; + toolbarEntries?: ToolbarEntry[]; hooks?: PluginHooks; setup?: (api: ReactGrabAPI, hooks: ActionContextHooks) => PluginConfig | void; } @@ -397,6 +621,8 @@ export interface ReactGrabAPI { unregisterPlugin: (name: string) => void; getPlugins: () => string[]; getDisplayName: (element: Element) => string | null; + toggleToolbarEntry: (entryId: string) => void; + closeToolbarEntry: () => void; } export interface OverlayBounds { @@ -550,6 +776,16 @@ export interface ReactGrabRendererProps { clearPromptPosition?: DropdownAnchor | null; onClearCommentsConfirm?: () => void; onClearCommentsCancel?: () => void; + toolbarEntries?: ToolbarEntry[]; + toolbarEntryOverrides?: Record< + string, + Partial> + >; + activeToolbarEntryId?: string | null; + activeToolbarEntryHandle?: ToolbarEntryHandle | null; + toolbarEntryDropdownPosition?: DropdownAnchor | null; + onToggleToolbarEntry?: (entryId: string) => void; + onToolbarEntryDismiss?: () => void; } export interface GrabbedBox { diff --git a/packages/react-grab/src/utils/comment-storage.ts b/packages/react-grab/src/utils/comment-storage.ts index 9209c02a5..345b42f4c 100644 --- a/packages/react-grab/src/utils/comment-storage.ts +++ b/packages/react-grab/src/utils/comment-storage.ts @@ -76,9 +76,7 @@ let didConfirmClear = readSessionFlag(CLEAR_CONFIRMED_KEY); export const loadComments = (): CommentItem[] => commentItems; -export const addCommentItem = ( - item: Omit, -): CommentItem[] => +export const addCommentItem = (item: Omit): CommentItem[] => persistCommentItems( [{ ...item, id: generateId("comment") }, ...commentItems].slice( 0, @@ -101,6 +99,9 @@ export const confirmClear = (): void => { sessionStorage.setItem(CLEAR_CONFIRMED_KEY, "1"); } catch (error) { // HACK: sessionStorage can throw in private browsing or when quota is exceeded - logRecoverableError("Failed to save clear preference to sessionStorage", error); + logRecoverableError( + "Failed to save clear preference to sessionStorage", + error, + ); } }; diff --git a/packages/react-grab/src/utils/fetch-git-blame.ts b/packages/react-grab/src/utils/fetch-git-blame.ts index afb94f633..34c493f1c 100644 --- a/packages/react-grab/src/utils/fetch-git-blame.ts +++ b/packages/react-grab/src/utils/fetch-git-blame.ts @@ -18,9 +18,7 @@ export const fetchGitBlame = async ( if (lineNumber) params.set("line", String(lineNumber)); try { - const response = await fetch( - `/__react-grab/git-blame?${params}`, - ); + const response = await fetch(`/__react-grab/git-blame?${params}`); if (!response.ok) { failCount++; return null; diff --git a/packages/react-grab/src/utils/label-fade-manager.ts b/packages/react-grab/src/utils/label-fade-manager.ts new file mode 100644 index 000000000..1d4ffafcc --- /dev/null +++ b/packages/react-grab/src/utils/label-fade-manager.ts @@ -0,0 +1,50 @@ +import { FEEDBACK_DURATION_MS, FADE_COMPLETE_BUFFER_MS } from "../constants.js"; + +interface LabelInstanceActions { + updateLabelInstance: (instanceId: string, status: "fading") => void; + removeLabelInstance: (instanceId: string) => void; +} + +export interface LabelFadeManager { + cancel: (instanceId: string) => void; + cancelAll: () => void; + schedule: (instanceId: string) => void; +} + +export const createLabelFadeManager = ( + actions: LabelInstanceActions, +): LabelFadeManager => { + const timeouts = new Map(); + + const cancel = (instanceId: string) => { + const timeoutId = timeouts.get(instanceId); + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + timeouts.delete(instanceId); + } + }; + + const cancelAll = () => { + for (const timeoutId of timeouts.values()) { + window.clearTimeout(timeoutId); + } + timeouts.clear(); + }; + + const schedule = (instanceId: string) => { + cancel(instanceId); + + const timeoutId = window.setTimeout(() => { + timeouts.delete(instanceId); + actions.updateLabelInstance(instanceId, "fading"); + setTimeout(() => { + timeouts.delete(instanceId); + actions.removeLabelInstance(instanceId); + }, FADE_COMPLETE_BUFFER_MS); + }, FEEDBACK_DURATION_MS); + + timeouts.set(instanceId, timeoutId); + }; + + return { cancel, cancelAll, schedule }; +}; diff --git a/packages/react-grab/src/utils/mount-root.ts b/packages/react-grab/src/utils/mount-root.ts index e710ed6ea..e16a415f1 100644 --- a/packages/react-grab/src/utils/mount-root.ts +++ b/packages/react-grab/src/utils/mount-root.ts @@ -59,8 +59,8 @@ export const mountRoot = (cssText?: string) => { // HACK: re-append after a delay to ensure we're the last child of body. // This handles two cases: // 1. Hydration blew away the DOM and the host was removed - // 2. Another tool (e.g. react-scan) appended at the same max z-index — - // being last in DOM order wins the stacking tiebreaker + // 2. Another tool (e.g. react-scan) appended at the same max z-index, + // so being last in DOM order wins the stacking tiebreaker // appendChild of an existing node is an atomic move (no flash, no reflow). setTimeout(() => { doc.appendChild(host); diff --git a/packages/website/AGENTS.md b/packages/website/AGENTS.md index 45ab2ce49..595e8d69a 100644 --- a/packages/website/AGENTS.md +++ b/packages/website/AGENTS.md @@ -36,7 +36,7 @@ Concise rules for building accessible, fast, delightful UIs Use MUST/SHOULD/NEVE - State & navigation - MUST: URL reflects state (deep-link filters/tabs/pagination/expanded panels) Prefer libs like [nuqs](https://nuqs.dev) - MUST: Back/Forward restores scroll - - MUST: Links are links—use `/` for navigation (support Cmd/Ctrl/middle-click) + - MUST: Links are links. Use `/` for navigation (support Cmd/Ctrl/middle-click) - Feedback - SHOULD: Optimistic UI; reconcile on response; on failure show error and rollback or offer Undo - MUST: Confirm destructive actions or provide Undo window @@ -47,7 +47,7 @@ Concise rules for building accessible, fast, delightful UIs Use MUST/SHOULD/NEVE - MUST: Delay first tooltip in a group; subsequent peers no delay - MUST: Intentional `overscroll-behavior: contain` in modals/drawers - MUST: During drag, disable text selection and set `inert` on dragged element/containers - - MUST: No “dead-looking” interactive zones—if it looks clickable, it is + - MUST: No “dead-looking” interactive zones. If it looks clickable, it is - Autofocus - SHOULD: Autofocus on desktop when there’s a single primary input; rarely on mobile (to avoid layout shift) @@ -64,7 +64,7 @@ Concise rules for building accessible, fast, delightful UIs Use MUST/SHOULD/NEVE ## Layout - SHOULD: Optical alignment; adjust by ±1px when perception beats geometry -- MUST: Deliberate alignment to grid/baseline/edges/optical centers—no accidental placement +- MUST: Deliberate alignment to grid/baseline/edges/optical centers. No accidental placement - SHOULD: Balance icon/text lockups (stroke/weight/size/spacing/color) - MUST: Verify mobile, laptop, ultra-wide (simulate ultra-wide at 50% zoom) - MUST: Respect safe areas (use env(safe-area-inset-\*)) @@ -80,7 +80,7 @@ Concise rules for building accessible, fast, delightful UIs Use MUST/SHOULD/NEVE - SHOULD: Curly quotes (“ ”); avoid widows/orphans - MUST: Tabular numbers for comparisons (`font-variant-numeric: tabular-nums` or a mono like Geist Mono) - MUST: Redundant status cues (not color-only); icons have text labels -- MUST: Don’t ship the schema—visuals may omit labels but accessible names still exist +- MUST: Don’t ship the schema. Visuals may omit labels but accessible names still exist - MUST: Use the ellipsis character `…` (not ``) - MUST: `scroll-margin-top` on headings for anchored links; include a “Skip to content” link; hierarchical `` - MUST: Resilient to user-generated content (short/avg/very long) @@ -111,7 +111,7 @@ Concise rules for building accessible, fast, delightful UIs Use MUST/SHOULD/NEVE - SHOULD: Nested radii: child ≤ parent; concentric - SHOULD: Hue consistency: tint borders/shadows/text toward bg hue - MUST: Accessible charts (color-blind-friendly palettes) -- MUST: Meet contrast—prefer [APCA](https://apcacontrast.com/) over WCAG 2 +- MUST: Meet contrast. Prefer [APCA](https://apcacontrast.com/) over WCAG 2 - MUST: Increase contrast on `:hover/:active/:focus` - SHOULD: Match browser UI to bg - SHOULD: Avoid gradient banding (use masks when needed) @@ -131,6 +131,6 @@ Concise rules for building accessible, fast, delightful UIs Use MUST/SHOULD/NEVE - MUST: Separate numbers & units with a space. - MUST: Use a non-breaking space e.g., 10 MB. - MUST: Default to positive language. Frame messages in an encouraging, problem-solving way, even for errors. -- MUST: Error messages guide the exit. Don't just state what went wrong—tell the user how to fix it. +- MUST: Error messages guide the exit. Don't just state what went wrong, tell the user how to fix it. - MUST: Avoid ambiguity. Labels are clear & specific. - MUST: Instead of the button label "Continue", say "Save API Key". diff --git a/packages/website/public/script.js b/packages/website/public/script.js index 3deae0eec..396a65d43 100644 --- a/packages/website/public/script.js +++ b/packages/website/public/script.js @@ -6,61 +6,61 @@ this.globalThis=this.globalThis||{};this.globalThis.__REACT_GRAB_MODULE__=(funct * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ -var Ah=Object.defineProperty;var X=(e,t)=>()=>(e&&(t=e(e=0)),t);var Th=(e,t)=>{for(var n in t)Ah(e,n,{get:t[n],enumerable:true});};function uu(e){let t=String(e),n=t.length-1;return De.context.id+(n?String.fromCharCode(96+n):"")+t}function Sa(e){De.context=e;}function _h(){return {...De.context,id:De.getNextContextId(),count:0}}function gn(e,t){let n=Le,o=Ae,i=e.length===0,r=t===void 0?o:t,a=i?bu:{owned:null,cleanups:null,context:r?r.context:null,owner:r},u=i?e:()=>e(()=>ht(()=>Rn(a)));Ae=a,Le=null;try{return Pt(u,!0)}finally{Le=n,Ae=o;}}function j(e,t){t=t?Object.assign({},Mi,t):Mi;let n={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},o=i=>(typeof i=="function"&&(W&&W.running&&W.sources.has(n)?i=i(n.tValue):i=i(n.value)),xu(n,i));return [vu.bind(n),o]}function fu(e,t,n){let o=Hi(e,t,true,Nt);Po&&W&&W.running?nt.push(o):Oo(o);}function K(e,t,n){let o=Hi(e,t,false,Nt);Po&&W&&W.running?nt.push(o):Oo(o);}function me(e,t,n){hu=Dh;let o=Hi(e,t,false,Nt);(o.user=true),gt?gt.push(o):Oo(o);}function ne(e,t,n){n=n?Object.assign({},Mi,n):Mi;let o=Hi(e,t,true,0);return o.observers=null,o.observerSlots=null,o.comparator=n.equals||void 0,Po&&W&&W.running?(o.tState=Nt,nt.push(o)):Oo(o),vu.bind(o)}function Rh(e){return e&&typeof e=="object"&&"then"in e}function Ta(e,t,n){let o,i,r;typeof t=="function"?(o=e,i=t,r={}):(o=true,i=e,r=t||{});let a=null,u=Ea,l=null,m=false,c=false,d="initialValue"in r,h=typeof o=="function"&&ne(o),y=new Set,[M,E]=(r.storage||j)(r.initialValue),[_,S]=j(void 0),[p,b]=j(void 0,{equals:false}),[x,L]=j(d?"ready":"unresolved");De.context&&(l=De.getNextContextId(),r.ssrLoadFrom==="initial"?u=r.initialValue:De.load&&De.has(l)&&(u=De.load(l)));function z(le,A,F,O){return a===le&&(a=null,O!==void 0&&(d=true),(le===u||A===u)&&r.onHydrated&&queueMicrotask(()=>r.onHydrated(O,{value:A})),u=Ea,W&&le&&m?(W.promises.delete(le),m=false,Pt(()=>{W.running=!0,Y(A,F);},false)):Y(A,F)),A}function Y(le,A){Pt(()=>{A===void 0&&E(()=>le),L(A!==void 0?"errored":d?"ready":"unresolved"),S(A);for(let F of y.keys())F.decrement();y.clear();},false);}function Ce(){let le=wr,A=M(),F=_();if(F!==void 0&&!a)throw F;return Le&&!Le.user&&le,A}function B(le=true){if(le!==false&&c)return;c=false;let A=h?h():o;if(m=W&&W.running,A==null||A===false){z(a,ht(M));return}W&&a&&W.promises.delete(a);let F,O=u!==Ea?u:ht(()=>{try{return i(A,{value:M(),refetching:le})}catch(ce){F=ce;}});if(F!==void 0){z(a,void 0,Ni(F),A);return}else if(!Rh(O))return z(a,O,void 0,A),O;return a=O,"v"in O?(O.s===1?z(a,O.v,void 0,A):z(a,void 0,Ni(O.v),A),O):(c=true,queueMicrotask(()=>c=false),Pt(()=>{L(d?"refreshing":"pending"),b();},false),O.then(ce=>z(O,ce,void 0,A),ce=>z(O,void 0,Ni(ce),A)))}Object.defineProperties(Ce,{state:{get:()=>x()},error:{get:()=>_()},loading:{get(){let le=x();return le==="pending"||le==="refreshing"}},latest:{get(){if(!d)return Ce();let le=_();if(le&&!a)throw le;return M()}}});let G=Ae;return h?fu(()=>(G=Ae,B(false))):B(false),[Ce,{refetch:le=>yu(G,()=>B(le)),mutate:E}]}function Bi(e){return Pt(e,false)}function ht(e){if(!_o&&Le===null)return e();let t=Le;Le=null;try{return _o?_o.untrack(e):e()}finally{Le=t;}}function Pe(e,t,n){let o=Array.isArray(e),i,r=n&&n.defer;return a=>{let u;if(o){u=Array(e.length);for(let m=0;mt(u,i,a));return i=u,l}}function ot(e){me(()=>ht(e));}function he(e){return Ae===null||(Ae.cleanups===null?Ae.cleanups=[e]:Ae.cleanups.push(e)),e}function $i(){return Le}function yu(e,t){let n=Ae,o=Le;Ae=e,Le=null;try{return Pt(t,!0)}catch(i){Vi(i);}finally{Ae=n,Le=o;}}function kh(e){if(W&&W.running)return e(),W.done;let t=Le,n=Ae;return Promise.resolve().then(()=>{Le=t,Ae=n;let o;return (Po||wr)&&(o=W||(W={sources:new Set,effects:[],promises:new Set,disposed:new Set,queue:new Set,running:true}),o.done||(o.done=new Promise(i=>o.resolve=i)),o.running=true),Pt(e,false),Le=Ae=null,o?o.done:void 0})}function vu(){let e=W&&W.running;if(this.sources&&(e?this.tState:this.state))if((e?this.tState:this.state)===Nt)Oo(this);else {let t=nt;nt=null,Pt(()=>Di(this),false),nt=t;}if(Le){let t=this.observers?this.observers.length:0;Le.sources?(Le.sources.push(this),Le.sourceSlots.push(t)):(Le.sources=[this],Le.sourceSlots=[t]),this.observers?(this.observers.push(Le),this.observerSlots.push(Le.sources.length-1)):(this.observers=[Le],this.observerSlots=[Le.sources.length-1]);}return e&&W.sources.has(this)?this.tValue:this.value}function xu(e,t,n){let o=W&&W.running&&W.sources.has(e)?e.tValue:e.value;if(!e.comparator||!e.comparator(o,t)){if(W){let i=W.running;(i||!n&&W.sources.has(e))&&(W.sources.add(e),e.tValue=t),i||(e.value=t);}else e.value=t;e.observers&&e.observers.length&&Pt(()=>{for(let i=0;i1e6)throw nt=[],new Error},false);}return t}function Oo(e){if(!e.fn)return;Rn(e);let t=Fi;pu(e,W&&W.running&&W.sources.has(e)?e.tValue:e.value,t),W&&!W.running&&W.sources.has(e)&&queueMicrotask(()=>{Pt(()=>{W&&(W.running=!0),Le=Ae=e,pu(e,e.tValue,t),Le=Ae=null;},false);});}function pu(e,t,n){let o,i=Ae,r=Le;Le=Ae=e;try{o=e.fn(t);}catch(a){return e.pure&&(W&&W.running?(e.tState=Nt,e.tOwned&&e.tOwned.forEach(Rn),e.tOwned=void 0):(e.state=Nt,e.owned&&e.owned.forEach(Rn),e.owned=null)),e.updatedAt=n+1,Vi(a)}finally{Le=r,Ae=i;}(!e.updatedAt||e.updatedAt<=n)&&(e.updatedAt!=null&&"observers"in e?xu(e,o,true):W&&W.running&&e.pure?(W.sources.add(e),e.tValue=o):e.value=o,e.updatedAt=n);}function Hi(e,t,n,o=Nt,i){let r={fn:e,state:o,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:Ae,context:Ae?Ae.context:null,pure:n};if(W&&W.running&&(r.state=0,r.tState=o),Ae===null||Ae!==bu&&(W&&W.running&&Ae.pure?Ae.tOwned?Ae.tOwned.push(r):Ae.tOwned=[r]:Ae.owned?Ae.owned.push(r):Ae.owned=[r]),_o&&r.fn){let[a,u]=j(void 0,{equals:false}),l=_o.factory(r.fn,u);he(()=>l.dispose());let m=()=>kh(u).then(()=>c.dispose()),c=_o.factory(r.fn,m);r.fn=d=>(a(),W&&W.running?c.track(d):l.track(d));}return r}function vr(e){let t=W&&W.running;if((t?e.tState:e.state)===0)return;if((t?e.tState:e.state)===yr)return Di(e);if(e.suspense&&ht(e.suspense.inFallback))return e.suspense.effects.push(e);let n=[e];for(;(e=e.owner)&&(!e.updatedAt||e.updatedAt=0;o--){if(e=n[o],t){let i=e,r=n[o+1];for(;(i=i.owner)&&i!==r;)if(W.disposed.has(i))return}if((t?e.tState:e.state)===Nt)Oo(e);else if((t?e.tState:e.state)===yr){let i=nt;nt=null,Pt(()=>Di(e,n[0]),false),nt=i;}}}function Pt(e,t){if(nt)return e();let n=false;t||(nt=[]),gt?n=true:gt=[],Fi++;try{let o=e();return Nh(n),o}catch(o){n||(gt=null),nt=null,Vi(o);}}function Nh(e){if(nt&&(Po&&W&&W.running?Mh(nt):Eu(nt),nt=null),e)return;let t;if(W){if(!W.promises.size&&!W.queue.size){let o=W.sources,i=W.disposed;gt.push.apply(gt,W.effects),t=W.resolve;for(let r of gt)"tState"in r&&(r.state=r.tState),delete r.tState;W=null,Pt(()=>{for(let r of i)Rn(r);for(let r of o){if(r.value=r.tValue,r.owned)for(let a=0,u=r.owned.length;ahu(n),false),t&&t();}function Eu(e){for(let t=0;t{o.delete(n),Pt(()=>{W.running=!0,vr(n);},false),W&&(W.running=false);}));}}function Dh(e){let t,n=0;for(t=0;t=0;t--)Rn(e.tOwned[t]);delete e.tOwned;}if(W&&W.running&&e.pure)Su(e,true);else if(e.owned){for(t=e.owned.length-1;t>=0;t--)Rn(e.owned[t]);e.owned=null;}if(e.cleanups){for(t=e.cleanups.length-1;t>=0;t--)e.cleanups[t]();e.cleanups=null;}W&&W.running?e.tState=0:e.state=0;}function Su(e,t){if(t||(e.tState=0,W.disposed.add(e)),e.owned)for(let n=0;n1?[]:null;return he(()=>Li(r)),()=>{let l=e()||[],m=l.length,c,d;return l[xr],ht(()=>{let y,M,E,_,S,p,b,x,L;if(m===0)a!==0&&(Li(r),r=[],o=[],i=[],a=0,u&&(u=[])),n.fallback&&(o=[Aa],i[0]=gn(z=>(r[0]=z,n.fallback())),a=1);else if(a===0){for(i=new Array(m),d=0;d=p&&x>=p&&o[b]===l[x];b--,x--)E[x]=i[b],_[x]=r[b],u&&(S[x]=u[b]);for(y=new Map,M=new Array(x+1),d=x;d>=p;d--)L=l[d],c=y.get(L),M[d]=c===void 0?-1:c,y.set(L,d);for(c=p;c<=b;c++)L=o[c],d=y.get(L),d!==void 0&&d!==-1?(E[d]=i[c],_[d]=r[c],u&&(S[d]=u[c]),d=M[d],y.set(L,d)):r[c]();for(d=p;dLi(r)),()=>{let m=e()||[],c=m.length;return m[xr],ht(()=>{if(c===0)return u!==0&&(Li(r),r=[],o=[],i=[],u=0,a=[]),n.fallback&&(o=[Aa],i[0]=gn(h=>(r[0]=h,n.fallback())),u=1),i;for(o[0]===Aa&&(r[0](),r=[],o=[],i=[],u=0),l=0;lm[l]):l>=o.length&&(i[l]=gn(d));for(;le(t||{}));return Sa(n),o}return ht(()=>e(t||{}))}function ki(){return true}function Ca(e){return (e=typeof e=="function"?e():e)?e:{}}function Hh(){for(let e=0,t=this.length;e=0;u--){let l=Ca(e[u])[a];if(l!==void 0)return l}},has(a){for(let u=e.length-1;u>=0;u--)if(a in Ca(e[u]))return true;return false},keys(){let a=[];for(let u=0;u=0;a--){let u=e[a];if(!u)continue;let l=Object.getOwnPropertyNames(u);for(let m=l.length-1;m>=0;m--){let c=l[m];if(c==="__proto__"||c==="constructor")continue;let d=Object.getOwnPropertyDescriptor(u,c);if(!o[c])o[c]=d.get?{enumerable:true,configurable:true,get:Hh.bind(n[c]=[d.get.bind(u)])}:d.value!==void 0?d:void 0;else {let h=n[c];h&&(d.get?h.push(d.get.bind(u)):d.value!==void 0&&h.push(()=>d.value));}}}let i={},r=Object.keys(o);for(let a=r.length-1;a>=0;a--){let u=r[a],l=o[u];l&&l.get?Object.defineProperty(i,u,l):i[u]=l?l.value:void 0;}return i}function Gt(e){let t="fallback"in e&&{fallback:()=>e.fallback};return ne(Lh(()=>e.each,e.children,t||void 0))}function zi(e){let t="fallback"in e&&{fallback:()=>e.fallback};return ne(Fh(()=>e.each,e.children,t||void 0))}function ie(e){let t=e.keyed,n=ne(()=>e.when,void 0,void 0),o=t?n:ne(n,void 0,{equals:(i,r)=>!i==!r});return ne(()=>{let i=o();if(i){let r=e.children;return typeof r=="function"&&r.length>0?ht(()=>r(t?i:()=>{if(!ht(o))throw Vh("Show");return n()})):r}return e.fallback},void 0,void 0)}var De,Oh,Ut,Ih,xr,Mi,du,hu,Nt,yr,bu,Ea,Ae,W,Po,_o,Le,nt,gt,Fi,iE,mu,wr,Aa,Bh,$h,Vh,qe=X(()=>{De={context:void 0,registry:void 0,effects:void 0,done:false,getContextId(){return uu(this.context.count)},getNextContextId(){return uu(this.context.count++)}};Oh=(e,t)=>e===t,Ut=Symbol("solid-proxy"),Ih=typeof Proxy=="function",xr=Symbol("solid-track"),Mi={equals:Oh},du=null,hu=Eu,Nt=1,yr=2,bu={owned:null,cleanups:null,context:null,owner:null},Ea={},Ae=null,W=null,Po=null,_o=null,Le=null,nt=null,gt=null,Fi=0;[iE,mu]=j(false);Aa=Symbol("fallback");Bh=false;$h={get(e,t,n){return t===Ut?n:e.get(t)},has(e,t){return t===Ut?true:e.has(t)},set:ki,deleteProperty:ki,getOwnPropertyDescriptor(e,t){return {configurable:true,enumerable:true,get(){return e.get(t)},set:ki,deleteProperty:ki}},ownKeys(e){return e.keys()}};Vh=e=>`Stale read from <${e}>.`;});function Xh(e,t){let n=Wh[e];return typeof n=="object"?n[t]?n.$:void 0:n}function Zh(e,t,n){let o=n.length,i=t.length,r=o,a=0,u=0,l=t[i-1].nextSibling,m=null;for(;ac-u){let M=t[a];for(;u{i=r,t===document?e():I(t,e(),t.firstChild?null:void 0,n);},o.owner),()=>{i(),t.textContent="";}}function D(e,t,n,o){let i,r=()=>{let u=document.createElement("template");return u.innerHTML=e,u.content.firstChild},a=t?()=>ht(()=>document.importNode(i||(i=r()),!0)):()=>(i||(i=r())).cloneNode(true);return a.cloneNode=a,a}function Ve(e,t=window.document){let n=t[Au]||(t[Au]=new Set);for(let o=0,i=e.length;oi.call(e,n[1],r));}else e.addEventListener(t,n,typeof n!="function"&&n);}function Qn(e,t,n={}){let o=Object.keys(t||{}),i=Object.keys(n),r,a;for(r=0,a=i.length;rtypeof t.ref=="function"&&Re(t.ref,e)),K(()=>tb(e,t,n,true,i,true)),i}function Re(e,t,n){return ht(()=>e(t,n))}function I(e,t,n,o){if(n!==void 0&&!o&&(o=[]),typeof t!="function")return Ro(e,t,o,n);K(i=>Ro(e,t(),i,n),o);}function tb(e,t,n,o,i={},r=false){t||(t={});for(let a in i)if(!(a in t)){if(a==="children")continue;i[a]=_u(e,a,null,i[a],n,r,t);}for(let a in t){if(a==="children"){continue}let u=t[a];i[a]=_u(e,a,u,i[a],n,r,t);}}function ko(e){return !!De.context&&!De.done&&(!e||e.isConnected)}function nb(e){return e.toLowerCase().replace(/-([a-z])/g,(t,n)=>n.toUpperCase())}function Tu(e,t,n){let o=t.trim().split(/\s+/);for(let i=0,r=o.length;im===e))return;let t=e.target,n=`$$${e.type}`,o=e.target,i=e.currentTarget,r=l=>Object.defineProperty(e,"target",{configurable:true,value:l}),a=()=>{let l=t[n];if(l&&!t.disabled){let m=t[`${n}Data`];if(m!==void 0?l.call(t,m,e):l.call(t,e),e.cancelBubble)return}return t.host&&typeof t.host!="string"&&!t.host._$host&&t.contains(e.target)&&r(t.host),true},u=()=>{for(;a()&&(t=t._$host||t.parentNode||t.host););};if(Object.defineProperty(e,"currentTarget",{configurable:true,get(){return t||document}}),De.registry&&!De.done&&(De.done=_$HY.done=true),e.composedPath){let l=e.composedPath();r(l[0]);for(let m=0;m{let l=t();for(;typeof l=="function";)l=l();n=Ro(e,l,n,o);}),()=>n;if(Array.isArray(t)){let l=[],m=n&&Array.isArray(n);if(_a(l,t,n,i))return K(()=>n=Ro(e,l,n,o,true)),()=>n;if(r){if(!l.length)return n;if(o===void 0)return n=[...e.childNodes];let c=l[0];if(c.parentNode!==e)return n;let d=[c];for(;(c=c.nextSibling)!==o;)d.push(c);return n=d}if(l.length===0){if(n=Io(e,n,o),u)return n}else m?n.length===0?Pu(e,l,o):Zh(e,n,l):(n&&Io(e),Pu(e,l));n=l;}else if(t.nodeType){if(r&&t.parentNode)return n=u?[t]:t;if(Array.isArray(n)){if(u)return n=Io(e,n,o,t);Io(e,n,null,t);}else n==null||n===""||!e.firstChild?e.appendChild(t):e.replaceChild(t,e.firstChild);n=t;}}return n}function _a(e,t,n,o){let i=false;for(let r=0,a=t.length;r=0;a--){let u=t[a];if(i!==u){let l=u.parentNode===e;!r&&!a?l?e.replaceChild(i,u):e.insertBefore(i,n):l&&u.remove();}else r=true;}}else e.insertBefore(i,n);return [i]}var Uh,Gh,jh,Kh,Wh,Yh,Ie,Au,w=X(()=>{qe();qe();Uh=["allowfullscreen","async","alpha","autofocus","autoplay","checked","controls","default","disabled","formnovalidate","hidden","indeterminate","inert","ismap","loop","multiple","muted","nomodule","novalidate","open","playsinline","readonly","required","reversed","seamless","selected","adauctionheaders","browsingtopics","credentialless","defaultchecked","defaultmuted","defaultselected","defer","disablepictureinpicture","disableremoteplayback","preservespitch","shadowrootclonable","shadowrootcustomelementregistry","shadowrootdelegatesfocus","shadowrootserializable","sharedstoragewritable"],Gh=new Set(["className","value","readOnly","noValidate","formNoValidate","isMap","noModule","playsInline","adAuctionHeaders","allowFullscreen","browsingTopics","defaultChecked","defaultMuted","defaultSelected","disablePictureInPicture","disableRemotePlayback","preservesPitch","shadowRootClonable","shadowRootCustomElementRegistry","shadowRootDelegatesFocus","shadowRootSerializable","sharedStorageWritable",...Uh]),jh=new Set(["innerHTML","textContent","innerText","children"]),Kh=Object.assign(Object.create(null),{className:"class",htmlFor:"for"}),Wh=Object.assign(Object.create(null),{class:"className",novalidate:{$:"noValidate",FORM:1},formnovalidate:{$:"formNoValidate",BUTTON:1,INPUT:1},ismap:{$:"isMap",IMG:1},nomodule:{$:"noModule",SCRIPT:1},playsinline:{$:"playsInline",VIDEO:1},readonly:{$:"readOnly",INPUT:1,TEXTAREA:1},adauctionheaders:{$:"adAuctionHeaders",IFRAME:1},allowfullscreen:{$:"allowFullscreen",IFRAME:1},browsingtopics:{$:"browsingTopics",IMG:1},defaultchecked:{$:"defaultChecked",INPUT:1},defaultmuted:{$:"defaultMuted",AUDIO:1,VIDEO:1},defaultselected:{$:"defaultSelected",OPTION:1},disablepictureinpicture:{$:"disablePictureInPicture",VIDEO:1},disableremoteplayback:{$:"disableRemotePlayback",AUDIO:1,VIDEO:1},preservespitch:{$:"preservesPitch",AUDIO:1,VIDEO:1},shadowrootclonable:{$:"shadowRootClonable",TEMPLATE:1},shadowrootdelegatesfocus:{$:"shadowRootDelegatesFocus",TEMPLATE:1},shadowrootserializable:{$:"shadowRootSerializable",TEMPLATE:1},sharedstoragewritable:{$:"sharedStorageWritable",IFRAME:1,IMG:1}});Yh=new Set(["beforeinput","click","dblclick","contextmenu","focusin","focusout","input","keydown","keyup","mousedown","mousemove","mouseout","mouseover","mouseup","pointerdown","pointermove","pointerout","pointerover","pointerup","touchend","touchmove","touchstart"]),Ie=e=>ne(()=>e());Au="_$DX_DELEGATE";});var Ar,Xi,Oa=X(()=>{Ar=null,Xi=()=>{if(Ar!==null)return Ar;try{Ar=window.matchMedia("(color-gamut: p3)").matches;}catch{Ar=false;}return Ar};});var cb,ub,db,Fo,Du=X(()=>{Oa();cb=Xi(),ub="210, 57, 192",db="0.84 0.19 0.78",Fo=e=>cb?`color(display-p3 ${db} / ${e})`:`rgba(${ub}, ${e})`;});var Lu,kn,jt,Fu,yt,Tr,Bu,$u,Hu,Vu,zu,Uu,Ia,Bo,$o,Gu,ju,Ku,Wu,Xu,_r,Yu,qu,Zu,Ju,Qu,Ra,Pr,Or,Ho,$t,Yi,ed,Ir,ka,Na,Ma,td,nd,od,rd,id,sd,Nn,ad,ld,Da,La,Kt,Rr,cd,ud,Fa,dd,Ba,kr,$a,Nr,fd,md,pd,gd,Ee,Ha,hd,bd,Va,eo,za,bn,yd,wd,vd,Mt,yn,xd,Ed,Cd,qi,Sd,Ua,Mr,Dr,Ga,Dt,ja,Ad,Td,_d,Ka,Pd,Od,Id,Wa,Rd,kd,Nd,Md,Xa,Lr,Dd,on,Ya,Ld,Fd,Bd,to,Vo,$d,Mn,Zi,qa,Za,Oe=X(()=>{Du();Lu="0.1.28",kn=8,jt=-1e3,Fu=.95,yt=1500,Tr=100,Bu=150,$u=50,Hu=200,Vu=500,zu=200,Uu=400,Ia=600,Bo=100,$o=3,Gu=200,ju=1e4,Ku=200,Wu=120,Xu=600,_r=2,Yu=32,qu=200,Zu=100,Ju=32,Qu=16,Ra=100,Pr=25,Or=10,Ho=2147483647,$t=2147483647,Yi=2147483645,ed=.7,Ir=.5,ka=.01,Na=100,Ma=2,td=Fo(.4),nd=Fo(.05),od=Fo(.5),rd=Fo(.08),id=Fo(.15),sd=50,Nn=8,ad=4,ld=.2,Da=50,La=16,Kt=4,Rr=100,cd=15,ud=3,Fa=["id","class","aria-label","data-testid","role","name","title"],dd=["Meta","Control","Shift","Alt"],Ba=new Set(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"]),kr="data-react-grab-frozen",$a="data-react-grab-ignore",Nr=.9,fd=1e3,md=2147483600,pd=400,gd=100,Ee=16,Ha=500,hd=300,bd=5,Va=150,eo=14,za=28,bn=150,yd=50,wd=78,vd=28,Mt=.5,yn="comment",xd=1500,Ed=3e3,Cd=3,qi="animate-[hint-flip-in_var(--transition-normal)_ease-out]",Sd=.75,Ua=32,Mr=3,Dr=20,Ga=100,Dt=1,ja=50,Ad=50,Td=6,_d=3,Ka=2,Pd=16,Od=100,Id=50,Wa=.01,Rd=1e3,kd=20,Nd=2*1024*1024,Md=100,Xa=200,Lr=8,Dd=8,on=8,Ya=11,Ld=180,Fd=280,Bd=100,to={left:-9999,top:-9999},Vo={left:"left center",right:"right center",top:"center top",bottom:"center bottom"},$d=1e3,Mn=95,Zi=229,qa=-9999,Za=new Set(["display","position","top","right","bottom","left","z-index","overflow","overflow-x","overflow-y","width","height","min-width","min-height","max-width","max-height","margin-top","margin-right","margin-bottom","margin-left","padding-top","padding-right","padding-bottom","padding-left","flex-direction","flex-wrap","justify-content","align-items","align-self","align-content","flex-grow","flex-shrink","flex-basis","order","gap","row-gap","column-gap","grid-template-columns","grid-template-rows","grid-template-areas","font-family","font-size","font-weight","font-style","line-height","letter-spacing","text-align","text-decoration-line","text-decoration-style","text-transform","text-overflow","text-shadow","white-space","word-break","overflow-wrap","vertical-align","color","background-color","background-image","background-position","background-size","background-repeat","border-top-width","border-right-width","border-bottom-width","border-left-width","border-top-style","border-right-style","border-bottom-style","border-left-style","border-top-color","border-right-color","border-bottom-color","border-left-color","border-top-left-radius","border-top-right-radius","border-bottom-left-radius","border-bottom-right-radius","box-shadow","opacity","transform","filter","backdrop-filter","object-fit","object-position"]);});var je,zo=X(()=>{je=e=>!!(e?.isConnected??e?.ownerDocument?.contains(e));});var et,Uo=X(()=>{et=e=>(e.tagName||"").toLowerCase();});var bb,yb,vt,Kd,Wd,no=X(()=>{Uo();bb=["input","textarea","select","searchbox","slider","spinbutton","menuitem","menuitemcheckbox","menuitemradio","option","radio","textbox","combobox"],yb=e=>{if(e.composed){let t=e.composedPath()[0];if(t instanceof HTMLElement)return t}else if(e.target instanceof HTMLElement)return e.target},vt=e=>{if(document.designMode==="on")return true;let t=yb(e);if(!t)return false;if(t.isContentEditable)return true;let n=et(t);return bb.some(o=>o===n||o===t.role)},Kd=e=>{let t=e.target;if(t instanceof HTMLInputElement||t instanceof HTMLTextAreaElement){let n=t.selectionStart??0;return (t.selectionEnd??0)-n>0}return false},Wd=()=>{let e=window.getSelection();return e?e.toString().length>0:false};});var Qa,xb,Eb,Fe,Ke,qd,rn=X(()=>{Qa=typeof window<"u",xb=e=>0,Eb=e=>{},Fe=Qa?(Object.getOwnPropertyDescriptor(Window.prototype,"requestAnimationFrame")?.value??window.requestAnimationFrame).bind(window):xb,Ke=Qa?(Object.getOwnPropertyDescriptor(Window.prototype,"cancelAnimationFrame")?.value??window.cancelAnimationFrame).bind(window):Eb,qd=()=>Qa?new Promise(e=>Fe(()=>e())):Promise.resolve();});var el,es,Zd,Cb,Fr,Qd,ts,ef,Jd,Br,Qi,Dn,tl,$r,nl,Ln,ol,ns,Hr=X(()=>{el="0.5.32",es=`bippy-${el}`,Zd=Object.defineProperty,Cb=Object.prototype.hasOwnProperty,Fr=()=>{},Qd=e=>{try{Function.prototype.toString.call(e).indexOf("^_^")>-1&&setTimeout(()=>{throw Error("React is running in production mode, but dead code elimination has not been applied. Read how to correctly configure React for production: https://reactjs.org/link/perf-use-production-build")});}catch{}},ts=(e=globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__)=>!!(e&&"getFiberRoots"in e),ef=false,Br=(e=globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__)=>ef?true:(e&&typeof e.inject=="function"&&(Jd=e.inject.toString()),!!Jd?.includes("(injected)")),Qi=new Set,Dn=new Set,tl=e=>{let t=new Map,n=0,o={_instrumentationIsActive:false,_instrumentationSource:es,checkDCE:Qd,hasUnsupportedRendererAttached:false,inject(i){let r=++n;return t.set(r,i),Dn.add(i),o._instrumentationIsActive||(o._instrumentationIsActive=true,Qi.forEach(a=>a())),r},on:Fr,onCommitFiberRoot:Fr,onCommitFiberUnmount:Fr,onPostCommitFiberRoot:Fr,renderers:t,supportsFiber:true,supportsFlight:true};try{Zd(globalThis,"__REACT_DEVTOOLS_GLOBAL_HOOK__",{configurable:!0,enumerable:!0,get(){return o},set(a){if(a&&typeof a=="object"){let u=o.renderers;o=a,u.size>0&&(u.forEach((l,m)=>{Dn.add(l),a.renderers.set(m,l);}),$r(e));}}});let i=window.hasOwnProperty,r=!1;Zd(window,"hasOwnProperty",{configurable:!0,value:function(...a){try{if(!r&&a[0]==="__REACT_DEVTOOLS_GLOBAL_HOOK__")return globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__=void 0,r=!0,-0}catch{}return i.apply(this,a)},writable:!0});}catch{$r(e);}return o},$r=e=>{e&&Qi.add(e);try{let t=globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;if(!t)return;if(!t._instrumentationSource){t.checkDCE=Qd,t.supportsFiber=!0,t.supportsFlight=!0,t.hasUnsupportedRendererAttached=!1,t._instrumentationSource=es,t._instrumentationIsActive=!1;let n=ts(t);if(n||(t.on=Fr),t.renderers.size){t._instrumentationIsActive=!0,Qi.forEach(r=>r());return}let o=t.inject,i=Br(t);i&&!n&&(ef=!0,t.inject({scheduleRefresh(){}})&&(t._instrumentationIsActive=!0)),t.inject=r=>{let a=o(r);return Dn.add(r),i&&t.renderers.set(a,r),t._instrumentationIsActive=!0,Qi.forEach(u=>u()),a};}(t.renderers.size||t._instrumentationIsActive||Br())&&e?.();}catch{}},nl=()=>Cb.call(globalThis,"__REACT_DEVTOOLS_GLOBAL_HOOK__"),Ln=e=>nl()?($r(e),globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__):tl(e),ol=()=>!!(typeof window<"u"&&(window.document?.createElement||window.navigator?.product==="ReactNative")),ns=()=>{try{ol()&&Ln();}catch{}};});var tf=X(()=>{Hr();ns();});function oo(e,t,n=false){if(!e)return null;let o=t(e);if(o instanceof Promise)return (async()=>{if(await o===true)return e;let r=n?e.return:e.child;for(;r;){let a=await bl(r,t,n);if(a)return a;r=n?null:r.sibling;}return null})();if(o===true)return e;let i=n?e.return:e.child;for(;i;){let r=hl(i,t,n);if(r)return r;i=n?null:i.sibling;}return null}var rl,il,sl,al,ll,cl,ul,dl,fl,ml,pl,gl,Fn,hl,bl,yl,sn;exports.isInstrumentationActive=void 0;var Xt,wl,vl=X(()=>{Hr();rl=0,il=1,sl=5,al=11,ll=13,cl=15,ul=16,dl=19,fl=26,ml=27,pl=28,gl=30,Fn=e=>{switch(e.tag){case 1:case 11:case 0:case 14:case 15:return true;default:return false}};hl=(e,t,n=false)=>{if(!e)return null;if(t(e)===true)return e;let o=n?e.return:e.child;for(;o;){let i=hl(o,t,n);if(i)return i;o=n?null:o.sibling;}return null},bl=async(e,t,n=false)=>{if(!e)return null;if(await t(e)===true)return e;let o=n?e.return:e.child;for(;o;){let i=await bl(o,t,n);if(i)return i;o=n?null:o.sibling;}return null},yl=e=>{let t=e;return typeof t=="function"?t:typeof t=="object"&&t?yl(t.type||t.render):null},sn=e=>{let t=e;if(typeof t=="string")return t;if(typeof t!="function"&&!(typeof t=="object"&&t))return null;let n=t.displayName||t.name||null;if(n)return n;let o=yl(t);return o&&(o.displayName||o.name)||null},exports.isInstrumentationActive=()=>{let e=globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;return !!e?._instrumentationIsActive||ts(e)||Br(e)},Xt=e=>{let t=globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;if(t?.renderers)for(let n of t.renderers.values())try{let o=n.findFiberByHostInstance?.(e);if(o)return o}catch{}if(typeof e=="object"&&e){if("_reactRootContainer"in e)return e._reactRootContainer?._internalRoot?.current?.child;for(let n in e)if(n.startsWith("__reactContainer$")||n.startsWith("__reactInternalInstance$")||n.startsWith("__reactFiber"))return e[n]||null}return null},wl=new Set;});var Vr=X(()=>{Hr();tf();vl();});function zr(e,t){let n=0,o=0,i=0;do i=hf[e.next()],n|=(i&31)<>>=1,r&&(n=-2147483648|-n),t+n}function af(e,t){return e.pos>=t?false:e.peek()!==Rb}function bf(e){let{length:t}=e,n=new Nb(e),o=[],i=0,r=0,a=0,u=0,l=0;do{let m=n.indexOf(";"),c=[],d=true,h=0;for(i=0;n.pos{Hr();vl();nf=/^[a-zA-Z][a-zA-Z\d+\-.]*:/,Ab=["rsc://","file:///","webpack://","webpack-internal://","node:","turbopack://","metro://","/app-pages-browser/","/(app-pages-browser)/"],Tb=["","eval",""],ff=/\.(jsx|tsx|ts|js)$/,_b=/(\.min|bundle|chunk|vendor|vendors|runtime|polyfill|polyfills)\.(js|mjs|cjs)$|(chunk|bundle|vendor|vendors|runtime|polyfill|polyfills|framework|app|main|index)[-_.][A-Za-z0-9_-]{4,}\.(js|mjs|cjs)$|[\da-f]{8,}\.(js|mjs|cjs)$|[-_.][\da-f]{20,}\.(js|mjs|cjs)$|\/dist\/|\/build\/|\/.next\/|\/out\/|\/node_modules\/|\.webpack\.|\.vite\.|\.turbopack\./i,Pb=/^\?[\w~.-]+(?:=[^&#]*)?(?:&[\w~.-]+(?:=[^&#]*)?)*$/,mf="(at Server)",Ob=/(^|@)\S+:\d+/,pf=/^\s*at .*(\S+:\d+|\(native\))/m,Ib=/^(eval@)?(\[native code\])?$/,rs=(e,t)=>{if(t?.includeInElement!==false){let n=e.split(` -`),o=[];for(let i of n)if(/^\s*at\s+/.test(i)){let r=of(i,void 0)[0];r&&o.push(r);}else if(/^\s*in\s+/.test(i)){let r=i.replace(/^\s*in\s+/,"").replace(/\s*\(at .*\)$/,"");o.push({functionName:r,source:i});}else if(i.match(Ob)){let r=rf(i,void 0)[0];r&&o.push(r);}return Cl(o,t)}return e.match(pf)?of(e,t):rf(e,t)},gf=e=>{if(!e.includes(":"))return [e,void 0,void 0];let t=e.startsWith("(")&&/:\d+\)$/.test(e)?e.slice(1,-1):e,n=/(.+?)(?::(\d+))?(?::(\d+))?$/.exec(t);return n?[n[1],n[2]||void 0,n[3]||void 0]:[t,void 0,void 0]},Cl=(e,t)=>t&&t.slice!=null?Array.isArray(t.slice)?e.slice(t.slice[0],t.slice[1]):e.slice(0,t.slice):e,of=(e,t)=>Cl(e.split(` -`).filter(n=>!!n.match(pf)),t).map(n=>{let o=n;o.includes("(eval ")&&(o=o.replace(/eval code/g,"eval").replace(/(\(eval at [^()]*)|(,.*$)/g,""));let i=o.replace(/^\s+/,"").replace(/\(eval code/g,"(").replace(/^.*?\s+/,""),r=i.match(/ (\(.+\)$)/);i=r?i.replace(r[0],""):i;let a=gf(r?r[1]:i);return {functionName:r&&i||void 0,fileName:["eval",""].includes(a[0])?void 0:a[0],lineNumber:a[1]?+a[1]:void 0,columnNumber:a[2]?+a[2]:void 0,source:o}}),rf=(e,t)=>Cl(e.split(` -`).filter(n=>!n.match(Ib)),t).map(n=>{let o=n;if(o.includes(" > eval")&&(o=o.replace(/ line (\d+)(?: > eval line \d+)* > eval:\d+:\d+/g,":$1")),!o.includes("@")&&!o.includes(":"))return {functionName:o};{let i=/(([^\n\r"\u2028\u2029]*".[^\n\r"\u2028\u2029]*"[^\n\r@\u2028\u2029]*(?:@[^\n\r"\u2028\u2029]*"[^\n\r@\u2028\u2029]*)*(?:[\n\r\u2028\u2029][^@]*)?)?[^@]*)@/,r=o.match(i),a=r&&r[1]?r[1]:void 0,u=gf(o.replace(i,""));return {functionName:a,fileName:u[0],lineNumber:u[1]?+u[1]:void 0,columnNumber:u[2]?+u[2]:void 0,source:o}}}),Rb=44,sf="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",kb=new Uint8Array(64),hf=new Uint8Array(128);for(let e=0;ewf&&e instanceof WeakRef,lf=(e,t,n,o)=>{if(n<0||n>=e.length)return null;let i=e[n];if(!i||i.length===0)return null;let r=null;for(let c of i)if(c[0]<=o)r=c;else break;if(!r||r.length<4)return null;let[,a,u,l]=r;if(a===void 0||u===void 0||l===void 0)return null;let m=t[a];return m?{columnNumber:l,fileName:m,lineNumber:u+1}:null},$b=(e,t,n)=>{if(e.sections){let o=null;for(let a of e.sections)if(t>a.offset.line||t===a.offset.line&&n>=a.offset.column)o=a;else break;if(!o)return null;let i=t-o.offset.line,r=t===o.offset.line?n-o.offset.column:n;return lf(o.map.mappings,o.map.sources,i,r)}return lf(e.mappings,e.sources,t-1,n)},Hb=(e,t)=>{let n=t.split(` -`),o;for(let r=n.length-1;r>=0&&!o;r--){let a=n[r].match(Fb);a&&(o=a[1]||a[2]);}if(!o)return null;let i=yf.test(o);if(!(Lb.test(o)||i||o.startsWith("/"))){let r=e.split("/");r[r.length-1]=o,o=r.join("/");}return o},Vb=e=>({file:e.file,mappings:bf(e.mappings),names:e.names,sourceRoot:e.sourceRoot,sources:e.sources,sourcesContent:e.sourcesContent,version:3}),zb=e=>{let t=e.sections.map(({map:o,offset:i})=>({map:{...o,mappings:bf(o.mappings)},offset:i})),n=new Set;for(let o of t)for(let i of o.map.sources)n.add(i);return {file:e.file,mappings:[],names:[],sections:t,sourceRoot:void 0,sources:Array.from(n),sourcesContent:void 0,version:3}},cf=e=>{if(!e)return false;let t=e.trim();if(!t)return false;let n=t.match(yf);if(!n)return true;let o=n[0].toLowerCase();return o==="http:"||o==="https:"},Ub=async(e,t=fetch)=>{if(!cf(e))return null;let n;try{let i=await t(e);if(!i.ok)return null;n=await i.text();}catch{return null}if(!n)return null;let o=Hb(e,n);if(!o||!cf(o))return null;try{let i=await t(o);if(!i.ok)return null;let r=await i.json();return "sections"in r?zb(r):Vb(r)}catch{return null}},Gb=async(e,t=true,n)=>{if(t&&Ur.has(e)){let r=Ur.get(e);if(r==null)return null;if(Bb(r)){let a=r.deref();if(a)return a;Ur.delete(e);}else return r}if(t&&os.has(e))return os.get(e);let o=Ub(e,n);t&&os.set(e,o);let i=await o;return t&&os.delete(e),t&&(i===null?Ur.set(e,null):Ur.set(e,wf?new WeakRef(i):i)),i},jb=async(e,t=true,n)=>await Promise.all(e.map(async o=>{if(!o.fileName)return o;let i=await Gb(o.fileName,t,n);if(!i||typeof o.lineNumber!="number"||typeof o.columnNumber!="number")return o;let r=$b(i,o.lineNumber,o.columnNumber);return r?{...o,source:r.fileName&&o.source?o.source.replace(o.fileName,r.fileName):o.source,fileName:r.fileName,lineNumber:r.lineNumber,columnNumber:r.columnNumber,isSymbolicated:true}:o})),Sl=e=>e._debugStack instanceof Error&&typeof e._debugStack?.stack=="string",Kb=()=>{let e=Ln();for(let t of [...Array.from(Dn),...Array.from(e.renderers.values())]){let n=t.currentDispatcherRef;if(n&&typeof n=="object")return "H"in n?n.H:n.current}return null},uf=e=>{for(let t of Dn){let n=t.currentDispatcherRef;n&&typeof n=="object"&&("H"in n?n.H=e:n.current=e);}},wn=e=>` - in ${e}`,Wb=(e,t)=>{let n=wn(e);return t&&(n+=` (at ${t})`),n},xl=false,El=(e,t)=>{if(!e||xl)return "";let n=Error.prepareStackTrace;Error.prepareStackTrace=void 0,xl=true;let o=Kb();uf(null);let i=console.error,r=console.warn;console.error=()=>{},console.warn=()=>{};try{let u={DetermineComponentFrameRoot(){let c;try{if(t){let d=function(){throw Error()};if(Object.defineProperty(d.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(d,[]);}catch(h){c=h;}Reflect.construct(e,[],d);}else {try{d.call();}catch(h){c=h;}e.call(d.prototype);}}else {try{throw Error()}catch(h){c=h;}let d=e();d&&typeof d.catch=="function"&&d.catch(()=>{});}}catch(d){if(d instanceof Error&&c instanceof Error&&typeof d.stack=="string")return [d.stack,c.stack]}return [null,null]}};u.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot",Object.getOwnPropertyDescriptor(u.DetermineComponentFrameRoot,"name")?.configurable&&Object.defineProperty(u.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});let[l,m]=u.DetermineComponentFrameRoot();if(l&&m){let c=l.split(` +var mb=Object.defineProperty;var Y=(e,t)=>()=>(e&&(t=e(e=0)),t);var pb=(e,t)=>{for(var n in t)mb(e,n,{get:t[n],enumerable:true});};function Mu(e){let t=String(e),n=t.length-1;return Me.context.id+(n?String.fromCharCode(96+n):"")+t}function Ba(e){Me.context=e;}function gb(){return {...Me.context,id:Me.getNextContextId(),count:0}}function xn(e,t){let n=De,o=Oe,i=e.length===0,r=t===void 0?o:t,a=i?Vu:{owned:null,cleanups:null,context:r?r.context:null,owner:r},u=i?e:()=>e(()=>mt(()=>Dn(a)));Oe=a,De=null;try{return At(u,!0)}finally{De=n,Oe=o;}}function j(e,t){t=t?Object.assign({},Wi,t):Wi;let n={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},o=i=>(typeof i=="function"&&(W&&W.running&&W.sources.has(n)?i=i(n.tValue):i=i(n.value)),ju(n,i));return [Gu.bind(n),o]}function Lu(e,t,n){let o=Qi(e,t,true,Dt);Uo&&W&&W.running?ot.push(o):Go(o);}function K(e,t,n){let o=Qi(e,t,false,Dt);Uo&&W&&W.running?ot.push(o):Go(o);}function fe(e,t,n){Hu=Cb;let o=Qi(e,t,false,Dt);(o.user=true),ft?ft.push(o):Go(o);}function ne(e,t,n){n=n?Object.assign({},Wi,n):Wi;let o=Qi(e,t,true,0);return o.observers=null,o.observerSlots=null,o.comparator=n.equals||void 0,Uo&&W&&W.running?(o.tState=Dt,ot.push(o)):Go(o),Gu.bind(o)}function wb(e){return e&&typeof e=="object"&&"then"in e}function Ha(e,t,n){let o,i,r;typeof t=="function"?(o=e,i=t,r={}):(o=true,i=e,r=t||{});let a=null,u=La,c=null,m=false,l=false,d="initialValue"in r,g=typeof o=="function"&&ne(o),b=new Set,[N,k]=(r.storage||j)(r.initialValue),[E,x]=j(void 0),[v,p]=j(void 0,{equals:false}),[y,I]=j(d?"ready":"unresolved");Me.context&&(c=Me.getNextContextId(),r.ssrLoadFrom==="initial"?u=r.initialValue:Me.load&&Me.has(c)&&(u=Me.load(c)));function L(z,we,P,O){return a===z&&(a=null,O!==void 0&&(d=true),(z===u||we===u)&&r.onHydrated&&queueMicrotask(()=>r.onHydrated(O,{value:we})),u=La,W&&z&&m?(W.promises.delete(z),m=false,At(()=>{W.running=!0,q(we,P);},false)):q(we,P)),we}function q(z,we){At(()=>{we===void 0&&k(()=>z),I(we!==void 0?"errored":d?"ready":"unresolved"),x(we);for(let P of b.keys())P.decrement();b.clear();},false);}function re(){let z=Sr,we=N(),P=E();if(P!==void 0&&!a)throw P;return De&&!De.user&&z,we}function B(z=true){if(z!==false&&l)return;l=false;let we=g?g():o;if(m=W&&W.running,we==null||we===false){L(a,mt(N));return}W&&a&&W.promises.delete(a);let P,O=u!==La?u:mt(()=>{try{return i(we,{value:N(),refetching:z})}catch(U){P=U;}});if(P!==void 0){L(a,void 0,Ki(P),we);return}else if(!wb(O))return L(a,O,void 0,we),O;return a=O,"v"in O?(O.s===1?L(a,O.v,void 0,we):L(a,void 0,Ki(O.v),we),O):(l=true,queueMicrotask(()=>l=false),At(()=>{I(d?"refreshing":"pending"),p();},false),O.then(U=>L(O,U,void 0,we),U=>L(O,void 0,Ki(U),we)))}Object.defineProperties(re,{state:{get:()=>y()},error:{get:()=>E()},loading:{get(){let z=y();return z==="pending"||z==="refreshing"}},latest:{get(){if(!d)return re();let z=E();if(z&&!a)throw z;return N()}}});let de=Oe;return g?Lu(()=>(de=Oe,B(false))):B(false),[re,{refetch:z=>zu(de,()=>B(z)),mutate:k}]}function Zi(e){return At(e,false)}function mt(e){if(!zo&&De===null)return e();let t=De;De=null;try{return zo?zo.untrack(e):e()}finally{De=t;}}function Te(e,t,n){let o=Array.isArray(e),i,r=n&&n.defer;return a=>{let u;if(o){u=Array(e.length);for(let m=0;mt(u,i,a));return i=u,c}}function Je(e){fe(()=>mt(e));}function xe(e){return Oe===null||(Oe.cleanups===null?Oe.cleanups=[e]:Oe.cleanups.push(e)),e}function Ji(){return De}function zu(e,t){let n=Oe,o=De;Oe=e,De=null;try{return At(t,!0)}catch(i){es(i);}finally{Oe=n,De=o;}}function vb(e){if(W&&W.running)return e(),W.done;let t=De,n=Oe;return Promise.resolve().then(()=>{De=t,Oe=n;let o;return (Uo||Sr)&&(o=W||(W={sources:new Set,effects:[],promises:new Set,disposed:new Set,queue:new Set,running:true}),o.done||(o.done=new Promise(i=>o.resolve=i)),o.running=true),At(e,false),De=Oe=null,o?o.done:void 0})}function Gu(){let e=W&&W.running;if(this.sources&&(e?this.tState:this.state))if((e?this.tState:this.state)===Dt)Go(this);else {let t=ot;ot=null,At(()=>Xi(this),false),ot=t;}if(De){let t=this.observers?this.observers.length:0;De.sources?(De.sources.push(this),De.sourceSlots.push(t)):(De.sources=[this],De.sourceSlots=[t]),this.observers?(this.observers.push(De),this.observerSlots.push(De.sources.length-1)):(this.observers=[De],this.observerSlots=[De.sources.length-1]);}return e&&W.sources.has(this)?this.tValue:this.value}function ju(e,t,n){let o=W&&W.running&&W.sources.has(e)?e.tValue:e.value;if(!e.comparator||!e.comparator(o,t)){if(W){let i=W.running;(i||!n&&W.sources.has(e))&&(W.sources.add(e),e.tValue=t),i||(e.value=t);}else e.value=t;e.observers&&e.observers.length&&At(()=>{for(let i=0;i1e6)throw ot=[],new Error},false);}return t}function Go(e){if(!e.fn)return;Dn(e);let t=qi;Bu(e,W&&W.running&&W.sources.has(e)?e.tValue:e.value,t),W&&!W.running&&W.sources.has(e)&&queueMicrotask(()=>{At(()=>{W&&(W.running=!0),De=Oe=e,Bu(e,e.tValue,t),De=Oe=null;},false);});}function Bu(e,t,n){let o,i=Oe,r=De;De=Oe=e;try{o=e.fn(t);}catch(a){return e.pure&&(W&&W.running?(e.tState=Dt,e.tOwned&&e.tOwned.forEach(Dn),e.tOwned=void 0):(e.state=Dt,e.owned&&e.owned.forEach(Dn),e.owned=null)),e.updatedAt=n+1,es(a)}finally{De=r,Oe=i;}(!e.updatedAt||e.updatedAt<=n)&&(e.updatedAt!=null&&"observers"in e?ju(e,o,true):W&&W.running&&e.pure?(W.sources.add(e),e.tValue=o):e.value=o,e.updatedAt=n);}function Qi(e,t,n,o=Dt,i){let r={fn:e,state:o,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:Oe,context:Oe?Oe.context:null,pure:n};if(W&&W.running&&(r.state=0,r.tState=o),Oe===null||Oe!==Vu&&(W&&W.running&&Oe.pure?Oe.tOwned?Oe.tOwned.push(r):Oe.tOwned=[r]:Oe.owned?Oe.owned.push(r):Oe.owned=[r]),zo&&r.fn){let[a,u]=j(void 0,{equals:false}),c=zo.factory(r.fn,u);xe(()=>c.dispose());let m=()=>vb(u).then(()=>l.dispose()),l=zo.factory(r.fn,m);r.fn=d=>(a(),W&&W.running?l.track(d):c.track(d));}return r}function Ar(e){let t=W&&W.running;if((t?e.tState:e.state)===0)return;if((t?e.tState:e.state)===Cr)return Xi(e);if(e.suspense&&mt(e.suspense.inFallback))return e.suspense.effects.push(e);let n=[e];for(;(e=e.owner)&&(!e.updatedAt||e.updatedAt=0;o--){if(e=n[o],t){let i=e,r=n[o+1];for(;(i=i.owner)&&i!==r;)if(W.disposed.has(i))return}if((t?e.tState:e.state)===Dt)Go(e);else if((t?e.tState:e.state)===Cr){let i=ot;ot=null,At(()=>Xi(e,n[0]),false),ot=i;}}}function At(e,t){if(ot)return e();let n=false;t||(ot=[]),ft?n=true:ft=[],qi++;try{let o=e();return xb(n),o}catch(o){n||(ft=null),ot=null,es(o);}}function xb(e){if(ot&&(Uo&&W&&W.running?Eb(ot):Ku(ot),ot=null),e)return;let t;if(W){if(!W.promises.size&&!W.queue.size){let o=W.sources,i=W.disposed;ft.push.apply(ft,W.effects),t=W.resolve;for(let r of ft)"tState"in r&&(r.state=r.tState),delete r.tState;W=null,At(()=>{for(let r of i)Dn(r);for(let r of o){if(r.value=r.tValue,r.owned)for(let a=0,u=r.owned.length;aHu(n),false),t&&t();}function Ku(e){for(let t=0;t{o.delete(n),At(()=>{W.running=!0,Ar(n);},false),W&&(W.running=false);}));}}function Cb(e){let t,n=0;for(t=0;t=0;t--)Dn(e.tOwned[t]);delete e.tOwned;}if(W&&W.running&&e.pure)Xu(e,true);else if(e.owned){for(t=e.owned.length-1;t>=0;t--)Dn(e.owned[t]);e.owned=null;}if(e.cleanups){for(t=e.cleanups.length-1;t>=0;t--)e.cleanups[t]();e.cleanups=null;}W&&W.running?e.tState=0:e.state=0;}function Xu(e,t){if(t||(e.tState=0,W.disposed.add(e)),e.owned)for(let n=0;n1?[]:null;return xe(()=>Yi(r)),()=>{let c=e()||[],m=c.length,l,d;return c[Tr],mt(()=>{let b,N,k,E,x,v,p,y,I;if(m===0)a!==0&&(Yi(r),r=[],o=[],i=[],a=0,u&&(u=[])),n.fallback&&(o=[$a],i[0]=xn(L=>(r[0]=L,n.fallback())),a=1);else if(a===0){for(i=new Array(m),d=0;d=v&&y>=v&&o[p]===c[y];p--,y--)k[y]=i[p],E[y]=r[p],u&&(x[y]=u[p]);for(b=new Map,N=new Array(y+1),d=y;d>=v;d--)I=c[d],l=b.get(I),N[d]=l===void 0?-1:l,b.set(I,d);for(l=v;l<=p;l++)I=o[l],d=b.get(I),d!==void 0&&d!==-1?(k[d]=i[l],E[d]=r[l],u&&(x[d]=u[l]),d=N[d],b.set(I,d)):r[l]();for(d=v;dYi(r)),()=>{let m=e()||[],l=m.length;return m[Tr],mt(()=>{if(l===0)return u!==0&&(Yi(r),r=[],o=[],i=[],u=0,a=[]),n.fallback&&(o=[$a],i[0]=xn(g=>(r[0]=g,n.fallback())),u=1),i;for(o[0]===$a&&(r[0](),r=[],o=[],i=[],u=0),c=0;cm[c]):c>=o.length&&(i[c]=xn(d));for(;ce(t||{}));return Ba(n),o}return mt(()=>e(t||{}))}function ji(){return true}function Fa(e){return (e=typeof e=="function"?e():e)?e:{}}function Pb(){for(let e=0,t=this.length;e=0;u--){let c=Fa(e[u])[a];if(c!==void 0)return c}},has(a){for(let u=e.length-1;u>=0;u--)if(a in Fa(e[u]))return true;return false},keys(){let a=[];for(let u=0;u=0;a--){let u=e[a];if(!u)continue;let c=Object.getOwnPropertyNames(u);for(let m=c.length-1;m>=0;m--){let l=c[m];if(l==="__proto__"||l==="constructor")continue;let d=Object.getOwnPropertyDescriptor(u,l);if(!o[l])o[l]=d.get?{enumerable:true,configurable:true,get:Pb.bind(n[l]=[d.get.bind(u)])}:d.value!==void 0?d:void 0;else {let g=n[l];g&&(d.get?g.push(d.get.bind(u)):d.value!==void 0&&g.push(()=>d.value));}}}let i={},r=Object.keys(o);for(let a=r.length-1;a>=0;a--){let u=r[a],c=o[u];c&&c.get?Object.defineProperty(i,u,c):i[u]=c?c.value:void 0;}return i}function Lt(e){let t="fallback"in e&&{fallback:()=>e.fallback};return ne(Sb(()=>e.each,e.children,t||void 0))}function ts(e){let t="fallback"in e&&{fallback:()=>e.fallback};return ne(Ab(()=>e.each,e.children,t||void 0))}function oe(e){let t=e.keyed,n=ne(()=>e.when,void 0,void 0),o=t?n:ne(n,void 0,{equals:(i,r)=>!i==!r});return ne(()=>{let i=o();if(i){let r=e.children;return typeof r=="function"&&r.length>0?mt(()=>r(t?i:()=>{if(!mt(o))throw Ob("Show");return n()})):r}return e.fallback},void 0,void 0)}var Me,bb,Yt,yb,Tr,Wi,Du,Hu,Dt,Cr,Vu,La,Oe,W,Uo,zo,De,ot,ft,qi,qE,Fu,Sr,$a,Tb,_b,Ob,Ke=Y(()=>{Me={context:void 0,registry:void 0,effects:void 0,done:false,getContextId(){return Mu(this.context.count)},getNextContextId(){return Mu(this.context.count++)}};bb=(e,t)=>e===t,Yt=Symbol("solid-proxy"),yb=typeof Proxy=="function",Tr=Symbol("solid-track"),Wi={equals:bb},Du=null,Hu=Ku,Dt=1,Cr=2,Vu={owned:null,cleanups:null,context:null,owner:null},La={},Oe=null,W=null,Uo=null,zo=null,De=null,ot=null,ft=null,qi=0;[qE,Fu]=j(false);$a=Symbol("fallback");Tb=false;_b={get(e,t,n){return t===Yt?n:e.get(t)},has(e,t){return t===Yt?true:e.has(t)},set:ji,deleteProperty:ji,getOwnPropertyDescriptor(e,t){return {configurable:true,enumerable:true,get(){return e.get(t)},set:ji,deleteProperty:ji}},ownKeys(e){return e.keys()}};Ob=e=>`Stale read from <${e}>.`;});function Lb(e,t){let n=Db[e];return typeof n=="object"?n[t]?n.$:void 0:n}function $b(e,t,n){let o=n.length,i=t.length,r=o,a=0,u=0,c=t[i-1].nextSibling,m=null;for(;al-u){let N=t[a];for(;u{i=r,t===document?e():R(t,e(),t.firstChild?null:void 0,n);},o.owner),()=>{i(),t.textContent="";}}function D(e,t,n,o){let i,r=()=>{let u=document.createElement("template");return u.innerHTML=e,u.content.firstChild},a=t?()=>mt(()=>document.importNode(i||(i=r()),!0)):()=>(i||(i=r())).cloneNode(true);return a.cloneNode=a,a}function He(e,t=window.document){let n=t[Yu]||(t[Yu]=new Set);for(let o=0,i=e.length;oi.call(e,n[1],r));}else e.addEventListener(t,n,typeof n!="function"&&n);}function fo(e,t,n={}){let o=Object.keys(t||{}),i=Object.keys(n),r,a;for(r=0,a=i.length;rtypeof t.ref=="function"&&Ie(t.ref,e)),K(()=>zb(e,t,n,true,i,true)),i}function Ie(e,t,n){return mt(()=>e(t,n))}function R(e,t,n,o){if(n!==void 0&&!o&&(o=[]),typeof t!="function")return Ko(e,t,o,n);K(i=>Ko(e,t(),i,n),o);}function zb(e,t,n,o,i={},r=false){t||(t={});for(let a in i)if(!(a in t)){if(a==="children")continue;i[a]=Zu(e,a,null,i[a],n,r,t);}for(let a in t){if(a==="children"){continue}let u=t[a];i[a]=Zu(e,a,u,i[a],n,r,t);}}function Wo(e){return !!Me.context&&!Me.done&&(!e||e.isConnected)}function Ub(e){return e.toLowerCase().replace(/-([a-z])/g,(t,n)=>n.toUpperCase())}function qu(e,t,n){let o=t.trim().split(/\s+/);for(let i=0,r=o.length;im===e))return;let t=e.target,n=`$$${e.type}`,o=e.target,i=e.currentTarget,r=c=>Object.defineProperty(e,"target",{configurable:true,value:c}),a=()=>{let c=t[n];if(c&&!t.disabled){let m=t[`${n}Data`];if(m!==void 0?c.call(t,m,e):c.call(t,e),e.cancelBubble)return}return t.host&&typeof t.host!="string"&&!t.host._$host&&t.contains(e.target)&&r(t.host),true},u=()=>{for(;a()&&(t=t._$host||t.parentNode||t.host););};if(Object.defineProperty(e,"currentTarget",{configurable:true,get(){return t||document}}),Me.registry&&!Me.done&&(Me.done=_$HY.done=true),e.composedPath){let c=e.composedPath();r(c[0]);for(let m=0;m{let c=t();for(;typeof c=="function";)c=c();n=Ko(e,c,n,o);}),()=>n;if(Array.isArray(t)){let c=[],m=n&&Array.isArray(n);if(Va(c,t,n,i))return K(()=>n=Ko(e,c,n,o,true)),()=>n;if(r){if(!c.length)return n;if(o===void 0)return n=[...e.childNodes];let l=c[0];if(l.parentNode!==e)return n;let d=[l];for(;(l=l.nextSibling)!==o;)d.push(l);return n=d}if(c.length===0){if(n=jo(e,n,o),u)return n}else m?n.length===0?Ju(e,c,o):$b(e,n,c):(n&&jo(e),Ju(e,c));n=c;}else if(t.nodeType){if(r&&t.parentNode)return n=u?[t]:t;if(Array.isArray(n)){if(u)return n=jo(e,n,o,t);jo(e,n,null,t);}else n==null||n===""||!e.firstChild?e.appendChild(t):e.replaceChild(t,e.firstChild);n=t;}}return n}function Va(e,t,n,o){let i=false;for(let r=0,a=t.length;r=0;a--){let u=t[a];if(i!==u){let c=u.parentNode===e;!r&&!a?c?e.replaceChild(i,u):e.insertBefore(i,n):c&&u.remove();}else r=true;}}else e.insertBefore(i,n);return [i]}var Rb,kb,Nb,Mb,Db,Fb,ke,Yu,w=Y(()=>{Ke();Ke();Rb=["allowfullscreen","async","alpha","autofocus","autoplay","checked","controls","default","disabled","formnovalidate","hidden","indeterminate","inert","ismap","loop","multiple","muted","nomodule","novalidate","open","playsinline","readonly","required","reversed","seamless","selected","adauctionheaders","browsingtopics","credentialless","defaultchecked","defaultmuted","defaultselected","defer","disablepictureinpicture","disableremoteplayback","preservespitch","shadowrootclonable","shadowrootcustomelementregistry","shadowrootdelegatesfocus","shadowrootserializable","sharedstoragewritable"],kb=new Set(["className","value","readOnly","noValidate","formNoValidate","isMap","noModule","playsInline","adAuctionHeaders","allowFullscreen","browsingTopics","defaultChecked","defaultMuted","defaultSelected","disablePictureInPicture","disableRemotePlayback","preservesPitch","shadowRootClonable","shadowRootCustomElementRegistry","shadowRootDelegatesFocus","shadowRootSerializable","sharedStorageWritable",...Rb]),Nb=new Set(["innerHTML","textContent","innerText","children"]),Mb=Object.assign(Object.create(null),{className:"class",htmlFor:"for"}),Db=Object.assign(Object.create(null),{class:"className",novalidate:{$:"noValidate",FORM:1},formnovalidate:{$:"formNoValidate",BUTTON:1,INPUT:1},ismap:{$:"isMap",IMG:1},nomodule:{$:"noModule",SCRIPT:1},playsinline:{$:"playsInline",VIDEO:1},readonly:{$:"readOnly",INPUT:1,TEXTAREA:1},adauctionheaders:{$:"adAuctionHeaders",IFRAME:1},allowfullscreen:{$:"allowFullscreen",IFRAME:1},browsingtopics:{$:"browsingTopics",IMG:1},defaultchecked:{$:"defaultChecked",INPUT:1},defaultmuted:{$:"defaultMuted",AUDIO:1,VIDEO:1},defaultselected:{$:"defaultSelected",OPTION:1},disablepictureinpicture:{$:"disablePictureInPicture",VIDEO:1},disableremoteplayback:{$:"disableRemotePlayback",AUDIO:1,VIDEO:1},preservespitch:{$:"preservesPitch",AUDIO:1,VIDEO:1},shadowrootclonable:{$:"shadowRootClonable",TEMPLATE:1},shadowrootdelegatesfocus:{$:"shadowRootDelegatesFocus",TEMPLATE:1},shadowrootserializable:{$:"shadowRootSerializable",TEMPLATE:1},sharedstoragewritable:{$:"sharedStorageWritable",IFRAME:1,IMG:1}});Fb=new Set(["beforeinput","click","dblclick","contextmenu","focusin","focusout","input","keydown","keyup","mousedown","mousemove","mouseout","mouseover","mouseup","pointerdown","pointermove","pointerout","pointerover","pointerup","touchend","touchmove","touchstart"]),ke=e=>ne(()=>e());Yu="_$DX_DELEGATE";});var Ir,ls,Ga=Y(()=>{Ir=null,ls=()=>{if(Ir!==null)return Ir;try{Ir=window.matchMedia("(color-gamut: p3)").matches;}catch{Ir=false;}return Ir};});var qb,Zb,Jb,Ln,sd=Y(()=>{Ga();qb=ls(),Zb="210, 57, 192",Jb="0.84 0.19 0.78",Ln=e=>qb?`color(display-p3 ${Jb} / ${e})`:`rgba(${Zb}, ${e})`;});var ad,Fn,qt,ja,gt,po,ld,cd,ud,dd,fd,md,Ka,qo,Zo,pd,gd,hd,bd,yd,Rr,wd,vd,xd,Ed,Cd,Wa,kr,Nr,Jo,_t,cs,Sd,Mr,Xa,Ya,qa,Ad,Td,_d,Pd,Od,Id,Rd,kd,Bn,Nd,Md,Za,Ja,Zt,Dr,Dd,Ld,Qa,Fd,el,Lr,tl,Fr,Bd,$d,Hd,Vd,Se,nl,zd,Ud,ol,go,rl,Cn,Gd,jd,Kd,Gt,Jt,Wd,il,Xd,Yd,Br,us,qd,sl,$r,Hr,al,Ft,ll,Zd,Jd,Qd,cl,ef,tf,nf,ul,of,rf,sf,af,dl,Vr,lf,cn,fl,cf,uf,df,ho,$n,ff,Hn,ds,ml,pl,Re=Y(()=>{sd();ad="0.1.29",Fn=8,qt=-1e3,ja=.95,gt=1500,po=100,ld=150,cd=50,ud=200,dd=500,fd=200,md=400,Ka=600,qo=100,Zo=3,pd=200,gd=1e4,hd=200,bd=120,yd=600,Rr=2,wd=32,vd=200,xd=100,Ed=32,Cd=16,Wa=100,kr=25,Nr=10,Jo=2147483647,_t=2147483647,cs=2147483645,Sd=.7,Mr=.5,Xa=.01,Ya=100,qa=2,Ad=Ln(.4),Td=Ln(.05),_d=Ln(.5),Pd=Ln(.08),Od=Ln(.3),Id=Ln(.04),Rd=Ln(.15),kd=50,Bn=8,Nd=4,Md=.2,Za=50,Ja=16,Zt=4,Dr=100,Dd=15,Ld=3,Qa=["id","class","aria-label","data-testid","role","name","title"],Fd=["Meta","Control","Shift","Alt"],el=new Set(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"]),Lr="data-react-grab-frozen",tl="data-react-grab-ignore",Fr=.9,Bd=1e3,$d=2147483600,Hd=400,Vd=100,Se=16,nl=500,zd=300,Ud=5,ol=150,go=14,rl=28,Cn=150,Gd=50,jd=78,Kd=28,Gt=.5,Jt="comment",Wd=1500,il=0,Xd=3e3,Yd=3,Br="absolute whitespace-nowrap px-1.5 py-0.5 rounded-[10px] text-[10px] text-black/60 pointer-events-none [corner-shape:superellipse(1.25)] filter-[drop-shadow(0px_1px_2px_#51515140)]",us="animate-[hint-flip-in_var(--transition-normal)_ease-out]",qd=.75,sl=32,$r=3,Hr=20,al=100,Ft=1,ll=50,Zd=50,Jd=6,Qd=3,cl=2,ef=16,tf=100,nf=50,ul=.01,of=1e3,rf=20,sf=2*1024*1024,af=100,dl=200,Vr=8,lf=8,cn=8,fl=11,cf=180,uf=280,df=100,ho={left:-9999,top:-9999},$n={left:"left center",right:"right center",top:"center top",bottom:"center bottom"},ff=1e3,Hn=95,ds=229,ml=-9999,pl=new Set(["display","position","top","right","bottom","left","z-index","overflow","overflow-x","overflow-y","width","height","min-width","min-height","max-width","max-height","margin-top","margin-right","margin-bottom","margin-left","padding-top","padding-right","padding-bottom","padding-left","flex-direction","flex-wrap","justify-content","align-items","align-self","align-content","flex-grow","flex-shrink","flex-basis","order","gap","row-gap","column-gap","grid-template-columns","grid-template-rows","grid-template-areas","font-family","font-size","font-weight","font-style","line-height","letter-spacing","text-align","text-decoration-line","text-decoration-style","text-transform","text-overflow","text-shadow","white-space","word-break","overflow-wrap","vertical-align","color","background-color","background-image","background-position","background-size","background-repeat","border-top-width","border-right-width","border-bottom-width","border-left-width","border-top-style","border-right-style","border-bottom-style","border-left-style","border-top-color","border-right-color","border-bottom-color","border-left-color","border-top-left-radius","border-top-right-radius","border-bottom-left-radius","border-bottom-right-radius","box-shadow","opacity","transform","filter","backdrop-filter","object-fit","object-position"]);});var We,Qo=Y(()=>{We=e=>!!(e?.isConnected??e?.ownerDocument?.contains(e));});var Qe,er=Y(()=>{Qe=e=>(e.tagName||"").toLowerCase();});var ry,iy,ht,wf,vf,bo=Y(()=>{er();ry=["input","textarea","select","searchbox","slider","spinbutton","menuitem","menuitemcheckbox","menuitemradio","option","radio","textbox","combobox"],iy=e=>{if(e.composed){let t=e.composedPath()[0];if(t instanceof HTMLElement)return t}else if(e.target instanceof HTMLElement)return e.target},ht=e=>{if(document.designMode==="on")return true;let t=iy(e);if(!t)return false;if(t.isContentEditable)return true;let n=Qe(t);return ry.some(o=>o===n||o===t.role)},wf=e=>{let t=e.target;if(t instanceof HTMLInputElement||t instanceof HTMLTextAreaElement){let n=t.selectionStart??0;return (t.selectionEnd??0)-n>0}return false},vf=()=>{let e=window.getSelection();return e?e.toString().length>0:false};});var hl,ly,cy,Be,qe,Cf,un=Y(()=>{hl=typeof window<"u",ly=e=>0,cy=e=>{},Be=hl?(Object.getOwnPropertyDescriptor(Window.prototype,"requestAnimationFrame")?.value??window.requestAnimationFrame).bind(window):ly,qe=hl?(Object.getOwnPropertyDescriptor(Window.prototype,"cancelAnimationFrame")?.value??window.cancelAnimationFrame).bind(window):cy,Cf=()=>hl?new Promise(e=>Be(()=>e())):Promise.resolve();});var bl,ps,Sf,uy,zr,Tf,gs,_f,Af,Ur,ms,Vn,yl,Gr,wl,zn,vl,hs,jr=Y(()=>{bl="0.5.32",ps=`bippy-${bl}`,Sf=Object.defineProperty,uy=Object.prototype.hasOwnProperty,zr=()=>{},Tf=e=>{try{Function.prototype.toString.call(e).indexOf("^_^")>-1&&setTimeout(()=>{throw Error("React is running in production mode, but dead code elimination has not been applied. Read how to correctly configure React for production: https://reactjs.org/link/perf-use-production-build")});}catch{}},gs=(e=globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__)=>!!(e&&"getFiberRoots"in e),_f=false,Ur=(e=globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__)=>_f?true:(e&&typeof e.inject=="function"&&(Af=e.inject.toString()),!!Af?.includes("(injected)")),ms=new Set,Vn=new Set,yl=e=>{let t=new Map,n=0,o={_instrumentationIsActive:false,_instrumentationSource:ps,checkDCE:Tf,hasUnsupportedRendererAttached:false,inject(i){let r=++n;return t.set(r,i),Vn.add(i),o._instrumentationIsActive||(o._instrumentationIsActive=true,ms.forEach(a=>a())),r},on:zr,onCommitFiberRoot:zr,onCommitFiberUnmount:zr,onPostCommitFiberRoot:zr,renderers:t,supportsFiber:true,supportsFlight:true};try{Sf(globalThis,"__REACT_DEVTOOLS_GLOBAL_HOOK__",{configurable:!0,enumerable:!0,get(){return o},set(a){if(a&&typeof a=="object"){let u=o.renderers;o=a,u.size>0&&(u.forEach((c,m)=>{Vn.add(c),a.renderers.set(m,c);}),Gr(e));}}});let i=window.hasOwnProperty,r=!1;Sf(window,"hasOwnProperty",{configurable:!0,value:function(...a){try{if(!r&&a[0]==="__REACT_DEVTOOLS_GLOBAL_HOOK__")return globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__=void 0,r=!0,-0}catch{}return i.apply(this,a)},writable:!0});}catch{Gr(e);}return o},Gr=e=>{e&&ms.add(e);try{let t=globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;if(!t)return;if(!t._instrumentationSource){t.checkDCE=Tf,t.supportsFiber=!0,t.supportsFlight=!0,t.hasUnsupportedRendererAttached=!1,t._instrumentationSource=ps,t._instrumentationIsActive=!1;let n=gs(t);if(n||(t.on=zr),t.renderers.size){t._instrumentationIsActive=!0,ms.forEach(r=>r());return}let o=t.inject,i=Ur(t);i&&!n&&(_f=!0,t.inject({scheduleRefresh(){}})&&(t._instrumentationIsActive=!0)),t.inject=r=>{let a=o(r);return Vn.add(r),i&&t.renderers.set(a,r),t._instrumentationIsActive=!0,ms.forEach(u=>u()),a};}(t.renderers.size||t._instrumentationIsActive||Ur())&&e?.();}catch{}},wl=()=>uy.call(globalThis,"__REACT_DEVTOOLS_GLOBAL_HOOK__"),zn=e=>wl()?(Gr(e),globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__):yl(e),vl=()=>!!(typeof window<"u"&&(window.document?.createElement||window.navigator?.product==="ReactNative")),hs=()=>{try{vl()&&zn();}catch{}};});var Pf=Y(()=>{jr();hs();});function yo(e,t,n=false){if(!e)return null;let o=t(e);if(o instanceof Promise)return (async()=>{if(await o===true)return e;let r=n?e.return:e.child;for(;r;){let a=await Ml(r,t,n);if(a)return a;r=n?null:r.sibling;}return null})();if(o===true)return e;let i=n?e.return:e.child;for(;i;){let r=Nl(i,t,n);if(r)return r;i=n?null:i.sibling;}return null}var xl,El,Cl,Sl,Al,Tl,_l,Pl,Ol,Il,Rl,kl,Un,Nl,Ml,Dl,dn;exports.isInstrumentationActive=void 0;var en,Ll,Fl=Y(()=>{jr();xl=0,El=1,Cl=5,Sl=11,Al=13,Tl=15,_l=16,Pl=19,Ol=26,Il=27,Rl=28,kl=30,Un=e=>{switch(e.tag){case 1:case 11:case 0:case 14:case 15:return true;default:return false}};Nl=(e,t,n=false)=>{if(!e)return null;if(t(e)===true)return e;let o=n?e.return:e.child;for(;o;){let i=Nl(o,t,n);if(i)return i;o=n?null:o.sibling;}return null},Ml=async(e,t,n=false)=>{if(!e)return null;if(await t(e)===true)return e;let o=n?e.return:e.child;for(;o;){let i=await Ml(o,t,n);if(i)return i;o=n?null:o.sibling;}return null},Dl=e=>{let t=e;return typeof t=="function"?t:typeof t=="object"&&t?Dl(t.type||t.render):null},dn=e=>{let t=e;if(typeof t=="string")return t;if(typeof t!="function"&&!(typeof t=="object"&&t))return null;let n=t.displayName||t.name||null;if(n)return n;let o=Dl(t);return o&&(o.displayName||o.name)||null},exports.isInstrumentationActive=()=>{let e=globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;return !!e?._instrumentationIsActive||gs(e)||Ur(e)},en=e=>{let t=globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;if(t?.renderers)for(let n of t.renderers.values())try{let o=n.findFiberByHostInstance?.(e);if(o)return o}catch{}if(typeof e=="object"&&e){if("_reactRootContainer"in e)return e._reactRootContainer?._internalRoot?.current?.child;for(let n in e)if(n.startsWith("__reactContainer$")||n.startsWith("__reactInternalInstance$")||n.startsWith("__reactFiber"))return e[n]||null}return null},Ll=new Set;});var Kr=Y(()=>{jr();Pf();Fl();});function Wr(e,t){let n=0,o=0,i=0;do i=zf[e.next()],n|=(i&31)<>>=1,r&&(n=-2147483648|-n),t+n}function Nf(e,t){return e.pos>=t?false:e.peek()!==yy}function Uf(e){let{length:t}=e,n=new vy(e),o=[],i=0,r=0,a=0,u=0,c=0;do{let m=n.indexOf(";"),l=[],d=true,g=0;for(i=0;n.pos{jr();Fl();Of=/^[a-zA-Z][a-zA-Z\d+\-.]*:/,fy=["rsc://","file:///","webpack://","webpack-internal://","node:","turbopack://","metro://","/app-pages-browser/","/(app-pages-browser)/"],my=["","eval",""],Bf=/\.(jsx|tsx|ts|js)$/,py=/(\.min|bundle|chunk|vendor|vendors|runtime|polyfill|polyfills)\.(js|mjs|cjs)$|(chunk|bundle|vendor|vendors|runtime|polyfill|polyfills|framework|app|main|index)[-_.][A-Za-z0-9_-]{4,}\.(js|mjs|cjs)$|[\da-f]{8,}\.(js|mjs|cjs)$|[-_.][\da-f]{20,}\.(js|mjs|cjs)$|\/dist\/|\/build\/|\/.next\/|\/out\/|\/node_modules\/|\.webpack\.|\.vite\.|\.turbopack\./i,gy=/^\?[\w~.-]+(?:=[^&#]*)?(?:&[\w~.-]+(?:=[^&#]*)?)*$/,$f="(at Server)",hy=/(^|@)\S+:\d+/,Hf=/^\s*at .*(\S+:\d+|\(native\))/m,by=/^(eval@)?(\[native code\])?$/,ys=(e,t)=>{if(t?.includeInElement!==false){let n=e.split(` +`),o=[];for(let i of n)if(/^\s*at\s+/.test(i)){let r=If(i,void 0)[0];r&&o.push(r);}else if(/^\s*in\s+/.test(i)){let r=i.replace(/^\s*in\s+/,"").replace(/\s*\(at .*\)$/,"");o.push({functionName:r,source:i});}else if(i.match(hy)){let r=Rf(i,void 0)[0];r&&o.push(r);}return Hl(o,t)}return e.match(Hf)?If(e,t):Rf(e,t)},Vf=e=>{if(!e.includes(":"))return [e,void 0,void 0];let t=e.startsWith("(")&&/:\d+\)$/.test(e)?e.slice(1,-1):e,n=/(.+?)(?::(\d+))?(?::(\d+))?$/.exec(t);return n?[n[1],n[2]||void 0,n[3]||void 0]:[t,void 0,void 0]},Hl=(e,t)=>t&&t.slice!=null?Array.isArray(t.slice)?e.slice(t.slice[0],t.slice[1]):e.slice(0,t.slice):e,If=(e,t)=>Hl(e.split(` +`).filter(n=>!!n.match(Hf)),t).map(n=>{let o=n;o.includes("(eval ")&&(o=o.replace(/eval code/g,"eval").replace(/(\(eval at [^()]*)|(,.*$)/g,""));let i=o.replace(/^\s+/,"").replace(/\(eval code/g,"(").replace(/^.*?\s+/,""),r=i.match(/ (\(.+\)$)/);i=r?i.replace(r[0],""):i;let a=Vf(r?r[1]:i);return {functionName:r&&i||void 0,fileName:["eval",""].includes(a[0])?void 0:a[0],lineNumber:a[1]?+a[1]:void 0,columnNumber:a[2]?+a[2]:void 0,source:o}}),Rf=(e,t)=>Hl(e.split(` +`).filter(n=>!n.match(by)),t).map(n=>{let o=n;if(o.includes(" > eval")&&(o=o.replace(/ line (\d+)(?: > eval line \d+)* > eval:\d+:\d+/g,":$1")),!o.includes("@")&&!o.includes(":"))return {functionName:o};{let i=/(([^\n\r"\u2028\u2029]*".[^\n\r"\u2028\u2029]*"[^\n\r@\u2028\u2029]*(?:@[^\n\r"\u2028\u2029]*"[^\n\r@\u2028\u2029]*)*(?:[\n\r\u2028\u2029][^@]*)?)?[^@]*)@/,r=o.match(i),a=r&&r[1]?r[1]:void 0,u=Vf(o.replace(i,""));return {functionName:a,fileName:u[0],lineNumber:u[1]?+u[1]:void 0,columnNumber:u[2]?+u[2]:void 0,source:o}}}),yy=44,kf="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",wy=new Uint8Array(64),zf=new Uint8Array(128);for(let e=0;ejf&&e instanceof WeakRef,Mf=(e,t,n,o)=>{if(n<0||n>=e.length)return null;let i=e[n];if(!i||i.length===0)return null;let r=null;for(let l of i)if(l[0]<=o)r=l;else break;if(!r||r.length<4)return null;let[,a,u,c]=r;if(a===void 0||u===void 0||c===void 0)return null;let m=t[a];return m?{columnNumber:c,fileName:m,lineNumber:u+1}:null},Ty=(e,t,n)=>{if(e.sections){let o=null;for(let a of e.sections)if(t>a.offset.line||t===a.offset.line&&n>=a.offset.column)o=a;else break;if(!o)return null;let i=t-o.offset.line,r=t===o.offset.line?n-o.offset.column:n;return Mf(o.map.mappings,o.map.sources,i,r)}return Mf(e.mappings,e.sources,t-1,n)},_y=(e,t)=>{let n=t.split(` +`),o;for(let r=n.length-1;r>=0&&!o;r--){let a=n[r].match(Sy);a&&(o=a[1]||a[2]);}if(!o)return null;let i=Gf.test(o);if(!(Cy.test(o)||i||o.startsWith("/"))){let r=e.split("/");r[r.length-1]=o,o=r.join("/");}return o},Py=e=>({file:e.file,mappings:Uf(e.mappings),names:e.names,sourceRoot:e.sourceRoot,sources:e.sources,sourcesContent:e.sourcesContent,version:3}),Oy=e=>{let t=e.sections.map(({map:o,offset:i})=>({map:{...o,mappings:Uf(o.mappings)},offset:i})),n=new Set;for(let o of t)for(let i of o.map.sources)n.add(i);return {file:e.file,mappings:[],names:[],sections:t,sourceRoot:void 0,sources:Array.from(n),sourcesContent:void 0,version:3}},Df=e=>{if(!e)return false;let t=e.trim();if(!t)return false;let n=t.match(Gf);if(!n)return true;let o=n[0].toLowerCase();return o==="http:"||o==="https:"},Iy=async(e,t=fetch)=>{if(!Df(e))return null;let n;try{let i=await t(e);if(!i.ok)return null;n=await i.text();}catch{return null}if(!n)return null;let o=_y(e,n);if(!o||!Df(o))return null;try{let i=await t(o);if(!i.ok)return null;let r=await i.json();return "sections"in r?Oy(r):Py(r)}catch{return null}},Ry=async(e,t=true,n)=>{if(t&&Xr.has(e)){let r=Xr.get(e);if(r==null)return null;if(Ay(r)){let a=r.deref();if(a)return a;Xr.delete(e);}else return r}if(t&&bs.has(e))return bs.get(e);let o=Iy(e,n);t&&bs.set(e,o);let i=await o;return t&&bs.delete(e),t&&(i===null?Xr.set(e,null):Xr.set(e,jf?new WeakRef(i):i)),i},ky=async(e,t=true,n)=>await Promise.all(e.map(async o=>{if(!o.fileName)return o;let i=await Ry(o.fileName,t,n);if(!i||typeof o.lineNumber!="number"||typeof o.columnNumber!="number")return o;let r=Ty(i,o.lineNumber,o.columnNumber);return r?{...o,source:r.fileName&&o.source?o.source.replace(o.fileName,r.fileName):o.source,fileName:r.fileName,lineNumber:r.lineNumber,columnNumber:r.columnNumber,isSymbolicated:true}:o})),Vl=e=>e._debugStack instanceof Error&&typeof e._debugStack?.stack=="string",Ny=()=>{let e=zn();for(let t of [...Array.from(Vn),...Array.from(e.renderers.values())]){let n=t.currentDispatcherRef;if(n&&typeof n=="object")return "H"in n?n.H:n.current}return null},Lf=e=>{for(let t of Vn){let n=t.currentDispatcherRef;n&&typeof n=="object"&&("H"in n?n.H=e:n.current=e);}},Sn=e=>` + in ${e}`,My=(e,t)=>{let n=Sn(e);return t&&(n+=` (at ${t})`),n},Bl=false,$l=(e,t)=>{if(!e||Bl)return "";let n=Error.prepareStackTrace;Error.prepareStackTrace=void 0,Bl=true;let o=Ny();Lf(null);let i=console.error,r=console.warn;console.error=()=>{},console.warn=()=>{};try{let u={DetermineComponentFrameRoot(){let l;try{if(t){let d=function(){throw Error()};if(Object.defineProperty(d.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(d,[]);}catch(g){l=g;}Reflect.construct(e,[],d);}else {try{d.call();}catch(g){l=g;}e.call(d.prototype);}}else {try{throw Error()}catch(g){l=g;}let d=e();d&&typeof d.catch=="function"&&d.catch(()=>{});}}catch(d){if(d instanceof Error&&l instanceof Error&&typeof d.stack=="string")return [d.stack,l.stack]}return [null,null]}};u.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot",Object.getOwnPropertyDescriptor(u.DetermineComponentFrameRoot,"name")?.configurable&&Object.defineProperty(u.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});let[c,m]=u.DetermineComponentFrameRoot();if(c&&m){let l=c.split(` `),d=m.split(` -`),h=0,y=0;for(;h=1&&y>=0&&c[h]!==d[y];)y--;for(;h>=1&&y>=0;h--,y--)if(c[h]!==d[y]){if(h!==1||y!==1)do if(h--,y--,y<0||c[h]!==d[y]){let M=` -${c[h].replace(" at new "," at ")}`,E=sn(e);return E&&M.includes("")&&(M=M.replace("",E)),M}while(h>=1&&y>=0);break}}}finally{xl=false,Error.prepareStackTrace=n,uf(o),console.error=i,console.warn=r;}let a=e?sn(e):"";return a?wn(a):""},Xb=(e,t)=>{let n=e.tag,o="";switch(n){case pl:o=wn("Activity");break;case il:o=El(e.type,true);break;case al:o=El(e.type.render,false);break;case rl:case cl:o=El(e.type,false);break;case sl:case fl:case ml:o=wn(e.type);break;case ul:o=wn("Lazy");break;case ll:o=e.child!==t&&t!==null?wn("Suspense Fallback"):wn("Suspense");break;case dl:o=wn("SuspenseList");break;case gl:o=wn("ViewTransition");break;default:return ""}return o},Yb=e=>{try{let t="",n=e,o=null;do{t+=Xb(n,o);let i=n._debugInfo;if(i&&Array.isArray(i))for(let r=i.length-1;r>=0;r--){let a=i[r];typeof a.name=="string"&&(t+=Wb(a.name,a.env));}o=n,n=n.return;}while(n);return t}catch(t){return t instanceof Error?` +`),g=0,b=0;for(;g=1&&b>=0&&l[g]!==d[b];)b--;for(;g>=1&&b>=0;g--,b--)if(l[g]!==d[b]){if(g!==1||b!==1)do if(g--,b--,b<0||l[g]!==d[b]){let N=` +${l[g].replace(" at new "," at ")}`,k=dn(e);return k&&N.includes("")&&(N=N.replace("",k)),N}while(g>=1&&b>=0);break}}}finally{Bl=false,Error.prepareStackTrace=n,Lf(o),console.error=i,console.warn=r;}let a=e?dn(e):"";return a?Sn(a):""},Dy=(e,t)=>{let n=e.tag,o="";switch(n){case Rl:o=Sn("Activity");break;case El:o=$l(e.type,true);break;case Sl:o=$l(e.type.render,false);break;case xl:case Tl:o=$l(e.type,false);break;case Cl:case Ol:case Il:o=Sn(e.type);break;case _l:o=Sn("Lazy");break;case Al:o=e.child!==t&&t!==null?Sn("Suspense Fallback"):Sn("Suspense");break;case Pl:o=Sn("SuspenseList");break;case kl:o=Sn("ViewTransition");break;default:return ""}return o},Ly=e=>{try{let t="",n=e,o=null;do{t+=Dy(n,o);let i=n._debugInfo;if(i&&Array.isArray(i))for(let r=i.length-1;r>=0;r--){let a=i[r];typeof a.name=="string"&&(t+=My(a.name,a.env));}o=n,n=n.return;}while(n);return t}catch(t){return t instanceof Error?` Error generating stack: ${t.message} -${t.stack}`:""}},Al=e=>{let t=Error.prepareStackTrace;Error.prepareStackTrace=void 0;let n=e;if(!n)return "";Error.prepareStackTrace=t,n.startsWith(`Error: react-stack-top-frame +${t.stack}`:""}},zl=e=>{let t=Error.prepareStackTrace;Error.prepareStackTrace=void 0;let n=e;if(!n)return "";Error.prepareStackTrace=t,n.startsWith(`Error: react-stack-top-frame `)&&(n=n.slice(29));let o=n.indexOf(` `);if(o!==-1&&(n=n.slice(o+1)),o=Math.max(n.indexOf("react_stack_bottom_frame"),n.indexOf("react-stack-bottom-frame")),o!==-1&&(o=n.lastIndexOf(` -`,o)),o!==-1)n=n.slice(0,o);else return "";return n},qb=e=>!!(e.fileName?.startsWith("rsc://")&&e.functionName),Zb=(e,t)=>e.fileName===t.fileName&&e.lineNumber===t.lineNumber&&e.columnNumber===t.columnNumber,Jb=e=>{let t=new Map;for(let n of e)for(let o of n.stackFrames){if(!qb(o))continue;let i=o.functionName,r=t.get(i)??[];r.some(a=>Zb(a,o))||(r.push(o),t.set(i,r));}return t},Qb=(e,t,n)=>{if(!e.functionName)return {...e,isServer:true};let o=t.get(e.functionName);if(!o||o.length===0)return {...e,isServer:true};let i=n.get(e.functionName)??0,r=o[i%o.length];return n.set(e.functionName,i+1),{...e,isServer:true,fileName:r.fileName,lineNumber:r.lineNumber,columnNumber:r.columnNumber,source:e.source?.replace(mf,`(${r.fileName}:${r.lineNumber}:${r.columnNumber})`)}},ey=e=>{let t=[];return oo(e,n=>{if(!Sl(n))return;let o=typeof n.type=="string"?n.type:sn(n.type)||"";t.push({componentName:o,stackFrames:rs(Al(n._debugStack?.stack))});},true),t},vf=async(e,t=true,n)=>{let o=ey(e),i=rs(Yb(e)),r=Jb(o),a=new Map;return jb(i.map(u=>u.source?.includes(mf)??false?Qb(u,r,a):u).filter((u,l,m)=>{if(l===0)return true;let c=m[l-1];return u.functionName!==c.functionName}),t,n)},df=e=>e.split("/").filter(Boolean).length,ty=e=>e.split("/").filter(Boolean)[0]??null,ny=e=>{let t=e.indexOf("/",1);if(t===-1||df(e.slice(0,t))!==1)return e;let n=e.slice(t);if(!ff.test(n)||df(n)<2)return e;let o=ty(n);return !o||o.startsWith("@")||o.length>4?e:n},Gr=e=>{if(!e||Tb.some(r=>r===e))return "";let t=e,n=t.startsWith("http://")||t.startsWith("https://");if(n)try{t=new URL(t).pathname;}catch{}if(n&&(t=ny(t)),t.startsWith("about://React/")){let r=t.slice(14),a=r.indexOf("/"),u=r.indexOf(":");t=a!==-1&&(u===-1||a{let t=Gr(e);return !(!t||!ff.test(t)||_b.test(t))};});var oy,ry,Ef,iy,Of,sy,ay,ly,cy,uy,dy,_l,Go,If,Rf,fy,kf,my,py,gy,hy,by,Nf,Cf,yy;exports.getStack=void 0;var wy,vy,xy,Pl,io,ro,is,Ey,Cy,Sy,Ay,Ty,_y,Py,Sf,Af,Mf,Df,Oy,Iy,Lf,Ry,Ff,Bf,ky,Ny,My,Dy,Ly,Fy,By,$y,Hy,$f,Vy,zy,Uy,Tf,_f,Gy,jy,Ky,Wy,Xy,Yy,qy,Zy,Jy,Qy,ew,tw,nw,ow,Pf,rw,ss,Kr,Hf,so,iw,sw,Vf,Ol=X(()=>{Vr();Tl();oy=5e3,ry=4e3,Ef=ry/2,iy=3,Of=1,sy=1,ay=3,ly=1,cy=["_","$","motion.","styled.","chakra.","ark.","Primitive.","Slot."],uy=new Set(["InnerLayoutRouter","RedirectErrorBoundary","RedirectBoundary","HTTPAccessFallbackErrorBoundary","HTTPAccessFallbackBoundary","LoadingBoundary","ErrorBoundary","InnerScrollAndFocusHandler","ScrollAndFocusHandler","RenderFromTemplateContext","OuterLayoutRouter","body","html","DevRootHTTPAccessFallbackBoundary","AppDevOverlayErrorBoundary","AppDevOverlay","HotReload","Router","ErrorBoundaryHandler","AppRouter","ServerRoot","SegmentStateProvider","RootErrorBoundary","LoadableComponent","MotionDOMComponent"]),dy=new Set(["Suspense","Fragment","StrictMode","Profiler","SuspenseList"]),Go=e=>(e&&(_l=void 0),_l??=typeof document<"u"&&!!(document.getElementById("__NEXT_DATA__")||document.querySelector("nextjs-portal")),_l),If=e=>uy.has(e)||dy.has(e)?true:cy.some(t=>e.startsWith(t)),Rf=e=>!(e.length<=ly||If(e)||e[0]!==e[0].toUpperCase()||e.includes("Provider")||e.includes("Context")),fy=e=>!(!e||If(e)||e==="SlotClone"||e==="Slot"),kf=["about://React/","rsc://React/"],my=e=>kf.some(t=>e.startsWith(t)),py=e=>{for(let t of kf){if(!e.startsWith(t))continue;let n=e.indexOf("/",t.length),o=e.lastIndexOf("?");if(n>-1&&o>-1)return decodeURI(e.slice(n+1,o))}return e},gy=async e=>{let t=[],n=[];for(let r=0;r",line1:a.lineNumber??null,column1:a.columnNumber??null,arguments:[]}));}if(n.length===0)return e;let o=new AbortController,i=setTimeout(()=>o.abort(),oy);try{let r=await fetch("/__nextjs_original-stack-frames",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({frames:n,isServer:!0,isEdgeServer:!1,isAppDirectory:!0}),signal:o.signal});if(!r.ok)return e;let a=await r.json(),u=[...e];for(let l=0;l{let t=new Map;return oo(e,n=>{if(!Sl(n))return false;let o=Al(n._debugStack.stack);if(!o)return false;for(let i of rs(o))!i.functionName||!i.fileName||my(i.fileName)&&(t.has(i.functionName)||t.set(i.functionName,{...i,isServer:true}));return false},true),t},by=(e,t)=>{if(!t.some(i=>i.isServer&&!i.fileName&&i.functionName))return t;let o=hy(e);return o.size===0?t:t.map(i=>{if(!i.isServer||i.fileName||!i.functionName)return i;let r=o.get(i.functionName);return r?{...i,fileName:r.fileName,lineNumber:r.lineNumber,columnNumber:r.columnNumber}:i})},Nf=e=>{if(!exports.isInstrumentationActive())return e;let t=e;for(;t;){if(Xt(t))return t;t=t.parentElement;}return e},Cf=new WeakMap,yy=async e=>{try{let t=Xt(e);if(!t)return null;let n=await vf(t);if(Go()){let o=by(t,n);return gy(o)}return n}catch{return null}},exports.getStack=e=>{if(!exports.isInstrumentationActive())return Promise.resolve([]);let t=Nf(e),n=Cf.get(t);if(n)return n;let o=yy(t);return Cf.set(t,o),o},wy=e=>{if(!e||e.length===0)return null;for(let t of e)if(t.fileName&&xf(t.fileName))return {filePath:Gr(t.fileName),lineNumber:t.lineNumber??null,columnNumber:null,componentName:t.functionName&&Rf(t.functionName)?t.functionName:null};return null},vy=async e=>{let t=await exports.getStack(e),n=wy(t);return n?[n]:[]},xy=async e=>{if(!exports.isInstrumentationActive())return null;let t=await exports.getStack(e);if(t){for(let r of t)if(r.functionName&&Rf(r.functionName))return r.functionName}let n=Nf(e),o=Xt(n);if(!o)return null;let i=o.return;for(;i;){if(Fn(i)){let r=sn(i.type);if(r&&fy(r))return r}i=i.return;}return null},Pl={name:"react",resolveStack:vy,resolveComponentName:xy},io=e=>typeof e=="object"&&e!==null&&!Array.isArray(e),ro=e=>typeof e=="string"?e:null,is=e=>typeof e=="number"&&Number.isFinite(e)?e:null,Ey="__svelte_meta",Cy=e=>{let t=e;for(;t;){let n=Reflect.get(t,Ey);if(io(n))return n;t=t.parentElement;}return null},Sy=e=>{let t=e.loc;if(!io(t))return null;let n=ro(t.file),o=is(t.line),i=is(t.column);return !n||o===null||i===null?null:{filePath:n,lineNumber:o,columnNumber:i+Of}},Ay=e=>{let t=e.parent;for(;io(t);){let n=ro(t.componentTag);if(n)return n;t=t.parent;}return null},Ty=e=>{let t=[],n=e.parent;for(;io(n);){let o=ro(n.file),i=is(n.line),r=is(n.column),a=ro(n.componentTag);o&&i!==null&&r!==null&&t.push({filePath:o,lineNumber:i,columnNumber:r+Of,componentName:a}),n=n.parent;}return t},_y=e=>{let t=Cy(e);if(!t)return [];let n=Sy(t);if(!n)return [];let o=[{filePath:n.filePath,lineNumber:n.lineNumber,columnNumber:n.columnNumber,componentName:Ay(t)}],i=new Set([`${n.filePath}:${n.lineNumber}:${n.columnNumber}`]);for(let r of Ty(t)){let a=`${r.filePath}:${r.lineNumber??""}:${r.columnNumber??""}`;i.has(a)||(i.add(a),o.push(r));}return o},Py={name:"svelte",resolveStack:_y},Sf=":",Af=e=>{let t=Number.parseInt(e,10);return Number.isNaN(t)||t<1?null:t},Mf=e=>{let t=e.lastIndexOf(Sf);if(t===-1)return null;let n=e.lastIndexOf(Sf,t-1);if(n===-1)return null;let o=e.slice(0,n);if(!o)return null;let i=e.slice(n+1,t),r=e.slice(t+1),a=Af(i),u=Af(r);return a===null||u===null?null:{filePath:o,lineNumber:a,columnNumber:u}},Df="data-v-inspector",Oy=`[${Df}]`,Iy="__vueParentComponent",Lf=e=>{if(!e)return null;let t=e.type;return io(t)?t:null},Ry=e=>{let t=Reflect.get(e,Iy);return io(t)?t:null},Ff=e=>{let t=e;for(;t;){let n=Ry(t);if(n)return n;t=t.parentElement;}return null},Bf=e=>e?ro(e.__name)??ro(e.name):null,ky=e=>e?ro(e.__file):null,Ny=e=>{if(!e)return null;let t=Reflect.get(e,"parent");return io(t)?t:null},My=e=>{let t=[],n=Ff(e);for(;n;)t.push(n),n=Ny(n);return t},Dy=e=>My(e).map(t=>{let n=Lf(t),o=ky(n);return o?{filePath:o,lineNumber:null,columnNumber:null,componentName:Bf(n)}:null}).filter(t=>!!t),Ly=e=>{let t=e.closest(Oy);if(!t)return null;let n=t.getAttribute(Df);if(!n)return null;let o=Mf(n);if(!o)return null;let i=Ff(e),r=Lf(i);return {filePath:o.filePath,lineNumber:o.lineNumber,columnNumber:o.columnNumber,componentName:Bf(r)}},Fy=e=>{let t=[],n=new Set,o=Ly(e);if(o){let i=`${o.filePath}|${o.componentName??""}`;t.push(o),n.add(i);}for(let i of Dy(e)){let r=`${i.filePath}|${i.componentName??""}`;n.has(r)||(n.add(r),t.push(i));}return t},By={name:"vue",resolveStack:Fy},$y="$$",Hy=/location:\s*["']([^"']+:\d+:\d+)["']/g,$f="/src/",Vy=".css",zy="?import",Uy="__SOLID_RUNTIME_MODULES__",Tf=new Map,_f=new Map,Gy=e=>{if(e.includes(zy))return false;let t=new URL(e,window.location.href).pathname;return t.endsWith(Vy)?false:t.includes($f)},jy=()=>{if(typeof window>"u")return [];let e=performance.getEntriesByType("resource"),t=new Set;for(let n of e)!n.name||!Gy(n.name)||t.add(n.name);return Array.from(t)},Ky=e=>{let t=Tf.get(e);if(t)return t;let n=fetch(e).then(o=>o.ok?o.text():null).catch(()=>null);return Tf.set(e,n),n},Wy=()=>{if(typeof window>"u")return [];let e=Reflect.get(window,Uy);return Array.isArray(e)?e:[]},Xy=async e=>{for(let t of Wy()){let n=t.content.indexOf(e);if(n!==-1)return {moduleUrl:t.url,moduleContent:t.content,handlerSourceIndex:n}}for(let t of jy()){let n=await Ky(t);if(!n)continue;let o=n.indexOf(e);if(o!==-1)return {moduleUrl:t,moduleContent:n,handlerSourceIndex:o}}return null},Yy=(e,t)=>{let n=Math.max(0,t-Ef),o=Math.min(e.length,t+Ef),i=e.slice(n,o),r=[];for(let l of i.matchAll(Hy)){let m=l[1];if(!m)continue;let c=Mf(m);if(!c||l.index===void 0)continue;let d=n+l.index;r.push({sourceInfo:{filePath:c.filePath,lineNumber:c.lineNumber,columnNumber:c.columnNumber,componentName:null},distance:Math.abs(d-t)});}r.sort((l,m)=>{let c=l.sourceInfo.lineNumber??0,d=m.sourceInfo.lineNumber??0;return d!==c?d-c:l.distance-m.distance});let a=new Set,u=[];for(let l of r){let m=`${l.sourceInfo.filePath}:${l.sourceInfo.lineNumber}:${l.sourceInfo.columnNumber}`;a.has(m)||(a.add(m),u.push(l.sourceInfo));}return u},qy=e=>{try{let t=decodeURIComponent(new URL(e,window.location.href).pathname);return t.includes($f)?t.startsWith("/")?t.slice(1):t:null}catch{return null}},Zy=(e,t)=>{let o=e.slice(0,t).split(` -`),i=o[o.length-1]??"";return {lineNumber:o.length,columnNumber:i.length+sy}},Jy=e=>{let t=e;for(;t;){for(let n of Object.getOwnPropertyNames(t)){if(!n.startsWith($y))continue;let o=Reflect.get(t,n);if(typeof o!="function")continue;let i=String(o).trim();if(!(i.length{let t=_f.get(e);if(t)return t;let n=(async()=>{let o=await Xy(e);if(!o)return [];let i=Yy(o.moduleContent,o.handlerSourceIndex);if(i.length>0)return i;let r=qy(o.moduleUrl);if(!r)return [];let a=Zy(o.moduleContent,o.handlerSourceIndex);return [{filePath:r,lineNumber:a.lineNumber,columnNumber:a.columnNumber,componentName:null}]})();return _f.set(e,n),n},ew=e=>{let t=Jy(e);return t?Qy(t.source):Promise.resolve([])},tw={name:"solid",resolveStack:ew},nw=e=>(e.tagName||"").toLowerCase(),ow=[Py,By,tw],Pf=async(e,t)=>{for(let n of t){let i=(await n.resolveStack(e)).filter(r=>r.filePath.length>0);if(i.length>0)return i}return []},rw=(e={})=>{let t=e.resolvers??ow,n=async a=>{let u=await Pl.resolveStack(a),l=await Pf(a,t);return u.length>0?[...u,...l]:l};return {resolveSource:async a=>(await n(a))[0]??null,resolveStack:n,resolveComponentName:async a=>{let u=await Pl.resolveComponentName?.(a);return u||((await Pf(a,t)).find(c=>c.componentName)?.componentName??null)},resolveElementInfo:async a=>{let u=await n(a),l=u[0]??null,m=u.find(c=>c.componentName)?.componentName??await Pl.resolveComponentName?.(a)??null;return {tagName:nw(a),componentName:m,source:l,stack:u}}}},ss=rw(),Kr=ss.resolveSource,Hf=ss.resolveStack,so=ss.resolveComponentName,ss.resolveElementInfo,iw=e=>{let t=[e.filePath];return e.lineNumber!==null&&t.push(String(e.lineNumber)),e.columnNumber!==null&&t.push(String(e.columnNumber)),t.join(":")},sw=e=>{let t=iw(e);return e.componentName?` +`,o)),o!==-1)n=n.slice(0,o);else return "";return n},Fy=e=>!!(e.fileName?.startsWith("rsc://")&&e.functionName),By=(e,t)=>e.fileName===t.fileName&&e.lineNumber===t.lineNumber&&e.columnNumber===t.columnNumber,$y=e=>{let t=new Map;for(let n of e)for(let o of n.stackFrames){if(!Fy(o))continue;let i=o.functionName,r=t.get(i)??[];r.some(a=>By(a,o))||(r.push(o),t.set(i,r));}return t},Hy=(e,t,n)=>{if(!e.functionName)return {...e,isServer:true};let o=t.get(e.functionName);if(!o||o.length===0)return {...e,isServer:true};let i=n.get(e.functionName)??0,r=o[i%o.length];return n.set(e.functionName,i+1),{...e,isServer:true,fileName:r.fileName,lineNumber:r.lineNumber,columnNumber:r.columnNumber,source:e.source?.replace($f,`(${r.fileName}:${r.lineNumber}:${r.columnNumber})`)}},Vy=e=>{let t=[];return yo(e,n=>{if(!Vl(n))return;let o=typeof n.type=="string"?n.type:dn(n.type)||"";t.push({componentName:o,stackFrames:ys(zl(n._debugStack?.stack))});},true),t},Kf=async(e,t=true,n)=>{let o=Vy(e),i=ys(Ly(e)),r=$y(o),a=new Map;return ky(i.map(u=>u.source?.includes($f)??false?Hy(u,r,a):u).filter((u,c,m)=>{if(c===0)return true;let l=m[c-1];return u.functionName!==l.functionName}),t,n)},Ff=e=>e.split("/").filter(Boolean).length,zy=e=>e.split("/").filter(Boolean)[0]??null,Uy=e=>{let t=e.indexOf("/",1);if(t===-1||Ff(e.slice(0,t))!==1)return e;let n=e.slice(t);if(!Bf.test(n)||Ff(n)<2)return e;let o=zy(n);return !o||o.startsWith("@")||o.length>4?e:n},Yr=e=>{if(!e||my.some(r=>r===e))return "";let t=e,n=t.startsWith("http://")||t.startsWith("https://");if(n)try{t=new URL(t).pathname;}catch{}if(n&&(t=Uy(t)),t.startsWith("about://React/")){let r=t.slice(14),a=r.indexOf("/"),u=r.indexOf(":");t=a!==-1&&(u===-1||a{let t=Yr(e);return !(!t||!Bf.test(t)||py.test(t))};});var Gy,jy,Xf,Ky,tm,Wy,Xy,Yy,qy,Zy,Jy,Gl,tr,nm,om,Qy,rm,ew,tw,nw,ow,rw,im,Yf,iw;exports.getStack=void 0;var sw,aw,lw,jl,vo,wo,ws,cw,uw,dw,fw,mw,pw,gw,qf,Zf,sm,am,hw,bw,lm,yw,cm,um,ww,vw,xw,Ew,Cw,Sw,Aw,Tw,_w,dm,Pw,Ow,Iw,Jf,Qf,Rw,kw,Nw,Mw,Dw,Lw,Fw,Bw,$w,Hw,Vw,zw,Uw,Gw,em,jw,vs,Zr,fm,xo,Kw,Ww,mm,Kl=Y(()=>{Kr();Ul();Gy=5e3,jy=4e3,Xf=jy/2,Ky=3,tm=1,Wy=1,Xy=3,Yy=1,qy=["_","$","motion.","styled.","chakra.","ark.","Primitive.","Slot."],Zy=new Set(["InnerLayoutRouter","RedirectErrorBoundary","RedirectBoundary","HTTPAccessFallbackErrorBoundary","HTTPAccessFallbackBoundary","LoadingBoundary","ErrorBoundary","InnerScrollAndFocusHandler","ScrollAndFocusHandler","RenderFromTemplateContext","OuterLayoutRouter","body","html","DevRootHTTPAccessFallbackBoundary","AppDevOverlayErrorBoundary","AppDevOverlay","HotReload","Router","ErrorBoundaryHandler","AppRouter","ServerRoot","SegmentStateProvider","RootErrorBoundary","LoadableComponent","MotionDOMComponent"]),Jy=new Set(["Suspense","Fragment","StrictMode","Profiler","SuspenseList"]),tr=e=>(e&&(Gl=void 0),Gl??=typeof document<"u"&&!!(document.getElementById("__NEXT_DATA__")||document.querySelector("nextjs-portal")),Gl),nm=e=>Zy.has(e)||Jy.has(e)?true:qy.some(t=>e.startsWith(t)),om=e=>!(e.length<=Yy||nm(e)||e[0]!==e[0].toUpperCase()||e.includes("Provider")||e.includes("Context")),Qy=e=>!(!e||nm(e)||e==="SlotClone"||e==="Slot"),rm=["about://React/","rsc://React/"],ew=e=>rm.some(t=>e.startsWith(t)),tw=e=>{for(let t of rm){if(!e.startsWith(t))continue;let n=e.indexOf("/",t.length),o=e.lastIndexOf("?");if(n>-1&&o>-1)return decodeURI(e.slice(n+1,o))}return e},nw=async e=>{let t=[],n=[];for(let r=0;r",line1:a.lineNumber??null,column1:a.columnNumber??null,arguments:[]}));}if(n.length===0)return e;let o=new AbortController,i=setTimeout(()=>o.abort(),Gy);try{let r=await fetch("/__nextjs_original-stack-frames",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({frames:n,isServer:!0,isEdgeServer:!1,isAppDirectory:!0}),signal:o.signal});if(!r.ok)return e;let a=await r.json(),u=[...e];for(let c=0;c{let t=new Map;return yo(e,n=>{if(!Vl(n))return false;let o=zl(n._debugStack.stack);if(!o)return false;for(let i of ys(o))!i.functionName||!i.fileName||ew(i.fileName)&&(t.has(i.functionName)||t.set(i.functionName,{...i,isServer:true}));return false},true),t},rw=(e,t)=>{if(!t.some(i=>i.isServer&&!i.fileName&&i.functionName))return t;let o=ow(e);return o.size===0?t:t.map(i=>{if(!i.isServer||i.fileName||!i.functionName)return i;let r=o.get(i.functionName);return r?{...i,fileName:r.fileName,lineNumber:r.lineNumber,columnNumber:r.columnNumber}:i})},im=e=>{if(!exports.isInstrumentationActive())return e;let t=e;for(;t;){if(en(t))return t;t=t.parentElement;}return e},Yf=new WeakMap,iw=async e=>{try{let t=en(e);if(!t)return null;let n=await Kf(t);if(tr()){let o=rw(t,n);return nw(o)}return n}catch{return null}},exports.getStack=e=>{if(!exports.isInstrumentationActive())return Promise.resolve([]);let t=im(e),n=Yf.get(t);if(n)return n;let o=iw(t);return Yf.set(t,o),o},sw=e=>{if(!e||e.length===0)return null;for(let t of e)if(t.fileName&&Wf(t.fileName))return {filePath:Yr(t.fileName),lineNumber:t.lineNumber??null,columnNumber:null,componentName:t.functionName&&om(t.functionName)?t.functionName:null};return null},aw=async e=>{let t=await exports.getStack(e),n=sw(t);return n?[n]:[]},lw=async e=>{if(!exports.isInstrumentationActive())return null;let t=await exports.getStack(e);if(t){for(let r of t)if(r.functionName&&om(r.functionName))return r.functionName}let n=im(e),o=en(n);if(!o)return null;let i=o.return;for(;i;){if(Un(i)){let r=dn(i.type);if(r&&Qy(r))return r}i=i.return;}return null},jl={name:"react",resolveStack:aw,resolveComponentName:lw},vo=e=>typeof e=="object"&&e!==null&&!Array.isArray(e),wo=e=>typeof e=="string"?e:null,ws=e=>typeof e=="number"&&Number.isFinite(e)?e:null,cw="__svelte_meta",uw=e=>{let t=e;for(;t;){let n=Reflect.get(t,cw);if(vo(n))return n;t=t.parentElement;}return null},dw=e=>{let t=e.loc;if(!vo(t))return null;let n=wo(t.file),o=ws(t.line),i=ws(t.column);return !n||o===null||i===null?null:{filePath:n,lineNumber:o,columnNumber:i+tm}},fw=e=>{let t=e.parent;for(;vo(t);){let n=wo(t.componentTag);if(n)return n;t=t.parent;}return null},mw=e=>{let t=[],n=e.parent;for(;vo(n);){let o=wo(n.file),i=ws(n.line),r=ws(n.column),a=wo(n.componentTag);o&&i!==null&&r!==null&&t.push({filePath:o,lineNumber:i,columnNumber:r+tm,componentName:a}),n=n.parent;}return t},pw=e=>{let t=uw(e);if(!t)return [];let n=dw(t);if(!n)return [];let o=[{filePath:n.filePath,lineNumber:n.lineNumber,columnNumber:n.columnNumber,componentName:fw(t)}],i=new Set([`${n.filePath}:${n.lineNumber}:${n.columnNumber}`]);for(let r of mw(t)){let a=`${r.filePath}:${r.lineNumber??""}:${r.columnNumber??""}`;i.has(a)||(i.add(a),o.push(r));}return o},gw={name:"svelte",resolveStack:pw},qf=":",Zf=e=>{let t=Number.parseInt(e,10);return Number.isNaN(t)||t<1?null:t},sm=e=>{let t=e.lastIndexOf(qf);if(t===-1)return null;let n=e.lastIndexOf(qf,t-1);if(n===-1)return null;let o=e.slice(0,n);if(!o)return null;let i=e.slice(n+1,t),r=e.slice(t+1),a=Zf(i),u=Zf(r);return a===null||u===null?null:{filePath:o,lineNumber:a,columnNumber:u}},am="data-v-inspector",hw=`[${am}]`,bw="__vueParentComponent",lm=e=>{if(!e)return null;let t=e.type;return vo(t)?t:null},yw=e=>{let t=Reflect.get(e,bw);return vo(t)?t:null},cm=e=>{let t=e;for(;t;){let n=yw(t);if(n)return n;t=t.parentElement;}return null},um=e=>e?wo(e.__name)??wo(e.name):null,ww=e=>e?wo(e.__file):null,vw=e=>{if(!e)return null;let t=Reflect.get(e,"parent");return vo(t)?t:null},xw=e=>{let t=[],n=cm(e);for(;n;)t.push(n),n=vw(n);return t},Ew=e=>xw(e).map(t=>{let n=lm(t),o=ww(n);return o?{filePath:o,lineNumber:null,columnNumber:null,componentName:um(n)}:null}).filter(t=>!!t),Cw=e=>{let t=e.closest(hw);if(!t)return null;let n=t.getAttribute(am);if(!n)return null;let o=sm(n);if(!o)return null;let i=cm(e),r=lm(i);return {filePath:o.filePath,lineNumber:o.lineNumber,columnNumber:o.columnNumber,componentName:um(r)}},Sw=e=>{let t=[],n=new Set,o=Cw(e);if(o){let i=`${o.filePath}|${o.componentName??""}`;t.push(o),n.add(i);}for(let i of Ew(e)){let r=`${i.filePath}|${i.componentName??""}`;n.has(r)||(n.add(r),t.push(i));}return t},Aw={name:"vue",resolveStack:Sw},Tw="$$",_w=/location:\s*["']([^"']+:\d+:\d+)["']/g,dm="/src/",Pw=".css",Ow="?import",Iw="__SOLID_RUNTIME_MODULES__",Jf=new Map,Qf=new Map,Rw=e=>{if(e.includes(Ow))return false;let t=new URL(e,window.location.href).pathname;return t.endsWith(Pw)?false:t.includes(dm)},kw=()=>{if(typeof window>"u")return [];let e=performance.getEntriesByType("resource"),t=new Set;for(let n of e)!n.name||!Rw(n.name)||t.add(n.name);return Array.from(t)},Nw=e=>{let t=Jf.get(e);if(t)return t;let n=fetch(e).then(o=>o.ok?o.text():null).catch(()=>null);return Jf.set(e,n),n},Mw=()=>{if(typeof window>"u")return [];let e=Reflect.get(window,Iw);return Array.isArray(e)?e:[]},Dw=async e=>{for(let t of Mw()){let n=t.content.indexOf(e);if(n!==-1)return {moduleUrl:t.url,moduleContent:t.content,handlerSourceIndex:n}}for(let t of kw()){let n=await Nw(t);if(!n)continue;let o=n.indexOf(e);if(o!==-1)return {moduleUrl:t,moduleContent:n,handlerSourceIndex:o}}return null},Lw=(e,t)=>{let n=Math.max(0,t-Xf),o=Math.min(e.length,t+Xf),i=e.slice(n,o),r=[];for(let c of i.matchAll(_w)){let m=c[1];if(!m)continue;let l=sm(m);if(!l||c.index===void 0)continue;let d=n+c.index;r.push({sourceInfo:{filePath:l.filePath,lineNumber:l.lineNumber,columnNumber:l.columnNumber,componentName:null},distance:Math.abs(d-t)});}r.sort((c,m)=>{let l=c.sourceInfo.lineNumber??0,d=m.sourceInfo.lineNumber??0;return d!==l?d-l:c.distance-m.distance});let a=new Set,u=[];for(let c of r){let m=`${c.sourceInfo.filePath}:${c.sourceInfo.lineNumber}:${c.sourceInfo.columnNumber}`;a.has(m)||(a.add(m),u.push(c.sourceInfo));}return u},Fw=e=>{try{let t=decodeURIComponent(new URL(e,window.location.href).pathname);return t.includes(dm)?t.startsWith("/")?t.slice(1):t:null}catch{return null}},Bw=(e,t)=>{let o=e.slice(0,t).split(` +`),i=o[o.length-1]??"";return {lineNumber:o.length,columnNumber:i.length+Wy}},$w=e=>{let t=e;for(;t;){for(let n of Object.getOwnPropertyNames(t)){if(!n.startsWith(Tw))continue;let o=Reflect.get(t,n);if(typeof o!="function")continue;let i=String(o).trim();if(!(i.length{let t=Qf.get(e);if(t)return t;let n=(async()=>{let o=await Dw(e);if(!o)return [];let i=Lw(o.moduleContent,o.handlerSourceIndex);if(i.length>0)return i;let r=Fw(o.moduleUrl);if(!r)return [];let a=Bw(o.moduleContent,o.handlerSourceIndex);return [{filePath:r,lineNumber:a.lineNumber,columnNumber:a.columnNumber,componentName:null}]})();return Qf.set(e,n),n},Vw=e=>{let t=$w(e);return t?Hw(t.source):Promise.resolve([])},zw={name:"solid",resolveStack:Vw},Uw=e=>(e.tagName||"").toLowerCase(),Gw=[gw,Aw,zw],em=async(e,t)=>{for(let n of t){let i=(await n.resolveStack(e)).filter(r=>r.filePath.length>0);if(i.length>0)return i}return []},jw=(e={})=>{let t=e.resolvers??Gw,n=async a=>{let u=await jl.resolveStack(a),c=await em(a,t);return u.length>0?[...u,...c]:c};return {resolveSource:async a=>(await n(a))[0]??null,resolveStack:n,resolveComponentName:async a=>{let u=await jl.resolveComponentName?.(a);return u||((await em(a,t)).find(l=>l.componentName)?.componentName??null)},resolveElementInfo:async a=>{let u=await n(a),c=u[0]??null,m=u.find(l=>l.componentName)?.componentName??await jl.resolveComponentName?.(a)??null;return {tagName:Uw(a),componentName:m,source:c,stack:u}}}},vs=jw(),Zr=vs.resolveSource,fm=vs.resolveStack,xo=vs.resolveComponentName,vs.resolveElementInfo,Kw=e=>{let t=[e.filePath];return e.lineNumber!==null&&t.push(String(e.lineNumber)),e.columnNumber!==null&&t.push(String(e.columnNumber)),t.join(":")},Ww=e=>{let t=Kw(e);return e.componentName?` in ${e.componentName} (at ${t})`:` - in ${t}`},Vf=(e,t=ay)=>t<1||e.length<1?"":e.slice(0,t).map(sw).join("");});var as,zf=X(()=>{as=(e,t)=>e.length>t?`${e.slice(0,t)}...`:e;});var aw,lw,cw,Uf,Gf,Wr,uw,Il;exports.formatElementInfo=void 0;var dw,jf,fw,mw,jo=X(()=>{Ol();Vr();Oe();Uo();zf();aw=new Set(["_","$","motion.","styled.","chakra.","ark.","Primitive.","Slot."]),lw=new Set(["InnerLayoutRouter","RedirectErrorBoundary","RedirectBoundary","HTTPAccessFallbackErrorBoundary","HTTPAccessFallbackBoundary","LoadingBoundary","ErrorBoundary","InnerScrollAndFocusHandler","ScrollAndFocusHandler","RenderFromTemplateContext","OuterLayoutRouter","body","html","DevRootHTTPAccessFallbackBoundary","AppDevOverlayErrorBoundary","AppDevOverlay","HotReload","Router","ErrorBoundaryHandler","AppRouter","ServerRoot","SegmentStateProvider","RootErrorBoundary","LoadableComponent","MotionDOMComponent"]),cw=new Set(["Suspense","Fragment","StrictMode","Profiler","SuspenseList"]),Uf=e=>{if(!e||lw.has(e)||cw.has(e))return false;for(let t of aw)if(e.startsWith(t))return false;return !(e==="SlotClone"||e==="Slot")},Gf=e=>{if(!exports.isInstrumentationActive())return e;let t=e;for(;t;){if(Xt(t))return t;t=t.parentElement;}return e},Wr=e=>{if(!exports.isInstrumentationActive())return null;let t=Gf(e),n=Xt(t);if(!n)return null;let o=n.return;for(;o;){if(Fn(o)){let i=sn(o.type);if(i&&Uf(i))return i}o=o.return;}return null},uw=(e,t)=>{if(!exports.isInstrumentationActive())return [];let n=Xt(e);if(!n)return [];let o=[];return oo(n,i=>{if(o.length>=t)return true;if(Fn(i)){let r=sn(i.type);r&&Uf(r)&&o.push(r);}return false},true),o},Il=async(e,t={})=>{let n=t.maxLines??$o,o=await Hf(e);if(o.length>0)return Vf(o,n);let i=uw(e,n);return i.length>0?i.map(r=>` - in ${r}`).join(""):""},exports.formatElementInfo=async(e,t={})=>{let n=Gf(e),o=mw(n),i=await Il(n,t);return i?`${o}${i}`:dw(n)},dw=e=>{let t=et(e);if(!(e instanceof HTMLElement)){let r=fw(e,{truncate:false,maxAttrs:Fa.length});return `<${t}${r} />`}let n=e.innerText?.trim()??e.textContent?.trim()??"",o="";for(let{name:r,value:a}of e.attributes)o+=` ${r}="${a}"`;let i=as(n,Rr);return i.length>0?`<${t}${o}> + in ${t}`},mm=(e,t=Xy)=>t<1||e.length<1?"":e.slice(0,t).map(Ww).join("");});var xs,pm=Y(()=>{xs=(e,t)=>e.length>t?`${e.slice(0,t)}...`:e;});var Xw,Yw,qw,gm,hm,nr,Zw,Wl;exports.formatElementInfo=void 0;var Jw,bm,Qw,ev,or=Y(()=>{Kl();Kr();Re();er();pm();Xw=new Set(["_","$","motion.","styled.","chakra.","ark.","Primitive.","Slot."]),Yw=new Set(["InnerLayoutRouter","RedirectErrorBoundary","RedirectBoundary","HTTPAccessFallbackErrorBoundary","HTTPAccessFallbackBoundary","LoadingBoundary","ErrorBoundary","InnerScrollAndFocusHandler","ScrollAndFocusHandler","RenderFromTemplateContext","OuterLayoutRouter","body","html","DevRootHTTPAccessFallbackBoundary","AppDevOverlayErrorBoundary","AppDevOverlay","HotReload","Router","ErrorBoundaryHandler","AppRouter","ServerRoot","SegmentStateProvider","RootErrorBoundary","LoadableComponent","MotionDOMComponent"]),qw=new Set(["Suspense","Fragment","StrictMode","Profiler","SuspenseList"]),gm=e=>{if(!e||Yw.has(e)||qw.has(e))return false;for(let t of Xw)if(e.startsWith(t))return false;return !(e==="SlotClone"||e==="Slot")},hm=e=>{if(!exports.isInstrumentationActive())return e;let t=e;for(;t;){if(en(t))return t;t=t.parentElement;}return e},nr=e=>{if(!exports.isInstrumentationActive())return null;let t=hm(e),n=en(t);if(!n)return null;let o=n.return;for(;o;){if(Un(o)){let i=dn(o.type);if(i&&gm(i))return i}o=o.return;}return null},Zw=(e,t)=>{if(!exports.isInstrumentationActive())return [];let n=en(e);if(!n)return [];let o=[];return yo(n,i=>{if(o.length>=t)return true;if(Un(i)){let r=dn(i.type);r&&gm(r)&&o.push(r);}return false},true),o},Wl=async(e,t={})=>{let n=t.maxLines??Zo,o=await fm(e);if(o.length>0)return mm(o,n);let i=Zw(e,n);return i.length>0?i.map(r=>` + in ${r}`).join(""):""},exports.formatElementInfo=async(e,t={})=>{let n=hm(e),o=ev(n),i=await Wl(n,t);return i?`${o}${i}`:Jw(n)},Jw=e=>{let t=Qe(e);if(!(e instanceof HTMLElement)){let r=Qw(e,{truncate:false,maxAttrs:Qa.length});return `<${t}${r} />`}let n=e.innerText?.trim()??e.textContent?.trim()??"",o="";for(let{name:r,value:a}of e.attributes)o+=` ${r}="${a}"`;let i=xs(n,Dr);return i.length>0?`<${t}${o}> ${i} -`:`<${t}${o} />`},jf=e=>as(e,cd),fw=(e,t={})=>{let{truncate:n=true,maxAttrs:o=ud}=t,i=[];for(let r of Fa){if(i.length>=o)break;let a=e.getAttribute(r);if(a){let u=n?jf(a):a;i.push(`${r}="${u}"`);}}return i.length>0?` ${i.join(" ")}`:""},mw=e=>{let t=et(e),n=e instanceof HTMLElement?e.innerText?.trim()??e.textContent?.trim()??"":e.textContent?.trim()??"",o="";for(let{name:h,value:y}of e.attributes)o+=` ${h}="${jf(y)}"`;let i=[],r=[],a=false,u=Array.from(e.childNodes);for(let h of u)h.nodeType!==Node.COMMENT_NODE&&(h.nodeType===Node.TEXT_NODE?h.textContent&&h.textContent.trim().length>0&&(a=true):h instanceof Element&&(a?r.push(h):i.push(h)));let l=h=>h.length===0?"":h.length<=2?h.map(y=>`<${et(y)} ...>`).join(` - `):`(${h.length} elements)`,m="",c=l(i);c&&(m+=` - ${c}`),n.length>0&&(m+=` - ${as(n,Rr)}`);let d=l(r);return d&&(m+=` +`:`<${t}${o} />`},bm=e=>xs(e,Dd),Qw=(e,t={})=>{let{truncate:n=true,maxAttrs:o=Ld}=t,i=[];for(let r of Qa){if(i.length>=o)break;let a=e.getAttribute(r);if(a){let u=n?bm(a):a;i.push(`${r}="${u}"`);}}return i.length>0?` ${i.join(" ")}`:""},ev=e=>{let t=Qe(e),n=e instanceof HTMLElement?e.innerText?.trim()??e.textContent?.trim()??"":e.textContent?.trim()??"",o="";for(let{name:g,value:b}of e.attributes)o+=` ${g}="${bm(b)}"`;let i=[],r=[],a=false,u=Array.from(e.childNodes);for(let g of u)g.nodeType!==Node.COMMENT_NODE&&(g.nodeType===Node.TEXT_NODE?g.textContent&&g.textContent.trim().length>0&&(a=true):g instanceof Element&&(a?r.push(g):i.push(g)));let c=g=>g.length===0?"":g.length<=2?g.map(b=>`<${Qe(b)} ...>`).join(` + `):`(${g.length} elements)`,m="",l=c(i);l&&(m+=` + ${l}`),n.length>0&&(m+=` + ${xs(n,Dr)}`);let d=c(r);return d&&(m+=` ${d}`),m.length>0?`<${t}${o}>${m} -`:`<${t}${o} />`};});var Yf,qf=X(()=>{Yf=(e,t=window.getComputedStyle(e))=>t.display!=="none"&&t.visibility!=="hidden"&&t.opacity!=="0";});var ao,us=X(()=>{Uo();ao=e=>{let t=et(e);return t==="html"||t==="body"};});var hw,bw,yw,ww,ds,Zf,ln,fs=X(()=>{Oe();qf();us();hw=e=>{if(e.hasAttribute("data-react-grab"))return true;let t=e.getRootNode();return t instanceof ShadowRoot&&t.host.hasAttribute("data-react-grab")},bw=e=>e.hasAttribute($a)||e.closest(`[${$a}]`)!==null,yw=e=>{let t=parseInt(e.zIndex,10);return e.pointerEvents==="none"&&e.position==="fixed"&&!isNaN(t)&&t>=md},ww=(e,t)=>{let n=t.position;if(n!=="fixed"&&n!=="absolute")return false;let o=e.getBoundingClientRect();if(!(o.width/window.innerWidth>=Nr&&o.height/window.innerHeight>=Nr))return false;let r=t.backgroundColor;if(r==="transparent"||r==="rgba(0, 0, 0, 0)"||parseFloat(t.opacity)<.1)return true;let u=parseInt(t.zIndex,10);return !isNaN(u)&&u>fd},ds=new WeakMap,Zf=()=>{ds=new WeakMap;},ln=e=>{if(ao(e)||hw(e)||bw(e))return false;let t=performance.now(),n=ds.get(e);if(n&&t-n.timestamp=Nr&&e.clientHeight/window.innerHeight>=Nr&&(yw(o)||ww(e,o))?false:(ds.set(e,{isVisible:true,timestamp:t}),true):(ds.set(e,{isVisible:false,timestamp:t}),false)};});var Xr,kl=X(()=>{Xr=(e,t)=>{let n=document.createElement("style");return n.setAttribute(e,""),n.textContent=t,document.head.appendChild(n),n};});var vw,tm,nm,om,rm,im,Nl,Bn,sm,am,xw,Jf,Qf,em,Yr,qr,ms,Zr,Jr=X(()=>{Qr();kl();vw="html { pointer-events: none !important; }",tm=["mouseenter","mouseleave","mouseover","mouseout","pointerenter","pointerleave","pointerover","pointerout"],nm=["focus","blur","focusin","focusout"],om=["background-color","color","border-color","box-shadow","transform","opacity","outline","filter","scale","visibility"],rm=["background-color","color","border-color","box-shadow","outline","outline-offset","outline-width","outline-color","outline-style","filter","opacity","ring-color","ring-width"],im=new Map,Nl=new Map,Bn=null,sm=e=>{e.stopImmediatePropagation();},am=e=>{e.preventDefault(),e.stopImmediatePropagation();},xw=(e,t)=>{let n=new Map;for(let o of t){let i=e.style.getPropertyValue(o);i&&n.set(o,i);}return n},Jf=(e,t,n)=>{let o=[];for(let i of document.querySelectorAll(e)){if(!(i instanceof HTMLElement)||n?.has(i))continue;let r=getComputedStyle(i),a=i.style.cssText,u=xw(i,t);for(let l of t){let m=r.getPropertyValue(l);m&&(a+=`${l}: ${m} !important; `);}o.push({element:i,frozenStyles:a,originalPropertyValues:u});}return o},Qf=(e,t)=>{for(let{element:n,frozenStyles:o,originalPropertyValues:i}of e)t.set(n,i),n.style.cssText=o;},em=(e,t)=>{for(let[n,o]of e)for(let i of t){let r=o.get(i);r?n.style.setProperty(i,r):n.style.removeProperty(i);}e.clear();},Yr=()=>{Bn&&(Bn.disabled=true);},qr=()=>{Bn&&(Bn.disabled=false);},ms=()=>{if(Bn)return;for(let n of tm)document.addEventListener(n,sm,true);for(let n of nm)document.addEventListener(n,am,true);let e=Jf(":hover",om),t=Jf(":focus, :focus-visible",rm,Nl);Qf(e,im),Qf(t,Nl),Bn=Xr("data-react-grab-frozen-pseudo",vw);},Zr=()=>{ps();for(let e of tm)document.removeEventListener(e,sm,true);for(let e of nm)document.removeEventListener(e,am,true);em(im,om),em(Nl,rm),Bn?.remove(),Bn=null;};});var lo,co,lm,Ml,Ew,gs,Xo,ps,Qr=X(()=>{fs();Oe();Jr();lo=null,co=null,lm=()=>{co!==null&&clearTimeout(co),co=setTimeout(()=>{co=null,qr();},Od);},Ml=()=>{co!==null&&(clearTimeout(co),co=null);},Ew=(e,t,n,o)=>{let i=Math.abs(e-n),r=Math.abs(t-o);return i<=Ka&&r<=Ka},gs=(e,t)=>{Ml(),Yr();let n=document.elementsFromPoint(e,t);return lm(),n},Xo=(e,t)=>{let n=performance.now();if(lo){let r=Ew(e,t,lo.clientX,lo.clientY),a=n-lo.timestamp{Ml(),qr(),lo=null;};});var oi,Vw,vn,ri=X(()=>{oi=null,Vw=()=>{if(typeof navigator>"u"||!("userAgentData"in navigator))return null;let e=navigator.userAgentData;if(typeof e!="object"||e===null||!("platform"in e))return null;let t=e.platform;return typeof t!="string"?null:t},vn=()=>{if(oi===null){if(typeof navigator>"u")return oi=false,oi;let e=navigator.platform??Vw()??navigator.userAgent;oi=/Mac|iPhone|iPad|iPod/i.test(e);}return oi};});var Ht,Vl=X(()=>{Ht=(e,t)=>{try{return e.composedPath().some(n=>n instanceof HTMLElement&&n.hasAttribute(t))}catch{return false}};});var Cs,Cm,Sm=X(()=>{Cm=()=>{if(Cs!==void 0)return Cs;let e=document.querySelector('script[src*="/_next/"]')?.src,t=e?new URL(e).pathname:"",n=t.indexOf("/_next/");return Cs=n>0?t.slice(0,n):"",Cs};});var Uw,Gw,qo,Ss=X(()=>{Tl();jo();Sm();Uw="https://react-grab.com",Gw=async(e,t)=>{let n=Go(),o=new URLSearchParams({file:e}),i=n?"line1":"line",r=n?"column1":"column";t&&o.set(i,String(t)),o.set(r,"1");let a=n?`${Cm()}/__nextjs_launch-editor`:"/__open-in-editor";return (await fetch(`${a}?${o}`)).ok},qo=async(e,t,n)=>{if(e=Gr(e),await Gw(e,t).catch(()=>false))return;let i=t?`&line=${t}`:"",r=`${Uw}/open-file?url=${encodeURIComponent(e)}${i}`,a=n?n(r,e,t):r;window.open(a,"_blank","noopener,noreferrer");};});var jw,ii,Ul=X(()=>{jw=e=>e??true,ii=(e,t)=>typeof e.enabled=="function"?t?e.enabled(t):false:jw(e.enabled);});var Ct,si=X(()=>{Ct=(e,t)=>{};});var Hm,zn,Zo,Wl=X(()=>{Oe();Hm="react-grab-toolbar-state",zn=()=>{try{let e=localStorage.getItem(Hm);if(!e)return null;let t=JSON.parse(e);if(typeof t!="object"||t===null)return null;let n=t;return {edge:n.edge==="top"||n.edge==="bottom"||n.edge==="left"||n.edge==="right"?n.edge:"bottom",ratio:typeof n.ratio=="number"?n.ratio:Mt,collapsed:typeof n.collapsed=="boolean"?n.collapsed:!1,enabled:typeof n.enabled=="boolean"?n.enabled:!0,defaultAction:typeof n.defaultAction=="string"?n.defaultAction:yn}}catch(e){console.warn("[react-grab] Failed to load toolbar state from localStorage:",e);}return null},Zo=e=>{try{localStorage.setItem(Hm,JSON.stringify(e));}catch(t){console.warn("[react-grab] Failed to save toolbar state to localStorage:",t);}};});var fo,xn,Km,Wm,ai,Qo,Qw,Xm,Ym,qm=X(()=>{rn();fo=false,xn=new Map,Km=-1,Wm=new WeakSet,ai=new Map,Qo=new Map,Qw=e=>Wm.has(e)?true:!fo||!("gsapVersions"in window)||!(new Error().stack??"").includes("_tick")?false:(Wm.add(e),true);typeof window<"u"&&(window.requestAnimationFrame=e=>{if(!Qw(e))return Fe(e);if(fo){let n=Km--;return xn.set(n,e),n}let t=Fe(n=>{if(fo){let o=Km--;xn.set(o,e),ai.set(t,o);return}e(n);});return t},window.cancelAnimationFrame=e=>{if(xn.has(e)){xn.delete(e);return}let t=Qo.get(e);if(t!==void 0){Ke(t.nativeId),Qo.delete(e);return}let n=ai.get(e);if(n!==void 0){xn.delete(n),ai.delete(e);return}Ke(e);});Xm=()=>{if(!fo){fo=true,xn.clear(),ai.clear();for(let[e,{nativeId:t,callback:n}]of Qo)Ke(t),xn.set(e,n);Qo.clear();}},Ym=()=>{if(fo){fo=false;for(let[e,t]of xn.entries()){let n=Fe(o=>{Qo.delete(e),t(o);});Qo.set(e,{nativeId:n,callback:t});}xn.clear(),ai.clear();}};});var ev,tv,Jm,Zm,mo,li,Zl,er,Rs,ci,ui,nv,ov,Qm,ep,tp,np,rv,op,tr,Jl,rp,ks,di,Ql=X(()=>{Oe();kl();qm();ev=` -[${kr}], -[${kr}] * { +`:`<${t}${o} />`};});var xm,Em=Y(()=>{xm=(e,t=window.getComputedStyle(e))=>t.display!=="none"&&t.visibility!=="hidden"&&t.opacity!=="0";});var An,Jr=Y(()=>{er();An=e=>{let t=Qe(e);return t==="html"||t==="body"};});var ov,rv,iv,sv,Ss,Cm,mn,As=Y(()=>{Re();Em();Jr();ov=e=>{if(e.hasAttribute("data-react-grab"))return true;let t=e.getRootNode();return t instanceof ShadowRoot&&t.host.hasAttribute("data-react-grab")},rv=e=>e.hasAttribute(tl)||e.closest(`[${tl}]`)!==null,iv=e=>{let t=parseInt(e.zIndex,10);return e.pointerEvents==="none"&&e.position==="fixed"&&!isNaN(t)&&t>=$d},sv=(e,t)=>{let n=t.position;if(n!=="fixed"&&n!=="absolute")return false;let o=e.getBoundingClientRect();if(!(o.width/window.innerWidth>=Fr&&o.height/window.innerHeight>=Fr))return false;let r=t.backgroundColor;if(r==="transparent"||r==="rgba(0, 0, 0, 0)"||parseFloat(t.opacity)<.1)return true;let u=parseInt(t.zIndex,10);return !isNaN(u)&&u>Bd},Ss=new WeakMap,Cm=()=>{Ss=new WeakMap;},mn=e=>{if(An(e)||ov(e)||rv(e))return false;let t=performance.now(),n=Ss.get(e);if(n&&t-n.timestamp=Fr&&e.clientHeight/window.innerHeight>=Fr&&(iv(o)||sv(e,o))?false:(Ss.set(e,{isVisible:true,timestamp:t}),true):(Ss.set(e,{isVisible:false,timestamp:t}),false)};});var Qr,Yl=Y(()=>{Qr=(e,t)=>{let n=document.createElement("style");return n.setAttribute(e,""),n.textContent=t,document.head.appendChild(n),n};});var av,_m,Pm,Om,Im,Rm,ql,Gn,km,Nm,lv,Sm,Am,Tm,ei,ti,Ts,ni,oi=Y(()=>{ri();Yl();av="html { pointer-events: none !important; }",_m=["mouseenter","mouseleave","mouseover","mouseout","pointerenter","pointerleave","pointerover","pointerout"],Pm=["focus","blur","focusin","focusout"],Om=["background-color","color","border-color","box-shadow","transform","opacity","outline","filter","scale","visibility"],Im=["background-color","color","border-color","box-shadow","outline","outline-offset","outline-width","outline-color","outline-style","filter","opacity","ring-color","ring-width"],Rm=new Map,ql=new Map,Gn=null,km=e=>{e.stopImmediatePropagation();},Nm=e=>{e.preventDefault(),e.stopImmediatePropagation();},lv=(e,t)=>{let n=new Map;for(let o of t){let i=e.style.getPropertyValue(o);i&&n.set(o,i);}return n},Sm=(e,t,n)=>{let o=[];for(let i of document.querySelectorAll(e)){if(!(i instanceof HTMLElement)||n?.has(i))continue;let r=getComputedStyle(i),a=i.style.cssText,u=lv(i,t);for(let c of t){let m=r.getPropertyValue(c);m&&(a+=`${c}: ${m} !important; `);}o.push({element:i,frozenStyles:a,originalPropertyValues:u});}return o},Am=(e,t)=>{for(let{element:n,frozenStyles:o,originalPropertyValues:i}of e)t.set(n,i),n.style.cssText=o;},Tm=(e,t)=>{for(let[n,o]of e)for(let i of t){let r=o.get(i);r?n.style.setProperty(i,r):n.style.removeProperty(i);}e.clear();},ei=()=>{Gn&&(Gn.disabled=true);},ti=()=>{Gn&&(Gn.disabled=false);},Ts=()=>{if(Gn)return;for(let n of _m)document.addEventListener(n,km,true);for(let n of Pm)document.addEventListener(n,Nm,true);let e=Sm(":hover",Om),t=Sm(":focus, :focus-visible",Im,ql);Am(e,Rm),Am(t,ql),Gn=Qr("data-react-grab-frozen-pseudo",av);},ni=()=>{_s();for(let e of _m)document.removeEventListener(e,km,true);for(let e of Pm)document.removeEventListener(e,Nm,true);Tm(Rm,Om),Tm(ql,Im),Gn?.remove(),Gn=null;};});var Eo,Co,Mm,Zl,cv,Ps,sr,_s,ri=Y(()=>{As();Re();oi();Eo=null,Co=null,Mm=()=>{Co!==null&&clearTimeout(Co),Co=setTimeout(()=>{Co=null,ti();},tf);},Zl=()=>{Co!==null&&(clearTimeout(Co),Co=null);},cv=(e,t,n,o)=>{let i=Math.abs(e-n),r=Math.abs(t-o);return i<=cl&&r<=cl},Ps=(e,t)=>{Zl(),ei();let n=document.elementsFromPoint(e,t);return Mm(),n},sr=(e,t)=>{let n=performance.now();if(Eo){let r=cv(e,t,Eo.clientX,Eo.clientY),a=n-Eo.timestamp{Zl(),ti(),Eo=null;};});var ci,Pv,Tn,ui=Y(()=>{ci=null,Pv=()=>{if(typeof navigator>"u"||!("userAgentData"in navigator))return null;let e=navigator.userAgentData;if(typeof e!="object"||e===null||!("platform"in e))return null;let t=e.platform;return typeof t!="string"?null:t},Tn=()=>{if(ci===null){if(typeof navigator>"u")return ci=false,ci;let e=navigator.platform??Pv()??navigator.userAgent;ci=/Mac|iPhone|iPad|iPod/i.test(e);}return ci};});var jt,ic=Y(()=>{jt=(e,t)=>{try{return e.composedPath().some(n=>n instanceof HTMLElement&&n.hasAttribute(t))}catch{return false}};});var Bs,Wm,Xm=Y(()=>{Wm=()=>{if(Bs!==void 0)return Bs;let e=document.querySelector('script[src*="/_next/"]')?.src,t=e?new URL(e).pathname:"",n=t.indexOf("/_next/");return Bs=n>0?t.slice(0,n):"",Bs};});var Iv,Rv,lr,$s=Y(()=>{Ul();or();Xm();Iv="https://react-grab.com",Rv=async(e,t)=>{let n=tr(),o=new URLSearchParams({file:e}),i=n?"line1":"line",r=n?"column1":"column";t&&o.set(i,String(t)),o.set(r,"1");let a=n?`${Wm()}/__nextjs_launch-editor`:"/__open-in-editor";return (await fetch(`${a}?${o}`)).ok},lr=async(e,t,n)=>{if(e=Yr(e),await Rv(e,t).catch(()=>false))return;let i=t?`&line=${t}`:"",r=`${Iv}/open-file?url=${encodeURIComponent(e)}${i}`,a=n?n(r,e,t):r;window.open(a,"_blank","noopener,noreferrer");};});var kv,di,ac=Y(()=>{kv=e=>e??true,di=(e,t)=>typeof e.enabled=="function"?t?e.enabled(t):false:kv(e.enabled);});var lt,fi=Y(()=>{lt=(e,t)=>{};});var up,Ao,mi,dc=Y(()=>{Re();up="react-grab-toolbar-state",Ao=()=>{try{let e=localStorage.getItem(up);if(!e)return null;let t=JSON.parse(e);if(typeof t!="object"||t===null)return null;let n=t;return {edge:n.edge==="top"||n.edge==="bottom"||n.edge==="left"||n.edge==="right"?n.edge:"bottom",ratio:typeof n.ratio=="number"?n.ratio:Gt,collapsed:typeof n.collapsed=="boolean"?n.collapsed:!1,enabled:typeof n.enabled=="boolean"?n.enabled:!0,defaultAction:typeof n.defaultAction=="string"?n.defaultAction:Jt}}catch(e){console.warn("[react-grab] Failed to load toolbar state from localStorage:",e);}return null},mi=e=>{try{localStorage.setItem(up,JSON.stringify(e));}catch(t){console.warn("[react-grab] Failed to save toolbar state to localStorage:",t);}};});var To,_n,hp,bp,pi,ur,Hv,yp,wp,vp=Y(()=>{un();To=false,_n=new Map,hp=-1,bp=new WeakSet,pi=new Map,ur=new Map,Hv=e=>bp.has(e)?true:!To||!("gsapVersions"in window)||!(new Error().stack??"").includes("_tick")?false:(bp.add(e),true);typeof window<"u"&&(window.requestAnimationFrame=e=>{if(!Hv(e))return Be(e);if(To){let n=hp--;return _n.set(n,e),n}let t=Be(n=>{if(To){let o=hp--;_n.set(o,e),pi.set(t,o);return}e(n);});return t},window.cancelAnimationFrame=e=>{if(_n.has(e)){_n.delete(e);return}let t=ur.get(e);if(t!==void 0){qe(t.nativeId),ur.delete(e);return}let n=pi.get(e);if(n!==void 0){_n.delete(n),pi.delete(e);return}qe(e);});yp=()=>{if(!To){To=true,_n.clear(),pi.clear();for(let[e,{nativeId:t,callback:n}]of ur)qe(t),_n.set(e,n);ur.clear();}},wp=()=>{if(To){To=false;for(let[e,t]of _n.entries()){let n=Be(o=>{ur.delete(e),t(o);});ur.set(e,{nativeId:n,callback:t});}_n.clear(),pi.clear();}};});var Vv,zv,Ep,xp,_o,gi,gc,dr,Ks,hi,bi,Uv,Gv,Cp,Sp,Ap,Tp,jv,_p,fr,hc,Pp,Ws,yi,bc=Y(()=>{Re();Yl();vp();Vv=` +[${Lr}], +[${Lr}] * { animation-play-state: paused !important; transition: none !important; } -`,tv=` +`,zv=` *, *::before, *::after { animation-play-state: paused !important; transition: none !important; } -`,Jm="svg",Zm=null,mo=[],li=[],Zl=[],er=null,Rs=[],ci=new Map,ui=[],nv=()=>{Zm||(Zm=Xr("data-react-grab-frozen-styles",ev));},ov=(e,t)=>e.length===t.length&&e.every((n,o)=>n===t[o]),Qm=e=>{let t=new Set;for(let n of e){n instanceof SVGSVGElement?t.add(n):n instanceof SVGElement&&n.ownerSVGElement&&t.add(n.ownerSVGElement);for(let o of n.querySelectorAll(Jm))o instanceof SVGSVGElement&&t.add(o);}return [...t]},ep=(e,t)=>{let n=Reflect.get(e,t);typeof n=="function"&&n.call(e);},tp=e=>{for(let t of e){let n=ci.get(t)??0;n===0&&ep(t,"pauseAnimations"),ci.set(t,n+1);}},np=e=>{for(let t of e){let n=ci.get(t);if(n){if(n===1){ci.delete(t),ep(t,"unpauseAnimations");continue}ci.set(t,n-1);}}},rv=e=>{let t=[];for(let n of e)for(let o of n.getAnimations({subtree:true}))o.playState==="running"&&t.push(o);return t},op=e=>{for(let t of e)try{t.finish();}catch{}},tr=e=>{if(e.length!==0&&!ov(e,Zl)){Jl(),Zl=[...e],nv(),mo=e,li=Qm(mo),tp(li);for(let t of mo)t.setAttribute(kr,"");ui=rv(mo);for(let t of ui)t.pause();}},Jl=()=>{if(!(mo.length===0&&li.length===0&&ui.length===0)){for(let e of mo)e.removeAttribute(kr);np(li),op(ui),mo=[],li=[],ui=[],Zl=[];}},rp=e=>e.length===0?(Jl(),()=>{}):(tr(e),Jl),ks=()=>{er||(er=Xr("data-react-grab-global-freeze",tv),Rs=Qm(Array.from(document.querySelectorAll(Jm))),tp(Rs),Xm());},di=()=>{if(!er)return;er.textContent=` +`,Ep="svg",xp=null,_o=[],gi=[],gc=[],dr=null,Ks=[],hi=new Map,bi=[],Uv=()=>{xp||(xp=Qr("data-react-grab-frozen-styles",Vv));},Gv=(e,t)=>e.length===t.length&&e.every((n,o)=>n===t[o]),Cp=e=>{let t=new Set;for(let n of e){n instanceof SVGSVGElement?t.add(n):n instanceof SVGElement&&n.ownerSVGElement&&t.add(n.ownerSVGElement);for(let o of n.querySelectorAll(Ep))o instanceof SVGSVGElement&&t.add(o);}return [...t]},Sp=(e,t)=>{let n=Reflect.get(e,t);typeof n=="function"&&n.call(e);},Ap=e=>{for(let t of e){let n=hi.get(t)??0;n===0&&Sp(t,"pauseAnimations"),hi.set(t,n+1);}},Tp=e=>{for(let t of e){let n=hi.get(t);if(n){if(n===1){hi.delete(t),Sp(t,"unpauseAnimations");continue}hi.set(t,n-1);}}},jv=e=>{let t=[];for(let n of e)for(let o of n.getAnimations({subtree:true}))o.playState==="running"&&t.push(o);return t},_p=e=>{for(let t of e)try{t.finish();}catch{}},fr=e=>{if(e.length!==0&&!Gv(e,gc)){hc(),gc=[...e],Uv(),_o=e,gi=Cp(_o),Ap(gi);for(let t of _o)t.setAttribute(Lr,"");bi=jv(_o);for(let t of bi)t.pause();}},hc=()=>{if(!(_o.length===0&&gi.length===0&&bi.length===0)){for(let e of _o)e.removeAttribute(Lr);Tp(gi),_p(bi),_o=[],gi=[],bi=[],gc=[];}},Pp=e=>e.length===0?(hc(),()=>{}):(fr(e),hc),Ws=()=>{dr||(dr=Qr("data-react-grab-global-freeze",zv),Ks=Cp(Array.from(document.querySelectorAll(Ep))),Ap(Ks),yp());},yi=()=>{if(!dr)return;dr.textContent=` *, *::before, *::after { transition: none !important; } -`;let e=[];for(let t of document.getAnimations()){if(t.effect instanceof KeyframeEffect){let n=t.effect.target;if(n instanceof Element&&n.getRootNode()instanceof ShadowRoot)continue}e.push(t);}op(e),er.remove(),er=null,np(Rs),Rs=[],Ym();};});var ut,ec,ip,sp,iv,nc,oc,fi,Ns,Ms,ap,lp,sv,cp,av,lv,up,cv,uv,dv,dp,fp,Ds,fv,mv,pv,gv,hv,tc,bv,Ls,rc=X(()=>{Vr();si();ut=false,ec=(e,t,n)=>{let o=e.get(t);if(o)return o;let i=n();return e.set(t,i),i},ip=new WeakMap,sp=new WeakMap,iv=new WeakMap,nc=new Set,oc=[],fi=[],Ns=new WeakMap,Ms=new WeakMap,ap=new WeakSet,lp=wl,sv=e=>{let t=e;for(;t.return;)t=t.return;return t.stateNode??null},cp=()=>{if(lp.size>0)return lp;let e=new Set,t=n=>{let o=Xt(n);if(o){let i=sv(o);i&&e.add(i);return}for(let i of Array.from(n.children))if(t(i),e.size>0)return};return t(document.body),e},av=(e,t)=>{if(!e)return t;if(!t)return e;if(!e.next||!t.next)return t;let n=e.next,o=t.next,i=e===n,r=t===o;return i&&r?(e.next=t,t.next=e):i?(e.next=o,t.next=e):r?(t.next=n,e.next=t):(e.next=o,t.next=n),t},lv=e=>{if(!e||Ns.has(e))return;let t={originalPendingDescriptor:Object.getOwnPropertyDescriptor(e,"pending"),pendingValueAtPause:e.pending,bufferedPending:null};typeof e.getSnapshot=="function"&&(t.originalGetSnapshot=e.getSnapshot,t.snapshotValueAtPause=e.getSnapshot(),e.getSnapshot=()=>ut?t.snapshotValueAtPause:t.originalGetSnapshot());let n=t.pendingValueAtPause;Object.defineProperty(e,"pending",{configurable:true,enumerable:true,get:()=>ut?null:n,set:o=>{if(ut){o!==null&&(t.bufferedPending=av(t.bufferedPending??null,o));return}n=o;}}),Ns.set(e,t);},up=e=>{if(!e)return [];let t=[],n=e.next;if(!n)return [];let o=n;do o&&(t.push(o.action),o=o.next);while(o&&o!==n);return t},cv=e=>{let t=Ns.get(e);if(!t)return;t.originalGetSnapshot&&(e.getSnapshot=t.originalGetSnapshot),t.originalPendingDescriptor?Object.defineProperty(e,"pending",t.originalPendingDescriptor):delete e.pending,e.pending=null;let n=e.dispatch;if(typeof n=="function"){let o=up(t.pendingValueAtPause??null),i=up(t.bufferedPending??null);for(let r of [...o,...i])fi.push(()=>n(r));}Ns.delete(e);},uv=e=>{if(Ms.has(e))return;let t={originalDescriptor:Object.getOwnPropertyDescriptor(e,"memoizedValue"),frozenValue:e.memoizedValue};Object.defineProperty(e,"memoizedValue",{configurable:true,enumerable:true,get(){return ut?t.frozenValue:t.originalDescriptor?.get?t.originalDescriptor.get.call(this):this._memoizedValue},set(n){if(ut){t.pendingValue=n,t.didReceivePendingValue=true;return}t.originalDescriptor?.set?t.originalDescriptor.set.call(this,n):this._memoizedValue=n;}}),t.originalDescriptor?.get||(e._memoizedValue=t.frozenValue),Ms.set(e,t);},dv=e=>{let t=Ms.get(e);t&&(t.originalDescriptor?Object.defineProperty(e,"memoizedValue",t.originalDescriptor):delete e.memoizedValue,t.didReceivePendingValue&&(e.memoizedValue=t.pendingValue),Ms.delete(e));},dp=(e,t)=>{let n=e.memoizedState;for(;n;)n.queue&&typeof n.queue=="object"&&t(n.queue),n=n.next;},fp=(e,t)=>{let n=e.dependencies?.firstContext;for(;n&&typeof n=="object"&&"memoizedValue"in n;)t(n),n=n.next;},Ds=(e,t)=>{e&&(Fn(e)&&t(e),Ds(e.child,t),Ds(e.sibling,t));},fv=e=>{dp(e,lv),fp(e,uv);},mv=e=>{dp(e,cv),fp(e,dv);},pv=e=>{if(ip.has(e))return;let t=e,n={useState:t.useState,useReducer:t.useReducer,useTransition:t.useTransition,useSyncExternalStore:t.useSyncExternalStore};ip.set(e,n),t.useState=(...o)=>{let i=n.useState.apply(e,o);if(!ut||!Array.isArray(i)||typeof i[1]!="function")return i;let[r,a]=i,u=ec(sp,a,()=>(...l)=>{ut?fi.push(()=>a(...l)):a(...l);});return [r,u]},t.useReducer=(...o)=>{let i=n.useReducer.apply(e,o);if(!ut||!Array.isArray(i)||typeof i[1]!="function")return i;let[r,a]=i,u=ec(sp,a,()=>(...l)=>{ut?fi.push(()=>a(...l)):a(...l);});return [r,u]},t.useTransition=(...o)=>{let i=n.useTransition.apply(e,o);if(!ut||!Array.isArray(i)||typeof i[1]!="function")return i;let[r,a]=i,u=ec(iv,a,()=>l=>{ut?oc.push(()=>a(l)):a(l);});return [r,u]},t.useSyncExternalStore=(o,i,r)=>{if(!ut)return n.useSyncExternalStore(o,i,r);let a=u=>o(()=>{ut?nc.add(u):u();});return n.useSyncExternalStore(a,i,r)};},gv=e=>{let t=e.currentDispatcherRef;if(!t||typeof t!="object")return;let n="H"in t?"H":"current",o=t[n];Object.defineProperty(t,n,{configurable:true,enumerable:true,get:()=>(o&&typeof o=="object"&&pv(o),o),set:i=>{o=i;}});},hv=e=>{queueMicrotask(()=>{try{for(let t of Ln().renderers.values())if(typeof t.scheduleUpdate=="function"){for(let n of e)if(n.current)try{t.scheduleUpdate(n.current);}catch(o){Ct("scheduleUpdate failed during unfreeze",o);}}}catch(t){Ct("scheduleReactUpdate failed",t);}});},tc=e=>{for(let t of e)try{t();}catch(n){Ct("Callback failed during state replay",n);}},bv=()=>{for(let e of Ln().renderers.values())ap.has(e)||(gv(e),ap.add(e));},Ls=()=>{if(ut)return ()=>{};bv(),ut=true;let e=cp();for(let t of e)Ds(t.current,fv);return ()=>{if(ut)try{let t=cp();for(let r of t)Ds(r.current,mv);let n=Array.from(nc),o=oc.slice(),i=fi.slice();ut=!1,tc(n),tc(o),tc(i),hv(t);}finally{nc.clear(),oc.length=0,fi.length=0;}}};});var nr,bp=X(()=>{nr=(e,t,n)=>e+(t-e)*n;});var xv,ac,po,yp,wp=X(()=>{w();w();w();w();qe();bp();Oe();rn();Oa();xv=D(""),ac={borderColor:od,fillColor:rd,lerpFactor:Fu},po={drag:{borderColor:td,fillColor:nd,lerpFactor:ed},selection:ac,grabbed:ac,processing:ac},yp=e=>{let t,n=null,o=0,i=0,r=1,a=null,u={drag:{canvas:null,context:null},selection:{canvas:null,context:null},grabbed:{canvas:null,context:null},processing:{canvas:null,context:null}},l=[],m=null,c=[],d=[],h=Xi()?"display-p3":"srgb",y=(A,F,O)=>{let ce=new OffscreenCanvas(A*O,F*O),pe=ce.getContext("2d",{colorSpace:h});return pe&&pe.scale(O,O),{canvas:ce,context:pe}},M=()=>{if(t){r=Math.max(window.devicePixelRatio||1,Ma),o=window.innerWidth,i=window.innerHeight,t.width=o*r,t.height=i*r,t.style.width=`${o}px`,t.style.height=`${i}px`,n=t.getContext("2d",{colorSpace:h}),n&&n.scale(r,r);for(let A of Object.keys(u))u[A]=y(o,i,r);}},E=A=>{if(!A)return 0;let F=A.match(/^(\d+(?:\.\d+)?)/);return F?parseFloat(F[1]):0},_=(A,F,O)=>({id:A,current:{x:F.x,y:F.y,width:F.width,height:F.height},target:{x:F.x,y:F.y,width:F.width,height:F.height},borderRadius:E(F.borderRadius),opacity:O?.opacity??1,targetOpacity:O?.targetOpacity??O?.opacity??1,createdAt:O?.createdAt,isInitialized:true}),S=(A,F,O)=>{A.target={x:F.x,y:F.y,width:F.width,height:F.height},A.borderRadius=E(F.borderRadius),O!==void 0&&(A.targetOpacity=O);},p=A=>A.boundsMultiple??[A.bounds],b=(A,F,O,ce,pe,Se,R,C,V=1)=>{if(ce<=0||pe<=0)return;let q=Math.min(ce/2,pe/2),oe=Math.min(Se,q);A.globalAlpha=V,A.beginPath(),oe>0?A.roundRect(F,O,ce,pe,oe):A.rect(F,O,ce,pe),A.fillStyle=R,A.fill(),A.strokeStyle=C,A.lineWidth=1,A.stroke(),A.globalAlpha=1;},x=()=>{let A=u.drag;if(!A.context)return;let F=A.context;if(F.clearRect(0,0,o,i),!e.dragVisible||!m)return;let O=po.drag;b(F,m.current.x,m.current.y,m.current.width,m.current.height,m.borderRadius,O.fillColor,O.borderColor);},L=()=>{let A=u.selection;if(!A.context)return;let F=A.context;if(F.clearRect(0,0,o,i),!e.selectionVisible)return;let O=po.selection;for(let ce of l){let pe=e.selectionIsFading?0:ce.opacity;b(F,ce.current.x,ce.current.y,ce.current.width,ce.current.height,ce.borderRadius,O.fillColor,O.borderColor,pe);}},z=(A,F)=>{let O=u[A];if(!O.context)return;let ce=O.context;ce.clearRect(0,0,o,i);let pe=po[A];for(let Se of F)b(ce,Se.current.x,Se.current.y,Se.current.width,Se.current.height,Se.borderRadius,pe.fillColor,pe.borderColor,Se.opacity);},Y=()=>{if(!n||!t)return;n.setTransform(1,0,0,1,0,0),n.clearRect(0,0,t.width,t.height),n.setTransform(r,0,0,r,0,0),x(),L(),z("grabbed",c),z("processing",d);let A=["drag","selection","grabbed","processing"];for(let F of A){let O=u[F];O.canvas&&n.drawImage(O.canvas,0,0,o,i);}},Ce=(A,F,O)=>{let ce=nr(A.current.x,A.target.x,F),pe=nr(A.current.y,A.target.y,F),Se=nr(A.current.width,A.target.width,F),R=nr(A.current.height,A.target.height,F),C=Math.abs(ce-A.target.x){let A=false;m?.isInitialized&&Ce(m,po.drag.lerpFactor)&&(A=true);for(let O of l)O.isInitialized&&Ce(O,po.selection.lerpFactor)&&(A=true);let F=Date.now();c=c.filter(O=>{let ce=O.id.startsWith("label-");if(O.isInitialized&&Ce(O,po.grabbed.lerpFactor,{interpolateOpacity:ce})&&(A=true),O.createdAt){let pe=F-O.createdAt,Se=yt+Na;if(pe>=Se)return false;if(pe>yt){let R=(pe-yt)/Na;O.opacity=1-R,A=true;}return true}return ce?!(Math.abs(O.opacity-O.targetOpacity)0});for(let O of d)O.isInitialized&&Ce(O,po.processing.lerpFactor)&&(A=true);Y(),A?a=Fe(B):a=null;},G=()=>{a===null&&(a=Fe(B));},le=()=>{M(),G();};return me(Pe(()=>[e.selectionVisible,e.selectionBounds,e.selectionBoundsMultiple,e.selectionIsFading,e.selectionShouldSnap],([A,F,O,,ce])=>{if(!A||!F&&(!O||O.length===0)){l=[],G();return}let pe;O&&O.length>0?pe=O:F?pe=[F]:pe=[],l=pe.map((Se,R)=>{let C=`selection-${R}`,V=l.find(q=>q.id===C);return V?(S(V,Se),ce&&(V.current={...V.target}),V):_(C,Se)}),G();})),me(Pe(()=>[e.dragVisible,e.dragBounds],([A,F])=>{if(!A||!F){m=null,G();return}m?S(m,F):m=_("drag",F),G();})),me(Pe(()=>[e.grabbedBoxes,e.labelInstances],([A,F])=>{let O=A??[],ce=new Set(O.map(C=>C.id)),pe=new Set(c.map(C=>C.id));for(let C of O)pe.has(C.id)||c.push(_(C.id,C.bounds,{createdAt:C.createdAt}));for(let C of c){let V=O.find(q=>q.id===C.id);V&&S(C,V.bounds);}let Se=F??[];for(let C of Se){let V=p(C),q=C.status==="fading"?0:1;for(let oe=0;oete.id===se);J?S(J,k,q):c.push(_(se,k,{opacity:1,targetOpacity:q}));}}let R=new Set;for(let C of Se){let V=p(C);for(let q=0;qC.id.startsWith("label-")?R.has(C.id):ce.has(C.id)),G();})),me(Pe(()=>e.agentSessions,A=>{if(!A||A.size===0){d=[],G();return}let F=[];for(let[O,ce]of A)for(let pe=0;peV.id===R);C?(S(C,Se),F.push(C)):F.push(_(R,Se));}d=F,G();})),ot(()=>{M(),G(),window.addEventListener("resize",le);let A=null,F=()=>{Math.max(window.devicePixelRatio||1,Ma)!==r&&(le(),O());},O=()=>{A&&A.removeEventListener("change",F),A=window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`),A.addEventListener("change",F);};O(),he(()=>{window.removeEventListener("resize",le),A&&A.removeEventListener("change",F),a!==null&&Ke(a);});}),(()=>{var A=xv(),F=t;return typeof F=="function"?Re(F,A):t=A,K(O=>ee(A,"z-index",String(Yi))),A})()};});var mi,lc=X(()=>{mi=(e,t)=>{e.style.height="auto",e.style.height=`${Math.min(e.scrollHeight,t)}px`;};});var Bs,cc=X(()=>{Oe();Bs=e=>{if(e<=0)return Nn;let t=e*ld;return Math.max(ad,Math.min(Nn,t))};});function vp(e){var t,n,o="";if(typeof e=="string"||typeof e=="number")o+=e;else if(typeof e=="object")if(Array.isArray(e)){var i=e.length;for(t=0;t{});var de,qt=X(()=>{Ep();de=(...e)=>xp(e);});var $s,uc=X(()=>{$s=e=>e.elementsCount&&e.elementsCount>1?{tagName:`${e.elementsCount} elements`,componentName:void 0}:{tagName:e.tagName||e.componentName||"element",componentName:e.tagName?e.componentName:void 0};});var Gn,pi=X(()=>{ri();Gn=e=>e==="Enter"?"\u21B5":vn()?`\u2318${e}`:`Ctrl+${e.replace("\u21E7","Shift+")}`;});var Ev,Hs,dc=X(()=>{w();w();w();Ev=D(''),Hs=e=>{let t=()=>e.size??12;return (()=>{var n=Ev();return K(o=>{var i=t(),r=t(),a=e.class;return i!==o.e&&Z(n,"width",o.e=i),r!==o.t&&Z(n,"height",o.t=r),a!==o.a&&Z(n,"class",o.a=a),o},{e:void 0,t:void 0,a:void 0}),n})()};});var Cv,Vs,fc=X(()=>{w();w();w();Cv=D(''),Vs=e=>{let t=()=>e.size??12;return (()=>{var n=Cv();return K(o=>{var i=t(),r=t(),a=e.class;return i!==o.e&&Z(n,"width",o.e=i),r!==o.t&&Z(n,"height",o.t=r),a!==o.a&&Z(n,"class",o.a=a),o},{e:void 0,t:void 0,a:void 0}),n})()};});var Sv,Cp,Sp=X(()=>{w();w();w();Sv=D(''),Cp=e=>{let t=()=>e.size??16;return (()=>{var n=Sv(),o=n.firstChild,i=o.nextSibling,r=i.nextSibling,a=r.nextSibling,u=a.nextSibling,l=u.nextSibling,m=l.nextSibling,c=m.nextSibling,d=c.nextSibling,h=d.nextSibling,y=h.nextSibling;y.nextSibling;return K(E=>{var _=t(),S=t(),p=e.class;return _!==E.e&&Z(n,"width",E.e=_),S!==E.t&&Z(n,"height",E.t=S),p!==E.a&&Z(n,"class",E.a=p),E},{e:void 0,t:void 0,a:void 0}),n})()};});var Av,zs,mc=X(()=>{w();w();w();cc();Av=D('