diff --git a/.changeset/v2-core.md b/.changeset/v2-core.md new file mode 100644 index 0000000..16be852 --- /dev/null +++ b/.changeset/v2-core.md @@ -0,0 +1,27 @@ +--- +'@dynamix-layout/core': major +--- + +**v2: an instance-based, pure layout engine.** See the migration guide for every rename. + +- **`createLayout(options)` replaces `new DynamixLayoutCore()`.** Each instance is independent: there is no module-level state, no static caches, no timers, and nothing touches the DOM. Two layouts on a page, or one per server request, no longer interfere. +- **Immutable snapshots** (`getSnapshot()` / `subscribe()`) share unchanged parts by reference: `tabsets`, `tabs`, `splitters`, `rects` and `drag`. +- **Typed actions** that return whether anything changed and never throw: `moveTab`, `moveTabset`, `selectTab`, `addTab`, `removeTab`, `setTabs`, `resizeSplitter`, `moveSplitterBy`, `maximize`/`restore`, `fold`/`unfold`, plus one drag model for tabs and splitters (`startDrag`, `updateDrag`, `endDrag`, `cancelDrag`) and hit testing (`getDropTarget`, `listDropTargets`). +- **`onLayoutChange(layout, reason)`** reports committed changes. +- **New saved format, `LayoutJSON`** (`version: 2`): readable names, explicit directions, and tab ids as the stable identity. +- **Old layouts:** v1 trees load automatically, or convert them with `migrateLayoutFromV1`. They render pixel-identically, as checked against layouts recorded from v1. +- **Deterministic ids**, safe for server rendering; no `crypto.randomUUID()`, which threw outside secure contexts. +- **Rects are relative to the layout root.** v1 misplaced layouts that weren't at the viewport's top-left. +- **No import-time side effects:** the console banner and `window.__DYNAMIX_LAYOUT__` are gone. Use `version` instead. +- **Fixed:** + - nested rows left behind after some moves; + - stale sizes when every open panel had weight 0; + - a row stuck at its minimum next to a folded tabset; + - the root staying oversized after the container shrank; + - center drops inserting before the last tab (they now append). +- **Packaging:** the CommonJS build loads with `require()` (it was ESM under a `.cjs.js` name), exports maps give each condition its own types, and the package installs on Node 18 and later (it required Node 22). + +**Breaking:** +- `DynamixLayoutCore`, `Node`, `Bond`, `Node.cache`, `Queue`, `createReactiveState`, the comparators, the drop-preview and DOM helpers, and the `LayoutTree`/`NodeOptions` types are removed. +- `updateTree(src, des, area)` becomes `moveTab`/`moveTabset(id, target)`, with `'contain'` renamed to `'center'`. +- `updateDimension` becomes `setContainerRect`, and the debounce arguments are gone; throttle in the adapter instead. diff --git a/.changeset/v2-react.md b/.changeset/v2-react.md new file mode 100644 index 0000000..2aafe28 --- /dev/null +++ b/.changeset/v2-react.md @@ -0,0 +1,32 @@ +--- +'@dynamix-layout/react': major +--- + +**v2: a small component API, headless hooks, accessibility and SSR.** See the migration guide for every rename. + +- **``**: + - `tabs` is a list of objects (`{ id, title, content, closable, target }`); + - `defaultLayout` (uncontrolled) or `layout` (controlled) takes v1 or v2 JSON, and `onLayoutChange(layout, { reason })` reports changes; + - `components`, `classNames` and `styles` replace the wrapper and styling props; + - `ref` gives an imperative handle. +- **The engine is created once and never rebuilt by prop changes.** An inline `tabs` array with unchanged ids does no engine work and remounts nothing, and moving a tab never remounts its content. +- **Headless API:** `useDynamixLayout` + `DynamixLayoutProvider`, `useTabset`, `useTab`, `useSplitter`, `useLayoutState` (re-renders only for the selected value), `useLayoutActions` and `useDragState`. Prop getters merge your refs, classes, styles and handlers. +- **Pointer-event dragging** for tabs, tabsets and splitters: touch long-press, Escape cancels, and it works over iframes. Drags and resizes never re-render React. +- **ResizeObserver** follows the container, so sidebars and flex parents resize the layout. +- **Accessibility:** WAI-ARIA tabs and window splitter patterns, keyboard resizing, keyboard move mode (Mod+Shift+M), and a live region. +- **SSR-safe and deterministic**, with a `'use client'` entry for the Next.js App Router. Works with React 18 and 19; the bundle no longer inlines React 19's JSX runtime. +- **Fixed:** callback refs are honoured, `layoutTree` changes are no longer ignored, and duplicate DOM ids are gone. + +- **Installs on Node 18 and later** (1.x required Node 22). + +**Breaking:** +- Removed: + - the v1 props and wrapper components (`WrapTab*`, `SliderElement`, `HoverElement`, `RootSplitterHoverEl`); + - `getTabOutput`; + - the v1 `useDynamixLayout` return shape. +- `updateJSON` becomes `onLayoutChange`, which receives v2 JSON and is not called on mount. +- `ref` returns a `DynamixLayoutHandle` (`handle.element` is the root). +- Keyboard shortcuts only act when focus is inside the layout. +- Default classes are `.dx-*`, and data attributes are `data-dx-*`. +- The stylesheet is `@dynamix-layout/react/styles.css` (`style.css` still works). +- React 17 is no longer supported. diff --git a/.changeset/v2-solid.md b/.changeset/v2-solid.md new file mode 100644 index 0000000..396c215 --- /dev/null +++ b/.changeset/v2-solid.md @@ -0,0 +1,20 @@ +--- +'@dynamix-layout/solid': major +--- + +**Runs on the v2 core.** `` keeps its props, default components and drag-and-drop, but each component now has its own engine instance. Two Solid layouts on a page no longer share state, and a layout away from the viewport's top-left is placed correctly. + +- **Installs on Node 18 and later** (1.x required Node 22). + +**New:** +- `tabs` is reactive: add an entry to open a tab, remove it to close one. Existing tabs keep their content mounted. +- Closable tabs: a third tuple element `{ title, closable }` and an `onTabClose` prop. +- `onReady(layout)` gives the engine for actions from code (`maximize`, `fold`, `selectTab`, `moveTab`, `reset`, `toJSON`, …). +- `updateJSON(layout, reason)` also says why it was called: `'move'`, `'resize'`, `'select'`, `'fold'`, `'maximize'`, `'tabs'`, `'reset'`, or `'mount'` for the first call (type `LayoutUpdateReason`). + +**Breaking:** +- `layoutTree` accepts v2 JSON (v1 trees are migrated automatically), and `updateJSON` receives v2 `LayoutJSON`. +- `getTabOutput` and the `TabEntry`/`TabInput`/`TabOutput` types are removed, and tab ids are the tab labels. +- `useDynamixLayout` returns a new shape. +- The slider timeout options no longer apply; splitters update once per frame. +- `solid-js` is a peer dependency. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a0450e..24c4e73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,3 +20,21 @@ jobs: - uses: actions/checkout@v7 - uses: ./.github/actions/ci-setup - uses: ./.github/actions/ci-checks + + react-18: + # The React package must work with React 18 as well as 19: install the + # packed packages into a project that only has React 18. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/ci-setup + - name: Build and pack + run: | + pnpm --filter @dynamix-layout/core --filter @dynamix-layout/react build + mkdir -p /tmp/pack + pnpm --filter @dynamix-layout/core --filter @dynamix-layout/react exec pnpm pack --pack-destination /tmp/pack + - name: Smoke test with React 18 + working-directory: scripts/react18-smoke + run: | + npm install --no-audit --no-fund --no-save react@18 react-dom@18 jsdom@26 /tmp/pack/*.tgz + npm test diff --git a/.gitignore b/.gitignore index 7ef234b..c867420 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ pids lib-cov react.html core.html +solid.html # Coverage directory used by tools like istanbul coverage *.lcov @@ -131,3 +132,6 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + +# macOS +.DS_Store diff --git a/README.md b/README.md index 9062275..a79763e 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,19 @@ # 🧩 Dynamix Layout -**Create dynamic, dockable, and resizable layouts with ease, similar to editors like VS Code.** +**Dockable, resizable tab layouts like VS Code's, for React and Solid, built on a framework-agnostic core.**

- -NPM - -NPM +npm: @dynamix-layout/react + + +npm: @dynamix-layout/solid -NPM +npm: @dynamix-layout/core License

@@ -31,156 +31,89 @@

-## Overview - -Dynamix Layout is a powerful JavaScript library designed to help you build complex, multi-panel user interfaces. It provides a core engine for managing the layout logic and a dedicated React wrapper for seamless integration into your React applications. If you've ever wanted to create a user experience with draggable tabs and resizable panels, Dynamix Layout is the tool for you. - ---- - -### Made with ❤️ by [Akash Aman](https://linktr.ee/akash_aman) - ---- -

- + Live Demo

---- - -![Demo](https://raw.githubusercontent.com/xcode-studio/dynamix-layout/main/assets/demo1.gif) - ---- - -## ✨ Key Features - -- **Draggable Tabs**: Easily drag and drop tabs to rearrange them or create new panel splits. -- **Resizable Panels**: Users can click and drag the space between panels to resize them. -- **Dynamic Splits**: Split any panel horizontally or vertically by dropping a tab onto its edge. -- **Save & Restore**: Serialize the entire layout state to JSON and restore it later. -- **Framework-Agnostic Core**: The core logic is written in pure TypeScript with zero dependencies. -- **Official React Wrapper**: A feature-rich React component (``) and hook (`useLayout`) for easy integration. - ---- - -## 📚 Packages in this Monorepo - -This repository contains the following packages: - -### │ 📦 @dynamix-layout/core - -The core, framework-agnostic layout engine. It handles all the complex logic of tree management, dimension calculation, and state updates. You can use this package to integrate Dynamix Layout with any framework (Vue, Svelte, Angular, etc.) or with vanilla JavaScript. +![Dragging tabs between panels and resizing them](https://raw.githubusercontent.com/xcode-studio/dynamix-layout/main/assets/demo1.gif) -➡️ **[View the detailed `@dynamix-layout/core` README](./packages/core/README.md)** +Your tabs live in **tabsets**, arranged in **rows** and separated by draggable **splitters**. Users drag tabs to rearrange or split panels, resize them, and maximize or fold a panel. The layout is plain JSON you can save and restore. -### │ 🚀 @dynamix-layout/react +## Features -The official React component library for Dynamix Layout. It provides a simple-to-use `` component and an advanced `useDynamixLayout` hook that handles all the rendering, state management, and event binding for you. This is the recommended package for all React developers. +- **Drag tabs** onto another tab bar, onto a panel's side to split it, or onto a layout edge. Works with mouse, pen and touch. +- **Resize** with splitters that respect minimum sizes. Only the two neighbouring panels move. +- **Maximize** a panel, or **fold** it to a strip, and get back the exact previous size. +- **Tab content never remounts** when tabs move, so editors, terminals and iframes keep their state. +- **Save and restore** the layout as versioned JSON. Layouts saved by v1 load automatically. +- **Accessible**: WAI-ARIA tabs and window splitter patterns, keyboard resizing, and a keyboard move mode. +- **SSR-safe and fast**: deterministic server markup, and drags and resizes never re-render React. +- **Three levels of API**: one component, headless hooks, or the framework-agnostic core. -➡️ **[View the detailed `@dynamix-layout/react` README](./packages/react/README.md)** +## Packages -### │ 🚀 @dynamix-layout/solid +| Package | | +|---|---| +| [`@dynamix-layout/react`](./packages/react) | `` and headless hooks for React 18 and 19 | +| [`@dynamix-layout/solid`](./packages/solid) | `` for SolidJS | +| [`@dynamix-layout/core`](./packages/core) | The engine: tree, geometry, drag-and-drop and serialization, with no framework | -The official SolidJS wrapper for `@dynamix-layout/core`. It provides a flexible `` component and an advanced `useDynamixLayout` hook to create fully dynamic, resizable, and draggable tab-based layouts with ease. - -➡️ **[View the detailed `@dynamix-layout/solid` README](./packages/solid/README.md)** - ---- - -## 📖 Examples - -This repository contains the following examples: - -| Example | Source Link | Live Demo Link | -|----------------|---------------------------|-------------------------------| -| React | [React](./examples/react) | [dx.xcode.cx](https://dx.xcode.cx) | -| Next.js | [Next.js](./examples/nextjs) | [nextjs.dx.xcode.cx](https://nextjs.dx.xcode.cx) | -| SolidJS | [SolidJS](./examples/solid) | [solidjs.dx.xcode.cx](https://solidjs.dx.xcode.cx) | -| SolidStart | [SolidStart](./examples/solidstart)| [solidstart.dx.xcode.cx](https://solidstart.dx.xcode.cx) | - ---- - -## 📦 Installation - -For React applications, you will need both the `react` and `core` packages. - -```bash -npm install @dynamix-layout/react @dynamix-layout/core -``` - -For Solid applications, you will need both the `solid` and `core` packages. +## Quick start (React) ```bash -npm install @dynamix-layout/solid @dynamix-layout/core +npm install @dynamix-layout/react ``` ---- - -## 🏁 Basic Usage - -Here's how easy it is to get started with the React component: - -```jsx +```tsx import { DynamixLayout } from '@dynamix-layout/react' -import '@dynamix-layout/react/style.css' +import '@dynamix-layout/react/styles.css' -function App() { - - const tabList = [ - ['editor',
Editor
], - ['preview',
Preview
], - ['terminal',
Terminal
], - ] +const tabs = [ + { id: 'editor', title: 'Editor', content: }, + { id: 'terminal', title: 'Terminal', content: }, + { id: 'preview', title: 'Preview', content: }, +] +export function App() { return ( - +
+ +
) } - -export default App ``` -Here's how easy it is to get started with the SolidJS component: - -```jsx -import { DynamixLayout } from '@dynamix-layout/solid' -import '@dynamix-layout/solid/style.css' - -function App() { - - const tabList = [ - ['editor',
Editor
], - ['preview',
Preview
], - ['terminal',
Terminal
], - ]; - - return ( - - ) -} - -export default App -``` +To save the layout, store what `onLayoutChange` gives you and pass it back as `defaultLayout`; see [Persisting layouts](./docs/guides/persisting-layouts.md). + +## Documentation + +- [Getting started](./docs/getting-started.md): concepts, the first layout, and saving and restoring. +- [API reference](./docs/api/README.md): every component, hook and function. +- Guides: + - [Custom tabs and splitters](./docs/guides/custom-components.md) + - [Theming](./docs/guides/theming.md) + - [Persisting layouts](./docs/guides/persisting-layouts.md) + - [Next.js and SSR](./docs/guides/nextjs-and-ssr.md) + - [Keyboard and accessibility](./docs/guides/accessibility.md) + - [Multiple layouts](./docs/guides/multiple-layouts.md) + - [Using the core without React](./docs/guides/core-without-react.md) + - [Performance](./docs/guides/performance.md) +- [Migrating from v1](./docs/migration-v1-to-v2.md). +- [Examples](./examples): every one has an **all-features showcase** (drag, split, resize, close, add, fold, maximize, restore, persist, reset): + - [React](./examples/react) (plus custom components, headless and controlled demos) + - [Next.js](./examples/nextjs) + - [Solid](./examples/solid) + - [SolidStart](./examples/solidstart) + - [Plain JavaScript](./examples/vanilla) on `@dynamix-layout/core`, no framework + +## Contributing + +Issues and pull requests are welcome; see [CONTRIBUTING.md](./CONTRIBUTING.md) and the [issue tracker](https://github.com/xcode-studio/dynamix-layout/issues). --- -## 🤝 Contributing - -Contributions, issues, and feature requests are welcome\! Feel free to check the [issues page](https://github.com/xcode-studio/dynamix-layout/issues). - -## 📝 License - -This project is [MIT](./LICENSE) licensed. - ---- - -
- -[![Patreon](https://img.shields.io/badge/Patreon-Support-F96854?style=for-the-badge&logo=patreon)](https://www.patreon.com/akashaman) -[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-Donate-FFDD00?style=for-the-badge&logo=buy-me-a-coffee)](https://www.buymeacoffee.com/akashaman) -[![Hire Me](https://img.shields.io/badge/Hire%20Me-Email-blue?style=for-the-badge&logo=gmail)](mailto:sir.akashaman@gmail.com) - ### Made with ❤️ by [Akash Aman](https://linktr.ee/akash_aman) -
+[MIT License](./LICENSE) diff --git a/apps/site/src/components/InstallationSection.tsx b/apps/site/src/components/InstallationSection.tsx index 45eccc5..3aad2b6 100644 --- a/apps/site/src/components/InstallationSection.tsx +++ b/apps/site/src/components/InstallationSection.tsx @@ -16,32 +16,29 @@ const InstallationSection = () => { } const installCommandsReact = { - npm: 'npm install @dynamix-layout/react @dynamix-layout/core', - yarn: 'yarn add @dynamix-layout/react @dynamix-layout/core', - pnpm: 'pnpm add @dynamix-layout/react @dynamix-layout/core', + npm: 'npm install @dynamix-layout/react', + yarn: 'yarn add @dynamix-layout/react', + pnpm: 'pnpm add @dynamix-layout/react', } const installCommandsSolidJS = { - npm: 'npm install @dynamix-layout/solid @dynamix-layout/core', - yarn: 'yarn add @dynamix-layout/solid @dynamix-layout/core', - pnpm: 'pnpm add @dynamix-layout/solid @dynamix-layout/core', + npm: 'npm install @dynamix-layout/solid solid-js', + yarn: 'yarn add @dynamix-layout/solid solid-js', + pnpm: 'pnpm add @dynamix-layout/solid solid-js', } const basicExampleReact = `import { DynamixLayout } from '@dynamix-layout/react' -import '@dynamix-layout/react/style.css' +import '@dynamix-layout/react/styles.css' -function App() { +const tabs = [ + { id: 'editor', title: 'Editor', content:
Editor
}, + { id: 'preview', title: 'Preview', content:
Preview
}, + { id: 'terminal', title: 'Terminal', content:
Terminal
}, +] - const tabList = [ - ['editor',
Editor
], - ['preview',
Preview
], - ['terminal',
Terminal
], - ] - - return ( - - ) +function App() { + return } export default App` diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 0000000..530fde4 --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,28 @@ +# API reference + +## `@dynamix-layout/react` + +| Export | What it is | +|---|---| +| [`DynamixLayout`](./dynamix-layout.md) | The component. Most apps only need this. | +| [`useDynamixLayout`](./use-dynamix-layout.md) and `DynamixLayoutProvider` | Headless layout: state and prop getters for a custom UI | +| [`useTabset`](./use-tabset.md) | One tabset: its panel and tab bar | +| [`useTab`](./use-tab.md) | One tab: its button and its content | +| [`useSplitter`](./use-splitter.md) | One splitter | +| [`useLayoutState`](./use-layout-state.md) | Subscribe to part of the layout state | +| [`useLayoutActions`](./use-layout-actions.md) | Stable actions (move, select, maximize, fold, …) | +| [`useDragState`](./use-drag-state.md) | The drag in progress | +| `Panel`, `TabBar`, `Tab`, `TabContent`, `Splitter`, `DropIndicator`, `RootDropZone`, `TabsetToolbar` | The default slot components, for composing your own ([guide](../guides/custom-components.md)) | + +The React package also re-exports from core: `migrateLayoutFromV1`, `isLayoutV1`, `DynamixLayoutError` and the layout types. + +## `@dynamix-layout/core` + +| Export | What it is | +|---|---| +| [`createLayout`](./create-layout.md) | Creates a layout instance (engine) | +| [`LayoutJSON`](./layout-json.md) | The saved-layout format, `LAYOUT_VERSION` | +| [`migrateLayoutFromV1`](./migrate-layout-from-v1.md) and `isLayoutV1` | Convert layouts saved by v1 | +| `DynamixLayoutError` | Thrown for invalid input ([codes](./create-layout.md#errors-and-warnings)) | +| `getTabBarPlacement`, `getTabContentRect`, `getRootDropZoneRect`, `applyRect`, `createFrameScheduler` | Helpers for writing adapters ([guide](../guides/core-without-react.md)) | +| `version` | The package version | diff --git a/docs/api/create-layout.md b/docs/api/create-layout.md new file mode 100644 index 0000000..90c1b26 --- /dev/null +++ b/docs/api/create-layout.md @@ -0,0 +1,141 @@ +# `createLayout` + +Creates a layout engine instance. It's framework-agnostic: no DOM access, no timers, no module-level state. Every instance is independent, so you can run several layouts on a page, or one per server request. + +```ts +import { createLayout } from '@dynamix-layout/core' + +function createLayout(options: LayoutOptions): Layout +``` + +## Options + +| Option | Type | Default | Description | +|---|---|---|---| +| `tabs` | `readonly TabInit[]` | required | The open tabs: `{ id: string; target?: DropTarget }`. Order only matters for the default layout. | +| `initialLayout` | `LayoutJSON \| LayoutTreeV1 \| null` | default layout | A saved layout. v1 trees are migrated automatically, with a `MIGRATED_FROM_V1` warning. | +| `minPanelSize` | `{ width: number; height: number }` | `{ width: 40, height: 40 }` | Smallest tabset size. | +| `splitterSize` | `number` | `10` | Splitter thickness. | +| `foldedSize` | `number` | `minPanelSize.height` | Size of a folded tabset along its row; usually the tab bar height. | +| `createId` | `(kind: 'row' \| 'tabset', hint?: string) => string` | deterministic | Id factory for rows and tabsets. The default gives `ts-` and `row-`, the same on server and client. | +| `onLayoutChange` | `(layout: LayoutJSON, reason: LayoutChangeReason) => void` | | After every committed change. Not called for drag frames, container resizes or `load()`. | +| `onWarning` | `(warning: LayoutWarning) => void` | `console.warn` in development | Non-fatal problems such as repaired saved layouts or unknown ids. | + +Default layout: each tab gets its own tabset, nested with alternating directions: `t1 | (t2 / (t3 | …))`. + +## The `Layout` instance + +Actions return `true` when they changed the layout and `false` when they were refused or did nothing. They never throw. + +| Method | Description | +|---|---| +| `getSnapshot()` | The current [snapshot](#snapshots). | +| `subscribe(listener)` | Called with the new snapshot after every change. Returns an unsubscribe function. | +| `setContainerRect(rect)` | The area to fill. `x`/`y` offset everything, which is how adapters apply padding. | +| `setTabs(tabs)` | Replace the open tabs. Unlisted tabs are removed; new ones are added (at their `target`, else the last active tabset) and activated. | +| `setOptions({ minPanelSize?, splitterSize?, foldedSize? })` | Change sizes without rebuilding. | +| `moveTab(tabId, target)` | Move a tab. See [`DropTarget`](#droptarget). | +| `moveTabset(tabsetId, target)` | Move a whole tabset (into another tabset, beside one, or to an edge). | +| `selectTab(tabId)` | Make a tab active. | +| `addTab(tab, target?)` | Add one tab. | +| `removeTab(tabId)` | Remove one tab. Emptied tabsets and single-child rows are dissolved. | +| `resizeSplitter(splitterId, point)` | Move a splitter so its centre is at `point`, clamped to the neighbours' minimum sizes. | +| `moveSplitterBy(splitterId, delta)` | Move a splitter by `delta` px along its row (keyboard resizing). | +| `maximize(tabsetId)`, `restore()`, `toggleMaximize(tabsetId)` | Maximize one tabset over the whole layout; the others stay in place, hidden. Needs at least two tabsets. | +| `fold(tabsetId)`, `unfold(tabsetId)`, `toggleFold(tabsetId)` | Fold to a strip along its row. Folding a row's last open child unfolds the most recently folded sibling. Needs a sibling. | +| `startDrag(source)` | Start a drag of `{ type: 'tab', tabId }`, `{ type: 'tabset', tabsetId }` or `{ type: 'splitter', splitterId }`. Tab and tabset drags leave maximized mode first. | +| `updateDrag(point, measurements?)` | Move the drag. Tab drags update `snapshot.drag.target`; splitter drags move the splitter. Never calls `onLayoutChange`. | +| `setDragTarget(target, measurements?)` | Set the target directly (keyboard move mode). | +| `endDrag()` | Commit: move the tab, or keep the splitter position, with one `onLayoutChange`. | +| `cancelDrag()` | End without moving; a dragged splitter goes back. | +| `getDropTarget(point, source, measurements?)` | Hit testing without starting a drag. | +| `getDropIndicatorRect(target, measurements?)` | The indicator rect for a target. | +| `listDropTargets(source)` | Every valid target, in reading order. | +| `getSplitterBounds(splitterId)` | `{ value, min, max }` in px, for ARIA. | +| `load(layout)` | Replace the layout (v1 or v2) without calling `onLayoutChange`. | +| `reset()` | Back to `initialLayout`, or the default layout of the current tabs. | +| `toJSON()` | The current [`LayoutJSON`](./layout-json.md). Returns the same object until the layout changes. | +| `destroy()` | Remove all listeners. Later actions are ignored with a `DESTROYED` warning. | + +`measurements` (`DropMeasurements`) describes the tab bars as rendered: `{ tabBars: Map }`, in layout-root coordinates. The engine can't know label widths, so adapters measure them once when a drag starts. Without measurements, tab bars aren't targets, but panels and edges still are. + +### `DropTarget` + +```ts +type DropTarget = + | { type: 'tabset'; tabsetId: string; position: 'top' | 'bottom' | 'left' | 'right' | 'center' } + | { type: 'tab'; tabId: string; position: 'before' | 'after' } + | { type: 'root'; position: 'top' | 'bottom' | 'left' | 'right' } +``` + +- **`tabset` + `center`:** append to that tabset. +- **`tabset` + a side:** beside it. If the side runs across the tabset's row, the tabset is wrapped in a new row. +- **`tab`:** into that tab's tabset, before or after it. +- **`root`:** at an edge of the whole layout. + +Refused moves: + +- a tab onto itself; +- a lone tab onto its own tabset; +- a tabset onto itself or its own tabs; +- the only tabset, or the only tab, in the layout. + +## Snapshots + +```ts +interface LayoutSnapshot { + root: RowNode // the tree + tabsets: ReadonlyMap // id, tabIds, activeTabId, parentDirection, isFolded, isMaximized, isHidden, canFold, canMaximize + splitters: ReadonlyMap // id, rowId, direction, beforeId, afterId, isLocked, isHidden + tabs: ReadonlyMap // id, tabsetId, isActive, isVisible + rects: LayoutRects // container, rows, tabsets, splitters (layout-root coordinates) + maximizedTabsetId: string | null + drag: DragState | null // source, target, indicator +} +``` + +Snapshots are immutable and **share unchanged parts by reference**: + +- A container resize changes `rects`, but not `tabsets`, `splitters` or `tabs`. +- Selecting a tab replaces one `TabsetState` and two `TabState`s; every other entry keeps its identity. + +Compare with `===` at any depth. In development the snapshot objects are frozen. + +## Errors and warnings + +`createLayout` and `load` throw `DynamixLayoutError` for input they can't use: + +| `code` | When | +|---|---| +| `INVALID_LAYOUT` | Malformed saved layout. `error.path` says where, e.g. `root.children[1].weight`. | +| `UNSUPPORTED_VERSION` | A `version` other than 2. | +| `DUPLICATE_TAB_ID` | Duplicate ids in `tabs`, in development. In production the first one wins and a warning is reported. | + +`onWarning` receives `{ code, message, path? }` with these codes: + +- `MIGRATED_FROM_V1` +- `DUPLICATE_TAB_ID` +- `INVALID_ACTIVE_TAB` +- `INVALID_WEIGHT` +- `UNKNOWN_ID` +- `DESTROYED` + +## Example + +```ts +const layout = createLayout({ + tabs: [{ id: 'editor' }, { id: 'terminal' }], + initialLayout: JSON.parse(localStorage.getItem('layout') ?? 'null'), + onLayoutChange: (json) => localStorage.setItem('layout', JSON.stringify(json)), +}) + +const unsubscribe = layout.subscribe((snapshot) => render(snapshot)) +layout.setContainerRect({ x: 0, y: 0, width: 1200, height: 800 }) + +// A splitter drag: transient frames, one change at the end. +layout.startDrag({ type: 'splitter', splitterId }) +layout.updateDrag({ x: 400, y: 300 }) +layout.endDrag() +``` + +More in [Using the core without React](../guides/core-without-react.md). diff --git a/docs/api/dynamix-layout.md b/docs/api/dynamix-layout.md new file mode 100644 index 0000000..766f158 --- /dev/null +++ b/docs/api/dynamix-layout.md @@ -0,0 +1,113 @@ +# `` + +A docking layout: tabs in tabsets, split by draggable splitters, with tab drag-and-drop, maximize and fold. Built on [`useDynamixLayout`](./use-dynamix-layout.md). + +```tsx +import { DynamixLayout } from '@dynamix-layout/react' +import '@dynamix-layout/react/styles.css' +``` + +## Signature + +```ts +const DynamixLayout: ForwardRefExoticComponent> +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `tabs` | `readonly TabItem[]` | required | The open tabs. See [`TabItem`](#tabitem). | +| `defaultLayout` | `LayoutJSON \| LayoutTreeV1` | default layout | Uncontrolled: the initial layout. Later changes are ignored. | +| `layout` | `LayoutJSON \| LayoutTreeV1` | | Controlled: the layout to show. Update it from `onLayoutChange`. | +| `onLayoutChange` | `(layout: LayoutJSON, details: { reason: LayoutChangeReason }) => void` | | After every committed change. `reason` is `'move'`, `'resize'`, `'select'`, `'fold'`, `'maximize'`, `'tabs'` or `'reset'`. Not called on mount, on container resize, or during a drag. | +| `onTabClose` | `(tabId: string) => void` | | Called by a closable tab's close button and the Delete key. Remove the tab from `tabs` to close it. | +| `minPanelSize` | `{ width: number; height: number }` | `{ width: 40, height: 40 }` | Smallest tabset size. The height is never less than `tabBarHeight`. | +| `splitterSize` | `number` | `10` | Splitter thickness, in px. | +| `tabBarHeight` | `number` | `40` | Tab bar height, and the size of a folded strip. | +| `showTabBar` | `boolean` | `true` | Hiding the tab bar also disables folding. | +| `padding` | `number \| { top?, right?, bottom?, left? }` | `0` | Space between the root's edge and the layout, in px. | +| `allowMaximize` | `boolean` | `true` | Shows the maximize button, and enables double-click and the shortcut. | +| `allowFold` | `boolean` | `true` | Shows the fold button and enables the shortcut. | +| `maximizeOnDoubleClick` | `boolean` | `true` | Double-click a tab bar to maximize or restore. | +| `keyboardShortcuts` | `boolean` | `true` | Alt/Option + "+" maximizes and Alt/Option + "-" folds the focused tabset. | +| `tabActivation` | `'automatic' \| 'manual'` | `'automatic'` | Arrow keys select tabs as they move focus (`automatic`), or only move focus (`manual`; Enter or Space selects). | +| `resizeThrottleMs` | `number` | `0` | Throttle for container resizes; `0` means once per animation frame. | +| `components` | `Partial` | | Replace any slot component. See [custom components](../guides/custom-components.md). | +| `classNames` | `Partial>` | | Extra class names, by slot. | +| `styles` | `Partial>` | | Extra styles, by slot. Positioning styles always win. | +| `id`, `className`, `style`, `aria-*`, `on*`, … | `HTMLAttributes` | | Passed to the root `
`. `id` also prefixes the DOM ids used for ARIA. | + +Slots (`LayoutSlot`): `root`, `panel`, `tabBar`, `tab`, `activeTab`, `tabClose`, `tabContent`, `splitter`, `dropIndicator`, `rootDropZone`, `toolbar`, `toolbarButton`. + +### `TabItem` + +| Field | Type | Default | Description | +|---|---|---|---| +| `id` | `string` | required | Unique and stable; saved layouts store it. | +| `title` | `ReactNode` | `id` | The tab's label. Text titles are also used in ARIA labels. | +| `content` | `ReactNode` | required | Rendered once, kept mounted while the tab exists. | +| `closable` | `boolean` | `false` | Shows a close button; closing calls `onTabClose`. | +| `target` | `DropTarget` | last active tabset | Where the tab goes the first time it appears. | + +## Imperative handle + +`ref` gives a `DynamixLayoutHandle`. Every function is stable: + +| Member | Description | +|---|---| +| `element` | The root `
`, or `null` before mount. | +| `moveTab(tabId, target)` / `moveTabset(tabsetId, target)` | Move to a [`DropTarget`](./create-layout.md#droptarget). Return `true` when something moved. | +| `selectTab(tabId)` | Make a tab active. | +| `focusTab(tabId)` | Move keyboard focus to a tab. | +| `maximize(tabsetId)`, `restore()`, `toggleMaximize(tabsetId)` | Maximize a tabset over the whole layout, and restore. | +| `fold(tabsetId)`, `unfold(tabsetId)`, `toggleFold(tabsetId)` | Fold a tabset to a strip, and unfold. | +| `reset()` | Back to `defaultLayout`, or the default layout of the current tabs. | +| `toJSON()` | The current [`LayoutJSON`](./layout-json.md). | +| `getSnapshot()` | The current [snapshot](./create-layout.md#snapshots). | + +There is no `addTab`/`removeTab` on the handle: change `tabs` instead. `tabs` is the single source of truth for which tabs exist. + +## Examples + +**Persisted, with custom tabs and theming:** + +```tsx + save(layout)} + minPanelSize={{ width: 120, height: 80 }} + splitterSize={6} + tabBarHeight={36} + padding={8} + components={{ Tab: MyTab }} + classNames={{ tab: 'my-tab', activeTab: 'is-active', splitter: 'my-splitter' }} + styles={{ tabBar: { background: '#111' } }} +/> +``` + +**Controlled** ([guide](../guides/persisting-layouts.md#controlled-mode)): + +```tsx +const [layout, setLayout] = useState() + +``` + +**Driving it from code:** + +```tsx +const ref = useRef(null) + +// later +ref.current?.moveTab('terminal', { type: 'root', position: 'bottom' }) +``` + +## Common pitfalls + +- **Nothing shows.** The layout fills its parent, so the parent needs a height. +- **Unstyled layout.** Import `@dynamix-layout/react/styles.css` once. +- **A tab didn't close.** Closing calls `onTabClose`; the tab stays until you remove it from `tabs`. +- **Ids must be unique and stable.** Changing a tab's id is a remove plus an add, so its content remounts. Changing only `title` or `content` is cheap and doesn't touch the layout. +- **Controlled mode reverts changes you don't store.** If `onLayoutChange` doesn't update `layout`, the user's change is undone. Use `defaultLayout` if you only want to observe changes. +- **Custom slot components must forward `ref`** (`forwardRef` on React 18). diff --git a/docs/api/layout-json.md b/docs/api/layout-json.md new file mode 100644 index 0000000..4d604f6 --- /dev/null +++ b/docs/api/layout-json.md @@ -0,0 +1,87 @@ +# `LayoutJSON` + +The saved-layout format: what `onLayoutChange` and `toJSON()` give you, and what `defaultLayout`, `layout`, `initialLayout` and `load()` accept. + +```ts +const LAYOUT_VERSION = 2 + +interface LayoutJSON { + version: 2 + root: RowJSON + /** Id of the maximized tabset, if any. */ + maximizedTabsetId?: string +} + +interface RowJSON { + type: 'row' + id: string + /** Share of the parent's space above the minimum sizes (like CSS flex-grow). Ignored on the root. */ + weight: number + direction: 'horizontal' | 'vertical' + children: (RowJSON | TabsetJSON)[] +} + +interface TabsetJSON { + type: 'tabset' + id: string + weight: number + activeTabId?: string + /** Written only when true. */ + isFolded?: boolean + children: TabJSON[] +} + +interface TabJSON { + type: 'tab' + /** The id you gave the tab. */ + id: string +} +``` + +## Example + +```json +{ + "version": 2, + "root": { + "type": "row", "id": "root", "weight": 100, "direction": "horizontal", + "children": [ + { "type": "tabset", "id": "ts-editor", "weight": 140, "activeTabId": "editor", + "children": [{ "type": "tab", "id": "editor" }] }, + { "type": "row", "id": "row-1", "weight": 60, "direction": "vertical", + "children": [ + { "type": "tabset", "id": "ts-terminal", "weight": 100, "activeTabId": "terminal", + "children": [{ "type": "tab", "id": "terminal" }] }, + { "type": "tabset", "id": "ts-preview", "weight": 100, "activeTabId": "preview", "isFolded": true, + "children": [{ "type": "tab", "id": "preview" }] } + ] } + ] + } +} +``` + +## Semantics + +- **Weights are proportions, not pixels.** Each row gives its children their minimum size, then shares the rest by weight. Folded tabsets get no share. A weight of `0` is valid: it's what a panel dragged to its minimum gets. So a saved layout keeps its proportions at any container size, and resizing the container never changes the JSON. +- **Directions alternate.** A row's children never have its own direction; such rows are merged when loading. The root may be vertical. +- **Tabs are identified by your ids.** Rows and tabsets have generated ids that are kept across saves; `maximizedTabsetId` refers to one. +- **Splitters and pixel sizes are not stored.** Splitters are derived from the tree. + +## Loading + +Saved layouts are validated and repaired: + +- Invalid weights become 100. +- An `activeTabId` that isn't in the tabset falls back to the first tab. +- Duplicate tabs keep their first occurrence. +- Empty tabsets and rows are removed. +- Single-child rows are dissolved. + +Each repair is reported through `onWarning`. Input that can't be used throws `DynamixLayoutError` with the path of the problem. + +The saved layout and the open `tabs` are reconciled: + +- Tabs missing from `tabs` are removed. +- Tabs missing from the layout are added to the first tabset (they don't become active). + +Layouts saved by v1 (`{ typNode, nodName, … }`) are recognized and migrated; see [`migrateLayoutFromV1`](./migrate-layout-from-v1.md). diff --git a/docs/api/migrate-layout-from-v1.md b/docs/api/migrate-layout-from-v1.md new file mode 100644 index 0000000..a857a48 --- /dev/null +++ b/docs/api/migrate-layout-from-v1.md @@ -0,0 +1,59 @@ +# `migrateLayoutFromV1` and `isLayoutV1` + +Converts a layout saved by v1 (`DynamixLayoutCore._root.toJSON()`, or the `updateJSON` payload) to [`LayoutJSON`](./layout-json.md). + +You rarely need to call it: `createLayout`, `layout.load()`, and the React and Solid components accept v1 trees and migrate them, with a `MIGRATED_FROM_V1` warning. Call it to convert stored data once, for example in a database migration. + +```ts +import { isLayoutV1, migrateLayoutFromV1 } from '@dynamix-layout/core' + +function isLayoutV1(input: unknown): input is LayoutTreeV1 +function migrateLayoutFromV1(tree: LayoutTreeV1, options?: { onWarning?: (warning: LayoutWarning) => void }): LayoutJSON +``` + +| Parameter | Type | Description | +|---|---|---| +| `tree` | `LayoutTreeV1` | The v1 layout. | +| `options.onWarning` | `(warning) => void` | Receives each repair. Default: ignore. | + +Returns the equivalent v2 layout. Throws `DynamixLayoutError('INVALID_LAYOUT')` when `tree` isn't a v1 layout. + +## Example + +```ts +const stored = JSON.parse(localStorage.getItem('layout') ?? 'null') +if (isLayoutV1(stored)) { + localStorage.setItem('layout', JSON.stringify(migrateLayoutFromV1(stored))) +} +``` + +## Mapping + +| v1 | v2 | +|---|---| +| `typNode` | `type` | +| row and tabset `uidNode` | `id` (kept, so `nodMaxd` still resolves) | +| tab `nodName` | tab `id`. A tab's identity in v1 was its label; tab `uidNode`s were regenerated every session and are dropped. | +| `nodPart` | `weight` (same meaning) | +| depth | `direction`: the root and even depths are `horizontal`, odd depths `vertical` | +| tabset `nodOpen` | `activeTabId` | +| tabset `nodFold: true` | `isFolded: true` | +| root `nodMaxd` | `maximizedTabsetId` | + +**Migrated layouts render pixel-identically.** This was checked against 112 layouts recorded from the v1 engine. The exceptions are layouts saved in states caused by v1 bugs; for those, v2 shows the intended result: +- a row stuck at its minimum size next to a folded tabset; +- stale sizes when every open panel had weight 0. + +## Edge cases + +| v1 input | Result | +|---|---| +| A root with one nested row (left by some v1 moves) | The row is flattened into the root, which becomes vertical. The rendering is identical. | +| `nodOpen` naming a missing tab | The first tab is active (`INVALID_ACTIVE_TAB` warning). | +| Duplicate tab names | The first one wins, later ones are dropped (`DUPLICATE_TAB_ID`). | +| `nodPart` that is negative or `NaN` | `100` (`INVALID_WEIGHT`). `0` and fractions are kept. | +| Empty tabsets or rows | Removed. | +| A tab directly inside a row | Wrapped in a tabset `ts-`. | +| A root that is a tabset | Wrapped in a root row. | +| `nodMaxd` naming a missing tabset | Dropped. | +| `bond` entries, unknown fields | Ignored. | diff --git a/docs/api/use-drag-state.md b/docs/api/use-drag-state.md new file mode 100644 index 0000000..a994594 --- /dev/null +++ b/docs/api/use-drag-state.md @@ -0,0 +1,23 @@ +# `useDragState` + +The drag in progress, or `null`. + +```ts +function useDragState(): DragState | null + +interface DragState { + source: { type: 'tab'; tabId: string } | { type: 'tabset'; tabsetId: string } | { type: 'splitter'; splitterId: string } + target: DropTarget | null // where it would land; null over nothing valid + indicator: Rect | null // the drop indicator rect, in layout-root coordinates +} +``` + +It changes when a drag starts or ends, or when the target changes, not on every pointer move. Use it for drag feedback, such as dimming the source or showing a "drop here" hint. Tab drags themselves are started by `useTab().getTabProps` and `useTabset().getTabBarProps`. + +```tsx +function DragHint() { + const drag = useDragState() + if (!drag || drag.source.type === 'splitter') return null + return
{drag.target ? 'Release to drop' : 'Drag onto a panel'}
+} +``` diff --git a/docs/api/use-dynamix-layout.md b/docs/api/use-dynamix-layout.md new file mode 100644 index 0000000..7b50718 --- /dev/null +++ b/docs/api/use-dynamix-layout.md @@ -0,0 +1,109 @@ +# `useDynamixLayout` and `DynamixLayoutProvider` + +The headless layout. It gives you state, prop getters and actions, and you render every element yourself. [``](./dynamix-layout.md) is built on it. + +```ts +function useDynamixLayout(options: UseDynamixLayoutOptions): UseDynamixLayoutResult +function DynamixLayoutProvider(props: { controller: LayoutController; children?: ReactNode }): ReactElement +``` + +## Options + +Everything `` accepts except `components`, `classNames`, `styles` and the root's HTML attributes, plus: + +| Option | Type | Default | Description | +|---|---|---|---| +| `tabs` | `readonly HeadlessTabItem[]` | required | Like [`TabItem`](./dynamix-layout.md#tabitem), but `content` is optional: you render content yourself. | +| `id` | `string` | `useId()` | Prefix for the DOM ids used in ARIA relationships. | +| `defaultLayout`, `layout`, `onLayoutChange`, `onTabClose`, `minPanelSize`, `splitterSize`, `tabBarHeight`, `showTabBar`, `padding`, `allowMaximize`, `allowFold`, `maximizeOnDoubleClick`, `keyboardShortcuts`, `tabActivation`, `resizeThrottleMs` | | | Same as [``](./dynamix-layout.md#props). | + +## Returns + +| Field | Type | Stable? | Description | +|---|---|---|---| +| `controller` | `LayoutController` | yes | Pass to `` so the other hooks work. | +| `getRootProps` | `PropGetter` | yes | Props for the root element: a ref (it observes the root's size), `position: relative`, keyboard shortcuts and focus tracking. | +| `tabsets` | `readonly TabsetState[]` | until the tabsets change | Each tabset's id, tab ids, active tab, fold and maximize state. Not affected by resizes or drag frames. | +| `splitters` | `readonly SplitterState[]` | until the splitters change | | +| `tabs` | `readonly TabState[]` | until the tabs change | Each tab's tabset and whether it's active and visible. | +| `tabIds` | `readonly string[]` | until the ids change | Tab ids in `tabs` order. **Render tab contents in this order:** it never changes when tabs move, so content never remounts. | +| `dropIndicator` | `{ target: DropTarget; props: SlotProps } \| null` | changes with the target | Present while a drag has a valid target. Spread `props` on an element. | +| `rootDropZones` | `readonly { side, isActive, props }[]` | changes with the drag | The four layout-edge targets, present while a tab or tabset is dragged. | +| `actions` | `LayoutActions` | yes | Same as [`useLayoutActions`](./use-layout-actions.md). | + +**Positions are not part of the returned state.** The library writes `left`/`top`/`width`/`height` to the elements you spread its props on. That's why pointer moves and resizes never re-render your components; see [Performance](../guides/performance.md). + +## Example + +```tsx +import { + DynamixLayoutProvider, + useDynamixLayout, + useSplitter, + useTab, + useTabset, +} from '@dynamix-layout/react' + +function MyLayout({ tabs }: { tabs: HeadlessTabItem[] }) { + const { controller, getRootProps, tabsets, splitters, tabIds, dropIndicator } = useDynamixLayout({ tabs }) + return ( + +
+ {tabsets.map((tabset) => )} + {tabIds.map((id) => )} + {splitters.map((splitter) => )} + {dropIndicator &&
} +
+ + ) +} + +function MyTabset({ tabsetId }: { tabsetId: string }) { + const { tabset, getPanelProps, getTabBarProps } = useTabset(tabsetId) + if (!tabset) return null + return ( + <> +
+
+ {tabset.tabIds.map((id) => )} +
+ + ) +} + +function MyTab({ tabId }: { tabId: string }) { + const { item, getTabProps } = useTab(tabId) + return +} + +function MyContent({ tabId }: { tabId: string }) { + const { item, getTabContentProps } = useTab(tabId) + return
{item?.content}
+} + +function MySplitter({ splitterId }: { splitterId: string }) { + const { getSplitterProps } = useSplitter(splitterId) + return
+} +``` + +The full working version is in [`examples/react/src/examples/HeadlessExample.tsx`](../../examples/react/src/examples/HeadlessExample.tsx). + +## Prop getters + +Every `getXProps(props?)` merges your props with the library's: + +- `className`s are joined; +- `style`s are merged, with positioning applied last; +- `ref`s are combined; +- for event handlers, yours runs first. Call `event.preventDefault()` in it to skip the library's handler. + +Every other prop the library sets (ARIA, `data-*`, `id`, `tabIndex`) wins, so accessibility stays correct. + +Headless layouts work without the stylesheet: positioned elements get `position: absolute` and the `hidden` attribute inline. Import `styles.css` for the default look, or the `[data-dx-hidden] { display: none !important }` rule if your elements set their own `display`. + +## Pitfalls + +- **The hooks need the provider.** Without it they throw: `` `useTab` must be used inside or . `` +- **Render each tab's content once,** as a direct child of the root, in `tabIds` order, not inside its tabset. The library positions it over the right panel. +- **Spread the prop getters' props, and keep their `ref`.** Without the ref, an element can't be positioned or measured. diff --git a/docs/api/use-layout-actions.md b/docs/api/use-layout-actions.md new file mode 100644 index 0000000..b92202c --- /dev/null +++ b/docs/api/use-layout-actions.md @@ -0,0 +1,33 @@ +# `useLayoutActions` + +Layout actions from anywhere inside a layout. The object and every function in it keep their identity for the layout's lifetime, so they're safe in effect dependencies and memoized callbacks. + +```ts +function useLayoutActions(): LayoutActions +``` + +| Action | Description | +|---|---| +| `moveTab(tabId, target)` | Move a tab to a [`DropTarget`](./create-layout.md#droptarget). Returns `true` if it moved. | +| `moveTabset(tabsetId, target)` | Move a whole tabset. | +| `selectTab(tabId)` | Make a tab active. | +| `maximize(tabsetId)`, `restore()`, `toggleMaximize(tabsetId)` | Maximize over the whole layout, and restore. | +| `fold(tabsetId)`, `unfold(tabsetId)`, `toggleFold(tabsetId)` | Fold to a strip, and unfold. | +| `reset()` | Back to the initial layout. | +| `toJSON()` | The current [`LayoutJSON`](./layout-json.md). | +| `getSnapshot()` | The current snapshot (doesn't subscribe). | + +Actions return `false` instead of throwing when they're refused (for example, moving the only tabset) or when the id is unknown (with a development warning). + +## Example + +```tsx +function OpenInSplit({ tabId }: { tabId: string }) { + const { moveTab, selectTab } = useLayoutActions() + return ( + + ) +} +``` diff --git a/docs/api/use-layout-state.md b/docs/api/use-layout-state.md new file mode 100644 index 0000000..b1f029b --- /dev/null +++ b/docs/api/use-layout-state.md @@ -0,0 +1,36 @@ +# `useLayoutState` + +Reads part of the layout state from anywhere inside a layout, and re-renders only when that part changes. + +```ts +function useLayoutState(selector: (snapshot: LayoutSnapshot) => T, isEqual?: (a: T, b: T) => boolean): T +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `selector` | `(snapshot) => T` | required | Picks a value from the [snapshot](./create-layout.md#snapshots). | +| `isEqual` | `(a, b) => boolean` | `Object.is` | Decides whether the value changed. | + +Snapshots share unchanged parts by reference: maps, entries and rects keep their identity until they change. So selecting an object, like `s.tabsets.get(id)`, re-renders only when that tabset changes. Built on `useSyncExternalStore`, so it's safe with concurrent rendering and SSR. + +## Examples + +```tsx +const activeTabId = useLayoutState((s) => s.tabsets.get(tabsetId)?.activeTabId) +const isMaximized = useLayoutState((s) => s.maximizedTabsetId !== null) +const tabCount = useLayoutState((s) => s.tabs.size) +``` + +Derived arrays need an `isEqual`, or they re-render on every change: + +```tsx +const ids = useLayoutState( + (s) => [...s.tabs.keys()], + (a, b) => a.length === b.length && a.every((id, i) => id === b[i]) +) +``` + +## Pitfalls + +- **Don't select `s.rects`** unless you need positions. They change on every resize and drag frame. +- **It must be used inside a layout** (`` or ``). diff --git a/docs/api/use-splitter.md b/docs/api/use-splitter.md new file mode 100644 index 0000000..d27025d --- /dev/null +++ b/docs/api/use-splitter.md @@ -0,0 +1,39 @@ +# `useSplitter` + +State and prop getters for one splitter. + +```ts +function useSplitter(splitterId: string): UseSplitterResult +``` + +## Returns + +| Field | Type | Description | +|---|---|---| +| `splitter` | `SplitterState \| undefined` | `id`, `rowId`, `direction`, `beforeId`, `afterId`, `isLocked` (next to a folded tabset), `isHidden` (while maximized). | +| `isDragging` | `boolean` | Being dragged. | +| `direction` | `'horizontal' \| 'vertical' \| undefined` | Direction of the row it divides. A `horizontal` row has a vertical bar. | +| `value`, `min`, `max` | `number` | The size of the panel before the splitter and its limits, in px, **as of the last render**. They aren't reactive, because they change on every drag frame. | +| `getSplitterProps` | `PropGetter` | Described below. | + +`getSplitterProps` gives the element: + +- `role="separator"` and `aria-orientation`; +- `aria-valuenow`, `aria-valuemin` and `aria-valuemax` as percentages, kept current by the library; +- `aria-controls` and an `aria-label` built from the neighbours' active tabs; +- `tabIndex`, plus `data-dx-direction` and `data-dx-locked`; +- dragging with pointer events, and resizing with arrow keys, Home and End. + +## Example + +```tsx +function Splitter({ splitterId }: { splitterId: string }) { + const { isDragging, getSplitterProps } = useSplitter(splitterId) + return
+} +``` + +## Pitfalls + +- **Give it a cursor and `touch-action: none`** (`styles.css` does): `ew-resize` for `data-dx-direction="horizontal"`, `ns-resize` for vertical. +- **Locked splitters can't be dragged.** Unfold the folded neighbour first. diff --git a/docs/api/use-tab.md b/docs/api/use-tab.md new file mode 100644 index 0000000..149a08c --- /dev/null +++ b/docs/api/use-tab.md @@ -0,0 +1,41 @@ +# `useTab` + +State and prop getters for one tab: its button in the tab bar and its content. + +```ts +function useTab(tabId: string): UseTabResult +``` + +## Returns + +| Field | Type | Description | +|---|---|---| +| `tab` | `TabState \| undefined` | `id`, `tabsetId`, `isActive`, `isVisible`. | +| `item` | `HeadlessTabItem \| undefined` | The item you passed in `tabs` (`title`, `content`, `closable`, …). | +| `isActive` | `boolean` | Shown in its tabset. | +| `isDragging` | `boolean` | Being dragged. | +| `select()` | `() => void` | Stable. | +| `close()` | `() => void` | Stable. Calls `onTabClose` when the tab is closable. | +| `getTabProps` | `PropGetter` | `role="tab"`, `id`, `aria-selected`, `aria-controls`, roving `tabIndex`, `data-state="active\|inactive"`. Click selects, dragging moves the tab, and keys follow the [tabs pattern](../guides/accessibility.md). | +| `getTabContentProps` | `PropGetter` | `role="tabpanel"`, `aria-labelledby`, positioned over the tabset and hidden while inactive. | + +Re-renders when this tab's state or its item changes. + +## Example + +```tsx +function Tab({ tabId }: { tabId: string }) { + const { item, isActive, close, getTabProps } = useTab(tabId) + return ( +
+ {item?.title} + {item?.closable && } +
+ ) +} +``` + +## Pitfalls + +- **Stop `pointerdown` on buttons inside a tab,** or pressing them starts a tab drag. +- **Render content with `getTabContentProps` once per tab,** not inside the tab bar. See [`useDynamixLayout`](./use-dynamix-layout.md#pitfalls). diff --git a/docs/api/use-tabset.md b/docs/api/use-tabset.md new file mode 100644 index 0000000..5801ddc --- /dev/null +++ b/docs/api/use-tabset.md @@ -0,0 +1,43 @@ +# `useTabset` + +State and prop getters for one tabset. + +```ts +function useTabset(tabsetId: string): UseTabsetResult +``` + +## Returns + +| Field | Type | Description | +|---|---|---| +| `tabset` | `TabsetState \| undefined` | `id`, `tabIds`, `activeTabId`, `parentDirection`, `isFolded`, `isMaximized`, `isHidden`, `canFold`, `canMaximize`. `undefined` once the tabset no longer exists. | +| `isRotated` | `boolean` | The tab bar is drawn as a vertical strip (folded in a side-by-side row). | +| `getPanelProps` | `PropGetter` | The tabset's box, positioned by the library. Put backgrounds and borders here. | +| `getTabBarProps` | `PropGetter` | `role="tablist"` and `aria-orientation`. Dragging its background moves the whole tabset, and double-clicking it maximizes. It carries `data-dx-folded`, `data-dx-maximized`, `data-dx-rotated` and `data-dx-hidden`. | +| `toggleMaximize()` | `() => void` | Stable. | +| `toggleFold()` | `() => void` | Stable. | + +Re-renders when this tabset's state changes (its tabs, active tab, fold or maximize), not on resizes or drags. + +## Example + +```tsx +function Tabset({ tabsetId }: { tabsetId: string }) { + const { tabset, getPanelProps, getTabBarProps, toggleMaximize } = useTabset(tabsetId) + if (!tabset) return null + return ( + <> +
+
+ {tabset.tabIds.map((id) => )} + +
+ + ) +} +``` + +## Pitfalls + +- **The tab bar is rotated with `transform: rotate(90deg)`** when `isRotated` is true; keep `transform-origin: 0 0` (as in `styles.css`) if you style it. +- **Buttons inside the tab bar don't start tabset drags,** and neither do tabs, links or inputs. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..3523482 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,115 @@ +# Getting started + +This guide uses React. The concepts are the same in [Solid](../packages/solid/README.md) and in the [core without a framework](./guides/core-without-react.md). + +## Concepts + +A layout is a **tree**: + +- A **tab** is one of your views, such as an editor, a terminal or a preview. You give each tab a stable `id`. +- A **tabset** is a group of tabs that shows one at a time, with a **tab bar** on top. It's drawn inside a **panel**. +- A **row** lays out its children (tabsets or more rows) side by side (`horizontal`) or stacked (`vertical`). Rows nest with alternating directions. The root is a row. +- A **splitter** sits between two neighbours in a row. Dragging it resizes those two and nothing else. + +Each row and tabset has a **weight**. A row gives every child its minimum size, then shares the remaining space in proportion to the weights, like CSS `flex-grow`. Splitter drags change the weights, which is why a saved layout keeps its proportions in any window size. + +Users change the tree by: + +- **dragging a tab** onto another tab bar (to join it), onto a panel's side (to split it), or onto an edge of the layout; +- **dragging a tab bar** to move a whole tabset; +- **dragging splitters**; +- **maximizing** a tabset over the whole layout, or **folding** it to a strip. Both restore the exact previous size. + +The tree is plain JSON ([`LayoutJSON`](./api/layout-json.md)) that you can save and restore. + +## The first layout + +```bash +npm install @dynamix-layout/react +``` + +```tsx +import { DynamixLayout, type TabItem } from '@dynamix-layout/react' +import '@dynamix-layout/react/styles.css' + +const tabs: TabItem[] = [ + { id: 'editor', title: 'Editor', content: }, + { id: 'terminal', title: 'Terminal', content: }, + { id: 'preview', title: 'Preview', content: }, +] + +export function App() { + return ( +
+ +
+ ) +} +``` + +- **The layout fills its parent.** Give the parent a height. It follows the parent's size with a `ResizeObserver`, so sidebars and flex layouts work. +- **Without a saved layout,** every tab gets its own tabset, arranged as `editor | (terminal / preview)`. +- **Tab ids must be unique and stable.** They identify tabs in saved layouts. Duplicate ids throw in development. +- **Content is mounted once** and is never remounted when tabs move, so editors, terminals and iframes keep their state. + +## Saving and restoring + +`onLayoutChange` is called after every change the user commits: a drop, a splitter release, selecting a tab, maximize or fold, and tabs being added or removed. It isn't called on mount or for each pointer move. Store what it gives you and pass it back as `defaultLayout`: + +```tsx +const STORAGE_KEY = 'my-app:layout' + +export function App() { + const [saved] = useState(() => { + const json = localStorage.getItem(STORAGE_KEY) + return json ? JSON.parse(json) : undefined + }) + return ( +
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(layout))} + /> +
+ ) +} +``` + +The saved layout and `tabs` don't have to match: + +- A tab in `tabs` that's missing from the saved layout (a feature you shipped since) is added to the first tabset. +- A tab in the saved layout that's missing from `tabs` is removed. + +Layouts saved by v1 are migrated automatically; see [Persisting layouts](./guides/persisting-layouts.md). + +## Adding, closing and selecting tabs + +`tabs` is the list of open tabs. Add an item to open a tab, and remove it to close one. A new tab opens in the tabset the user last used, or where its `target` says: + +```tsx +const [tabs, setTabs] = useState(initialTabs) + +const openFile = (path: string) => + setTabs((current) => [...current, { id: path, title: basename(path), content: , closable: true }]) + + setTabs((current) => current.filter((tab) => tab.id !== id))} +/> +``` + +To control the layout from code, use the `ref` handle ([`DynamixLayoutHandle`](./api/dynamix-layout.md#imperative-handle)) or [`useLayoutActions`](./api/use-layout-actions.md): + +```tsx +const layout = useRef(null) +layout.current?.moveTab('terminal', { type: 'root', position: 'bottom' }) +layout.current?.selectTab('preview') +``` + +## Next steps + +- Restyle it: [Theming](./guides/theming.md), or replace parts with [custom components](./guides/custom-components.md). +- Build a completely custom UI with the [headless hooks](./api/use-dynamix-layout.md). +- Render on the server: [Next.js and SSR](./guides/nextjs-and-ssr.md). +- Keyboard users: [Keyboard and accessibility](./guides/accessibility.md). diff --git a/docs/guides/accessibility.md b/docs/guides/accessibility.md new file mode 100644 index 0000000..615ca84 --- /dev/null +++ b/docs/guides/accessibility.md @@ -0,0 +1,64 @@ +# Keyboard and accessibility + +Every part follows a WAI-ARIA pattern and can be used without a mouse. + +## Tabs ([Tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/)) + +- **Roles:** the tab bar is `role="tablist"` (`aria-orientation="vertical"` on a folded strip). Each tab is `role="tab"` with `aria-selected` and `aria-controls`. Its content is `role="tabpanel"` with `aria-labelledby`. +- **Focus:** one tab per tab bar is focusable (roving `tabindex`), so Tab moves between tab bars and contents. + +| Key (on a tab) | Action | +|---|---| +| ← / → (↑ / ↓ on a folded strip) | Focus the previous/next tab, wrapping. With `tabActivation="automatic"` (default) it's also selected. | +| Home / End | First / last tab | +| Enter / Space | Select the focused tab (for `tabActivation="manual"`) | +| Delete / Backspace | Close the tab, if it's closable (calls `onTabClose`) | +| Ctrl/⌘ + Shift + M | Start **move mode** (below) | + +## Moving tabs from the keyboard + +Move mode is a keyboard drag: + +1. **Start.** Focus a tab and press **Ctrl/⌘ + Shift + M**. +2. **Choose a target.** **Arrow keys** cycle through every valid target: each tabset's center and sides, then the layout's edges. The drop indicator shows the target, and a polite live region announces it, e.g. "Move Terminal: right of Editor". +3. **Drop or cancel.** **Enter** or **Space** drops; **Escape** or **Tab** cancels. + +From code, use [`moveTab`](../api/use-layout-actions.md) with a [`DropTarget`](../api/create-layout.md#droptarget). + +## Splitters ([Window splitter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)) + +- **Roles:** each splitter is `role="separator"` with `aria-orientation`. `aria-valuenow`, `aria-valuemin` and `aria-valuemax` are the panel's share of the pair, in percent. `aria-controls` points at the panel before it, and the `aria-label` reads e.g. "Resize Editor and Terminal". +- **Focus:** every splitter is focusable. A splitter next to a folded panel is `aria-disabled`. + +| Key (on a splitter) | Action | +|---|---| +| ← / → (side-by-side panels), ↑ / ↓ (stacked panels) | Move 10px | +| Shift + arrow | Move 50px | +| Home / End | Move to the minimum / maximum | +| Enter | Fold or unfold the panel before it (when folding is allowed) | + +## Maximize and fold + +The toolbar buttons are real buttons: +- Maximize has `aria-label` "Maximize" or "Restore" and `aria-pressed`. +- Fold has `aria-label` "Fold" or "Unfold" and `aria-expanded`. + +| Shortcut (focus inside the layout) | Action | +|---|---| +| Alt/Option + "+" | Maximize or restore the focused tabset | +| Alt/Option + "-" | Fold or unfold the focused tabset | + +The shortcuts act on the tabset that contains the focus, or that you last clicked. They only listen inside the layout, so several layouts on a page don't conflict. Turn them off with `keyboardShortcuts={false}`. Double-clicking a tab bar also maximizes it (`maximizeOnDoubleClick`). + +## Pointer and touch + +- **Mouse and pen:** a tab drag starts after the pointer moves 4px, so clicks still select tabs. +- **Touch:** a drag starts after a 350ms long press; a quick swipe scrolls the tab bar instead. +- **Cancel:** Escape cancels any drag. + +## Your own components + +The prop getters (`getTabProps`, `getSplitterProps`, …) apply all of the above. If you replace a slot or build a [headless](../api/use-dynamix-layout.md) UI: +- spread their props onto real focusable elements; +- don't remove `role`, `aria-*` or `tabIndex`; +- keep a visible focus style. `styles.css` uses `--dx-focus-ring`. diff --git a/docs/guides/core-without-react.md b/docs/guides/core-without-react.md new file mode 100644 index 0000000..3790e88 --- /dev/null +++ b/docs/guides/core-without-react.md @@ -0,0 +1,97 @@ +# Using the core without React + +`@dynamix-layout/core` holds all the layout logic and has no dependencies. An adapter for another framework, or a vanilla app, does five things: + +1. **Create** an instance: `createLayout({ tabs, initialLayout, onLayoutChange })`. +2. **Measure** the container and call `setContainerRect`, again on every resize. +3. **Render** from snapshots: `subscribe((snapshot) => …)`. +4. **Translate input** into actions: clicks, drags and keys. +5. **Clean up** with `destroy()`. + +```ts +import { + createLayout, + createFrameScheduler, + getTabBarPlacement, + getTabContentRect, + type LayoutSnapshot, +} from '@dynamix-layout/core' + +const TAB_BAR = 36 +const root = document.querySelector('#layout')! +const layout = createLayout({ + tabs: [{ id: 'editor' }, { id: 'terminal' }, { id: 'preview' }], + foldedSize: TAB_BAR, + minPanelSize: { width: 80, height: TAB_BAR }, + onLayoutChange: (json) => localStorage.setItem('layout', JSON.stringify(json)), +}) + +// 2. Measure. Rects are relative to the root's padding box. +const measure = () => layout.setContainerRect({ x: 0, y: 0, width: root.clientWidth, height: root.clientHeight }) +const resizeFrame = createFrameScheduler(measure) +new ResizeObserver(() => resizeFrame.schedule(null)).observe(root) +measure() + +// 3. Render. +layout.subscribe(render) +render(layout.getSnapshot()) + +function render(snapshot: LayoutSnapshot) { + for (const [id, tabset] of snapshot.tabsets) { + const rect = snapshot.rects.tabsets.get(id)! + const bar = getTabBarPlacement(rect, tabset, TAB_BAR) // rotated when folded side by side + drawTabBar(id, bar.rect, bar.isRotated, tabset.tabIds, tabset.activeTabId, tabset.isHidden) + } + for (const [id, tab] of snapshot.tabs) { + const tabsetRect = snapshot.rects.tabsets.get(tab.tabsetId)! + positionContent(id, getTabContentRect(tabsetRect, TAB_BAR), tab.isVisible) + } + for (const [id, splitter] of snapshot.splitters) drawSplitter(id, snapshot.rects.splitters.get(id)!, splitter) + drawIndicator(snapshot.drag?.indicator ?? null) +} +``` + +## Input + +Coordinates passed to the engine are relative to the root: `clientX - rootBox.left - root.clientLeft`. + +**Splitters:** + +```ts +splitterEl.addEventListener('pointerdown', (event) => { + if (!layout.startDrag({ type: 'splitter', splitterId })) return // locked or maximized + splitterEl.setPointerCapture(event.pointerId) + const frame = createFrameScheduler((p: Point) => layout.updateDrag(p)) + const move = (e: PointerEvent) => frame.schedule(toRoot(e)) + const up = () => { + frame.flush() + layout.endDrag() // one onLayoutChange + splitterEl.removeEventListener('pointermove', move) + } + splitterEl.addEventListener('pointermove', move) + splitterEl.addEventListener('pointerup', up, { once: true }) +}) +``` + +**Tabs:** + +1. On drag start, measure the tab bars (`DropMeasurements`: each tab bar's rect and its tabs' rects) and call `startDrag({ type: 'tab', tabId })`. +2. On move, call `updateDrag(point, measurements)`; `snapshot.drag.target` and `snapshot.drag.indicator` follow the pointer. +3. On release, call `endDrag()`. Escape calls `cancelDrag()`. + +Without measurements, drops onto tab bars aren't available, but panels and layout edges still work. + +**Everything else** maps to one call each: +- `selectTab(id)` on click; +- `toggleMaximize(tabsetId)` and `toggleFold(tabsetId)`; +- `moveSplitterBy(id, ±10)` for arrow keys; +- `listDropTargets(source)` and `setDragTarget(target)` for a keyboard move mode. + +## Rendering tips + +- **Render each tab's content once** and position it with `getTabContentRect`. Never move content between parents; iframes reload when moved. +- **Snapshots share unchanged parts by reference**, so compare with `===` to skip work: unchanged `rects.tabsets.get(id)` objects mean that tabset didn't move. +- **A rotated tab bar** (`isRotated`) is an element `rect.width` wide and `rect.height` tall, rotated 90° around its top-left corner (`transform-origin: 0 0`). It covers the folded strip. +- **Hidden tabsets** (`isHidden`, while another tabset is maximized) should stay mounted but invisible. + +[`examples/vanilla`](../../examples/vanilla) is a complete plain-DOM renderer in about 500 lines ([`dom-layout.ts`](../../examples/vanilla/src/dom-layout.ts)): tab and tab bar drags with measurements, splitters, close buttons, fold and maximize toolbars, keyboard support and persistence. The [Solid adapter](../../packages/solid/src/hooks/useLayout.ts) is another one. diff --git a/docs/guides/custom-components.md b/docs/guides/custom-components.md new file mode 100644 index 0000000..cad43b8 --- /dev/null +++ b/docs/guides/custom-components.md @@ -0,0 +1,71 @@ +# Custom tab and splitter components + +`` renders every part through a **slot component** you can replace with `components`: + +| Slot | Default | Receives (besides DOM props) | +|---|---|---| +| `Panel` | `
` behind the tabset | `tabset` | +| `TabBar` | `
` | `tabset`, `isRotated`, `children` (tabs and the toolbar) | +| `Tab` | title, plus a close button when closable | `tab` (your item), `isActive`, `isDragging`, `onClose?`, `closeButtonProps?` | +| `TabContent` | `
` | `tab`, `isActive`, `children` (the content) | +| `Splitter` | a bar with a CSS grip | `splitter`, `isDragging` | +| `DropIndicator` | a highlighted box | `target` | +| `RootDropZone` | a handle on each layout edge, shown while dragging | `side`, `isActive` | +| `TabsetToolbar` | maximize and fold buttons | `tabset`, `isRotated`, `canMaximize`, `canFold`, `onToggleMaximize`, `onToggleFold`, `className`, `style`, `buttonClassName`, `buttonStyle` | + +The DOM props contain everything that makes the part work: ARIA attributes, `data-*` state, event handlers, the positioning `style`, and a `ref`. **Spread them on your root element and forward the `ref`.** Destructure the state props first, so they don't reach the DOM. + +## A custom tab + +```tsx +import { forwardRef, type Ref } from 'react' +import { DynamixLayout, type TabProps } from '@dynamix-layout/react' + +const MyTab = forwardRef(function MyTab( + { tab, isActive, isDragging, onClose, closeButtonProps, className, ...props }, + ref +) { + return ( +
} + {...props} + className={`${className} my-tab ${isActive ? 'my-tab--active' : ''} ${isDragging ? 'my-tab--dragging' : ''}`} + > + + {tab.title ?? tab.id} + {onClose && closeButtonProps && } +
+ ) +}) + + +``` + +`closeButtonProps` stops the press from starting a drag, labels the button, and calls `onTabClose`. Use it for your close button. + +## A custom splitter + +```tsx +const MySplitter = forwardRef(function MySplitter( + { splitter, isDragging, className, ...props }, + ref +) { + return ( +
+ +
+ ) +}) +``` + +The default stylesheet draws a grip with `.dx-splitter::after`. Add `after:hidden` (Tailwind) or `.my-splitter::after { display: none }` if you draw your own. + +## React 18 and 19 + +Slot components must forward `ref`. On React 18 that means `forwardRef`, as above. On React 19 you can take `ref` as a regular prop instead. + +## When to use the headless hooks instead + +Use `components` when the default structure is fine and you only want different markup or behaviour inside a part. To change the structure itself, such as tab bars below the content or a single shared tab bar, use [`useDynamixLayout`](../api/use-dynamix-layout.md) and render everything yourself. + +A complete example with shadcn-style tabs is in [`examples/react/src/components/wrapper.tsx`](../../examples/react/src/components/wrapper.tsx). diff --git a/docs/guides/multiple-layouts.md b/docs/guides/multiple-layouts.md new file mode 100644 index 0000000..d0419c5 --- /dev/null +++ b/docs/guides/multiple-layouts.md @@ -0,0 +1,18 @@ +# Multiple layouts on one page + +Every `` (and every `createLayout()`) is a separate instance with its own state. Put as many on a page as you like: + +```tsx +
+ + +
+``` + +- **DOM ids** used for ARIA are scoped by React's `useId`, or by the `id` prop when you pass one, so they never collide. +- **Keyboard shortcuts** listen on each layout's root, so only the layout that has focus reacts. +- **Hooks** (`useTab`, `useLayoutState`, …) use the nearest layout. +- **Drags stay inside their layout.** Moving tabs between two layouts isn't supported. +- **Tab ids only need to be unique within a layout.** + +v1 kept its state in module globals, so two layouts on a page overwrote each other. That's fixed in v2. diff --git a/docs/guides/nextjs-and-ssr.md b/docs/guides/nextjs-and-ssr.md new file mode 100644 index 0000000..817abf9 --- /dev/null +++ b/docs/guides/nextjs-and-ssr.md @@ -0,0 +1,54 @@ +# Next.js and server rendering + +`@dynamix-layout/react` renders on the server and hydrates without warnings: + +- **No `window` or `document` at import or during render.** All measuring happens in effects. +- **The first render is deterministic.** Rows and tabsets get ids derived from your tab ids (`ts-editor`), and DOM ids come from React's `useId`. Server and client produce the same markup. +- **Before the first measurement** the root has `data-dx-measuring`, and the stylesheet hides its children. On the client the layout measures itself before the first paint, so there's no flash. + +## App Router + +The package's entry starts with `'use client'`, so you can render `` from a client component. Tab content is often interactive, so the page is usually a client component too: + +```tsx +// app/page.tsx +'use client' +import { DynamixLayout } from '@dynamix-layout/react' +import '@dynamix-layout/react/styles.css' +import { tabs } from './tabs' + +export default function Page() { + return +} +``` + +There's no need for `dynamic(() => import(…), { ssr: false })`; v1 needed it. You can still use it for heavy tab content such as editors. + +Server components can read and migrate saved layouts with the core, which has no client code: + +```ts +import { isLayoutV1, migrateLayoutFromV1 } from '@dynamix-layout/core' + +const saved = await db.getLayout(user.id) +const layout = isLayoutV1(saved) ? migrateLayoutFromV1(saved) : saved +``` + +## Restoring a saved layout without a mismatch + +The server and the client must start from the same layout. If you keep it in `localStorage`, the server can't read it. Either: + +- store it where the server can read it (a cookie or your database) and pass it as `defaultLayout` on both sides; or +- render the layout only after mount when you restore from `localStorage`: + +```tsx +const [saved, setSaved] = useState(undefined) +useEffect(() => setSaved(load() ?? null), []) +if (saved === undefined) return null +return +``` + +## Pitfalls + +- **Give the layout a height.** `100vh`, or a parent with a fixed or flex height. In the App Router, a `body` with `height: 100%` doesn't fill the viewport by itself. +- **Import the stylesheet once,** in the page or in `app/layout.tsx`. +- **Two copies of `@types/react`** (common in monorepos with mixed React versions) cause "cannot be used as a JSX component" errors. Make sure your app resolves one copy; see the `paths` in [the Next.js example's tsconfig](../../examples/nextjs/tsconfig.json). diff --git a/docs/guides/performance.md b/docs/guides/performance.md new file mode 100644 index 0000000..032e5ec --- /dev/null +++ b/docs/guides/performance.md @@ -0,0 +1,26 @@ +# Performance + +## What re-renders + +- **Pointer moves never re-render React.** During splitter drags, drop targeting and container resizes, the engine computes new rects, and an internal writer applies `left`/`top`/`width`/`height` directly to the elements. Components render the same positions from the current snapshot whenever React does render, so the two always agree. +- **Components re-render only for state they read.** Each tab, tabset and splitter part subscribes to its own entry of the snapshot. Entries keep their identity until they change, so selecting a tab re-renders that tabset's parts and nothing else. +- **The engine is created once per layout.** Changing `minPanelSize`, `splitterSize` or `tabBarHeight` updates it. Changing `tabs` updates it only when the set of ids changes. + +## Your side + +- **Inline `tabs` arrays are fine.** A new array with the same ids does no engine work, and a tab re-renders only if its own `title`, `content`, `closable` or `target` changed. Elements created inline (`content: `) are new objects on every render, so that tab's content re-renders with its parent, as it would anywhere in React. Memoize heavy content, or keep the `tabs` array in state, if that matters. +- **Content is never remounted** when tabs move, and keeps its DOM position, so iframes don't reload. Changing a tab's `id` remounts it. +- **`useLayoutState` selectors should return stable values:** primitives, or objects from the snapshot. Derived arrays need an `isEqual`. +- **`resizeThrottleMs`** limits container updates during window resizes; the default `0` updates once per animation frame. +- **Hidden tab content stays mounted** (inactive tabs, panels hidden behind a maximized one). To pause expensive work in hidden content, read `useTab(id).isActive` or `useLayoutState((s) => s.tabs.get(id)?.isVisible)`. + +## Sizes + +Minified and gzipped, measured on the 2.0 build: + +| Package | Size | +|---|---| +| `@dynamix-layout/core` | 9.5 kB | +| `@dynamix-layout/react` | 9.5 kB, plus core (React is a peer dependency) | +| `@dynamix-layout/solid` | 5.3 kB, plus core | +| `styles.css` (React) | 1.3 kB | diff --git a/docs/guides/persisting-layouts.md b/docs/guides/persisting-layouts.md new file mode 100644 index 0000000..28c38bc --- /dev/null +++ b/docs/guides/persisting-layouts.md @@ -0,0 +1,97 @@ +# Persisting layouts + +A layout is plain JSON ([`LayoutJSON`](../api/layout-json.md)). Save it from `onLayoutChange` and pass it back on the next visit. + +## Uncontrolled (most apps) + +`defaultLayout` is read once; after that the component owns the layout. + +```tsx +const STORAGE_KEY = 'my-app:layout' + +function load(): LayoutJSON | undefined { + try { + const json = localStorage.getItem(STORAGE_KEY) + return json ? JSON.parse(json) : undefined + } catch { + return undefined + } +} + +export function Workspace() { + const [saved] = useState(load) + return ( + localStorage.setItem(STORAGE_KEY, JSON.stringify(layout))} + /> + ) +} +``` + +**When `onLayoutChange` is called:** +- after a drop (`'move'`); +- when a splitter is released, or moved with the keyboard (`'resize'`); +- when a tab is selected (`'select'`); +- when a tabset is folded (`'fold'`) or maximized (`'maximize'`); +- when tabs are added or removed (`'tabs'`); +- after `reset()` (`'reset'`). + +**When it isn't called:** +- on mount; +- on container resizes, since weights are proportions and resizing doesn't change the JSON; +- for each frame of a drag. + +Saving on every call is fine. If you persist to a server, debounce it. + +## Controlled mode + +Pass `layout` and update it from `onLayoutChange`. The layout always shows what you pass: + +```tsx +const [layout, setLayout] = useState(load) + + { + setLayout(next) + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) + }} +/> +``` + +- **Changes you don't store are reverted.** A change that `onLayoutChange` doesn't store is undone before the next paint. You can also store a modified copy. +- **You can replace the layout at any time,** for example to switch between saved workspaces: set `layout` to another `LayoutJSON`, or a v1 tree. +- **Drag frames are not changes.** A splitter drag moves freely and is reported once, on release. + +The [controlled example](../../examples/react/src/examples/ControlledExample.tsx) persists to localStorage and adds and closes tabs. + +## Saved layouts and the `tabs` you pass + +Apps change; a saved layout can mention tabs you no longer have, and miss new ones: + +- tabs in `tabs` but not in the saved layout are **added** to the first tabset; +- tabs in the saved layout but not in `tabs` are **removed**. + +Tabs are matched by `id`, so keep ids stable across releases. + +## Layouts saved by v1 + +v1 saved a different shape (`{ typNode, nodName, nodKids, … }`). Pass it to `defaultLayout`, `layout` or `load()` as it is: it's migrated automatically and renders the same. In development you'll see a one-time warning suggesting you save it again. To convert stored layouts in bulk, use [`migrateLayoutFromV1`](../api/migrate-layout-from-v1.md). + +## Validation + +Saved data can be corrupted or hand-edited. Repairable problems are fixed and reported through `onWarning` (core) or `console.warn` (development). These include invalid weights, an unknown active tab, duplicate tabs and empty nodes. Unusable input (not a layout, or an unknown `version`) throws a `DynamixLayoutError` with the path of the problem. If you load untrusted data, catch it: + +```tsx +function safeLayout(json: unknown) { + try { + createLayout({ tabs: [], initialLayout: json as LayoutJSON }) // validate only + return json as LayoutJSON + } catch { + return undefined + } +} +``` diff --git a/docs/guides/theming.md b/docs/guides/theming.md new file mode 100644 index 0000000..553d7ca --- /dev/null +++ b/docs/guides/theming.md @@ -0,0 +1,90 @@ +# Theming + +From the lightest to the most control: + +1. **CSS variables** on `.dx-root`, or any ancestor that sets them on `.dx-root`. +2. **`classNames` and `styles`**, by slot. +3. **State attributes** in your own CSS. +4. **[Custom components](./custom-components.md)**. + +Import the default stylesheet once: + +```ts +import '@dynamix-layout/react/styles.css' +``` + +## CSS variables + +| Variable | Default | Used for | +|---|---|---| +| `--dx-font` | `inherit` | Root font | +| `--dx-tab-bar-bg` | `#dfdfdf` | Tab bar background (the toolbar inherits it) | +| `--dx-tab-bg` | `#dfdfdf` | Inactive tab | +| `--dx-tab-active-bg` | `#ffffff` | Active tab | +| `--dx-tab-color` | `inherit` | Tab text | +| `--dx-tab-radius` | `4px` | Tab corners | +| `--dx-tab-gap` | `6px` | Space between tabs | +| `--dx-splitter-bg` | `#ffffff` | Splitter | +| `--dx-splitter-hover-bg` | `#e8f2ff` | Splitter on hover | +| `--dx-grip-color` | `#9a9a9a` | Splitter grip dots | +| `--dx-drop-indicator-bg` | `rgba(0, 175, 249, 0.35)` | Drop indicator fill | +| `--dx-drop-indicator-border` | `2px dashed rgb(0, 196, 42)` | Drop indicator border | +| `--dx-root-drop-zone-bg` | `#0081f9` | Layout-edge drop handles | +| `--dx-focus-ring` | `2px solid #0081f9` | Keyboard focus outline | + +A dark theme: + +```css +.dark .dx-root { + --dx-tab-bar-bg: #18181b; + --dx-tab-bg: #27272a; + --dx-tab-active-bg: #3f3f46; + --dx-tab-color: #e4e4e7; + --dx-splitter-bg: #09090b; + --dx-splitter-hover-bg: #27272a; + --dx-grip-color: #52525b; +} +``` + +## `classNames` and `styles` + +Both are keyed by slot: `root`, `panel`, `tabBar`, `tab`, `activeTab`, `tabClose`, `tabContent`, `splitter`, `dropIndicator`, `rootDropZone`, `toolbar` and `toolbarButton`. Class names are added next to the defaults (`.dx-tab`, …). Styles are merged under the positioning styles, so `left`/`top`/`width`/`height` always come from the layout. + +```tsx + +``` + +Inline `classNames` and `styles` objects are compared by content, so creating them inline doesn't re-render the layout's parts. + +## Classes and state attributes + +| Element | Class | Attributes | +|---|---|---| +| root | `.dx-root` | `data-dx-measuring` (before the first measurement), `data-dx-dragging` | +| panel | `.dx-panel` | `data-dx-hidden` | +| tab bar | `.dx-tab-bar` | `data-dx-folded`, `data-dx-maximized`, `data-dx-rotated`, `data-dx-has-toolbar`, `data-dx-hidden` | +| tab | `.dx-tab` | `data-state="active \| inactive"` | +| close button | `.dx-tab-close` | | +| tab content | `.dx-tab-content` | `data-dx-hidden` | +| splitter | `.dx-splitter` | `data-dx-direction`, `data-dx-locked`, `data-dx-hidden` | +| drop indicator | `.dx-drop-indicator` | | +| edge drop zone | `.dx-root-drop-zone` | `data-dx-side`, `data-dx-active` | +| toolbar | `.dx-toolbar`, `.dx-toolbar-button` | | + +Examples: + +```css +.dx-tab-bar[data-dx-folded] { background: var(--strip-bg); } +.dx-root[data-dx-dragging] .dx-tab-content { opacity: 0.85; } +.dx-splitter[data-dx-direction='horizontal']:hover { background: var(--accent); } +``` + +**Hiding uses `[data-dx-hidden]`** (`display: none !important` in the stylesheet) plus the `hidden` attribute. Don't override `display` on hidden elements. + +## Tailwind + +`classNames` works with utility classes as they are. To react to state, use data variants: `data-[state=active]:bg-white` on tabs, or `group-data-[dx-dragging]:…` with `group` on the root. See the [custom components example](../../examples/react/src/components/layout-config.ts). diff --git a/docs/migration-v1-to-v2.md b/docs/migration-v1-to-v2.md new file mode 100644 index 0000000..933fbf9 --- /dev/null +++ b/docs/migration-v1-to-v2.md @@ -0,0 +1,239 @@ +# Migrating from v1 to v2 + +v2 replaces the global v1 engine with independent instances. It simplifies the React API from 46 props to one small component plus hooks, and adds a versioned save format. **Layouts your users saved with v1 keep working**: v2 loads them as they are, and they render the same. + +## Steps + +1. **Upgrade.** `npm install @dynamix-layout/react@2` (or `@dynamix-layout/solid@2`). `@dynamix-layout/core` is now a dependency of the adapters, so you only need it if you import from it. +2. **Change the stylesheet import** to `@dynamix-layout/react/styles.css`. +3. **Convert `tabs`** from tuples to objects, and fold `tabNames` into `title`. +4. **Rename the layout props.** `layoutTree` becomes `defaultLayout` (or `layout` for controlled mode), and `updateJSON` becomes `onLayoutChange`, which now receives v2 JSON. +5. **Rename the size props:** `minPanelSize`, `splitterSize`, `tabBarHeight`, `showTabBar`, `padding`. +6. **Replace the wrapper and styling props** with `components`, `classNames` and `styles`. +7. **Remove the debounce props**; use `resizeThrottleMs` if you need throttling. +8. If you used `ref`, `useDynamixLayout` or `@dynamix-layout/core` directly, see the sections below. +9. If your CSS targets `.DefaultWrap*` classes or `data-uid`/`data-type`, switch to the [`.dx-*` classes and `data-dx-*` attributes](./guides/theming.md#classes-and-state-attributes). + +Saved layouts need no changes. You may want to convert them once; see [Saved layouts](#saved-layouts). + +## React: before and after + +```tsx +// v1 +import { DynamixLayout } from '@dynamix-layout/react' +import '@dynamix-layout/react/dist/layout.css' + +], + ['terminal', ], + ]} + tabNames={new Map([['editor', 'Editor'], ['terminal', 'Terminal']])} + layoutTree={saved} + updateJSON={(tree) => save(tree)} + minTabWidth={120} + minTabHeight={80} + bondWidth={6} + tabHeadHeight={36} + pad={{ t: 8, b: 8, l: 8, r: 8 }} + WrapTabLabel={MyLabel} + SliderElement={MySlider} + tabLabelElementClass="my-tab" + sliderElementStyles={{ background: '#222' }} + disableResizeTimeout={false} + windowResizeTimeout={16} +/> + +// v2 +import { DynamixLayout } from '@dynamix-layout/react' +import '@dynamix-layout/react/styles.css' + + }, + { id: 'terminal', title: 'Terminal', content: }, + ]} + defaultLayout={saved} // v1 layouts load as they are + onLayoutChange={(layout) => save(layout)} + minPanelSize={{ width: 120, height: 80 }} + splitterSize={6} + tabBarHeight={36} + padding={8} + components={{ Tab: MyTab, Splitter: MySplitter }} + classNames={{ tab: 'my-tab' }} + styles={{ splitter: { background: '#222' } }} + resizeThrottleMs={16} +/> +``` + +## Rename table + +### React props + +| v1 | v2 | +|---|---| +| `tabs: [id, node][]` + `tabNames` | `tabs: { id, title?, content, closable?, target? }[]` | +| `layoutTree` | `defaultLayout` (uncontrolled) / `layout` (controlled) | +| `updateJSON(tree)` | `onLayoutChange(layout, { reason })` | +| `minTabWidth` / `minTabHeight` | `minPanelSize: { width, height }` | +| `bondWidth` | `splitterSize` | +| `tabHeadHeight` | `tabBarHeight` | +| `enableTabbar` | `showTabBar` | +| `pad: { t, b, l, r }` | `padding: number \| { top, right, bottom, left }` | +| `enableMaximize` / `enableCollapse` / `enableDoubleClickMaximize` | `allowMaximize` / `allowFold` / `maximizeOnDoubleClick` | +| `keyboardShortcuts` | unchanged (now scoped to focus inside the layout) | +| `WrapTabPanel` | `components.Panel` (now a visible box behind each tabset) | +| `WrapTabHead` | `components.TabBar` | +| `WrapTabLabel` | `components.Tab` | +| `WrapTabBody` | `components.TabContent` | +| `SliderElement` | `components.Splitter` | +| `HoverElement` | `components.DropIndicator` | +| `RootSplitterHoverEl` | `components.RootDropZone` | +| `TabsetToolbar` | `components.TabsetToolbar` | +| `tabPanelElementStyles` / `…Class` | `styles.panel` / `classNames.panel` | +| `tabHeadElementStyles` / `…Class` | `styles.tabBar` / `classNames.tabBar` | +| `tabLabelElementStyles` / `…Class` | `styles.tab` / `classNames.tab` (also `activeTab`, `tabClose`) | +| `tabBodyElementStyles` / `…Class` | `styles.tabContent` / `classNames.tabContent` | +| `sliderElementStyles` / `…Class` | `styles.splitter` / `classNames.splitter` | +| `hoverElementStyles` / `…Class` | `styles.dropIndicator` / `classNames.dropIndicator` | +| `RootSplitterHoverElStyles` / `…Class` | `styles.rootDropZone` / `classNames.rootDropZone` | +| `disableResizeTimeout` + `windowResizeTimeout` | `resizeThrottleMs` (`0` = once per frame) | +| `disableSliderTimeout` + `sliderUpdateTimeout` | removed: splitters always update once per frame | +| `rootId` | `id` (optional) | +| `ref` → root `
` | `ref` → `DynamixLayoutHandle` (`handle.element` is the `
`) | + +### Toolbar props (`TabsetToolbar`) + +| v1 | v2 | +|---|---| +| `maximized`, `folded` | `tabset.isMaximized`, `tabset.isFolded` | +| `rotated` | `isRotated` | +| `rowIsHorizontal` | `tabset.parentDirection === 'horizontal'` | +| `showMaximize`, `showFold` | `canMaximize`, `canFold` | +| `onToggleMaximize`, `onToggleFold` | unchanged | + +### React exports + +| v1 | v2 | +|---|---| +| `useDynamixLayout` (31 values: refs, DnD handlers, setters) | a new headless [`useDynamixLayout`](./api/use-dynamix-layout.md), plus `useTabset`, `useTab`, `useSplitter`, `useLayoutState`, `useLayoutActions`, `useDragState` | +| `getTabOutput`, `TabInput`, `TabOutput`, `TabEntry`, `DivFC`, `useDynamixLayoutOptions` | removed | +| `DefaultWrapTabLabel`, `DefaultWrapTabHead`, … | `Tab`, `TabBar`, `TabContent`, `Panel`, `Splitter`, `DropIndicator`, `RootDropZone`, `TabsetToolbar` | +| `LayoutProps` | `DynamixLayoutProps` | +| `.DefaultWrapTabLabel` etc. CSS classes | `.dx-tab`, `.dx-tab-bar`, `.dx-tab-content`, `.dx-panel`, `.dx-splitter`, `.dx-drop-indicator`, `.dx-root-drop-zone` | +| `data-uid`, `data-type` | `data-dx-id`, `data-dx-slot` | +| `@dynamix-layout/react/dist/layout.css` (never existed) / `style.css` | `@dynamix-layout/react/styles.css` (`style.css` still works) | + +### Core + +| v1 | v2 | +|---|---| +| `new DynamixLayoutCore(options)` + statics `_root`, `_tree`, `_minW`, `_minH`, `_bond`, `_inst` | `createLayout(options)`, one independent instance each | +| `Node`, `Bond`, `Node.cache` | `layout.getSnapshot()` / `subscribe()` | +| `LayoutTree` | `LayoutJSON` (`LayoutTreeV1` describes the old shape) | +| `NodeOptions` | `TabsetState`, `SplitterState`, `TabState`, plus `snapshot.rects` | +| `Dimension { w, h, x, y }` | `Rect { x, y, width, height }` | +| `typNode`, `uidNode`, `nodName` | `type`, `id` (tabs: the tab's id, which was `nodName`) | +| `nodPart` | `weight` | +| `nodeDir` (boolean, inverted for tabsets) | `direction` on rows, `parentDirection` on tabsets | +| `nodDims` | `snapshot.rects.tabsets.get(id)` | +| `nodOpen` | `activeTabId` / `isActive` | +| `nodKids` | `children` / `tabIds` | +| `nodFold`, `nodMaxd` | `isFolded`, `maximizedTabsetId` / `isMaximized` | +| `nodHidden`, `nodLocked`, `nodFoldable`, `nodMaximizable` | `isHidden`, `isLocked`, `canFold`, `canMaximize` | +| `minW`, `minH` | `minPanelSize` | +| `bond` | `splitterSize` | +| `collapsedSize` | `foldedSize` | +| `tabs: string[]` | `tabs: { id, target? }[]` | +| `tree` | `initialLayout` | +| `updateDimension(dim, disableTimeout?, timeout?)` | `setContainerRect(rect)` | +| `updateSlider` / `updateSliderDimension` | `resizeSplitter(id, point)`, `moveSplitterBy(id, delta)`, or `startDrag` / `updateDrag` / `endDrag` | +| `updateTree(src, des, area)` | `moveTab(tabId, target)` / `moveTabset(tabsetId, target)`; `'contain'` is now `'center'` | +| `collapse` / `expand` / `toggleCollapse` | `fold` / `unfold` / `toggleFold` | +| `maximizedId` | `snapshot.maximizedTabsetId` | +| `DynamixLayoutCore._root.toJSON()` | `layout.toJSON()` | +| `clearAllCache()` | `destroy()` | +| `Queue`, `createReactiveState`, `areNodeOptions*Equal`, iterators, `insertNode*`, `removeKid`, … | removed (internal) | +| `getTabsetDropPreview`, `getNavbarDropPreview`, `getRootSplitPreview` | `layout.getDropTarget` + `layout.getDropIndicatorRect` | +| `setElementRect`, `placeTabbar`, `getTabbarPlacement`, `getTabBodyRect` | `applyRect`, `getTabBarPlacement`, `getTabContentRect` | +| console banner, `window.__DYNAMIX_LAYOUT__` | removed; `version` export | + +## Behaviour changes + +| Change | Why | +|---|---| +| `onLayoutChange` isn't called on mount, and receives `LayoutJSON` (`version: 2`). | Mount isn't a change; the new format has readable names and stable tab ids. | +| A tab in `tabs` that's missing from the saved layout is **added** (v1 silently left it out). A saved tab missing from `tabs` is **removed** (v1 showed a label with no content). | `tabs` is the single source of truth for which tabs exist. | +| Duplicate tab ids throw in development. | v1 merged their contents silently. | +| Dropping a tab in the middle of a panel **appends** it. | v1 inserted it before the last tab. | +| Splitting beside a folded tabset unfolds it. | v1 left the new row stuck at its minimum size. | +| Tab dragging uses pointer events (touch long-press, Escape cancels). HTML5 drag events on custom wrappers no longer fire; use `useDragState`. | Touch support, one drag model, no fake drag image. | +| Keyboard shortcuts act only when focus is inside the layout. | Several layouts on a page no longer all react. | +| The layout can sit anywhere on the page. | v1 placed content wrongly unless the layout was at the viewport's top-left. | +| Changing `tabs` updates the layout instead of rebuilding it. | v1 reset the layout to its initial state whenever `tabs` changed identity. | +| React 18 works; React 17 isn't supported. | The v1 bundle inlined React 19's JSX runtime. | + +## `ref` and actions + +```tsx +// v1: ref was the root div; there was no API to move or select tabs. +// v2: +const layout = useRef(null) + + +layout.current?.element // the root div +layout.current?.moveTab('terminal', { type: 'root', position: 'bottom' }) +layout.current?.selectTab('editor') +layout.current?.toggleMaximize('ts-editor') +``` + +Inside the layout, [`useLayoutActions()`](./api/use-layout-actions.md) gives the same actions without a ref. + +## Core: before and after + +```ts +// v1 +import { DynamixLayoutCore, Node } from '@dynamix-layout/core' +const engine = new DynamixLayoutCore({ tabs: ['a', 'b'], tree: saved, minW: 40, minH: 40, bond: 10 }) +engine.updateDimension({ x: 0, y: 0, w: 800, h: 600 }, true) +Node.cache.nodOpts.onChange((tabsets) => tabsets.forEach((t) => draw(t.uidNode, t.nodDims))) +engine.updateTree(tabUid, tabsetUid, 'contain') +const json = DynamixLayoutCore._root.toJSON() + +// v2 +import { createLayout } from '@dynamix-layout/core' +const layout = createLayout({ tabs: [{ id: 'a' }, { id: 'b' }], initialLayout: saved, minPanelSize: { width: 40, height: 40 }, splitterSize: 10 }) +layout.setContainerRect({ x: 0, y: 0, width: 800, height: 600 }) +layout.subscribe((s) => s.tabsets.forEach((t) => draw(t.id, s.rects.tabsets.get(t.id)!))) +layout.moveTab('a', { type: 'tabset', tabsetId: 'ts-b', position: 'center' }) +const json = layout.toJSON() +``` + +## Solid + +Solid keeps its v1 props and default components; it runs on the v2 core underneath. Changes: + +- `updateJSON` receives `LayoutJSON`, and `layoutTree` accepts it (v1 trees still load). +- `getTabOutput` and the `TabEntry`/`TabInput`/`TabOutput` types are removed. +- `solid-js` is a peer dependency. +- The slider timeout options no longer have an effect. + +## Saved layouts + +You don't need to do anything: pass a v1 layout wherever a layout is accepted and it's migrated on load. To convert stored data once, for example so a backend can read it: + +```ts +import { isLayoutV1, migrateLayoutFromV1 } from '@dynamix-layout/core' + +for (const row of await db.layouts.all()) { + if (isLayoutV1(row.data)) await db.layouts.update(row.id, { data: migrateLayoutFromV1(row.data) }) +} +``` + +Migration keeps: +- tabset and row ids; +- weights, so proportions are identical; +- the active tab; +- folded and maximized state. + +Tab ids become the v1 tab labels. Damaged layouts are repaired, and each repair is reported. See [`migrateLayoutFromV1`](./api/migrate-layout-from-v1.md#edge-cases) for every case. diff --git a/docs/v2/01-audit.md b/docs/v2/01-audit.md new file mode 100644 index 0000000..1c43cc5 --- /dev/null +++ b/docs/v2/01-audit.md @@ -0,0 +1,381 @@ +# dynamix-layout v2 — Phase 1 audit + +Status: **draft for review**. No source code was changed in this phase. + +- **Baseline:** branch `feat/maximize-and-fold-ui` at `1be7413`. That is `main` (core 1.1.0, react 1.0.2, solid 1.0.2) plus the still-open PR #83 (maximize and fold). Features that exist only in PR #83 are marked **[#83]**. +- **Line references** are to that commit. +- **Evidence.** Items marked **(probed)** were checked by running throwaway Vitest / React Testing Library scripts against the source. The scripts were deleted afterwards, and their output is quoted where it matters. + +--- + +## 0. Summary + +| Area | Verdict | +|---|---| +| Global singleton state | **Confirmed, and worse than described.** Two instances don't just overwrite each other: instance A's methods mutate instance B's tree (probed). | +| Engine lifecycle in React | **Confirmed.** Every new `tabs` identity rebuilds the engine from the *initial* tree, and the rendered DOM keeps pointing at the old engine's node ids (probed). | +| SSR | **Confirmed.** `renderToString` works but isn't deterministic (random UUIDs in markup), so hydration will mismatch (probed). | +| Packaging | **Confirmed**, plus two new problems: (a) the React bundle *inlines React 19's `jsx-runtime`*, so the package can't work with React 18; (b) the React package has no `test` script (CI still runs its one smoke test through the root Vitest projects; see P13). | +| Public API | About 60 exports across core and react. Most are internals. Three exported React types describe an API that doesn't exist. | +| Serialized format | Documented in §5. Tab identity is the tab **label** (`nodName`); `uidNode` of tabs is a per-session random id and is ignored on load. | +| New bugs | 30 items in §4. The most important are B1–B6 and B25. B26–B29 were found later by the characterization fixtures, and B30 while building v2. | + +--- + +## 1. Feature inventory + +### 1.1 Layout model (core) + +| # | Feature | Where | Notes | +|---|---|---|---| +| F1 | Tree of `row → tabset → tab` nodes with bonds (splitters) between siblings | `core/src/app/node.ts:7-225` (`Node`), `:227-256` (`Bond`) | Bonds are a doubly-linked list woven through `Node.prev/next`, not children. | +| F2 | Alternating direction: the root row lays out horizontally, and every level flips | `tree-builder.ts:60-79`, `:90`, `:127`; `Node.cache.mapDirs` | Not serialized; derived from depth. | +| F3 | Initial layout from a list of tab names | `tree-builder.ts:134-242` | Right-leaning binary tree: `ts(t1) \| row(ts(t2) / row(…))`. One tab → one tabset (`:151-171`). | +| F4 | Restore from a saved `LayoutTree` | `tree-builder.ts:81-132`, `:244-260`; `dynamix.ts:106-128` | BFS zip of the JSON tree against the node tree. Tab ids are re-mapped from labels (`:255-257`). | +| F5 | Minimum sizes: per-tabset `minW`/`minH`, propagated up (sum along the axis plus bonds, max across it) | `geometry.ts:19-136`; `node.ts:80-100` | Tab bar height raises `minH` in the adapters (`react/src/hooks/useLayout.ts:76-78`). | +| F6 | Root overflow: if the container is smaller than the tree minimum, the root grows past it and is clipped | `geometry.ts:144-170`; root `overflow:hidden` (`react/src/components/Layout.tsx:180`) | Users can't scroll to the clipped part. | +| F7 | Space distribution by weight (`part`) of the space *above the minimum*, with cumulative boundary rounding | `node.ts:102-191` | `part` isn't a percentage (see §5.3). | +| F8 | Splitter resize: clamp to the neighbours' minimums, re-weight only the two neighbours, recompute only that subtree | `slider.ts:30-126` | Edges outside the dragged pair don't move. | +| F9 | Optional debounce for splitter and container updates | `slider.ts:7-28`; `geometry.ts:263-285` | Both share **one** `engine.cntdown` timer (see B12). Disabled by default in the adapters. | +| F10 | Injectable `createId` and `timer` | `state.ts:5-15`; `dynamix.ts:82-85` | Added in 1.1.0. Stored globally. | +| F11 | Tree mutations from drag-and-drop | `tree-mutations.ts:8-107` | See 1.2. | +| F12 | Tree normalization after a removal: collapse single-child rows into the grandparent; delete an empty tabset | `tree-mutations.ts:447-500`, `:527-575` | Incomplete (see B7). | +| F13 | Serialization `Node.toJSON()` | `node.ts:193-224` | Only reachable through the static `DynamixLayoutCore._root.toJSON()`. | +| F14 | Reactive output maps (`nodOpts` tabsets, `bndOpts` bonds, `tabOpts` tabs) with an equality check | `geometry.ts:172-261`; `reactive-state.ts`; `comparator.ts` | Static, not per instance. | +| F15 | **[#83]** Maximize one tabset over the whole layout; the others stay mounted and hidden; restore is exact | `view-state.ts:73-92`; `geometry.ts:190-237` | Persisted as root `nodMaxd`. | +| F16 | **[#83]** Fold a tabset to a strip along its row; unfold restores the exact size; folding the last open sibling unfolds the most recently folded one | `view-state.ts:94-125`; `geometry.ts:84-100`; `node.ts:114-124` | Persisted as tabset `nodFold`. Neighbouring bonds are locked (`geometry.ts:249`, `slider.ts:55`). | +| F17 | **[#83]** View-state normalization: a lone tabset can't stay folded, every row keeps one open child, a missing maximized id is dropped | `view-state.ts:52-71` | | +| F18 | Framework-agnostic helpers: drop-preview geometry, tab bar placement, body rect, rAF scheduler | `drop-preview.ts`, `dom.ts` | Added in 1.1.0 and used by both adapters. | + +### 1.2 Drag-and-drop operations (`updateTree(src, des, area)`) + +| Source → target, area | Result | Where | +|---|---|---| +| tab → tab, `left`/`right` | Insert the tab before/after the target tab in its tabset | `tree-mutations.ts:122-192` | +| tab → tabset, `contain` | Append to the tabset (rewritten as "beside its last tab") | `:77-79` | +| tabset → tab or tabset, `contain` | Merge all its tabs into the target tabset | `:168-189` | +| tab or tabset → tabset, `top`/`bottom`/`left`/`right` | Split: a sibling if the axis matches, otherwise wrap both in a new row | `:194-251`, `:253-445` | +| tab or tabset → root, a side | Dock at the layout edge, wrapping the existing children if needed | `:322-407` | +| Guards | Ignore: a move onto itself; a lone tab onto its own tabset; the only tabset/tab in the layout; nested-row targets; `row` sources | `:17-87` | +| Side effects | Any move ends maximize, and a moved tabset arrives unfolded **[#83]** | `:89-91` | + +### 1.3 React adapter features + +| # | Feature | Where | +|---|---|---| +| R1 | `` | `components/Layout.tsx:50-436` | +| R2 | **All tab bodies are rendered as flat siblings of the root and positioned absolutely.** Moving a tab between tabsets therefore never remounts its content (editors, iframes and terminals keep their state). | `Layout.tsx:335-368` — **this behaviour must survive v2** | +| R3 | Imperative DOM fast path: engine change listeners write rects directly; React re-renders only on structural changes | `hooks/useLayout.ts:124-187` | +| R4 | Splitter drag with pointer events and pointer capture, coalesced by rAF | `useLayout.ts:189-254` | +| R5 | Tab and tabset drag with HTML5 DnD: transparent drag image, drop indicator, root-edge targets, tab-bar insertion marker, folded-strip target **[#83]** | `useLayout.ts:256-585` | +| R6 | During a drag: `.is-dragging` disables pointer events inside tab bodies, so iframes don't swallow events; overlay panels catch drops; `document.body.style.cursor` is changed | `layout.css:27-29`; `useLayout.ts:414-458`, `:486-566` | +| R7 | Active tab switch, done imperatively (no React render) | `useLayout.ts:587-648` | +| R8 | Container measurement with `getBoundingClientRect` and padding `pad {t,b,l,r}` | `Layout.tsx:108-118` | +| R9 | Container resize on `window.resize` only | `useLayout.ts:650-682` | +| R10 | Tab bar toggle (`enableTabbar`), height (`tabHeadHeight`), display names (`tabNames`) | `Layout.tsx:65`, `:71-72`, `:306` | +| R11 | Six replaceable wrappers plus defaults: `WrapTabPanel`, `WrapTabHead`, `WrapTabLabel`, `WrapTabBody`, `SliderElement`, `HoverElement`; **[#83]** `TabsetToolbar` | `Layout.tsx:64-70`, `:99`; `components/Default.tsx` | +| R12 | Fourteen styling props (7 `*Styles`, 7 `*Class`) | `Layout.tsx:81-94` | +| R13 | `updateJSON(tree)` on mount, after a drop, after a splitter release, and after maximize/fold | `useLayout.ts:674`, `:557`, `:229`, `:364` | +| R14 | **[#83]** Toolbar (maximize, fold), tab-bar double-click, and Alt +/- shortcuts for the last-touched tabset | `Default.tsx:338-406`; `useLayout.ts:358-412` | +| R15 | Default stylesheet: active tab colours, hidden scrollbars, toolbar, `[data-dx-hidden]` | `components/layout.css` | + +--- + +## 2. Public API inventory + +"Public" means exported from the package entry. "Intended" is my judgement of whether a user should rely on it. + +### 2.1 `@dynamix-layout/core` (`src/index.ts` → `src/app/index.ts`) + +| Export | Kind | Intended public? | Notes | +|---|---|---|---| +| `DynamixLayoutCore` | class | Yes (the only engine entry) | See the method table below. | +| `Node`, `Bond` | class | **No** | Exported so the adapters can read `Node.cache`. | +| `Queue` | class | **No** | 41 methods; core uses 13 (§3, P8). | +| `createReactiveState` | fn | **No** | | +| `areNodeOptionsMapEqual`, `areNodeOptionsEqual` | fn | **No** | | +| `getTabsetDropPreview`, `getNavbarDropPreview`, `getRootSplitPreview`, `isSameDropPreview` | fn | Semi (adapter helpers, 1.1.0) | Pure; worth keeping, possibly under a subpath. | +| `setElementRect`, `getTabBodyRect`, `getTabbarPlacement`, `placeTabbar`, `createFrameScheduler` | fn | Semi (adapter helpers) | Touch the DOM only when called. | +| Types `Dimension`, `LayoutTree`, `NodeOptions`, `BaseNode`, `NodeType`, `NodeTypeWithBond`, `TabsIds`, `NodeCache`, `RootAdjustment`, `ReactiveValue`, `ChangeListener`, `DropArea`, `RootSide`, `DropPreview`, `PreviewRect`, `TabbarPlacement`, `FrameScheduler` | types | Only `LayoutTree` and `Dimension` are user-facing | `Timer` appears in the public constructor signature but **isn't exported**. | +| *(side effect)* console banner and `window.__DYNAMIX_LAYOUT__` | import-time | No | P11. | + +**`DynamixLayoutCore` members (all public):** +- Static accessors: `_root`, `_tree`, `_minW`, `_minH`, `_bond`, `_inst` (`dynamix.ts:22-68`). +- Fields: `nodeOps` (unused), `cntdown`, `tabsIds`. +- Methods: + - Iterators: `JSONLayoutIterator`, `NodeLayoutIterator`, `NodeLayoutRecursiveIterator`. + - Tree building: `updateChildDirections`, `createNodeLayout`, `createNodeTreeFromJSONTree`, `createBinaryNodeTreeFromQueue`, `createNodeFromJSON`. + - Geometry: `calcTabsetCountAndMinDim`, `pruneStaleDirections`, `calculateRootAdjustment`, `calcDimensions`, `updateDimension`, `updateSlider`, `updateSliderDimension`. + - Mutations: `updateTree`, `isOnlyContent`, `moveNodeAsTab`, `moveRelativeToRoot`, `moveRelativeToTabset`, `insertNode`, `insertNodeWithNewParent`, `moveAdjacentNodeToGrandParent`, `removeRelation`, `removeNode`, `removeKid`, `clearAllCache`. + - **[#83]** `maximizedId`, `maximize`, `restore`, `toggleMaximize`, `collapse`, `expand`, `toggleCollapse`. +- Of these, a user needs about 8: construct, `updateDimension`, `updateSlider`, `updateTree`, serialize, and the view-state methods. + +### 2.2 `@dynamix-layout/react` (`src/index.ts`) + +| Export | Kind | Intended public? | Notes | +|---|---|---|---| +| `DynamixLayout` | component | Yes | 46 props (§3, API surface). | +| `useDynamixLayout` | hook | **No in practice** | It needs `tabOutput` (from `getTabOutput`) and `dimensions` callbacks, and returns 31 members, including raw refs, DnD handlers and 5 deprecated setters. | +| `getTabOutput` | fn | **No** | Calls `crypto.randomUUID()` per tab (`Layout.tsx:33`). | +| `DefaultWrapTabLabel`, `DefaultWrapTabBody`, `DefaultWrapTabHead`, `DefaultWrapTabPanel`, `DefaultHoverElement`, `DefaultSliderElement`, `RootSplitterHoverEl`, `DefaultTabsetToolbar` **[#83]** | components | Yes (for composing custom wrappers) | Inline styles everywhere, so they're hard to theme. | +| `LayoutProps`, `TabsetToolbarProps` **[#83]**, `TabInput`, `TabOutput`, `TabEntry`, `DivFC`, `useDynamixLayoutOptions` | types | `LayoutProps` and `TabsetToolbarProps` only | | +| `DynamixLayoutProps`, `UseDynamixLayoutOptions`, `UseDynamixLayoutResult` | types | **Dead** | They describe an API (`setTabs`, `setLayoutTree`, `tabs: string[]`) that doesn't exist. They're used nowhere (`types/index.ts:4-22`). Same in Solid. | +| `./style.css` | CSS subpath | Yes | | + +--- + +## 3. Verification of known problems + +### Architecture + +**P1. Global singleton state — CONFIRMED (and broader).** +- `state.ts:18-33` is module-level mutable state. Its fields: + - `createId`, `timer`, `root`, `tree`, `minW`, `minH`, `bond`, `inst`; + - **[#83]** `maximized`, `collapsedSize`, `foldSeq`. +- The static accessors are at `dynamix.ts:22-68`. `Node.cache` is static at `node.ts:8-25`. +- **Probe: two engines.** + - I created engine A (`a1,a2`), then engine B (`b1,b2,b3`). `_root` contained `[b1,b2,b3]`. + - Calling `a.updateTree(b1, b3, 'contain')` **succeeded and rearranged B's tabs**. +- **Probe: two ``s on a page.** + - Layout `#one` (tabs A, B) rendered the **tab bars of layout two** (`cd`) above its own bodies. +- **SSR:** every request on a server shares the same module state. React StrictMode creates the instance twice; the second overwrites the first, which only works because both write the same globals. +- Also, every core module reads `layoutState` directly, not through `engine`. The "injected `engine`" parameter only gives access to methods; the data stays global. + +**P2. Side effects during render — CONFIRMED.** +- `useLayout.ts:80-96` writes `DynamixLayoutCore._bond/_minH/_minW` and constructs the engine inside `useMemo`. +- The constructor writes globals (`dynamix.ts:101-130`) and clears the shared caches (via `calcTabsetCountAndMinDim`). +- Component defaults read the statics (`Layout.tsx:56-58`, `:71`), so a default depends on whichever layout was constructed last. +- Solid does the same (`solid/src/components/Layout.tsx:94-100`, `:121`). + +**P3. Engine recreated too often — CONFIRMED, with a worse consequence.** +- The deps `[layoutJSON, tabOutput, …]` are at `useLayout.ts:97-106`. `tabOutput = useMemo(() => getTabOutput(tabs), [tabs])` is at `Layout.tsx:104`. +- So any parent render with an inline `tabs` array (the official example does this: `examples/react/src/App.tsx:238`) rebuilds the engine. +- **Probe: rerender with a new `tabs` identity.** + - The engine root was replaced, but the tab bars kept the **old** tabset uids, and the tab body ids changed. + - After that, clicks and drops target uids that no longer exist in `Node.cache.mapElem`, so they silently do nothing. +- Also, `layoutJSON` is `useState(layoutTree)` (`useLayout.ts:45-47`) and is never updated from user moves. Each rebuild therefore **resets the layout to the initial tree, losing all user changes**. +- The resize effect (`:650-682`) doesn't depend on `layoutInstance`, so its closure keeps the old engine. It only works because the old and new engines share globals. + +**P4. The class is a thin facade with everything public — CONFIRMED.** +- `dynamix.ts:133-296`: 33 of 37 methods are one-line forwards to free functions. +- All internals are public: iterators, `insertNodeWithNewParent`, `removeKid`, `clearAllCache`, the field `cntdown`, and the unused field `nodeOps` (`:70`, `:117-120`). + +**P5. Mixed concerns in the React hook — CONFIRMED.** +- `useLayout.ts` is 733 lines (it was 627 before #83). One hook holds: + - engine construction; + - three cache subscriptions with imperative DOM writes; + - splitter pointer handling; + - HTML5 DnD (7 handlers); + - drag-image creation and `document.body.style.cursor` writes; + - active-tab switching through DOM `dataset`; + - window resize; + - **[#83]** maximize/fold, keyboard shortcuts and double-click. +- Solid mirrors it (`solid/src/hooks/useLayout.ts`, 657 lines). + +**P6. Resize detection uses `window.resize` — CONFIRMED.** See `useLayout.ts:665`, and Solid `useLayout.ts:594`. A container resized by a sidebar or flex parent isn't detected; the layout stays at its old size until the window resizes. + +**P7. HTML5 DnD for tabs — CONFIRMED; recommend moving to pointer events.** +- Tabs and tab bars use `draggable` with `onDragStart/Over/End` (`Layout.tsx:228-250`, `:287-289`). Consequences: + - no touch support; + - a fake 1×1 drag image is attached to `document.body` (`useLayout.ts:424-438`); + - no keyboard path; + - the drop is decided in `onDragEnd`, not `onDrop`. +- Splitters already use pointer events with capture (`:236-253`). +- A pointer-based tab drag also lets us use the same `.is-dragging` path and hit-test with `elementFromPoint` against the engine's rects, rather than DOM `dragover` on overlay panels. +- I'll evaluate this in the Phase 2 design. Risks: losing native drag-out to other windows (not supported today anyway), and needing a movement threshold so clicks still select tabs. + +**P8. Custom 702-line `Queue` — CONFIRMED oversized.** +- Of 41 methods, core uses: `enqueue`, `enqueueFront`, `insert`, `insertQueue`, `dequeue`, `indexOf`, `removeAt`, `get`, `peek`, `peekBack`, `size`, `isEmpty`, `clear`, and iteration. +- The rest are aliases (`push/unshift/fifo*/filo*/lifo*/lilo*/front/back`) or unused. +- Children lists are tiny (a few items), so a plain `readonly T[]` is enough. BFS iterators can use an array plus an index. + +### Packaging and DX + +**P9. `react` peer dependency is `^0.0.1` — CONFIRMED.** See `react/package.json:81`. `react-dom` lists `^17 || ^18 || ^19`, but React 17 can't work: React 19's jsx-runtime is bundled, as described next. + +> **NEW P9b (severe). The React bundle inlines `react/jsx-runtime`.** +> - Rollup `external` is `['react', 'react-dom', '@dynamix-layout/core']` (`react/vite.config.ts:54`). That matches `react` exactly, not `react/jsx-runtime`. +> - `dist/index.es.js` therefore embeds React **19**'s jsx runtime: `Symbol.for("react.transitional.element")`, plus a `process.env.NODE_ENV` switch. +> - React 18 doesn't recognise that element symbol, so the package can't render under React 18. It also references `process`, which breaks unbundled ESM use. +> - Solid isn't affected. + +**P10. Documented CSS import path fails — CONFIRMED.** +- `react/README.md:42` says `@dynamix-layout/react/dist/layout.css`. The file doesn't exist (the build emits `dist/index.css`), and `exports` only exposes `./style.css` (`package.json:48`). +- Also, `react/README.md:183` tells React users to import `@dynamix-layout/solid/style.css` (a copy-paste error). + +**P11. Import-time side effects in core — CONFIRMED.** +- An IIFE in `core/src/index.ts:8-51` prints a collapsed console banner and sets `window.__DYNAMIX_LAYOUT__ = { __LICENSE__, __LAYOUT__ }`, but `package.json:50` declares `"sideEffects": false`. +- Because of that flag, bundlers may drop the banner or keep it depending on usage, which is non-deterministic. +- The full MIT licence text is embedded **twice** in `dist/core.es.js` (the banner plus the `__LICENSE__` define). + +**P12. Repository links point to `akash-aman/dynamix-layout` — CONFIRMED.** Locations: +- `README.md:19`, `:52`, `:170` +- `CONTRIBUTING.md:9`, `:13` +- `SECURITY.md:9` +- `packages/core/README.md:21` +- `packages/react/README.md:23` +- the core banner, `core/src/index.ts:38` + +The `package.json` `repository` fields are already correct. + +**P13. The React package has a single smoke test — CONFIRMED.** +- `react/src/test/layout.test.ts` has one render test. +- `react/package.json` has no `test` script (`:68-76`), so `pnpm --filter @dynamix-layout/react test` does nothing. Solid has a script. +- *Correction (Phase 3):* the first version of this audit said the test never runs in CI. That was wrong. CI runs the root `vitest`, whose `projects` include `packages/react/vite.config.ts`, so the smoke test did run. +- The React `lint` script also lacks `--max-warnings 0`, unlike core. + +### Naming — CONFIRMED + +Every abbreviation listed in the brief exists. Additional ones: +- `cntdown`, `uqid`, `unId`, `papa`, `dada` (`tree-mutations.ts:533-534`), `desHst`, `insrtIndex`, `gndHost`, `bnd`, `rghtName`, `totlMinSize`, `curntOffset`, `KidNode`, `LT` (`useLayout.ts:85`). +- The typo `supress` (`dynamix.ts:180`, `geometry.ts:175`). +- `nodMaxd`, `nodFold` **[#83]**. These follow the v1 naming scheme; v2 renames them. + +Inconsistencies: +- `rowIsHorizontal` vs `nodeDir` semantics: a tabset's `nodeDir` is the *opposite* of its row (`dom.ts:40-41`). +- `RootSplitterHoverElStyles` (PascalCase) vs `hoverElementStyles`. +- The type `useDynamixLayoutOptions` is lowercase. + +### API surface — CONFIRMED + +- `LayoutProps` has 46 props (`react/types/index.ts:41-99`). Configuration groups: + - 14 styling props; + - 6 wrappers plus `TabsetToolbar`; + - 4 debounce props, where `disable*Timeout` defaults to `true`, so the `*Timeout` values do nothing unless the user flips both. +- Tabs are tuples, the label is the id, and `tabNames` provides the display name. +- `useDynamixLayout` returns 31 members (`useLayout.ts:695-732`). `isUpdating` is never set to `true` anywhere (dead state). +- No controlled mode, no imperative handle, and no programmatic add/remove/select/move. + +--- + +## 4. Bugs found (not fixed) + +Severity: **H** = data loss, crash or wrong behaviour in normal use; **M** = wrong in an edge case or a real environment; **L** = cosmetic or DX. + +| # | Sev | Bug | Evidence | +|---|---|---|---| +| B1 | **H** | **Parent re-render resets the layout and breaks interaction.** A new `tabs` identity rebuilds the engine from the initial tree; the DOM keeps the stale uids. | P3, probed. | +| B2 | **H** | **The React package doesn't work with React 18 or 17** (bundled React 19 jsx-runtime) despite its peer range. | P9b. | +| B3 | **H** | **Two layouts on one page corrupt each other.** | P1, probed. | +| B4 | **M** | **SSR markup isn't deterministic** (random tab body ids), so hydration fails. The Next.js example avoids this only with `dynamic(..., { ssr: false })` (`examples/nextjs/src/app/page.tsx:8-18`). | Probed. | +| B5 | **M** | **`crypto.randomUUID` throws outside secure contexts** (plain `http://` on a LAN IP or an internal host), so the layout crashes on construction. | `state.ts:10`; `Layout.tsx:33`; Solid `Layout.tsx:26`. | +| B6 | **M** | **A callback `ref` on `` is never called.** | `Layout.tsx:106` (`typeof ref === 'function' ? ownRef : …`), probed. | +| B7 | M | **Non-normalized tree after some moves.** Example: `b1 \| (b2 / b3)`, then move `b1` into `b3`. `removeNode` removes `ts(b1)` and calls `moveAdjacentNodeToGrandParent(row)` with the root as the parent; that warns "has no host" and returns. The root is left with one nested `row` child. `updateTree` still returns `true`, and that shape is what gets saved. | Probed; `tree-mutations.ts:544-554`, `:447-451`. | +| B8 | M | **Saved layout vs `tabs` drift is silent.** Tabs added to `tabs` after a layout was saved never appear. Tabs removed from `tabs` still show a tab label, with no body. | Probed (`tabs:[x,y,z]` + tree with x,y → `[x,y]`; `tabs:[x]` → `[x,y]`). | +| B9 | M | **Duplicate tab labels** create several core tabs, but every React map is keyed by label (`Layout.tsx:29-41`). Bodies collapse into one, and `tabset.open` (stored as a *name*) marks every duplicate active. | Probed (core keeps 3 tabs for `['a','a','b']`). | +| B10 | M | **The `layoutTree` prop is read only on mount.** Later changes are ignored (`useState(layoutTree)`), with no warning. | `useLayout.ts:45-47`. | +| B11 | M | **The published snapshot is mutated in place.** `calcTabsetCountAndMinDim` calls `Node.cache.nodOpts.get().clear()`, so a listener holding the last snapshot sees it emptied. `updateActiveTab` also writes `nodOpen` into the cached objects (`useLayout.ts:619`, `:632-646`). | Probed (snapshot size 3 → 0); `geometry.ts:29-30`. | +| B12 | M | **The splitter and resize debounces share one timer** (`engine.cntdown`). With timeouts enabled, a resize during a drag cancels the pending splitter update, or the other way round. | `slider.ts:19-27`; `geometry.ts:276-284`. | +| B13 | M | **Solid ignores `0` and `false`-y numeric props** (`props.bondWidth \|\| …`, `tabHeadHeight \|\| …`), so `bondWidth={0}` is impossible. Solid also reads `tabs` and `layoutTree` once, so later changes are ignored. | `solid/src/components/Layout.tsx:94-100`, `:121`, `:44`. | +| B14 | M | **Duplicate DOM ids.** `id={rootId}` defaults to `dynamix-layout-root` on every instance, and tab body `id`s are UUIDs that change on every rebuild. | `Layout.tsx:80`, `:170`, `:340`. | +| B15 | M | The resize effect closes over a stale `layoutInstance`. It works only through shared globals. Once state is per instance, this becomes a real bug. | `useLayout.ts:650-682`. | +| B16 | M | **`onDragEnd` schedules `requestAnimationFrame` without cancelling it on unmount.** It can run after unmount and call `setState` / `updateJSON`. | `useLayout.ts:554-558`. | +| B17 | L | `updateJSON` fires on every mount with the initial tree. Under StrictMode it fires twice. | `useLayout.ts:674`. | +| B18 | L | `updateTree` warns "Source node … not found" even when the *destination* is missing. `console.warn` calls in the engine can't be silenced (21 call sites). | `tree-mutations.ts:23`; grep. | +| B19 | L | The keyboard-shortcut effect has no dependency array, so it re-subscribes the `window` listener on every render. **[#83]** | `useLayout.ts:393-412`. | +| B20 | L | Core `package.json` `"browser": "./dist/core.iife.js"` points bundlers that honour `browser` at an IIFE with no exports. The IIFE and UMD builds are also emitted for React, where `exports` never references them. | `core/package.json:33`; `react/vite.config.ts:50`. | +| B21 | L | Solid lists `solid-js` under `dependencies`, not `peerDependencies`. That risks two Solid runtimes. | `solid/package.json:89-92`. | +| B22 | L | `Timer` appears in the public constructor options but isn't exported. | `dynamix.ts:85`; `index.ts`. | +| B23 | L | `examples/svelte` contains only a stale `dist/` (no `package.json`). It's dead weight in the repo. | `ls examples/svelte`. | +| B24 | L | The comment in the React `layout.css` says "for the SolidJS components". A fallback "No content" body uses Tailwind classes the library doesn't ship. | `layout.css:1`; `Layout.tsx:360-364`. | +| B25 | **H** | **Wrong placement unless the root sits at the viewport's top-left.** The container rect uses viewport coordinates (`rect.left/top`, `Layout.tsx:108-118`), but children are absolutely positioned *inside* the root. A root offset by 150px drew its tab bars at 300px, and the far side was clipped. The page scroll position isn't tracked either. Hidden today because every example is full-viewport. | Probed in agent-browser on `examples/react` (root `margin-left:150px` → tab bar `style.left=150px`, on screen at 300px). | +| B26 | L | **A center drop inserts the tab before the target's last tab**, not at the end: `'contain'` is rewritten to "beside the last tab" with `insertFlg` true. | Found by the characterization fixtures; `tree-mutations.ts:77-79`, `:142-143`. | +| B27 | M | **Splitting beside a folded tabset leaves a row stuck at its minimum size.** The tabset turns into a row but keeps `collapsed`, so the row gets no extra space, its splitters lock, and no toolbar can unfold it. The flag is also saved (`nodFold` on a row). | Fixtures (scenarios 7 and 15); `tree-mutations.ts:409-444`. | +| B28 | M | **A row whose open children all have weight 0 keeps stale sizes**, sometimes drawn outside the row. A splitter dragged fully to one side writes weight 0; once the other sibling folds, `calcDimensions` returns early. | Fixtures (scenario 8); `node.ts:124`. | +| B29 | L | **After the container shrinks below the layout minimum, the root stays oversized** until the next window resize, because the grown size is written back into the root's dimensions and reused by later recomputes. | Fixtures (scenario 9); `geometry.ts:160-163`. | +| B30 | **H** | **The published React package's type declarations are broken.** They import `'../../../core/src'`, a path into this repo's sources that doesn't exist in the package, so core types (`LayoutTree`, `NodeOptions`, …) resolve to `any` for users. `vite-plugin-dts` rewrites the tsconfig path alias. | `npm pack @dynamix-layout/react@1.0.2`: `dist/types/types/index.d.ts` line 2. Found in Phase 3. | + +--- + +## 5. Serialized format (v1 `LayoutTree`) + +### 5.1 TypeScript shape (`core/src/type/index.ts:25-41`) + +```ts +type LayoutTree = { + typNode: 'row' | 'tabset' | 'tab' | 'bond' // 'bond' never appears in output + nodName: string + uidNode: string + nodPart: number + nodOpen?: string | boolean // output: string, tabsets only + nodKids?: LayoutTree[] + nodFold?: boolean // [#83] tabset only + nodMaxd?: string // [#83] root only +} +``` + +### 5.2 What `Node.toJSON()` actually writes (`node.ts:193-224`) + +Key order: `typNode, nodPart, nodName, uidNode, nodOpen?, nodFold?, nodMaxd?, nodKids?`. A real output (probed, two tabs): + +```json +{ + "typNode": "row", "nodPart": 100, + "nodName": "dynamix-layout-root", "uidNode": "dynamix-layout-root", + "nodKids": [ + { "typNode": "tabset", "nodPart": 100, "nodName": "", "uidNode": "3ddc2c41-…", "nodOpen": "x", + "nodKids": [{ "typNode": "tab", "nodPart": 100, "nodName": "x", "uidNode": "fc8ab160-…" }] }, + { "typNode": "tabset", "nodPart": 100, "nodName": "", "uidNode": "adf88532-…", "nodOpen": "y", + "nodKids": [{ "typNode": "tab", "nodPart": 100, "nodName": "y", "uidNode": "f24bb0d8-…" }] } + ] +} +``` + +| Node | Field rules | +|---|---| +| **Root** | `typNode:"row"`. `nodName` and `uidNode` both equal `rootId` (default `"dynamix-layout-root"`). `nodPart:100`. On load the saved root uid is **ignored**; the live `rootId` is used. **[#83]** optional `nodMaxd`: the uid of a tabset. | +| **Row** | `nodName:""`, `uidNode`: random UUID. Always has `nodKids` in practice. Direction isn't stored: the root is horizontal and directions alternate by depth. | +| **Tabset** | `nodName:""`, `uidNode`: random UUID (stable across saves within a session and restored on load). `nodOpen`: the **label** of the active tab, omitted when empty. `nodKids`: tabs. **[#83]** `nodFold:true` when folded (omitted otherwise). | +| **Tab** | `nodName`: **the tab's label, which is its identity**. `uidNode`: in React/Solid, a random UUID from `getTabOutput`, new every page load; on load it's **replaced** by `tabsIds.get(nodName)` (`tree-builder.ts:255-257`). `nodPart:100`, meaningless for tabs. No `nodKids`. | + +### 5.3 Semantics that migration must respect + +- **`nodPart` is a weight, not a size or percentage.** + - Each child gets `min + extra × part / Σpart(open siblings)`, where `extra = parent size − Σmin − bonds` (`node.ts:107-157`). + - Parts are floats after a splitter drag (`slider.ts:84-95`). New nodes default to 100. + - Converting to a v2 `size` means either keeping weight semantics or accepting that a restored layout looks slightly different. Phase 2 decision; see Q4. +- **Identity:** tabs are matched by `nodName`; tabsets and rows by `uidNode`. +- **Direction is positional.** Moving a row to another depth flips its direction. A migration must compute `direction` from depth: even depth (root = 0) is horizontal. +- **Load algorithm:** BFS-zip the JSON against a freshly built node tree (`tree-builder.ts:81-111`). Tabs present in `tabs` but absent from the tree are dropped; tabs in the tree but absent from `tabs` are kept (B8). + +### 5.4 Shapes that occur in saved data (migration must accept) + +1. **Root with a single child row.** This happens after B7. The direction of that row's children is vertical, because the row sits at depth 1. +2. **Nested rows whose direction equals their parent's.** This can't happen by construction, but I'll treat it as possible in hand-edited files. +3. **Tabset `nodOpen` naming a tab that isn't among its kids.** This is possible after B9-style duplicates or hand edits. +4. **Duplicate tab `nodName`s** (B9). +5. **Fractional `nodPart`s, and `nodPart` ≤ 0.** The second is not produced by the code, but hand edits are possible. +6. **`nodFold` / `nodMaxd` present [#83], or absent** (everything saved by ≤ 1.1.0). +7. **Unknown fields**, to be ignored. + +Unverified (to be pinned by characterization tests in Phase 3): empty tabsets, a row directly containing tabs, and a root whose `typNode` isn't `row`. + +--- + +## 6. Baseline numbers (for Phase 5 comparison) + +| Artifact | Raw | gzip -9 | +|---|---|---| +| `core/dist/core.es.js` | 45.8 kB | 11.0 kB (includes the licence twice and the banner) | +| `react/dist/index.es.js` | 41.1 kB | 10.4 kB (includes the React 19 jsx-runtime) | +| `react/dist/index.css` | 1.0 kB | 0.5 kB | +| `solid/dist/index.es.js` | 29.7 kB | 7.6 kB | + +Tests: +- Core has 6 files, 44 `it` blocks, and generated cases in `manual`/`dynamic`; the whole repo runs 3,766 tests. +- React has 1 test (not executed). +- Solid has 1 test file. + +--- + +## 7. Open questions (need your decision before Phase 2) + +1. **PR #83 (maximize/fold).** Should it merge first, so v2 includes and ports it? The audit assumes yes. +2. **Solid's version.** The brief lists Solid with no version and says to mark only `core` and `react` major. But Solid is published (1.0.2) and depends on the core API and types that v2 removes. Should Solid be **major** too, with a port that keeps its props, or should it pin core 1.x until a later Solid redesign? +3. **Tabs missing from a saved layout (B8).** In v2, should a tab in `tabs` but absent from the layout be (a) appended to the active or first tabset, (b) ignored as in v1, or (c) reported through a callback? Should a layout tab with no matching entry in `tabs` be dropped? +4. **Meaning of `size` in v2.** The options: + - (a) keep v1 weight-of-extra-space semantics, so migration is lossless and resize behaviour is unchanged; + - (b) switch to a proportional share of the whole size, like react-resizable-panels percentages; + - with (b), migration converts weights using the minimums, and restored layouts can shift by a few pixels. +5. **Duplicate tab ids.** Should v2 throw in development and dedupe with a warning in production? +6. **`examples/svelte`** (stale `dist/` only): delete it? diff --git a/docs/v2/02-design.md b/docs/v2/02-design.md new file mode 100644 index 0000000..40181d8 --- /dev/null +++ b/docs/v2/02-design.md @@ -0,0 +1,1152 @@ +# dynamix-layout v2 — Phase 2 design + +Status: **approved and implemented** on the `v2` branch. The final API is documented in [`docs/api`](../api/README.md) and [the migration guide](../migration-v1-to-v2.md); where the implementation differs from this proposal, the docs are authoritative. Differences found during implementation are recorded in §0 and §2.2. Builds on [01-audit.md](./01-audit.md); `F*`, `R*`, `P*` and `B*` refer to items there. +No source code changes in this phase. + +--- + +## 0. Decisions taken (please confirm or overturn) + +The open questions from the audit were answered "go ahead", so I took the recommended option for each. Every one is cheap to flip now and expensive later. + +| # | Decision | Why | +|---|---|---| +| D1 | **PR #83 (maximize and fold) merges before v2 work starts.** v2 ports it. | It's done and reviewed; porting it once is cheaper than rebasing v2 onto it later. | +| D2 | **`@dynamix-layout/solid` also gets a major (2.0.0).** It's a minimal port: props unchanged, but it now reads and writes the v2 JSON. | Solid depends on core types and APIs that v2 removes. A minor bump would break Solid users, which violates semver. | +| D3 | **`tabs` is the list of open tabs; the layout only decides where they go.** A tab in `tabs` that's missing from the layout is added to the last active tabset (or to its `target`). A layout tab that's missing from `tabs` is removed. | It fixes B8, and it gives one source of truth for "which tabs exist". Closing a tab means removing it from `tabs` (see §3.4). | +| D4 | **Keep v1's sizing semantics, and name the field `weight`, not `size`.** A weight works like CSS `flex-grow` with `flex-basis` set to the minimum size. | Migration stays lossless (v1 layouts restore pixel-exact). Splitter and resize behaviour doesn't change. A field named `size` would suggest pixels or percentages, and it would be neither. | +| D5 | **Duplicate tab ids throw in development. In production the first occurrence wins, and an `onWarning` is emitted.** | Fixes B9 loudly where developers see it, without crashing production apps. | +| D6 | **Delete `examples/svelte`** (it contains only a stale `dist/`). | Dead weight (B23). | +| D7 | **Tab drag moves from HTML5 DnD to pointer events.** | Touch support; one drag model for tabs and splitters; drop targets computed from engine rects instead of DOM overlays; Escape to cancel; a keyboard move mode becomes possible. See §3.9. | +| D8 | **All rects are relative to the layout root, not the viewport.** | Fixes B25. Positioning becomes independent of where the layout sits on the page or how far the page is scrolled. | +| D9 | **React's imperative handle has no `addTab`/`removeTab`.** Tabs are added or removed by changing the `tabs` prop. Core keeps `addTab`/`removeTab` for headless and vanilla users. | In React, a handle that adds tabs would create a second, content-less source of truth that competes with the `tabs` prop (see D3). | +| D10 | **Publish a final v1.x patch** (packaging fixes plus `@deprecated` JSDoc, no runtime warnings). See §7. | React 18 users are broken today (B2) and shouldn't have to wait for v2. | + +--- + +## 1. Goals and non-goals + +**Goals** +- Instance-based, fully typed, pure core: no globals, no timers, no DOM at import time. +- A small React API at three levels: component, headless hooks, and state/actions anywhere. +- Lossless migration of saved v1 layouts. +- SSR-safe, StrictMode-safe and accessible. +- Fix every **H** and **M** bug in the audit. +- **Keep the behaviours users depend on**: + - tab contents never remount when tabs move (R2); + - the edges outside a dragged splitter pair never move (F8); + - exact restore after maximize and after fold (F15/F16); + - drag stays smooth over iframes (R6); + - the imperative DOM fast path during drags (R3). + +**Non-goals for 2.0** (listed as follow-ups in §12) +- Dragging tabs out to other windows (not supported in v1 either). +- Scrollable layouts smaller than their minimum size. v1 clips (F6); v2 keeps that. +- Redesigning the Solid API. +- Undo/redo, floating panels, popouts. + +--- + +## 2. Core (`@dynamix-layout/core`) + +### 2.1 Source layout + +``` +packages/core/src/ + model/ + types.ts # LayoutNode, RowNode, TabsetNode, TabNode, Direction, Rect, Point + guards.ts # isRow, isTabset, isTab + tree/ + build.ts # buildDefaultTree(tabIds, createId) ← tree-builder.ts:134-242 + find.ts # findNode, findParent, findTabset, walk + insert.ts # insertTab, insertBeside, dockAtRoot ← tree-mutations.ts:122-445 + remove.ts # removeTab, removeTabset ← tree-mutations.ts:527-630 + move.ts # moveTab, moveTabset (remove + insert, plus guards ← :8-107) + normalize.ts # prune empties, flatten single-child and same-direction rows, fix activeTabId + view-state.ts # fold / unfold / maximize rules ← view-state.ts + reconcile-tabs.ts # apply the tabs list (D3) + geometry/ + min-size.ts # min-size propagation ← geometry.ts:19-136 + distribute.ts # cumulative-boundary distribution ← node.ts:102-191 + compute-rects.ts # computeLayoutRects (incl. maximize/fold overrides) + splitter.ts # clamp and re-weight a splitter pair ← slider.ts:30-126 + tab-bar.ts # getTabBarRect, getTabContentRect ← dom.ts:10-46 + drop/ + drop-target.ts # getDropTarget(point, snapshot, measurements) + drop-indicator.ts # getDropIndicatorRect(target, …) ← drop-preview.ts + drop-targets.ts # listDropTargets(source) for keyboard move mode + store/ + snapshot.ts # deriving TabsetState/SplitterState/TabState with structural sharing + store.ts # createStore: getSnapshot/subscribe/setState (sync, no timers) + serialize/ + schema.ts # LayoutJSON types, LAYOUT_VERSION + to-json.ts / from-json.ts (validation with path-based errors) + migrate-v1.ts # migrateLayoutFromV1, isLayoutV1 + dom/ + frame-scheduler.ts # createFrameScheduler (moved from dom.ts:54-96) + apply-rect.ts # applyRect(el, rect) — used by adapters' fast path + ids.ts # createDeterministicIdGenerator + dev.ts # isDev, warn helpers (no import-time work) + errors.ts # DynamixLayoutError, error codes + create-layout.ts # the instance: wires tree + geometry + drop + store + index.ts # public exports only +``` + +Removed: `dynamix.ts`, `node.ts`, `state.ts`, `queue.ts`, `reactive-state.ts`, `comparator.ts`, the banner in `index.ts`, and the `window.__DYNAMIX_LAYOUT__` global (P1, P4, P8, P11). + +**Rules.** +- No module-level mutable state. +- `tree/`, `geometry/`, `drop/` and `serialize/` are pure functions: data in, data out, no instance access. +- `create-layout.ts` is the only stateful module, and it owns one store per instance. +- Children are plain `readonly` arrays (replacing `Queue`, P8). + +### 2.2 Model + +```ts +export type Direction = 'horizontal' | 'vertical' +export interface Point { readonly x: number; readonly y: number } +export interface Rect { readonly x: number; readonly y: number; readonly width: number; readonly height: number } + +export interface TabNode { readonly type: 'tab'; readonly id: string } // id = user tab id + +export interface TabsetNode { + readonly type: 'tabset' + readonly id: string + /** Share of the parent row's space above the minimum sizes (like CSS flex-grow). */ + readonly weight: number + readonly activeTabId: string // always one of children (normalized) + readonly isFolded: boolean + readonly children: readonly TabNode[] // never empty (normalized) +} + +export interface RowNode { + readonly type: 'row' + readonly id: string + readonly weight: number + readonly direction: Direction + readonly children: readonly (RowNode | TabsetNode)[] +} + +export type LayoutNode = RowNode | TabsetNode | TabNode +``` + +- **Direction is stored**, not derived from depth as in v1 (F2). That makes flattening exact, and it allows a vertical root. +- **Splitters are derived**: one between each pair of adjacent children of a row. A splitter's id is `` `${before.id}~${after.id}` ``, so it's stable when unrelated siblings change and deterministic across server and client. + +**Invariants**, enforced by `normalize.ts` after every mutation and every load: +1. The root is a row, and it may have zero children (an empty layout). +2. Tabsets have at least one tab, and `activeTabId` is one of them. +3. A non-root row has at least 2 children. A single-child row is replaced by its child, **which keeps its own weight**. That is what v1 does when it dissolves a row (`moveAdjacentNodeToGrandParent`), so v1 interactions and saved layouts stay pixel-identical. This fixes B7. +4. A child row never has its parent's direction; same-direction rows are flattened, and their children keep their own weights (the same rule as invariant 3). + + *Implementation note:* the root absorbs a single child row (taking its direction, children and weight). The root's weight stands for that row: v1 kept a horizontal root with one vertical row child, whose weight matters again if something is later docked beside it. A root that is horizontal, or has at most one child, is a plain root: horizontal, weight 100. +5. Weights are finite and **≥ 0**. Invalid values become 100. Zero is valid: v1 writes it when a splitter is dragged all the way to a neighbour's minimum. If every unfolded child of a row has weight 0, they share the space equally (v1 left stale sizes, B28). +6. Tab ids are unique across the layout. +7. View-state rules from #83 (F17): a tabset alone in its row can't be folded; every row keeps at least one unfolded child; `maximizedTabsetId` must exist. + +### 2.3 Geometry (pure, behaviour-preserving) + +```ts +export interface GeometryConfig { + readonly minPanelSize: { readonly width: number; readonly height: number } + readonly splitterSize: number + readonly foldedSize: number +} +export interface LayoutRects { + readonly container: Rect + readonly rows: ReadonlyMap + readonly tabsets: ReadonlyMap // maximize override applied + readonly splitters: ReadonlyMap +} +export function computeLayoutRects( + root: RowNode, container: Rect, config: GeometryConfig, maximizedTabsetId: string | null, + previous?: LayoutRects, // unchanged rects are reused by reference +): LayoutRects +``` + +The v1 algorithm is ported as is: +- min-size propagation (F5); +- root overflow growth (F6); +- weight distribution over the space above the minimums, with **cumulative boundary rounding** (F7); +- a folded tabset is `foldedSize` along its row and keeps its weight (F16); +- a maximized tabset gets the container rect, and the others are marked hidden (F15). + +Rects are in **root coordinates**: `container.x/y` is the padding offset, so the root's top-left corner is (0, 0) (D8). + +Splitter resize (`geometry/splitter.ts`) is the v1 clamp-and-re-weight of the two neighbours (`slider.ts:69-123`). It returns a new root with only that row path copied. + +Performance: v1 recomputed only the dragged subtree. v2 recomputes rects for the whole tree, which is O(nodes) and tiny for realistic layouts (< 200 nodes). Unchanged `Rect` objects are reused by reference, so the DOM writer skips them. A drag benchmark in Phase 5 guards against regressions; if it regresses, subtree recompute is a local optimization inside `compute-rects.ts`. + +### 2.4 Drop targets (pure) + +```ts +export type Side = 'top' | 'bottom' | 'left' | 'right' +export type DropTarget = + | { readonly type: 'tabset'; readonly tabsetId: string; readonly position: Side | 'center' } + | { readonly type: 'tab'; readonly tabId: string; readonly position: 'before' | 'after' } + | { readonly type: 'root'; readonly position: Side } + +/** Tab bar geometry the engine can't know (label widths). Measured by the adapter, in root coordinates. */ +export interface DropMeasurements { + readonly tabBars: ReadonlyMap +} + +export function getDropTarget(snapshot: LayoutSnapshot, point: Point, source: DragSource, + measurements?: DropMeasurements, options?: { edgeSize?: number }): DropTarget | null +export function getDropIndicatorRect(snapshot: LayoutSnapshot, target: DropTarget, measurements?: DropMeasurements): Rect +``` + +The brief proposed `{ targetId, position }`. I chose a discriminated union instead, because a tab, a tabset and the root accept different positions. A union makes invalid pairs such as `{ tab, 'top' }` impossible to type. + +The v1 rules are kept: +- tabset thirds (`drop-preview.ts:26-61`); +- tab-bar insertion marker (`:97-158`); +- root edges (`:64-91`) for a pointer within `edgeSize` (default 16px) of a root edge; +- a folded strip is always `center` (#83). + +`v1 'contain'` becomes `'center'`. Invalid targets return `null`: a lone tab onto its own tabset, a tabset onto itself, or the only tabset moving anywhere (`tree-mutations.ts:27-87`). + +### 2.5 Store and snapshots + +```ts +export interface TabsetState { + readonly id: string + readonly tabIds: readonly string[] + readonly activeTabId: string + /** Direction of the row this tabset sits in. */ + readonly parentDirection: Direction + readonly isFolded: boolean + readonly isMaximized: boolean + readonly isHidden: boolean // another tabset is maximized + readonly canFold: boolean + readonly canMaximize: boolean +} +export interface SplitterState { + readonly id: string + readonly rowId: string + readonly direction: Direction // the row's direction: 'horizontal' row ⇒ vertical bar + readonly beforeId: string + readonly afterId: string + readonly isLocked: boolean // next to a folded tabset + readonly isHidden: boolean +} +export interface TabState { + readonly id: string + readonly tabsetId: string + readonly isActive: boolean + readonly isVisible: boolean // active, not folded, not hidden, body height > 0 +} +export type DragSource = + | { readonly type: 'tab'; readonly tabId: string } + | { readonly type: 'tabset'; readonly tabsetId: string } + | { readonly type: 'splitter'; readonly splitterId: string } +export interface DragState { readonly source: DragSource; readonly target: DropTarget | null } + +export interface LayoutSnapshot { + readonly root: RowNode + readonly tabsets: ReadonlyMap + readonly splitters: ReadonlyMap + readonly tabs: ReadonlyMap + readonly rects: LayoutRects + readonly maximizedTabsetId: string | null + readonly drag: DragState | null +} +``` + +**Immutability and structural sharing.** Each change produces a new snapshot object. Each field keeps its previous reference unless it changed: +- A container resize or splitter drag changes `rects` (and, for a splitter, `root`), but not `tabsets`, `splitters` or `tabs`. +- Selecting a tab replaces one `TabsetState` and two `TabState`s. Every other entry keeps its object identity. + +React can therefore compare by reference (`Object.is`) at every level. The snapshot is frozen in development, so accidental mutation throws; this fixes B11. + +**Notifications are synchronous:** one notification per action, with no timers (P-brief, B12). Batching and throttling belong to the adapter (`createFrameScheduler`, `resizeThrottleMs`). + +### 2.6 `createLayout()` — public API + +```ts +export interface TabInit { + /** Unique, stable id. It's also what the saved layout stores. */ + readonly id: string + /** Where the tab goes the first time it appears. Default: the last active tabset. */ + readonly target?: DropTarget +} + +export type LayoutChangeReason = 'move' | 'resize' | 'select' | 'fold' | 'maximize' | 'tabs' | 'reset' + +export interface LayoutOptions { + /** Open tabs. Order matters only for the default layout. */ + tabs: readonly TabInit[] + /** Saved layout (v2 JSON, or v1 tree that is migrated automatically). */ + initialLayout?: LayoutJSON | LayoutTreeV1 | null + /** @default { width: 40, height: 40 } */ + minPanelSize?: { width: number; height: number } + /** @default 10 */ + splitterSize?: number + /** Size of a folded tabset along its row. @default minPanelSize.height */ + foldedSize?: number + /** Id factory for rows and tabsets. @default deterministic per instance */ + createId?: (kind: 'row' | 'tabset') => string + /** Called after every committed change (never for transient drag frames or programmatic `load`). */ + onLayoutChange?: (layout: LayoutJSON, reason: LayoutChangeReason) => void + /** @default console.warn in development, silent in production */ + onWarning?: (warning: LayoutWarning) => void +} + +export interface Layout { + getSnapshot(): LayoutSnapshot + subscribe(listener: (snapshot: LayoutSnapshot) => void): () => void + + setContainerRect(rect: Rect): void + setTabs(tabs: readonly TabInit[]): void + setOptions(options: Partial>): void + + moveTab(tabId: string, target: DropTarget): boolean + moveTabset(tabsetId: string, target: DropTarget): boolean + selectTab(tabId: string): boolean + addTab(tab: TabInit, target?: DropTarget): boolean + removeTab(tabId: string): boolean + + /** Moves a splitter so its centre is at `position` (root coordinates); clamped to min sizes. */ + resizeSplitter(splitterId: string, position: Point): boolean + /** Keyboard resizing: moves a splitter by `delta` px along its row. */ + moveSplitterBy(splitterId: string, delta: number): boolean + + maximize(tabsetId: string): boolean + restore(): boolean + toggleMaximize(tabsetId: string): boolean + fold(tabsetId: string): boolean + unfold(tabsetId: string): boolean + toggleFold(tabsetId: string): boolean + + /** Pointer-driven interactions (tabs, tabsets and splitters share one drag model). */ + startDrag(source: DragSource): boolean + updateDrag(point: Point, measurements?: DropMeasurements): void // transient: no onLayoutChange + endDrag(): boolean // commits: one onLayoutChange + cancelDrag(): void + + getDropTarget(point: Point, measurements?: DropMeasurements): DropTarget | null + getDropIndicatorRect(target: DropTarget, measurements?: DropMeasurements): Rect + /** Every valid target for the current drag source, in reading order (keyboard move mode). */ + listDropTargets(source: DragSource): readonly DropTarget[] + /** Splitter position and bounds in px, for ARIA (aria-valuenow/min/max). */ + getSplitterBounds(splitterId: string): { value: number; min: number; max: number } | null + + /** Replace the layout (no onLayoutChange). */ + load(layout: LayoutJSON | LayoutTreeV1): void + /** Back to `initialLayout`, or to the default layout built from the current tabs. */ + reset(): void + toJSON(): LayoutJSON // memoized per root + /** Removes all listeners; later calls are ignored with a development warning. */ + destroy(): void +} + +export function createLayout(options: LayoutOptions): Layout +``` + +Example (the brief's shape, refined): + +```ts +const layout = createLayout({ + tabs: [{ id: 'editor' }, { id: 'terminal' }], + initialLayout: saved, // v1 or v2 + minPanelSize: { width: 40, height: 40 }, + splitterSize: 10, + onLayoutChange: (json) => localStorage.setItem('layout', JSON.stringify(json)), +}) +const unsubscribe = layout.subscribe((snapshot) => render(snapshot)) +layout.setContainerRect({ x: 0, y: 0, width: 1200, height: 800 }) +layout.moveTab('terminal', { type: 'tabset', tabsetId: layout.getSnapshot().tabs.get('editor')!.tabsetId, position: 'bottom' }) +``` + +**Return values.** Actions return `true` if they changed the layout. A disallowed or no-op action returns `false`; this replaces v1's 21 `console.warn` calls (B18). An unknown id also returns `false`, plus a development warning. Actions never throw. + +**What throws `DynamixLayoutError`** (`errors.ts`), always with `code`, `message` and, for JSON errors, a `path`: +- invalid JSON passed to `createLayout` / `load` (`INVALID_LAYOUT`, with a `path` such as `root.children[1].weight`); +- an unsupported `version` (`UNSUPPORTED_VERSION`); +- duplicate tab ids in development (`DUPLICATE_TAB_ID`, D5). + +**IDs.** The default generator is deterministic per instance: +- The initial tabsets are named after their first tab: `ts-editor`. +- Later rows and tabsets get a counter: `row-1`, `ts-2`. +- Ids already present in a loaded layout are skipped. + +So the same input gives the same ids on server and client, with no `crypto.randomUUID()` (B4, B5). User tab ids are used unchanged. + +**Timers.** None in core. `createFrameScheduler` stays available under `dom/` for adapters. + +### 2.7 Serialized format v2 + +```ts +export const LAYOUT_VERSION = 2 +export interface LayoutJSON { + version: 2 + root: RowJSON + /** Id of the maximized tabset, if any. */ + maximizedTabsetId?: string +} +export interface RowJSON { + type: 'row'; id: string; weight: number; direction: Direction + children: (RowJSON | TabsetJSON)[] +} +export interface TabsetJSON { + type: 'tabset'; id: string; weight: number + activeTabId?: string + isFolded?: boolean // written only when true + children: TabJSON[] +} +export interface TabJSON { type: 'tab'; id: string } +``` + +- Splitters aren't serialized. Neither are rects: sizes are weights, so a container resize never changes the JSON. +- The root's `weight` is ignored and written as `100`. +- Tab ids are the user's ids, so a saved layout is meaningful across sessions. This differs from v1, where tab `uidNode` was a per-session UUID. + +### 2.8 `migrateLayoutFromV1` + +```ts +export interface LayoutTreeV1 { // exported for typing old data + typNode: 'row' | 'tabset' | 'tab' | 'bond'; nodName: string; uidNode: string; nodPart: number + nodOpen?: string | boolean; nodKids?: LayoutTreeV1[]; nodFold?: boolean; nodMaxd?: string +} +export function isLayoutV1(input: unknown): input is LayoutTreeV1 +export function migrateLayoutFromV1(tree: LayoutTreeV1, options?: { onWarning?: (w: LayoutWarning) => void }): LayoutJSON +``` + +`createLayout({ initialLayout })` and `layout.load()` call it automatically when `isLayoutV1(input)` is true. In that case they emit a `MIGRATED_FROM_V1` development warning, suggesting the app re-save the layout. + +Mapping (matches audit §5): + +| v1 | v2 | +|---|---| +| `typNode` | `type` | +| row/tabset `uidNode` | `id` (kept, so `nodMaxd` still resolves) | +| tab `nodName` | tab `id` (**v1 tab identity is the label**; tab `uidNode` is discarded) | +| `nodPart` | `weight` (same semantics, D4; a non-finite or negative value → 100) | +| depth parity | `direction` (root and even depths `horizontal`, odd depths `vertical`), computed **before** normalizing | +| tabset `nodOpen` (a tab label) | `activeTabId` (falls back to the first child) | +| tabset `nodFold: true` | `isFolded: true` | +| root `nodMaxd` | `maximizedTabsetId` (dropped if the id doesn't exist) | +| root `nodName`/`uidNode` (= `rootId`) | root `id` kept; irrelevant to the user | + +Edge cases (audit §5.4): + +| Input | Handling | +|---|---| +| Root with a single nested row (B7) | Row flattened into the root. The root takes the row's direction (`vertical`), so it's pixel-identical. | +| Same-direction nested rows (hand-edited) | Flattened; children keep their weights (invariant 4). | +| `nodOpen` naming a missing tab | `activeTabId` = first tab; `INVALID_ACTIVE_TAB` warning. | +| Duplicate tab `nodName`s | The first occurrence (BFS order) wins and later ones are dropped; `DUPLICATE_TAB_ID` warning (migration never throws on this). | +| Fractional `nodPart` | Kept as is. | +| `nodPart` < 0, `NaN` | 100; `INVALID_WEIGHT` warning. (`0` is valid.) | +| Empty tabset / empty row | Removed; the parent is then normalized. | +| Tab directly inside a row | Wrapped in a new tabset (deterministic id `ts-`). | +| Root that is a tabset | Wrapped in a root row. | +| `typNode: 'bond'` or unknown fields | Ignored. | +| Not an object, or missing `typNode` | `DynamixLayoutError('INVALID_LAYOUT')`. | + +Migration is pure and generates no random ids. Phase 3 tests it against fixtures produced by the **current v1 code** (§10). + +--- + +## 3. React (`@dynamix-layout/react`) + +### 3.1 Source layout + +``` +packages/react/src/ + context/ + layout-context.ts # LayoutContext { controller } + useLayoutContext (dev error when missing) + DynamixLayoutProvider.tsx + hooks/ + use-dynamix-layout.ts # creates/owns the controller; returns prop getters + lists + use-layout-state.ts # selector subscription (useSyncExternalStore) + use-layout-actions.ts + use-tabset.ts + use-tab.ts + use-splitter.ts + use-drag-state.ts + internal/ + controller.ts # engine + tab registry + element registry + options sync + rect-writer.ts # the DOM fast path (one documented module, see §3.8) + pointer-drag.ts # pointer-event tab/tabset/splitter drags (§3.9) + container-observer.ts # ResizeObserver + rAF (§3.10) + keyboard.ts # ARIA tab navigation, splitter keys, shortcuts, move mode + dom-ids.ts # deterministic, CSS-safe ids from useId + tab id + components/ + DynamixLayout.tsx # Level 1, composes everything below + Panel.tsx TabBar.tsx Tab.tsx TabContent.tsx Splitter.tsx + DropIndicator.tsx RootDropZone.tsx TabsetToolbar.tsx + utils/ + merge-props.ts merge-refs.ts use-isomorphic-layout-effect.ts use-stable-callback.ts + types.ts + index.ts # 'use client' banner added at build time + styles.css +``` + +Each file has one responsibility and aims for < 200 lines. `use-layout.ts` (733 lines, P5) is split across `controller`, `rect-writer`, `pointer-drag`, `container-observer` and `keyboard`. + +### 3.2 Level 1 — `` + +```ts +export interface TabItem { + /** Unique, stable id. Stored in saved layouts. */ + id: string + /** Tab bar label. @default id */ + title?: ReactNode + content: ReactNode + /** Show a close button; closing calls `onTabClose`. @default false */ + closable?: boolean + /** Placement the first time this tab appears. @default last active tabset */ + target?: DropTarget +} + +export type LayoutSlot = + | 'root' | 'panel' | 'tabBar' | 'tab' | 'activeTab' | 'tabClose' | 'tabContent' + | 'splitter' | 'dropIndicator' | 'rootDropZone' | 'toolbar' | 'toolbarButton' + +export interface DynamixLayoutProps + extends Omit, 'children' | 'defaultValue' | 'onChange'> { + tabs: readonly TabItem[] + /** Uncontrolled: initial layout (v1 or v2). Later changes are ignored. */ + defaultLayout?: LayoutJSON | LayoutTreeV1 + /** Controlled layout (v1 or v2). Use with `onLayoutChange`. */ + layout?: LayoutJSON | LayoutTreeV1 + onLayoutChange?: (layout: LayoutJSON, details: { reason: LayoutChangeReason }) => void + onTabClose?: (tabId: string) => void + /** @default { width: 40, height: 40 } */ + minPanelSize?: { width: number; height: number } + /** @default 10 */ + splitterSize?: number + /** @default 40 */ + tabBarHeight?: number + /** @default true */ + showTabBar?: boolean + /** @default 0 */ + padding?: number | { top?: number; right?: number; bottom?: number; left?: number } + /** @default true */ + allowMaximize?: boolean + /** @default true (requires the tab bar) */ + allowFold?: boolean + /** @default true */ + maximizeOnDoubleClick?: boolean + /** Alt/Option + "+" maximize, Alt/Option + "-" fold, for the focused tabset. @default true */ + keyboardShortcuts?: boolean + /** WAI-ARIA tabs activation. @default 'automatic' */ + tabActivation?: 'automatic' | 'manual' + /** Container resize throttle; 0 = once per animation frame. @default 0 */ + resizeThrottleMs?: number + components?: Partial + classNames?: Partial> + styles?: Partial> +} + +export const DynamixLayout: (props: DynamixLayoutProps & { ref?: React.Ref }) => React.ReactElement +``` + +That's **23 own props** instead of 46. Custom components replace the 6 wrappers; one `classNames` and one `styles` object replace the 14 styling props; one throttle prop replaces 4 debounce props. + +Defaults equal the v1 defaults (`bond 10`, `minW/minH 40`, `tabHeadHeight = minH = 40`). Upgrading without setting any options therefore looks the same. + +As in v1 (`useLayout.ts:76-78`), the effective minimum height is `max(minPanelSize.height, tabBarHeight)` when the tab bar is shown. The fold size equals `tabBarHeight` (or `minPanelSize.height` without a tab bar). + +**Slot components** (each receives DOM props to spread, plus state; must forward `ref`): + +```ts +type SlotProps = React.HTMLAttributes & { ref?: React.Ref } +export interface LayoutComponents { + Panel: React.ComponentType + TabBar: React.ComponentType + Tab: React.ComponentType & { tab: TabItem; isActive: boolean; isDragging: boolean; onClose?: () => void }> + TabContent: React.ComponentType + Splitter: React.ComponentType + DropIndicator: React.ComponentType + RootDropZone: React.ComponentType + TabsetToolbar: React.ComponentType +} +export interface TabsetToolbarProps { + tabset: TabsetState; isRotated: boolean; canMaximize: boolean; canFold: boolean + onToggleMaximize: () => void; onToggleFold: () => void +} +``` + +`Panel` is now a real background element for each tabset; that's where borders and backgrounds go. v1's `WrapTabPanel` was a transparent overlay shown only during drags, and pointer hit-testing no longer needs it. + +### 3.3 Tabs reconciliation (fixes B1, P3) + +- The engine is created **once per component instance**, in a `useState` initializer. `createLayout` is pure, so StrictMode's double-invoke just discards one instance. +- Each render computes the **id list**. Only when the ids change (a joined-string comparison) does the controller call `engine.setTabs()`, in a layout effect. +- Titles, content and `closable` live in a tab registry passed through context. Changing them never touches the engine. +- So an inline `tabs` array with unchanged ids causes **zero engine work**, and content never remounts. A test asserts this (§10). +- `minPanelSize`, `splitterSize`, `tabBarHeight` and `showTabBar` changes call `engine.setOptions()` without recreating the engine. + +### 3.4 Controlled and uncontrolled + +**Uncontrolled** (`defaultLayout`): +- The engine owns the layout, and `onLayoutChange` reports each committed change. +- Changes to `defaultLayout` after mount are ignored, like React's `defaultValue`. + +**Controlled** (`layout` + `onLayoutChange`): +1. A user action is applied by the engine, and `onLayoutChange(next)` fires. +2. The wrapper schedules a reconcile render. +3. In a layout effect, if `layout` is not the object it last emitted or loaded, it calls `engine.load(layout)`. + +So if the parent stores `next`, nothing reloads; if it ignores or edits it, the layout reverts to (or adopts) the parent's value. All of this happens before paint, so there's no visible flicker. + +Transient drag frames (splitter moves, drop-target changes) aren't changes; only `endDrag` commits. + +**Closing tabs** follows the same model: the close button calls `onTabClose(id)`, and the parent removes the tab from `tabs`. + +`onLayoutChange` reasons: + +| Reason | Fired after | +|---|---| +| `'move'` | a drop | +| `'resize'` | a splitter release or a keyboard resize | +| `'select'` | selecting a tab | +| `'fold'` / `'maximize'` | the view-state toggles | +| `'tabs'` | `tabs` added or removed | + +It is **not** fired on mount (B17), on container resize, or after a programmatic `load`. + +### 3.5 Imperative handle + +```ts +export interface DynamixLayoutHandle { + readonly element: HTMLDivElement | null + moveTab(tabId: string, target: DropTarget): boolean + moveTabset(tabsetId: string, target: DropTarget): boolean + selectTab(tabId: string): boolean + focusTab(tabId: string): void + maximize(tabsetId: string): boolean + restore(): boolean + toggleMaximize(tabsetId: string): boolean + fold(tabsetId: string): boolean + unfold(tabsetId: string): boolean + toggleFold(tabsetId: string): boolean + reset(): void + toJSON(): LayoutJSON + getSnapshot(): LayoutSnapshot +} +``` + +There is no `addTab`/`removeTab` (D9): change `tabs`, optionally with `TabItem.target` for placement. Both function refs and object refs work (fixes B6). + +### 3.6 Level 2 — headless hooks + +```ts +export interface UseDynamixLayoutOptions extends Omit> { + tabs: readonly Pick[] // content not required + id?: string +} +export interface UseDynamixLayoutResult { + /** Pass to so child hooks work. Stable. */ + controller: LayoutController + getRootProps:

& { ref?: React.Ref }>(props?: P) => P + tabsets: readonly TabsetState[] // changes only on structural changes + splitters: readonly SplitterState[] + tabIds: readonly string[] + dropIndicator: { target: DropTarget; props: SlotProps } | null + rootDropZones: readonly { side: Side; isActive: boolean; props: SlotProps }[] // empty unless dragging + actions: LayoutActions // stable +} +export function useDynamixLayout(options: UseDynamixLayoutOptions): UseDynamixLayoutResult +export function DynamixLayoutProvider(props: { controller: LayoutController; children: ReactNode }): ReactElement + +export function useTabset(tabsetId: string): { + tabset: TabsetState; isRotated: boolean + getPanelProps: PropGetter; getTabBarProps: PropGetter // tablist role, drag source for the whole tabset + toggleMaximize(): void; toggleFold(): void +} +export function useTab(tabId: string): { + tab: TabState; isActive: boolean; isDragging: boolean + select(): void; close(): void + getTabProps: PropGetter // role=tab, aria-*, roving tabindex, pointer drag, keyboard + getTabContentProps: PropGetter // role=tabpanel, positioning ref +} +export function useSplitter(splitterId: string): { + splitter: SplitterState; isDragging: boolean; direction: Direction + value: number; min: number; max: number // px, for custom UI + getSplitterProps: PropGetter // role=separator, aria-*, pointer + keys +} +export function useDragState(): DragState | null +``` + +The brief's example, corrected so the child hooks have context: + +```tsx +function MyLayout({ tabs }: { tabs: MyTab[] }) { + const { controller, getRootProps, tabsets, splitters, tabIds, dropIndicator } = useDynamixLayout({ tabs }) + return ( + +

+ {tabsets.map((ts) => )} + {tabIds.map((id) => )} {/* flat: never remounts */} + {splitters.map((s) => )} + {dropIndicator &&
} +
+ + ) +} +``` + +`useTabDrag` from the brief became `useDragState`. Drag *initiation* is already part of `getTabProps` and `getTabBarProps`, so a separate drag hook would be a second way to do the same thing. `useContainerRect` stays internal; `getRootProps` wires it. + +**Prop getters** merge rather than overwrite: +- `ref`s are combined. +- `className`s are joined. +- `style` merges user style first, then positioning (`left/top/width/height/transform`) last. +- Event handlers run the user's first; if the user calls `event.preventDefault()`, the internal handler is skipped (documented). + +A missing provider throws in development: `` `useTab` must be used inside or . `` + +### 3.7 Level 3 — state and actions anywhere + +```ts +export function useLayoutState(selector: (snapshot: LayoutSnapshot) => T, isEqual?: (a: T, b: T) => boolean): T +export function useLayoutActions(): LayoutActions // stable object +export type LayoutActions = Pick +``` + +`useLayoutState` is built on `useSyncExternalStore` with a memoized selector: it caches by snapshot reference plus selector and checks `isEqual`, defaulting to `Object.is`. It's about 30 lines and needs no dependency. Because snapshots share structure (§2.5), `s.tabsets.get(id)?.activeTabId` re-renders only when that value changes; a test asserts the render counts (§10). + +**Stability guarantee** (documented in the API docs): + +| Value | Stable across renders? | +|---|---| +| `controller`, `actions`, every function in hook results, `select`/`close`/`toggle*`, `getXProps` | Yes, always | +| `tabsets`, `splitters`, `tabIds` arrays | Change only when the structure changes (not on resize or drag frames) | +| `TabsetState` / `SplitterState` / `TabState` objects | Change only when that entry changes | +| `dropIndicator` | Changes when the target changes (not on every pointer move) | +| Rects | Not exposed through React state; applied by the fast path | + +### 3.8 DOM fast path (`internal/rect-writer.ts`) + +It keeps v1's key performance property (R3): **pointer moves never re-render React.** + +1. Components register their element under `slot:id` (`panel`, `tabBar`, `tabContent`, `splitter`, `dropIndicator`, `toolbar`) through the ref in the prop getters. +2. The writer subscribes to the engine. When `snapshot.rects` changes reference, it walks the registry and writes `left/top/width/height` (and `transform` for rotated strips) **only for rects whose object identity changed**. +3. Visibility goes through attributes (`data-dx-hidden`, `data-dx-visible`) styled in CSS. Inline `display` is never written, for the reason in #83 (an inline `display` wipes the slot component's own value). +4. Components *also* render positioning from the current snapshot at render time. That makes the first paint and SSR correct, and React's diff (props against props, not against the DOM) never fights the writer. + +The module is internal, documented in its header, and covered by a test that drags a splitter and asserts zero React commits. + +### 3.9 Pointer-based tab dragging (D7, replaces P7) + +- **Start.** `pointerdown` on a tab or tab bar, then movement past 4px for mouse/pen, or a **350ms long press** for touch so the tab bar can still be scrolled by panning. Then `setPointerCapture` and `engine.startDrag`. A click without movement selects the tab, as today. +- **Move.** Moves are coalesced to one per frame with `createFrameScheduler`. The pointer is converted to root coordinates, and `engine.updateDrag(point, measurements)` is called. `measurements` (tab bar and label rects) is measured once at drag start and again if a tab bar scrolls. +- **End.** `pointerup` → `engine.endDrag()` (performs the move, one `onLayoutChange`). **Escape** or `pointercancel` → `cancelDrag()`. +- **Feedback.** + - The root gets `data-dx-dragging`, which keeps `.is-dragging`-style rules so iframes and editors inside tab content don't swallow events (R6). + - The cursor comes from CSS on the captured element; there are no `document.body.style` writes (P5). + - `DropIndicator` and `RootDropZone` render from `snapshot.drag`. +- **Maximize.** As in #83, starting a drag restores maximize first, so every target is visible. +- **Splitters** use the same module and `startDrag({ type: 'splitter' })`. +- **Lost by this change:** native HTML5 drag events on the wrappers. Users who attached `onDragStart` and similar to custom `WrapTab*` components must move to `useDragState`. This is listed as a breaking change. + +### 3.10 Container measurement + +- A `ResizeObserver` on the root measures the content box minus `padding`. Updates are batched per animation frame, or throttled by `resizeThrottleMs`. This replaces `window.resize` (P6) and catches sidebars and flex parents. +- The first measurement happens synchronously in an isomorphic layout effect (`getBoundingClientRect`), so there's no flash. +- Rects are root-relative (D8), so page scroll and the layout's offset don't matter (B25). + +### 3.11 SSR, StrictMode, Next.js + +- **No `window`/`document` at import or during render.** Everything DOM-related lives in effects or event handlers. The core is pure. +- **Deterministic first render.** + - Engine ids are deterministic (§2.6), and DOM ids come from `useId()` (`internal/dom-ids.ts` encodes tab ids into CSS-safe ids). This fixes B4 and B14. + - Before the first measurement the root carries `data-dx-measuring` (CSS `visibility: hidden` on its children). Server and client render the same markup, and the client measures before paint. +- **`'use client'`.** The React build prepends `'use client';` to the entry chunk with a Rollup `output.banner`. `migrateLayoutFromV1` and the types come from core, which is server-safe. +- **StrictMode.** + - Every effect returns a cleanup: RO disconnect, pointer listeners, frame cancel, keyboard listeners. The double mount/unmount leaves nothing behind (tested with spies, §10). + - React never calls `engine.destroy()`, because StrictMode remounts reuse the same engine; listener cleanup is enough. +- The Next.js example drops `dynamic(..., { ssr: false })` and renders on the server. + +### 3.12 Accessibility (new) + +**Tabs (WAI-ARIA Tabs pattern).** +- Tab bar: `role="tablist"`, with `aria-orientation="horizontal"` (`vertical` on a rotated strip). +- Each tab: `role="tab"`, `id`, `aria-selected`, `aria-controls` pointing at the content id, and a roving `tabindex` (active `0`, others `-1`). +- Content: `role="tabpanel"`, `aria-labelledby`, `tabindex="0"`. +- Keys: ←/→ (↑/↓ on a vertical strip), Home and End move focus. With `tabActivation: 'automatic'`, focus also selects; with `'manual'`, Enter or Space selects. Delete closes a `closable` tab (calls `onTabClose`). + +**Splitters (Window Splitter pattern).** +- `role="separator"`, `aria-orientation`: `vertical` for a bar between side-by-side panels, `horizontal` otherwise. +- `aria-valuenow`/`aria-valuemin`/`aria-valuemax` as percentages of the pair, from `getSplitterBounds`. +- `aria-controls` points at the leading panel. `aria-label` defaults to "Resize {before} and {after}", using tab titles. +- `tabindex="0"`. Arrow keys move 10px (Shift: 50px); Home/End go to min/max. Enter folds or unfolds the leading tabset when it can fold, per APG "collapse". +- A locked splitter gets `aria-disabled="true"`. + +**Toolbar.** Real ` + ) + + return ( +
+
+ + ← Basic demo + + {button('Add tab', () => addTab())} + {button('Add tab at the bottom', () => + addTab({ type: 'root', position: 'bottom' }) + )} + {button('Maximize / restore Editor', () => + act('Maximize', () => + layout.current?.toggleMaximize(tabsetOf('editor') ?? '') + ) + )} + {button('Fold / unfold Terminal', () => + act('Fold', () => + layout.current?.toggleFold(tabsetOf('terminal') ?? '') + ) + )} + {button('Select Preview', () => + act('Select', () => layout.current?.selectTab('preview')) + )} + {button('Move Notes next to Editor', () => + act('Move', () => + layout.current?.moveTab('notes', { + type: 'tab', + tabId: 'editor', + position: 'after', + }) + ) + )} + {button('Reset layout', () => { + setTabs(initialTabs) + layout.current?.reset() + })} + {button('Forget saved layout', () => + localStorage.removeItem(STORAGE_KEY) + )} +
+
+ {log.length + ? log.join(' · ') + : 'Changes appear here (onLayoutChange reasons).'} +
+ {mounted ? ( + { + localStorage.setItem(STORAGE_KEY, JSON.stringify(json)) + setLog((current) => [reason, ...current].slice(0, 8)) + }} + onTabClose={(id) => + setTabs((current) => + current.filter((tab) => tab.id !== id) + ) + } + /> + ) : ( +
+ )} +
+ ) +} diff --git a/examples/nextjs/src/app/showcase/page.tsx b/examples/nextjs/src/app/showcase/page.tsx new file mode 100644 index 0000000..62bd728 --- /dev/null +++ b/examples/nextjs/src/app/showcase/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from 'next' +import Showcase from './Showcase' + +export const metadata: Metadata = { title: 'Dynamix Layout showcase' } + +export default function ShowcasePage() { + return +} diff --git a/examples/nextjs/tsconfig.json b/examples/nextjs/tsconfig.json index 7df89e7..1a04a15 100644 --- a/examples/nextjs/tsconfig.json +++ b/examples/nextjs/tsconfig.json @@ -19,7 +19,9 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "react": ["./node_modules/@types/react"], + "react/*": ["./node_modules/@types/react/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx index 3ac4ddb..f55b74a 100644 --- a/examples/react/src/App.tsx +++ b/examples/react/src/App.tsx @@ -1,320 +1,77 @@ -import Editor from './components/Editor' -import { Terminal } from './components/Terminal' -import { DynamixLayout } from '@dynamix-layout/react' -import { IframePreview } from './components/IframePreview' -import '@dynamix-layout/react/style.css' -import './App.css' +import { lazy, Suspense, useEffect, useState } from 'react' -import { - DefaultSliderElement, - DefaultHoverElement, - DefaultWrapTabBody, - DefaultWrapTabHead, - DefaultWrapTabLabel, - DefaultWrapTabPanel, -} from './components/wrapper' -import { Slider } from './components/ui/slider' -import { cn } from './lib/utils' -import { Card } from './components/ui/card' -import { useState } from 'react' +const EXAMPLES = { + showcase: { + label: 'All features', + Component: lazy(() => import('./examples/ShowcaseExample')), + }, + custom: { + label: 'Custom components', + Component: lazy(() => import('./examples/CustomComponentsExample')), + }, + basic: { + label: 'Basic', + Component: lazy(() => import('./examples/BasicExample')), + }, + headless: { + label: 'Headless hooks', + Component: lazy(() => import('./examples/HeadlessExample')), + }, + controlled: { + label: 'Controlled + localStorage', + Component: lazy(() => import('./examples/ControlledExample')), + }, +} as const +type ExampleKey = keyof typeof EXAMPLES -const dummyTsCode = ` -// TypeScript Dummy Code Example -type User = { - id: number; - name: string; - email: string; - isActive: boolean; -}; - -const users: User[] = Array.from({ length: 100 }, (_, i) => ({ - id: i + 1, - name: \`User\${i + 1}\`, - email: \`user\${i + 1}@example.com\`, - isActive: i % 2 === 0, -})); - -function getActiveUsers(users: User[]): User[] { - return users.filter(u => u.isActive); -} - -function printUsers(users: User[]): void { - users.forEach(u => { - console.log(\`ID: \${u.id}, Name: \${u.name}, Email: \${u.email}, Active: \${u.isActive}\`); - }); -} - -printUsers(getActiveUsers(users)); -` - -const dummyHtmlCode = ` - - - - - - Dummy HTML - - - -
-

Dummy HTML Content

-
    - ${Array.from({ length: 50 }, (_, i) => `
  • Item ${i + 1}: Lorem ipsum dolor sit amet, consectetur adipiscing elit.
  • `).join('\n')} -
-
- - -` - -const dummyCssCode = ` -/* Large Dummy CSS Example */ -body { - background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); - color: #333; - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; +const fromHash = (): ExampleKey => { + const key = window.location.hash.slice(1) + return key in EXAMPLES ? (key as ExampleKey) : 'showcase' } -.container { - margin: 40px auto; - max-width: 900px; - background: #fff; - border-radius: 12px; - box-shadow: 0 4px 24px rgba(0,0,0,0.08); - padding: 32px; -} -${Array.from({ length: 30 }, (_, i) => `.box${i + 1} { width: ${100 + i * 5}px; height: 40px; background: #${((Math.random() * 0xffffff) << 0).toString(16)}; margin-bottom: 8px; border-radius: 4px; }`).join('\n')} -` -function App() { - const [minTabHeight, setMinTabHeight] = useState(40) - const [minTabWidth, setMinTabWidth] = useState(40) - const [bondWidth, setBondWidth] = useState(10) +/** One page per API level, picked by the URL hash (#basic, #headless, …). */ +export default function App() { + const [current, setCurrent] = useState(fromHash) + useEffect(() => { + const onHashChange = () => setCurrent(fromHash()) + window.addEventListener('hashchange', onHashChange) + return () => window.removeEventListener('hashchange', onHashChange) + }, []) + const { Component } = EXAMPLES[current] return ( -
- { - console.log(layoutTree)} - pad={{ t: 0, b: 0, l: 0, r: 0 }} - SliderElement={DefaultSliderElement} - HoverElement={DefaultHoverElement} - WrapTabBody={DefaultWrapTabBody} - WrapTabHead={DefaultWrapTabHead} - WrapTabLabel={DefaultWrapTabLabel} - WrapTabPanel={DefaultWrapTabPanel} - minTabHeight={minTabHeight} - minTabWidth={minTabWidth} - tabHeadHeight={40} - bondWidth={bondWidth} - style={{ overflow: 'auto' }} - tabBodyElementClass="rounded-b-2 border-b-2 shadow-lg" - tabs={[ - [ - 'TS', - , - ], - [ - 'Html', - , - ], - ['preview', ], - [ - 'css', - , - ], - ['terminal', ], - ]} - /> - } -
- -
- - setMinTabHeight(val)} - min={1} - max={800} - step={1} - className={cn('w-[60%]')} - /> -
-
- - setBondWidth(val)} - min={1} - max={100} - step={1} - className={cn('w-[60%]')} - /> -
-
- - setMinTabWidth(val)} - min={1} - max={1400} - step={1} - className={cn('w-[60%]')} - /> -
-
-
+
+ +
+ + + +
) } - -export default App diff --git a/examples/react/src/components/layout-config.ts b/examples/react/src/components/layout-config.ts new file mode 100644 index 0000000..b4c6280 --- /dev/null +++ b/examples/react/src/components/layout-config.ts @@ -0,0 +1,9 @@ +import type { LayoutComponents, LayoutSlot } from '@dynamix-layout/react' +import { Splitter, Tab } from './wrapper' + +export const components: Partial = { Tab, Splitter } + +export const classNames: Partial> = { + tabBar: 'bg-gray-200 text-muted-foreground rounded-t-sm', + tabContent: 'rounded-b-2 border border-b-2 shadow-lg bg-background', +} diff --git a/examples/react/src/components/wrapper.tsx b/examples/react/src/components/wrapper.tsx index 5f8c4cd..f380140 100644 --- a/examples/react/src/components/wrapper.tsx +++ b/examples/react/src/components/wrapper.tsx @@ -1,285 +1,82 @@ import { cn } from '@/lib/utils' -import { forwardRef, type ReactNode, type HTMLAttributes } from 'react' +import { forwardRef, type HTMLAttributes, type Ref } from 'react' +import type { SplitterProps, TabProps } from '@dynamix-layout/react' -export const DefaultSliderElement = forwardRef< - HTMLDivElement, +/** A shadcn-style tab: the library passes ARIA, data-state and handlers in `props`. */ +export const Tab = forwardRef(function Tab( { - children?: ReactNode - direction?: boolean - } & HTMLAttributes ->(({ children, className, direction = false, ...props }, ref) => ( -
+ tab, + isActive, + isDragging, + onClose, + closeButtonProps, + className, + ...props + }, + ref +) { + return (
} + {...(props as HTMLAttributes)} className={cn( - 'flex items-center gap-1', - direction ? 'flex-col' : 'flex-row' + className, + 'inline-flex h-[calc(100%-6px)] items-center gap-1.5 rounded-md border border-transparent px-2 text-sm font-medium whitespace-nowrap text-foreground', + 'data-[state=active]:bg-background data-[state=active]:shadow-sm dark:data-[state=active]:bg-input/30', + 'focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50', + isActive && 'font-semibold', + isDragging && 'opacity-60' )} > - {!direction ? ( - - - - - - - - - ) : ( + × + + )} +
+ ) +}) + +/** A transparent splitter with a lucide grip, rotated for vertical rows. */ +export const Splitter = forwardRef( + function Splitter({ splitter, isDragging, className, ...props }, ref) { + return ( +
- )} -
- {children} -
-)) - -DefaultSliderElement.displayName = 'DefaultSliderElement' - -export const DefaultWrapTabLabel = forwardRef< - HTMLDivElement, - { children?: ReactNode; active?: boolean } & HTMLAttributes ->(({ children, active = false, ...props }, ref) => ( -
- {children} -
-)) -DefaultWrapTabLabel.displayName = 'DefaultWrapTabLabel' - -export const DefaultWrapTabBody = forwardRef< - HTMLDivElement, - { children?: ReactNode } & HTMLAttributes ->(({ children, ...props }, ref) => ( -
- {children} -
-)) -DefaultWrapTabBody.displayName = 'DefaultWrapTabBody' - -export const DefaultWrapTabHead = forwardRef< - HTMLDivElement, - { children?: ReactNode } & HTMLAttributes ->(({ children, ...props }, ref) => ( -
- {children} -
-)) -DefaultWrapTabHead.displayName = 'DefaultWrapTabHead' - -export const DefaultWrapTabPanel = forwardRef< - HTMLDivElement, - { children?: ReactNode } & HTMLAttributes ->(({ children, ...props }, ref) => ( -
- {children} -
-)) -DefaultWrapTabPanel.displayName = 'DefaultWrapTabPanel' - -export const DefaultHoverElement = forwardRef< - HTMLDivElement, - { children?: ReactNode } & HTMLAttributes ->(({ children, ...props }, ref) => ( -
- {children} -
-)) -DefaultHoverElement.displayName = 'DefaultHoverElement' - -export const RootSplitterHoverEl = forwardRef< - HTMLDivElement, - { - children?: ReactNode - area: 'left' | 'right' | 'top' | 'bottom' - size?: { w: string; h: string } - gap?: string - } & HTMLAttributes ->( - ( - { - children, - area, - size = { h: '20%', w: '6px' }, - gap = '0px', - ...props - }, - ref - ) => { - props.style = { ...props.style } - - if (area === 'left') { - props.style = { - borderTopRightRadius: '16px', - borderBottomRightRadius: '16px', - height: size.h, - left: gap, - width: size.w, - ...props.style, - } - props.style.top = `calc(50% - ${props.style.height} / 2)` - } else if (area === 'top') { - props.style = { - borderBottomLeftRadius: '16px', - borderBottomRightRadius: '16px', - width: size.h, - top: gap, - height: size.w, - ...props.style, - } - props.style.left = `calc(50% - ${props.style.width} / 2)` - } else if (area === 'right') { - props.style = { - borderTopLeftRadius: '16px', - borderBottomLeftRadius: '16px', - height: size.h, - right: gap, - width: size.w, - ...props.style, - } - props.style.top = `calc(50% - ${props.style.height} / 2)` - } else if (area === 'bottom') { - props.style = { - borderTopLeftRadius: '16px', - borderTopRightRadius: '16px', - width: size.h, - bottom: gap, - height: size.w, - ...props.style, - } - props.style.left = `calc(50% - ${props.style.width} / 2)` - } - - return ( -
- {children}
) } ) -RootSplitterHoverEl.displayName = 'RootSplitterHoverEl' diff --git a/examples/react/src/examples/BasicExample.tsx b/examples/react/src/examples/BasicExample.tsx new file mode 100644 index 0000000..67b6e2e --- /dev/null +++ b/examples/react/src/examples/BasicExample.tsx @@ -0,0 +1,23 @@ +import { DynamixLayout, type TabItem } from '@dynamix-layout/react' +import '@dynamix-layout/react/styles.css' + +const box = (background: string, text: string) => ( +
{text}
+) + +const tabs: TabItem[] = [ + { id: 'editor', title: 'Editor', content: box('#fffde7', 'Editor') }, + { id: 'terminal', title: 'Terminal', content: box('#e8f5e9', 'Terminal') }, + { id: 'preview', title: 'Preview', content: box('#e3f2fd', 'Preview') }, + { + id: 'notes', + title: 'Notes', + content: box('#fce4ec', 'Drag me'), + closable: false, + }, +] + +/** Level 1: the component with default styles. Drag tabs, splitters, double-click a tab bar. */ +export default function BasicExample() { + return +} diff --git a/examples/react/src/examples/ControlledExample.tsx b/examples/react/src/examples/ControlledExample.tsx new file mode 100644 index 0000000..a9564bd --- /dev/null +++ b/examples/react/src/examples/ControlledExample.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react' +import { + DynamixLayout, + type LayoutJSON, + type TabItem, +} from '@dynamix-layout/react' +import '@dynamix-layout/react/styles.css' + +const STORAGE_KEY = 'dynamix-layout:controlled-example' + +const load = (): LayoutJSON | undefined => { + try { + const saved = localStorage.getItem(STORAGE_KEY) + return saved ? (JSON.parse(saved) as LayoutJSON) : undefined + } catch { + return undefined + } +} + +/** Controlled mode: the layout lives in state and is persisted to localStorage. */ +export default function ControlledExample() { + const [layout, setLayout] = useState(load) + const [tabs, setTabs] = useState(() => + ['a', 'b', 'c'].map((id) => ({ + id, + title: `Tab ${id.toUpperCase()}`, + content:

Tab {id}

, + closable: true, + })) + ) + + const onLayoutChange = (next: LayoutJSON) => { + setLayout(next) + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) + } + const addTab = () => { + const id = `t${Date.now().toString(36)}` + setTabs((current) => [ + ...current, + { + id, + title: `New ${current.length + 1}`, + content:

{id}

, + closable: true, + }, + ]) + } + + return ( +
+
+ + + + Reload the page: the layout is restored. + +
+ + setTabs((current) => current.filter((tab) => tab.id !== id)) + } + /> +
+ ) +} diff --git a/examples/react/src/examples/CustomComponentsExample.tsx b/examples/react/src/examples/CustomComponentsExample.tsx new file mode 100644 index 0000000..08797ad --- /dev/null +++ b/examples/react/src/examples/CustomComponentsExample.tsx @@ -0,0 +1,291 @@ +import Editor from '../components/Editor' +import { Terminal } from '../components/Terminal' +import { + DynamixLayout, + type LayoutTreeV1, + type TabItem, +} from '@dynamix-layout/react' +import { IframePreview } from '../components/IframePreview' +import '@dynamix-layout/react/styles.css' +import '../App.css' + +import { classNames, components } from '../components/layout-config' +import { Slider } from '../components/ui/slider' +import { cn } from '../lib/utils' +import { Card } from '../components/ui/card' +import { useState } from 'react' + +const dummyTsCode = ` +// TypeScript Dummy Code Example +type User = { + id: number; + name: string; + email: string; + isActive: boolean; +}; + +const users: User[] = Array.from({ length: 100 }, (_, i) => ({ + id: i + 1, + name: \`User\${i + 1}\`, + email: \`user\${i + 1}@example.com\`, + isActive: i % 2 === 0, +})); + +function getActiveUsers(users: User[]): User[] { + return users.filter(u => u.isActive); +} + +function printUsers(users: User[]): void { + users.forEach(u => { + console.log(\`ID: \${u.id}, Name: \${u.name}, Email: \${u.email}, Active: \${u.isActive}\`); + }); +} + +printUsers(getActiveUsers(users)); +` + +const dummyHtmlCode = ` + + + + + + Dummy HTML + + + +
+

Dummy HTML Content

+
    + ${Array.from({ length: 50 }, (_, i) => `
  • Item ${i + 1}: Lorem ipsum dolor sit amet, consectetur adipiscing elit.
  • `).join('\n')} +
+
+ + +` + +const dummyCssCode = ` +/* Large Dummy CSS Example */ +body { + background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); + color: #333; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; +} +.container { + margin: 40px auto; + max-width: 900px; + background: #fff; + border-radius: 12px; + box-shadow: 0 4px 24px rgba(0,0,0,0.08); + padding: 32px; +} +${Array.from({ length: 30 }, (_, i) => `.box${i + 1} { width: ${100 + i * 5}px; height: 40px; background: #${((Math.random() * 0xffffff) << 0).toString(16)}; margin-bottom: 8px; border-radius: 4px; }`).join('\n')} +` + +/** A layout saved by v1; v2 migrates it on load. */ +const savedV1Layout: LayoutTreeV1 = { + typNode: 'row', + nodPart: 100, + nodName: 'dynamix-layout-root', + uidNode: 'dynamix-layout-root', + nodKids: [ + { + typNode: 'row', + nodPart: 95.13923576423574, + nodName: '', + uidNode: 'ad18bbee-2260-4172-bcbe-bead8ab919cf', + nodOpen: 'preview', + nodKids: [ + { + typNode: 'row', + nodPart: 253.63885510746962, + nodName: '', + uidNode: 'e4917519-3c9c-4ab9-adea-3a608fa8d45e', + nodOpen: 'TS', + nodKids: [ + { + typNode: 'tabset', + nodPart: 95.68121135993162, + nodName: '', + uidNode: '4c880a2e-df03-4183-9043-9d0cbc25d1a9', + nodOpen: 'TS', + nodKids: [ + { + typNode: 'tab', + nodPart: 100, + nodName: 'TS', + uidNode: + '9c5b720f-63b4-480b-ba80-717000b4f6df', + }, + ], + }, + { + typNode: 'tabset', + nodPart: 102.46562527840646, + nodName: '', + uidNode: '94a19aba-384a-4a4f-977a-2b3e5cf45453', + nodOpen: 'preview', + nodKids: [ + { + typNode: 'tab', + nodPart: 100, + nodName: 'preview', + uidNode: + '84932e7f-9efe-4437-b27e-242db949b47d', + }, + ], + }, + { + typNode: 'row', + nodPart: 101.85316336166194, + nodName: '', + uidNode: 'd06eb31a-ca54-4c17-ab58-ccc55c5a25e4', + nodOpen: 'css', + nodKids: [ + { + typNode: 'tabset', + nodPart: 100, + nodName: '', + uidNode: + 'f4fb69bf-d5ee-426c-b420-867ecc111f25', + nodOpen: 'css', + nodKids: [ + { + typNode: 'tab', + nodPart: 100, + nodName: 'css', + uidNode: + '595562f3-908e-4002-b107-1f86fd2f621a', + }, + ], + }, + { + typNode: 'tabset', + nodPart: 100, + nodName: '', + uidNode: + 'f6453908-1037-4d62-9a8d-cb8684ef311c', + nodOpen: 'Html', + nodKids: [ + { + typNode: 'tab', + nodPart: 100, + nodName: 'Html', + uidNode: + 'd19f9840-85b4-4a98-ba7d-dd2445c26500', + }, + ], + }, + ], + }, + ], + }, + { + typNode: 'tabset', + nodPart: 97.75714553534206, + nodName: '', + uidNode: 'f2b421d9-b10d-41db-a410-df41f6b01e8a', + nodOpen: 'terminal', + nodKids: [ + { + typNode: 'tab', + nodPart: 100, + nodName: 'terminal', + uidNode: '0c9bce4c-2bc5-4f68-95e5-1db51d143b9c', + }, + ], + }, + ], + }, + ], +} + +const tabs: TabItem[] = [ + { id: 'TS', content: }, + { id: 'Html', content: }, + { id: 'preview', title: 'Preview', content: }, + { + id: 'css', + title: 'CSS', + content: , + }, + { id: 'terminal', title: 'Terminal', content: }, +] + +/** Level 1 with custom slot components (shadcn-style), a saved v1 layout and live size controls. */ +export default function CustomComponentsExample() { + const [minTabHeight, setMinTabHeight] = useState(40) + const [minTabWidth, setMinTabWidth] = useState(40) + const [bondWidth, setBondWidth] = useState(10) + + return ( +
+ { + + console.log(reason, layout) + } + components={components} + classNames={classNames} + minPanelSize={{ width: minTabWidth, height: minTabHeight }} + tabBarHeight={40} + splitterSize={bondWidth} + tabs={tabs} + /> + } +
+ +
+ + setMinTabHeight(val)} + min={1} + max={800} + step={1} + className={cn('w-[60%]')} + /> +
+
+ + setBondWidth(val)} + min={1} + max={100} + step={1} + className={cn('w-[60%]')} + /> +
+
+ + setMinTabWidth(val)} + min={1} + max={1400} + step={1} + className={cn('w-[60%]')} + /> +
+
+
+
+ ) +} diff --git a/examples/react/src/examples/HeadlessExample.tsx b/examples/react/src/examples/HeadlessExample.tsx new file mode 100644 index 0000000..e686c81 --- /dev/null +++ b/examples/react/src/examples/HeadlessExample.tsx @@ -0,0 +1,131 @@ +import { + DynamixLayoutProvider, + useDynamixLayout, + useSplitter, + useTab, + useTabset, + type HeadlessTabItem, +} from '@dynamix-layout/react' + +const tabs: HeadlessTabItem[] = [ + { id: 'one', title: 'One' }, + { id: 'two', title: 'Two' }, + { id: 'three', title: 'Three' }, +] + +function Tab({ tabId }: { tabId: string }) { + const { getTabProps, isActive, item } = useTab(tabId) + return ( + + ) +} + +function Tabset({ tabsetId }: { tabsetId: string }) { + const { tabset, getPanelProps, getTabBarProps } = useTabset(tabsetId) + if (!tabset) return null + return ( + <> +
+
+ {tabset.tabIds.map((id) => ( + + ))} +
+ + ) +} + +function Content({ tabId }: { tabId: string }) { + const { getTabContentProps, item } = useTab(tabId) + return ( +
+ Content of {item?.title} +
+ ) +} + +function Splitter({ splitterId }: { splitterId: string }) { + const { getSplitterProps, isDragging } = useSplitter(splitterId) + return ( +
+ ) +} + +/** Level 2: no default components or styles, only hooks and prop getters. */ +export default function HeadlessExample() { + const { + controller, + getRootProps, + tabsets, + splitters, + tabIds, + dropIndicator, + } = useDynamixLayout({ + tabs, + tabBarHeight: 36, + splitterSize: 6, + padding: 8, + }) + return ( + +
+ {tabsets.map((tabset) => ( + + ))} + {tabIds.map((id) => ( + + ))} + {splitters.map((splitter) => ( + + ))} + {dropIndicator && ( +
+ )} +
+ + ) +} diff --git a/examples/react/src/examples/ShowcaseExample.tsx b/examples/react/src/examples/ShowcaseExample.tsx new file mode 100644 index 0000000..7238081 --- /dev/null +++ b/examples/react/src/examples/ShowcaseExample.tsx @@ -0,0 +1,225 @@ +import { useRef, useState, type ReactNode } from 'react' +import { + DynamixLayout, + type DynamixLayoutHandle, + type LayoutJSON, + type TabItem, +} from '@dynamix-layout/react' +import type { RowJSON } from '@dynamix-layout/core' +import '@dynamix-layout/react/styles.css' + +const STORAGE_KEY = 'dynamix-layout:showcase' + +const loadSaved = (): LayoutJSON | undefined => { + try { + const json = localStorage.getItem(STORAGE_KEY) + return json ? (JSON.parse(json) as LayoutJSON) : undefined + } catch { + return undefined + } +} + +const panel = (background: string, children: ReactNode) => ( +
+ {children} +
+) + +const HELP = ( +
    +
  • + Drag a tab onto another tab bar, onto a panel's side to split + it, or onto a layout edge. +
  • +
  • + Drag a tab bar to move the whole panel.{' '} + Drag a splitter to resize. +
  • +
  • + Fold or maximize with the buttons at the end of a tab + bar (hover it), or double-click a tab bar to maximize. +
  • +
  • + Keyboard: arrows move between tabs, Alt+={' '} + maximizes, Alt+- folds, Ctrl/ + ⌘+Shift+M moves the focused tab. +
  • +
  • + Close tabs with × (or Delete), add them + with the buttons above. The layout is saved to localStorage: reload + the page. +
  • +
+) + +const initialTabs: TabItem[] = [ + { id: 'guide', title: 'Guide', content: panel('#fffde7', HELP) }, + { + id: 'editor', + title: 'Editor', + content: panel('#e3f2fd', 'Editor: focus or click to select'), + }, + { + id: 'terminal', + title: 'Terminal', + content: panel( + '#212121', + $ _ + ), + }, + { + id: 'preview', + title: 'Preview', + content: panel('#e8f5e9', 'Preview'), + closable: true, + }, + { + id: 'notes', + title: 'Notes', + content: panel('#fce4ec', 'Notes (closable)'), + closable: true, + }, +] + +const newTab = (n: number, target?: TabItem['target']): TabItem => ({ + id: `new-${n}`, + title: `New ${n}`, + content: panel('#ede7f6', `Tab new-${n}`), + closable: true, + target, +}) +const newTabNumber = (id: string) => Number(/^new-(\d+)$/.exec(id)?.[1] ?? 0) + +/** Tab ids in a saved layout, so added and closed tabs survive a reload too. */ +const tabIdsOf = (row: RowJSON): string[] => + row.children.flatMap((child) => + child.type === 'row' + ? tabIdsOf(child) + : child.children.map((tab) => tab.id) + ) + +/** The tabs a saved layout had open, or the initial ones. */ +const restoreTabs = (saved: LayoutJSON | undefined): TabItem[] => { + if (!saved) return initialTabs + const known = new Map(initialTabs.map((tab) => [tab.id, tab])) + return tabIdsOf(saved.root).flatMap((id) => { + const tab = + known.get(id) ?? + (newTabNumber(id) ? newTab(newTabNumber(id)) : null) + return tab ? [tab] : [] + }) +} + +/** Every feature on one page: drag, split, resize, close, add, fold, maximize, restore, persist, reset. */ +export default function ShowcaseExample() { + const layout = useRef(null) + const [saved] = useState(loadSaved) + const [tabs, setTabs] = useState(() => restoreTabs(saved)) + const [log, setLog] = useState([]) + const counter = useRef( + Math.max(0, ...tabs.map((tab) => newTabNumber(tab.id))) + ) + + const tabsetOf = (tabId: string) => + layout.current?.getSnapshot().tabs.get(tabId)?.tabsetId + const act = (name: string, run: () => boolean | void) => { + const result = run() + if (result === false) + setLog((current) => + [`${name}: not possible right now`, ...current].slice(0, 8) + ) + } + + const addTab = (target?: TabItem['target']) => { + const tab = newTab(++counter.current, target) + setTabs((current) => [...current, tab]) + } + + const button = (label: string, onClick: () => void) => ( + + ) + + return ( +
+
+ {button('Add tab', () => addTab())} + {button('Add tab at the bottom', () => + addTab({ type: 'root', position: 'bottom' }) + )} + {button('Maximize / restore Editor', () => + act('Maximize', () => + layout.current?.toggleMaximize(tabsetOf('editor') ?? '') + ) + )} + {button('Fold / unfold Terminal', () => + act('Fold', () => + layout.current?.toggleFold(tabsetOf('terminal') ?? '') + ) + )} + {button('Select Preview', () => + act('Select', () => layout.current?.selectTab('preview')) + )} + {button('Move Notes next to Editor', () => + act('Move', () => + layout.current?.moveTab('notes', { + type: 'tab', + tabId: 'editor', + position: 'after', + }) + ) + )} + {button('Reset layout', () => { + setTabs(initialTabs) + layout.current?.reset() + })} + {button('Forget saved layout', () => + localStorage.removeItem(STORAGE_KEY) + )} +
+
+ {log.length + ? log.join(' · ') + : 'Changes appear here (onLayoutChange reasons).'} +
+ { + localStorage.setItem(STORAGE_KEY, JSON.stringify(json)) + setLog((current) => [reason, ...current].slice(0, 8)) + }} + onTabClose={(id) => + setTabs((current) => current.filter((tab) => tab.id !== id)) + } + /> +
+ ) +} diff --git a/examples/solid/src/App.tsx b/examples/solid/src/App.tsx index 7bbd242..6081c06 100644 --- a/examples/solid/src/App.tsx +++ b/examples/solid/src/App.tsx @@ -1,22 +1,214 @@ -import { tabs } from './comp' -import { DynamixLayout } from '@dynamix-layout/solid' +import { createSignal, For, type JSX } from 'solid-js' +import { + DynamixLayout, + type LayoutJSON, + type TabItem, +} from '@dynamix-layout/solid' +import type { DropTarget, Layout, RowJSON } from '@dynamix-layout/core' import '@dynamix-layout/solid/style.css' import './App.css' -function App() { +const STORAGE_KEY = 'dynamix-layout:solid-showcase' + +const loadSaved = (): LayoutJSON | undefined => { + try { + const json = localStorage.getItem(STORAGE_KEY) + return json ? (JSON.parse(json) as LayoutJSON) : undefined + } catch { + return undefined + } +} + +const panel = (background: string, children: JSX.Element) => ( +
+ {children} +
+) + +const guide = ( +
    +
  • + Drag a tab onto another tab bar, onto a panel's side to split + it, or onto a layout edge. +
  • +
  • + Drag a tab bar to move the whole panel.{' '} + Drag a splitter to resize. +
  • +
  • + Fold or maximize with the buttons at the end of a tab + bar, or double-click a tab bar. Alt+= maximizes and Alt+- folds the + panel you last clicked. +
  • +
  • + Close tabs with ×, add them with the buttons above. + The layout is saved to localStorage: reload the page. +
  • +
+) + +const initialTabs = (): TabItem[] => [ + ['guide', panel('#fffde7', guide), { title: 'Guide' }], + ['editor', panel('#e3f2fd', 'Editor'), { title: 'Editor' }], + [ + 'terminal', + panel('#212121', $ _), + { title: 'Terminal' }, + ], + [ + 'preview', + panel('#e8f5e9', 'Preview'), + { title: 'Preview', closable: true }, + ], + [ + 'notes', + panel('#fce4ec', 'Notes (closable)'), + { title: 'Notes', closable: true }, + ], +] + +const newTab = (n: number): TabItem => [ + `new-${n}`, + panel('#ede7f6', `Tab new-${n}`), + { title: `New ${n}`, closable: true }, +] +const newTabNumber = (id: string) => Number(/^new-(\d+)$/.exec(id)?.[1] ?? 0) + +/** Tab ids in a saved layout, so added and closed tabs survive a reload too. */ +const tabIdsOf = (row: RowJSON): string[] => + row.children.flatMap((child) => + child.type === 'row' + ? tabIdsOf(child) + : child.children.map((tab) => tab.id) + ) + +/** The tabs a saved layout had open, or the initial ones. */ +const restoreTabs = (saved: LayoutJSON | undefined): TabItem[] => { + if (!saved) return initialTabs() + const known = new Map(initialTabs().map((tab) => [tab[0], tab])) + return tabIdsOf(saved.root).flatMap((id) => { + const tab = + known.get(id) ?? + (newTabNumber(id) ? newTab(newTabNumber(id)) : null) + return tab ? [tab] : [] + }) +} + +/** Every feature on one page: drag, split, resize, close, add, fold, maximize, restore, persist, reset. */ +export default function App() { + let layout: Layout | undefined + const saved = loadSaved() + const [tabs, setTabs] = createSignal(restoreTabs(saved)) + const [log, setLog] = createSignal([]) + let counter = Math.max(0, ...tabs().map(([id]) => newTabNumber(id))) + + const tabsetOf = (tabId: string) => + layout?.getSnapshot().tabs.get(tabId)?.tabsetId ?? '' + const note = (entry: string) => + setLog((current) => [entry, ...current].slice(0, 8)) + const act = (name: string, done: boolean | undefined) => { + if (done === false) note(`${name}: not possible right now`) + } + const addTab = (target?: DropTarget) => { + const entry = newTab(++counter) + const id = entry[0] + // `tabs` places new tabs in the last active tabset; to drop one at a + // target, add it to the engine first and `tabs` keeps it where it is. + if (target) layout?.addTab({ id }, target) + setTabs([...tabs(), entry]) + } + + const actions: [string, () => void][] = [ + ['Add tab', () => addTab()], + [ + 'Add tab at the bottom', + () => addTab({ type: 'root', position: 'bottom' }), + ], + [ + 'Maximize / restore Editor', + () => act('Maximize', layout?.toggleMaximize(tabsetOf('editor'))), + ], + [ + 'Fold / unfold Terminal', + () => act('Fold', layout?.toggleFold(tabsetOf('terminal'))), + ], + ['Select Preview', () => act('Select', layout?.selectTab('preview'))], + [ + 'Move Notes next to Editor', + () => + act( + 'Move', + layout?.moveTab('notes', { + type: 'tab', + tabId: 'editor', + position: 'after', + }) + ), + ], + [ + 'Reset layout', + () => { + setTabs(initialTabs()) + layout?.reset() + }, + ], + ['Forget saved layout', () => localStorage.removeItem(STORAGE_KEY)], + ] + return ( - <> +
+
+ + {([label, run]) => } + +
+
+ {log().length ? log().join(' · ') : 'Changes appear here.'} +
console.log(layoutTree)} - tabs={tabs} - pad={{ t: 0, b: 0, l: 0, r: 0 }} - tabHeadHeight={40} - bondWidth={10} - minTabHeight={40} - minTabWidth={100} + tabs={tabs()} + layoutTree={saved} + onReady={(engine) => (layout = engine)} + onTabClose={(id) => + setTabs(tabs().filter(([tabId]) => tabId !== id)) + } + updateJSON={(json, reason) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(json)) + if (reason !== 'mount') note(reason) + }} + tabHeadHeight={36} + bondWidth={6} + minTabWidth={120} + minTabHeight={80} /> - +
) } - -export default App diff --git a/examples/solidstart/src/components/Showcase.tsx b/examples/solidstart/src/components/Showcase.tsx new file mode 100644 index 0000000..75373ba --- /dev/null +++ b/examples/solidstart/src/components/Showcase.tsx @@ -0,0 +1,231 @@ +import { batch, createSignal, For, type JSX } from 'solid-js' +import { + DynamixLayout, + type LayoutJSON, + type TabItem, +} from '@dynamix-layout/solid' +import type { DropTarget, Layout, RowJSON } from '@dynamix-layout/core' +import '@dynamix-layout/solid/style.css' + +// This module touches `localStorage` and the DOM, so the route loads it with +// `clientOnly`: it never runs during server rendering, and hydration has +// nothing to mismatch. + +const STORAGE_KEY = 'dynamix-layout:solidstart-showcase' + +const loadSaved = (): LayoutJSON | undefined => { + try { + const json = localStorage.getItem(STORAGE_KEY) + return json ? (JSON.parse(json) as LayoutJSON) : undefined + } catch { + return undefined + } +} + +const panel = (background: string, children: JSX.Element) => ( +
+ {children} +
+) + +const guide = () => ( +
    +
  • + Drag a tab onto another tab bar, onto a panel's side to split + it, or onto a layout edge. +
  • +
  • + Drag a tab bar to move the whole panel.{' '} + Drag a splitter to resize. +
  • +
  • + Fold or maximize with the buttons at the end of a tab + bar, or double-click a tab bar. Alt+= maximizes and Alt+- folds the + panel you last clicked. +
  • +
  • + Close tabs with ×, add them with the buttons above. + The layout is saved to localStorage: reload the page. +
  • +
+) + +const initialTabs = (): TabItem[] => [ + ['guide', panel('#fffde7', guide()), { title: 'Guide' }], + ['editor', panel('#e3f2fd', 'Editor'), { title: 'Editor' }], + [ + 'terminal', + panel('#212121', $ _), + { title: 'Terminal' }, + ], + [ + 'preview', + panel('#e8f5e9', 'Preview'), + { title: 'Preview', closable: true }, + ], + [ + 'notes', + panel('#fce4ec', 'Notes (closable)'), + { title: 'Notes', closable: true }, + ], +] + +const newTab = (n: number): TabItem => [ + `new-${n}`, + panel('#ede7f6', `Tab new-${n}`), + { title: `New ${n}`, closable: true }, +] +const newTabNumber = (id: string) => Number(/^new-(\d+)$/.exec(id)?.[1] ?? 0) + +/** Tab ids in a saved layout, so added and closed tabs survive a reload too. */ +const tabIdsOf = (row: RowJSON): string[] => + row.children.flatMap((child) => + child.type === 'row' + ? tabIdsOf(child) + : child.children.map((tab) => tab.id) + ) + +/** The tabs a saved layout had open, or the initial ones. */ +const restoreTabs = (saved: LayoutJSON | undefined): TabItem[] => { + if (!saved) return initialTabs() + const known = new Map(initialTabs().map((tab) => [tab[0], tab])) + return tabIdsOf(saved.root).flatMap((id) => { + const tab = + known.get(id) ?? + (newTabNumber(id) ? newTab(newTabNumber(id)) : null) + return tab ? [tab] : [] + }) +} + +/** Every feature on one page: drag, split, resize, close, add, fold, maximize, restore, persist, reset. */ +export default function Showcase() { + let layout: Layout | undefined + const saved = loadSaved() + const [tabs, setTabs] = createSignal(restoreTabs(saved)) + const [log, setLog] = createSignal([]) + let counter = Math.max(0, ...tabs().map(([id]) => newTabNumber(id))) + + const tabsetOf = (tabId: string) => + layout?.getSnapshot().tabs.get(tabId)?.tabsetId ?? '' + const note = (entry: string) => + setLog((current) => [entry, ...current].slice(0, 8)) + const act = (name: string, done: boolean | undefined) => { + if (done === false) note(`${name}: not possible right now`) + } + const addTab = (target?: DropTarget) => { + const entry = newTab(++counter) + const id = entry[0] + batch(() => { + // The Solid adapter places tabs added through `tabs` by default; + // to drop one at a target, add it to the engine first. The + // reactive `tabs` sync then keeps it where it is. + if (target) layout?.addTab({ id }, target) + setTabs([...tabs(), entry]) + }) + } + + const actions: [string, () => void][] = [ + ['Add tab', () => addTab()], + [ + 'Add tab at the bottom', + () => addTab({ type: 'root', position: 'bottom' }), + ], + [ + 'Maximize / restore Editor', + () => act('Maximize', layout?.toggleMaximize(tabsetOf('editor'))), + ], + [ + 'Fold / unfold Terminal', + () => act('Fold', layout?.toggleFold(tabsetOf('terminal'))), + ], + ['Select Preview', () => act('Select', layout?.selectTab('preview'))], + [ + 'Move Notes next to Editor', + () => + act( + 'Move', + layout?.moveTab('notes', { + type: 'tab', + tabId: 'editor', + position: 'after', + }) + ), + ], + [ + 'Reset layout', + () => { + setTabs(initialTabs()) + layout?.reset() + }, + ], + [ + 'Forget saved layout', + () => { + localStorage.removeItem(STORAGE_KEY) + note('saved layout forgotten') + }, + ], + ] + + return ( +
+
+ + {([label, run]) => ( + + )} + +
+
+ {log().length ? log().join(' · ') : 'Changes appear here.'} +
+ (layout = engine)} + onTabClose={(id) => + setTabs(tabs().filter(([tabId]) => tabId !== id)) + } + updateJSON={(json, reason) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(json)) + if (reason !== 'mount') note(reason) + }} + tabHeadHeight={36} + bondWidth={6} + minTabWidth={120} + minTabHeight={80} + /> +
+ ) +} diff --git a/examples/solidstart/src/routes/index.tsx b/examples/solidstart/src/routes/index.tsx index 846312a..0d87a28 100644 --- a/examples/solidstart/src/routes/index.tsx +++ b/examples/solidstart/src/routes/index.tsx @@ -35,7 +35,16 @@ export default function Home() { //---------------------------------- return ( -
+
+ {/* 4. Use a component. It will only render its children when the `when` condition is true. Since `isClient()` is false on the server, nothing inside the diff --git a/examples/solidstart/src/routes/showcase.tsx b/examples/solidstart/src/routes/showcase.tsx new file mode 100644 index 0000000..fe73790 --- /dev/null +++ b/examples/solidstart/src/routes/showcase.tsx @@ -0,0 +1,26 @@ +import { Title } from '@solidjs/meta' +import { clientOnly } from '@solidjs/start' + +// The showcase reads its saved layout from localStorage, so it renders on the +// client only. The server sends the page shell and the fallback. +const Showcase = clientOnly(() => import('../components/Showcase')) + +export default function ShowcasePage() { + return ( +
+ SolidStart - Showcase + + Loading layout…
} + /> + + ) +} diff --git a/examples/vanilla/README.md b/examples/vanilla/README.md new file mode 100644 index 0000000..ab36ce6 --- /dev/null +++ b/examples/vanilla/README.md @@ -0,0 +1,18 @@ +# Plain JavaScript example + +Dynamix Layout with no framework: [`src/dom-layout.ts`](./src/dom-layout.ts) renders `@dynamix-layout/core` snapshots with plain DOM, and [`src/main.ts`](./src/main.ts) is the same all-features showcase as the React and Solid examples. + +```bash +pnpm --filter @repo/vanilla-example dev # http://localhost:3012 +``` + +What it shows: + +- **Drag** a tab onto another tab bar, onto a panel's side to split it, or onto a layout edge; drag a tab bar to move the whole panel. Escape cancels. +- **Resize** with splitters (pointer, or arrow keys when focused). +- **Close** closable tabs with × or Delete; **add** tabs, optionally at a target (`{ type: 'root', position: 'bottom' }`). +- **Fold** (collapse) and **maximize/restore** from the tab bar toolbar, by double-clicking a tab bar, with Alt+- / Alt+=, or from code. +- **Select** and **move** tabs from code, **persist** the layout to localStorage, **reset** it. +- Tab content is created once and never re-parented, so form state (try the Editor's textarea) survives every move. + +`createDomLayout(root, options)` returns `{ engine, setTabs, destroy }`; `engine` is the core [`Layout`](../../docs/api/create-layout.md), so every action is one call. diff --git a/examples/vanilla/index.html b/examples/vanilla/index.html new file mode 100644 index 0000000..deae5fb --- /dev/null +++ b/examples/vanilla/index.html @@ -0,0 +1,12 @@ + + + + + + Dynamix Layout: plain JavaScript + + +
+ + + diff --git a/examples/vanilla/package.json b/examples/vanilla/package.json new file mode 100644 index 0000000..d40f223 --- /dev/null +++ b/examples/vanilla/package.json @@ -0,0 +1,21 @@ +{ + "name": "@repo/vanilla-example", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@dynamix-layout/core": "workspace:*", + "typescript": "~5.8.3", + "vite": "^7.3.6" + }, + "packageManager": "pnpm@10.13.1", + "engines": { + "node": "^22.0.0", + "pnpm": "^10.0.0" + } +} diff --git a/examples/vanilla/src/dom-layout.ts b/examples/vanilla/src/dom-layout.ts new file mode 100644 index 0000000..2f43662 --- /dev/null +++ b/examples/vanilla/src/dom-layout.ts @@ -0,0 +1,506 @@ +import { + applyRect, + createFrameScheduler, + createLayout, + getTabBarPlacement, + getTabContentRect, + type DragSource, + type DropMeasurements, + type DropTarget, + type Layout, + type LayoutChangeReason, + type LayoutJSON, + type LayoutSnapshot, + type Point, + type Rect, + type TabBarMeasurement, + type TabsetState, +} from '@dynamix-layout/core' + +/** A tab as the app describes it. */ +export interface TabDef { + readonly id: string + readonly title: string + /** Created once and never moved between parents, so its state survives every move. */ + readonly content: HTMLElement + readonly closable?: boolean + /** Where the tab goes the first time it appears. */ + readonly target?: DropTarget +} + +export interface DomLayoutOptions { + readonly tabs: readonly TabDef[] + readonly initialLayout?: LayoutJSON | null + readonly tabBarHeight?: number + readonly splitterSize?: number + readonly padding?: number + readonly minPanelSize?: { width: number; height: number } + readonly onLayoutChange?: ( + layout: LayoutJSON, + reason: LayoutChangeReason + ) => void + /** The × button or Delete key asks to close a closable tab. */ + readonly onTabClose?: (tabId: string) => void +} + +export interface DomLayout { + readonly engine: Layout + /** Replaces the open tabs; content elements of kept tabs stay where they are. */ + setTabs(tabs: readonly TabDef[]): void + destroy(): void +} + +/** Pixels a pointer must travel before a press on a tab becomes a drag. */ +const DRAG_THRESHOLD = 4 + +const el = ( + tag: K, + className: string, + parent?: HTMLElement +) => { + const element = document.createElement(tag) + element.className = className + parent?.append(element) + return element +} + +const relativeRect = (rect: DOMRect, origin: Point): Rect => ({ + x: rect.left - origin.x, + y: rect.top - origin.y, + width: rect.width, + height: rect.height, +}) + +/** + * Renders a layout into `root` with plain DOM: tab bars, close buttons, + * fold/maximize toolbars, splitters and a drop indicator, all driven by the + * engine's snapshots. Every interaction is one engine call. + */ +export function createDomLayout( + root: HTMLElement, + options: DomLayoutOptions +): DomLayout { + const tabBarHeight = options.tabBarHeight ?? 36 + const padding = options.padding ?? 0 + let defs = new Map(options.tabs.map((tab) => [tab.id, tab])) + + const engine = createLayout({ + tabs: options.tabs, + initialLayout: options.initialLayout, + splitterSize: options.splitterSize ?? 6, + minPanelSize: options.minPanelSize ?? { + width: 80, + height: tabBarHeight, + }, + foldedSize: tabBarHeight, + onLayoutChange: options.onLayoutChange, + }) + + root.classList.add('dx-root') + const panels = new Map() + const bars = new Map() + const barStates = new Map() + const contents = new Map() + const splitters = new Map() + const indicator = el('div', 'dx-drop-indicator', root) + indicator.hidden = true + + // --- Coordinates and measurements --- + + const toRootPoint = (event: PointerEvent): Point => { + const box = root.getBoundingClientRect() + return { + x: event.clientX - box.left - root.clientLeft, + y: event.clientY - box.top - root.clientTop, + } + } + + const measure = (): DropMeasurements => { + const box = root.getBoundingClientRect() + const origin = { + x: box.left + root.clientLeft, + y: box.top + root.clientTop, + } + const tabBars = new Map() + bars.forEach((bar, tabsetId) => { + if (bar.hidden) return + tabBars.set(tabsetId, { + rect: relativeRect(bar.getBoundingClientRect(), origin), + isRotated: bar.hasAttribute('data-dx-rotated'), + tabs: Array.from( + bar.querySelectorAll('.dx-tab'), + (tab) => ({ + id: tab.dataset.id!, + rect: relativeRect(tab.getBoundingClientRect(), origin), + }) + ), + }) + }) + return { tabBars } + } + + const resize = () => + engine.setContainerRect({ + x: padding, + y: padding, + width: Math.max(0, root.clientWidth - padding * 2), + height: Math.max(0, root.clientHeight - padding * 2), + }) + const resizeFrame = createFrameScheduler(resize) + const observer = new ResizeObserver(() => resizeFrame.schedule(null)) + observer.observe(root) + + // --- Pointer drags (tabs, tab bars, splitters) --- + + let stopDrag: (() => void) | null = null + + /** + * Follows a press: `onClick` if it never moved past the threshold, + * otherwise a drag of `source` that ends in `endDrag` (or `cancelDrag` on Escape). + */ + const trackPress = ( + event: PointerEvent, + source: DragSource, + onClick?: () => void + ) => { + if (event.button !== 0 || stopDrag) return + const start = { x: event.clientX, y: event.clientY } + const isSplitter = source.type === 'splitter' + let dragging = false + let measurements: DropMeasurements | undefined + const frame = createFrameScheduler((point: Point) => + engine.updateDrag(point, measurements) + ) + + const begin = () => { + if (!isSplitter) measurements = measure() + dragging = engine.startDrag(source) + if (dragging) root.setAttribute('data-dx-dragging', '') + return dragging + } + const move = (e: PointerEvent) => { + if ( + !dragging && + Math.hypot(e.clientX - start.x, e.clientY - start.y) < + DRAG_THRESHOLD + ) + return + if (!dragging && !begin()) return finish() + frame.schedule(toRootPoint(e)) + } + const up = () => { + if (dragging) { + frame.flush() + engine.endDrag() + } else onClick?.() + finish() + } + const key = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return + frame.cancel() + if (dragging) engine.cancelDrag() + finish() + } + const finish = () => { + frame.cancel() + root.removeAttribute('data-dx-dragging') + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + window.removeEventListener('pointercancel', up) + window.removeEventListener('keydown', key) + stopDrag = null + } + + stopDrag = () => { + if (dragging) engine.cancelDrag() + finish() + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + window.addEventListener('pointercancel', up) + window.addEventListener('keydown', key) + // A splitter drags from the first pixel; tabs wait for the threshold. + if (isSplitter && !begin()) finish() + } + + // --- Tab bars --- + + const closeTab = (tabId: string) => { + if (defs.get(tabId)?.closable) options.onTabClose?.(tabId) + } + + const toolbarButton = ( + parent: HTMLElement, + label: string, + text: string, + run: () => void + ) => { + const button = el('button', 'dx-toolbar-button', parent) + button.type = 'button' + button.title = label + button.setAttribute('aria-label', label) + button.textContent = text + button.addEventListener('pointerdown', (e) => e.stopPropagation()) + button.addEventListener('click', run) + } + + const fillTabBar = (bar: HTMLElement, tabset: TabsetState) => { + bar.replaceChildren() + bar.setAttribute('role', 'tablist') + for (const tabId of tabset.tabIds) { + const def = defs.get(tabId) + const isActive = tabId === tabset.activeTabId + const tab = el('div', 'dx-tab', bar) + tab.dataset.id = tabId + tab.dataset.state = isActive ? 'active' : 'inactive' + tab.setAttribute('role', 'tab') + tab.setAttribute('aria-selected', String(isActive)) + tab.tabIndex = isActive ? 0 : -1 + el('span', 'dx-tab-title', tab).textContent = def?.title ?? tabId + if (def?.closable) { + const close = el('button', 'dx-tab-close', tab) + close.type = 'button' + close.textContent = '×' + close.tabIndex = -1 + close.setAttribute('aria-label', `Close ${def.title}`) + close.addEventListener('pointerdown', (e) => + e.stopPropagation() + ) + close.addEventListener('click', () => closeTab(tabId)) + } + tab.addEventListener('pointerdown', (e) => { + e.stopPropagation() + trackPress(e, { type: 'tab', tabId }, () => { + engine.selectTab(tabId) + focusTab(tabId) + }) + }) + tab.addEventListener('keydown', (e) => onTabKey(e, tabset, tabId)) + } + + const toolbar = el('div', 'dx-toolbar', bar) + if (tabset.canFold) + toolbarButton( + toolbar, + tabset.isFolded ? 'Unfold' : 'Fold', + tabset.isFolded ? '▸' : '▾', + () => engine.toggleFold(tabset.id) + ) + if (tabset.canMaximize) + toolbarButton( + toolbar, + tabset.isMaximized ? 'Restore' : 'Maximize', + tabset.isMaximized ? '❐' : '□', + () => engine.toggleMaximize(tabset.id) + ) + bar.toggleAttribute( + 'data-dx-has-toolbar', + toolbar.childElementCount > 0 + ) + } + + const focusTab = (tabId: string) => + root + .querySelector( + `.dx-tab[data-id="${CSS.escape(tabId)}"]` + ) + ?.focus() + + const onTabKey = (e: KeyboardEvent, tabset: TabsetState, tabId: string) => { + const index = tabset.tabIds.indexOf(tabId) + const step = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0 + if (step) { + const next = + tabset.tabIds[ + (index + step + tabset.tabIds.length) % tabset.tabIds.length + ] + engine.selectTab(next) + focusTab(next) + } else if (e.key === 'Delete') closeTab(tabId) + else if (e.altKey && e.key === '=') engine.toggleMaximize(tabset.id) + else if (e.altKey && e.key === '-') engine.toggleFold(tabset.id) + else return + e.preventDefault() + } + + const createTabBar = (tabsetId: string) => { + const bar = el('div', 'dx-tab-bar', root) + // Pressing the empty part of a tab bar drags the whole tabset. + bar.addEventListener('pointerdown', (e) => + trackPress(e, { type: 'tabset', tabsetId }) + ) + bar.addEventListener('dblclick', (e) => { + if ((e.target as HTMLElement).closest('.dx-toolbar')) return + engine.toggleMaximize(tabsetId) + }) + return bar + } + + // --- Splitters --- + + const createSplitter = (splitterId: string) => { + const splitter = el('div', 'dx-splitter', root) + splitter.tabIndex = 0 + splitter.setAttribute('role', 'separator') + splitter.addEventListener('pointerdown', (e) => + trackPress(e, { type: 'splitter', splitterId }) + ) + splitter.addEventListener('keydown', (e) => { + const delta = { + ArrowLeft: -10, + ArrowUp: -10, + ArrowRight: 10, + ArrowDown: 10, + }[e.key] + if (!delta) return + e.preventDefault() + engine.moveSplitterBy(splitterId, delta) + }) + return splitter + } + + // --- Rendering --- + + /** Creates, updates and removes one element per id in `ids`. */ + const sync = ( + elements: Map, + items: ReadonlyMap, + create: (id: string) => HTMLElement, + update: (element: HTMLElement, item: T, id: string) => void + ) => { + elements.forEach((element, id) => { + if (items.has(id)) return + element.remove() + elements.delete(id) + }) + items.forEach((item, id) => { + let element = elements.get(id) + if (!element) elements.set(id, (element = create(id))) + update(element, item, id) + }) + } + + const render = (snapshot: LayoutSnapshot) => { + const { rects } = snapshot + + sync( + panels, + snapshot.tabsets, + () => el('div', 'dx-panel', root), + (panel, tabset, id) => { + panel.hidden = tabset.isHidden + applyRect(panel, rects.tabsets.get(id)!) + } + ) + + sync(bars, snapshot.tabsets, createTabBar, (bar, tabset, id) => { + // Snapshots share unchanged tabset states, so `!==` means "redraw the tabs". + if (barStates.get(id) !== tabset || !bar.firstChild) { + fillTabBar(bar, tabset) + barStates.set(id, tabset) + } + for (const tabId of tabset.tabIds) { + const title = defs.get(tabId)?.title ?? tabId + const label = bar.querySelector( + `.dx-tab[data-id="${CSS.escape(tabId)}"] .dx-tab-title` + ) + if (label && label.textContent !== title) + label.textContent = title + } + const placement = getTabBarPlacement( + rects.tabsets.get(id)!, + tabset, + tabBarHeight + ) + bar.hidden = tabset.isHidden + bar.toggleAttribute('data-dx-rotated', placement.isRotated) + bar.toggleAttribute('data-dx-folded', tabset.isFolded) + bar.style.transform = placement.isRotated ? 'rotate(90deg)' : '' + applyRect(bar, placement.rect) + }) + barStates.forEach((_, id) => { + if (!snapshot.tabsets.has(id)) barStates.delete(id) + }) + + sync( + contents, + snapshot.tabs, + (id) => { + const content = el('div', 'dx-tab-content', root) + content.setAttribute('role', 'tabpanel') + const def = defs.get(id) + if (def) content.append(def.content) + return content + }, + (content, tab) => { + content.hidden = !tab.isVisible + if (tab.isVisible) + applyRect( + content, + getTabContentRect( + rects.tabsets.get(tab.tabsetId)!, + tabBarHeight + ) + ) + } + ) + + sync( + splitters, + snapshot.splitters, + createSplitter, + (splitter, state, id) => { + splitter.hidden = state.isHidden + splitter.dataset.dxDirection = state.direction + splitter.toggleAttribute('data-dx-locked', state.isLocked) + splitter.setAttribute( + 'aria-orientation', + state.direction === 'horizontal' ? 'vertical' : 'horizontal' + ) + const bounds = engine.getSplitterBounds(id) + if (bounds) { + splitter.setAttribute( + 'aria-valuenow', + String(Math.round(bounds.value)) + ) + splitter.setAttribute( + 'aria-valuemin', + String(Math.round(bounds.min)) + ) + splitter.setAttribute( + 'aria-valuemax', + String(Math.round(bounds.max)) + ) + } + applyRect(splitter, rects.splitters.get(id)!) + } + ) + + const box = snapshot.drag?.indicator + indicator.hidden = !box + if (box) applyRect(indicator, box) + } + + const unsubscribe = engine.subscribe(render) + resize() + render(engine.getSnapshot()) + + return { + engine, + setTabs(tabs) { + defs = new Map(tabs.map((tab) => [tab.id, tab])) + barStates.clear() // titles or closable may have changed + engine.setTabs(tabs) + render(engine.getSnapshot()) + }, + destroy() { + stopDrag?.() + resizeFrame.cancel() + observer.disconnect() + unsubscribe() + engine.destroy() + root.replaceChildren() + root.classList.remove('dx-root') + }, + } +} diff --git a/examples/vanilla/src/main.ts b/examples/vanilla/src/main.ts new file mode 100644 index 0000000..f8bd60b --- /dev/null +++ b/examples/vanilla/src/main.ts @@ -0,0 +1,178 @@ +import type { DropTarget, LayoutJSON, RowJSON } from '@dynamix-layout/core' +import { createDomLayout, type TabDef } from './dom-layout' +import './style.css' + +const STORAGE_KEY = 'dynamix-layout:vanilla-showcase' + +const loadSaved = (): LayoutJSON | null => { + try { + const json = localStorage.getItem(STORAGE_KEY) + return json ? (JSON.parse(json) as LayoutJSON) : null + } catch { + return null + } +} + +const panel = (background: string, html: string) => { + const element = document.createElement('div') + element.className = 'panel' + element.style.background = background + element.innerHTML = html + return element +} + +const HELP = `
    +
  • Drag a tab onto another tab bar, onto a panel's side to split it, or onto a layout edge.
  • +
  • Drag a tab bar to move the whole panel. Drag a splitter (or focus it and use the arrows) to resize.
  • +
  • Fold or maximize with the buttons at the end of a tab bar (hover it), or double-click a tab bar to maximize.
  • +
  • Keyboard: arrows move between tabs, Alt+= maximizes, Alt+- folds, Escape cancels a drag.
  • +
  • Close tabs with × (or Delete), add them with the buttons above. The layout is saved to localStorage: reload the page.
  • +
` + +const initialTabs: TabDef[] = [ + { id: 'guide', title: 'Guide', content: panel('#fffde7', HELP) }, + { + id: 'editor', + title: 'Editor', + content: panel( + '#e3f2fd', + 'Editor: type here, then move the tab. The text survives because content is never re-created.
' + ), + }, + { + id: 'terminal', + title: 'Terminal', + content: panel('#212121', '$ _'), + }, + { + id: 'preview', + title: 'Preview', + content: panel('#e8f5e9', 'Preview'), + closable: true, + }, + { + id: 'notes', + title: 'Notes', + content: panel('#fce4ec', 'Notes (closable)'), + closable: true, + }, +] + +const newTab = (n: number, target?: DropTarget): TabDef => ({ + id: `new-${n}`, + title: `New ${n}`, + content: panel('#ede7f6', `Tab new-${n}`), + closable: true, + target, +}) +const newTabNumber = (id: string) => Number(/^new-(\d+)$/.exec(id)?.[1] ?? 0) + +/** Tab ids in a saved layout, so added and closed tabs survive a reload too. */ +const tabIdsOf = (row: RowJSON): string[] => + row.children.flatMap((child) => + child.type === 'row' + ? tabIdsOf(child) + : child.children.map((tab) => tab.id) + ) + +/** The tabs a saved layout had open, or the initial ones. */ +const restoreTabs = (saved: LayoutJSON | null): TabDef[] => { + if (!saved) return initialTabs + const known = new Map(initialTabs.map((tab) => [tab.id, tab])) + return tabIdsOf(saved.root).flatMap((id) => { + const tab = + known.get(id) ?? + (newTabNumber(id) ? newTab(newTabNumber(id)) : null) + return tab ? [tab] : [] + }) +} + +const app = document.querySelector('#app')! +const controls = document.createElement('div') +controls.className = 'controls' +const log = document.createElement('div') +log.className = 'log' +log.textContent = 'Changes appear here (onLayoutChange reasons).' +const root = document.createElement('div') +app.append(controls, log, root) + +const saved = loadSaved() +let tabs = restoreTabs(saved) +let counter = Math.max(0, ...tabs.map((tab) => newTabNumber(tab.id))) +const entries: string[] = [] +const write = (entry: string) => { + entries.unshift(entry) + log.textContent = entries.slice(0, 8).join(' · ') +} + +const layout = createDomLayout(root, { + tabs, + initialLayout: saved, + padding: 6, + splitterSize: 6, + tabBarHeight: 36, + minPanelSize: { width: 120, height: 80 }, + onLayoutChange: (json, reason) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(json)) + write(reason) + }, + onTabClose: (id) => setTabs(tabs.filter((tab) => tab.id !== id)), +}) +const { engine } = layout + +const setTabs = (next: TabDef[]) => { + tabs = next + layout.setTabs(tabs) +} + +const tabsetOf = (tabId: string) => + engine.getSnapshot().tabs.get(tabId)?.tabsetId ?? '' + +/** Runs an engine action and logs it when the engine refuses it. */ +const act = (name: string, run: () => boolean) => { + if (!run()) write(`${name}: not possible right now`) +} + +const addTab = (target?: DropTarget) => { + setTabs([...tabs, newTab(++counter, target)]) +} + +const button = (label: string, onClick: () => void) => { + const element = document.createElement('button') + element.type = 'button' + element.textContent = label + element.addEventListener('click', onClick) + controls.append(element) +} + +button('Add tab', () => addTab()) +button('Add tab at the bottom', () => + addTab({ type: 'root', position: 'bottom' }) +) +button('Maximize / restore Editor', () => + act('Maximize', () => engine.toggleMaximize(tabsetOf('editor'))) +) +button('Fold / unfold Terminal', () => + act('Fold', () => engine.toggleFold(tabsetOf('terminal'))) +) +button('Select Preview', () => act('Select', () => engine.selectTab('preview'))) +button('Move Notes next to Editor', () => + act('Move', () => + engine.moveTab('notes', { + type: 'tab', + tabId: 'editor', + position: 'after', + }) + ) +) +button('Reset layout', () => { + setTabs(initialTabs) + engine.reset() +}) +button('Forget saved layout', () => localStorage.removeItem(STORAGE_KEY)) + +// Vite HMR: tear the old instance down before the module re-runs. +import.meta.hot?.dispose(() => { + layout.destroy() + app.replaceChildren() +}) diff --git a/examples/vanilla/src/style.css b/examples/vanilla/src/style.css new file mode 100644 index 0000000..985bac3 --- /dev/null +++ b/examples/vanilla/src/style.css @@ -0,0 +1,283 @@ +/* The same look as @dynamix-layout/react/styles.css, for plain DOM. */ +.dx-root { + --dx-font: inherit; + --dx-tab-bar-bg: #dfdfdf; + --dx-tab-bg: #dfdfdf; + --dx-tab-active-bg: #ffffff; + --dx-tab-color: inherit; + --dx-tab-radius: 4px; + --dx-tab-gap: 6px; + --dx-splitter-bg: #ffffff; + --dx-splitter-hover-bg: #e8f2ff; + --dx-grip-color: #9a9a9a; + --dx-drop-indicator-bg: rgba(0, 175, 249, 0.35); + --dx-drop-indicator-border: 2px dashed rgb(0, 196, 42); + --dx-root-drop-zone-bg: #0081f9; + --dx-focus-ring: 2px solid #0081f9; + + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + box-sizing: border-box; + font-family: var(--dx-font); +} + +/* Server render and first client render: nothing is measured yet. */ +.dx-root[data-dx-measuring] > * { + visibility: hidden; +} + +.dx-root *, +.dx-root *::before, +.dx-root *::after { + box-sizing: border-box; +} + +.dx-panel, +.dx-tab-bar, +.dx-tab-content, +.dx-splitter, +.dx-drop-indicator, +.dx-root-drop-zone { + position: absolute; +} + +.dx-panel { + z-index: 1; +} + +/* --- Tab bar and tabs --- */ +.dx-tab-bar { + z-index: 3; + display: flex; + align-items: center; + gap: var(--dx-tab-gap); + padding-left: 8px; + background: var(--dx-tab-bar-bg); + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + transform-origin: 0 0; + touch-action: pan-x; + cursor: default; +} +.dx-tab-bar::-webkit-scrollbar { + display: none; +} +.dx-tab-bar[data-dx-has-toolbar] { + padding-right: 64px; +} + +.dx-tab { + display: inline-flex; + flex: none; + align-items: center; + gap: 4px; + padding: 4px 6px; + border-radius: var(--dx-tab-radius); + background: var(--dx-tab-bg); + color: var(--dx-tab-color); + font-size: 14px; + white-space: nowrap; + cursor: pointer; + user-select: none; +} +.dx-tab[data-state='active'] { + background: var(--dx-tab-active-bg); +} +.dx-tab:focus-visible, +.dx-splitter:focus-visible, +.dx-toolbar-button:focus-visible { + outline: var(--dx-focus-ring); + outline-offset: -2px; +} + +.dx-tab-close { + display: grid; + place-items: center; + width: 18px; + height: 18px; + padding: 0; + border: 0; + border-radius: 3px; + background: transparent; + color: inherit; + cursor: pointer; +} +.dx-tab-close:hover { + background: rgba(0, 0, 0, 0.08); +} + +/* --- Tab content --- */ +.dx-tab-content { + z-index: 2; + overflow: auto; + overscroll-behavior: contain; + scrollbar-width: none; +} +.dx-tab-content::-webkit-scrollbar { + display: none; +} +/* While dragging, iframes and editors inside tabs must not swallow pointer events. */ +.dx-root[data-dx-dragging] .dx-tab-content > * { + pointer-events: none; +} +.dx-root[data-dx-dragging], +.dx-root[data-dx-dragging] * { + cursor: grabbing; +} + +/* --- Splitters --- */ +.dx-splitter { + z-index: 4; + display: grid; + place-items: center; + background: var(--dx-splitter-bg); + touch-action: none; +} +.dx-splitter[data-dx-direction='horizontal'] { + cursor: ew-resize; +} +.dx-splitter[data-dx-direction='vertical'] { + cursor: ns-resize; +} +.dx-splitter:hover { + background: var(--dx-splitter-hover-bg); +} +.dx-splitter[data-dx-locked] { + pointer-events: none; +} +/* A grip of three dots across the bar. */ +.dx-splitter::after { + content: ''; + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--dx-grip-color); + box-shadow: + 0 -6px 0 var(--dx-grip-color), + 0 6px 0 var(--dx-grip-color); +} +.dx-splitter[data-dx-direction='vertical']::after { + box-shadow: + -6px 0 0 var(--dx-grip-color), + 6px 0 0 var(--dx-grip-color); +} + +/* --- Drag feedback --- */ +.dx-drop-indicator { + z-index: 6; + background: var(--dx-drop-indicator-bg); + border: var(--dx-drop-indicator-border); + border-radius: 10px; + pointer-events: none; + transition: all 0.15s ease; +} + +.dx-root-drop-zone { + z-index: 5; + background: var(--dx-root-drop-zone-bg); + opacity: 0.6; + pointer-events: none; +} +.dx-root-drop-zone[data-dx-active] { + opacity: 1; +} +.dx-root-drop-zone[data-dx-side='left'] { + border-radius: 0 16px 16px 0; +} +.dx-root-drop-zone[data-dx-side='right'] { + border-radius: 16px 0 0 16px; +} +.dx-root-drop-zone[data-dx-side='top'] { + border-radius: 0 0 16px 16px; +} +.dx-root-drop-zone[data-dx-side='bottom'] { + border-radius: 16px 16px 0 0; +} + +/* --- Tabset toolbar (maximize / fold) --- */ +/* Shown while the tab bar is hovered or focused; folded strips always show it. */ +.dx-toolbar { + position: absolute; + top: 0; + right: 6px; + height: 100%; + display: flex; + align-items: center; + gap: 2px; + padding-left: 6px; + /* Opaque, so overflowing tabs slide underneath instead of showing through. */ + background: inherit; + opacity: 0; + transition: opacity 0.15s ease; +} +.dx-tab-bar:hover .dx-toolbar, +.dx-tab-bar[data-dx-folded] .dx-toolbar, +.dx-toolbar:focus-within { + opacity: 1; +} +@media (hover: none) { + .dx-toolbar { + opacity: 1; + } +} + +.dx-toolbar-button { + display: grid; + place-items: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: inherit; + cursor: pointer; +} +.dx-toolbar-button:hover { + background-color: rgba(0, 0, 0, 0.08); +} + +.dx-root [hidden] { + display: none !important; +} + +/* --- Page --- */ +html, +body, +#app { + height: 100%; + margin: 0; + font-family: system-ui, sans-serif; +} +#app { + display: grid; + grid-template-rows: auto auto 1fr; +} +.controls { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px; + border-bottom: 1px solid #eee; +} +.controls button { + padding: 4px 10px; +} +.log { + min-height: 20px; + padding: 4px 8px; + font-size: 12px; + color: #666; +} +.panel { + height: 100%; + padding: 16px; + line-height: 1.6; +} +.panel ul { + margin: 0; + padding-left: 18px; +} diff --git a/examples/vanilla/src/vite-env.d.ts b/examples/vanilla/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/examples/vanilla/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/vanilla/tsconfig.json b/examples/vanilla/tsconfig.json new file mode 100644 index 0000000..b3d4465 --- /dev/null +++ b/examples/vanilla/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/examples/vanilla/vite.config.ts b/examples/vanilla/vite.config.ts new file mode 100644 index 0000000..cb4096c --- /dev/null +++ b/examples/vanilla/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + server: { + port: 3012, + }, +}) diff --git a/package.json b/package.json index 97c330a..7502c4c 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@testing-library/react": "^16.3.3", "@types/node": "^24.19.1", "@vitejs/plugin-react": "^4.7.0", + "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "^4.1.11", "eslint": "^9.39.5", "eslint-plugin-react": "^7.37.5", diff --git a/packages/core/README.md b/packages/core/README.md index 8d89746..1e396b4 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,207 +1,38 @@ -
+# @dynamix-layout/core -[![Patreon](https://img.shields.io/badge/Patreon-Support-F96854?style=for-the-badge&logo=patreon)](https://www.patreon.com/akashaman) -[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-Donate-FFDD00?style=for-the-badge&logo=buy-me-a-coffee)](https://www.buymeacoffee.com/akashaman) -[![Hire Me](https://img.shields.io/badge/Hire%20Me-Email-blue?style=for-the-badge&logo=gmail)](mailto:sir.akashaman@gmail.com) +The framework-agnostic engine behind Dynamix Layout. It handles the layout tree (rows, tabsets and tabs), geometry and minimum sizes, splitters, drag-and-drop targets, maximize and fold, and versioned serialization. It has no dependencies, no global state and no DOM access. Every `createLayout()` is an independent instance. -
+Use it directly to build an adapter for another framework, or through [`@dynamix-layout/react`](https://www.npmjs.com/package/@dynamix-layout/react) or [`@dynamix-layout/solid`](https://www.npmjs.com/package/@dynamix-layout/solid). -# 🚀 @dynamix-layout/core +## Install -## Overview - -**@dynamix-layout/core** is the **engine** that powers the **creation**, **management**, and **dynamic modification** of your layout structures. It handles the **internal logic** of the layout tree, including **node creation**, **dimension calculation**, and **state updates**. This package works with a **hierarchical tree structure** composed of `Node` objects and exposes a **reactive state** for seamless integration with modern UI frameworks. - ---- - -### Made with ❤️ by [Akash Aman](https://linktr.ee/akash_aman) - ---- - -![alt text](https://raw.githubusercontent.com/xcode-studio/dynamix-layout/main/assets/demo1.gif) - ---- - -## Core Classes - -The package is built around three primary classes: `DynamixLayoutCore`, `Node`, and `Bond`. - -1. **`DynamixLayoutCore`**: The main controller class that you instantiate to create and manage the entire layout. It serves as the primary public API. -2. **`Node`**: The fundamental building block of the layout tree. Each `Node` can be a `row`, a `tabset`, or a `tab`. -3. **`Bond`**: A special object that represents the draggable separator between two nodes, allowing for resizing. - ---- - -## `DynamixLayoutCore` Class - -The `DynamixLayoutCore` class is the central point of control for your entire layout configuration. - -### Constructor - -When initializing the `DynamixLayoutCore` class, you provide a single **options object**. - -`new DynamixLayoutCore(options)` - -- **`options`** (`object`): An object containing configuration properties. - - **`tabs`** (`string[]`): An array of tab names to generate an initial binary tree layout. Defaults to `[]`. - - **`tree`** (`LayoutTree | null`): A serialized JSON representation of a previously saved layout (`LayoutTree` object). If provided, it will recreate that specific layout. Defaults to `null`. - - **`tabsIds`** (`Map`): A map where keys are tab names and values are their unique IDs. This is useful for persisting tab identities across sessions. - - **`minW`** (`number`): The minimum width for a `tabset`. Defaults to `40`. - - **`minH`** (`number`): The minimum height for a `tabset`. Defaults to `40`. - - **`bond`** (`number`): The width or height (in pixels) of the draggable separator (`Bond`) between nodes. Defaults to `10`. - -### Reactive Properties - -The layout's state is exposed through static reactive properties on the `Node` class. You can subscribe to these to drive your UI. - -- **`Node.cache.nodOpts`**: A reactive map of all `tabset` nodes and their options. -- **`Node.cache.bndOpts`**: A reactive map of all `bond` objects. -- **`Node.cache.tabOpts`**: A reactive map of all `tab` nodes within their respective tabsets. - -### Methods - -#### 🌳 `updateTree(src, des, layout)` - -This is the core method for rearranging the layout. It moves a source node (`src`) to a new position relative to a destination node (`des`). - -- **`src`** (`string`): The unique ID of the node to move (can be a `tab` or a `tabset`). -- **`des`** (`string`): The unique ID of the destination node. -- **`layout`** (`'top' | 'bottom' | 'left' | 'right' | 'contain'`): Specifies how the source should be placed relative to the destination. - - `top`, `bottom`, `left`, `right`: Places the source node by splitting the destination area. - - `contain`: Places a source `tab` into the destination `tabset`. - -**Returns**: `boolean` - `true` if the update was successful, `false` otherwise. - -```javascript -// Moves the node with 'sidebar-id' to be to the left of 'main-content-id' -layout.updateTree('sidebar-id', 'main-content-id', 'left') +```bash +npm install @dynamix-layout/core ``` ---- +## Quick start -#### 📏 `updateDimension(dim, disableTimeout, timeout)` +```ts +import { createLayout } from '@dynamix-layout/core' -Recalculates all node dimensions within the layout, typically after the main container has been resized. - -- **`dim`** (`{ w, h, x, y }`): An object representing the new dimensions of the root container. -- **`disableTimeout`** (`boolean`): If `true`, the update runs instantly. Otherwise, it's debounced. Defaults to `false`. -- **`timeout`** (`number`): The debounce delay in milliseconds. Defaults to `2`. - - - -```javascript -// On window resize, update the layout dimensions -layout.updateDimension({ - w: window.innerWidth, - h: window.innerHeight, - x: 0, - y: 0, +const layout = createLayout({ + tabs: [{ id: 'editor' }, { id: 'terminal' }, { id: 'preview' }], + onLayoutChange: (json) => localStorage.setItem('layout', JSON.stringify(json)), }) -``` - ---- - -#### ↔️ `updateSlider(id, dim, disableTimeout, timeout)` - -Updates the layout in response to a user dragging a `Bond` (slider). - -- **`id`** (`string`): The unique ID of the `Bond` being moved. -- **`dim`** (`{ x, y }`): An object with the new coordinates of the slider. -- **`disableTimeout`** (`boolean`): If `true`, the update runs instantly. Defaults to `false`. -- **`timeout`** (`number`): The debounce delay in milliseconds. Defaults to `2`. - - -```javascript -// When a user drags a separator -layout.updateSlider('bond-123', { x: 450, y: 300 }) -``` - ---- - -#### 🗑️ `clearAllCache()` - -Clears all internal caches for nodes, bonds, dimensions, and other layout data. Use this when completely destroying or resetting the layout. - -```javascript -layout.clearAllCache() -``` - ---- - -## `Node` Class - -The `Node` represents a single element in the layout tree. You will typically interact with Node data through the reactive properties rather than manipulating `Node` objects directly. - -### Constructor - -`new Node(options)` - -- **`options`** (`object`): An object containing node properties like `type`, `host`, `name`, `part`, `dims`, `unId`, and `open`. - -### Static Cache (`Node.cache`) - -The `Node` class contains a static `cache` object. This centralized cache holds the state for the entire layout, including maps of all nodes, dimensions, and the **reactive options** that your UI will consume. **Direct manipulation of the cache is not recommended.** - -### Methods - -#### 💾 `toJSON()` - -Serializes the node and its entire subtree into a JSON object (`LayoutTree`). This is used to save the current state of the layout. - -```javascript -const rootNode = DynamixLayoutCore._root // Access the static root node -const layoutJSON = rootNode.toJSON() -// localStorage.setItem('savedLayout', JSON.stringify(layoutJSON)); -``` - -## Example Usage - -```javascript -import { DynamixLayoutCore, Node } from '@dynamix-layout/core' - -// 1. Initialize the Layout class -// Provide an options object to the constructor -const layout = new DynamixLayoutCore({ - tabs: ['editor', 'terminal', 'preview'], -}) - -// ...or restore a layout from a saved JSON object -// const savedLayout = JSON.parse(localStorage.getItem('savedLayout')); -// const layout = new DynamixLayoutCore({ tree: savedLayout }); - -// 2. Set the initial dimensions of your container -layout.updateDimension({ w: 1200, h: 800, x: 0, y: 0 }, true) - -// 3. Subscribe to reactive state to render your UI. -const unsubscribe = Node.cache.nodOpts.onChange((nodeOptionsMap) => { - console.log('Layout updated!', nodeOptionsMap) - // Your UI rendering logic here... +layout.subscribe((snapshot) => { + for (const [id, rect] of snapshot.rects.tabsets) draw(id, rect) }) - -// 4. Move a tab to a new location -// (In a real app, you'd get these IDs from the reactive state) -setTimeout(() => { - const terminalTabId = 'some-unique-id-for-terminal-tab' - const editorTabsetId = 'some-unique-id-for-editor-tabset' - - // Move the terminal tab into the editor's tabset - layout.updateTree(terminalTabId, editorTabsetId, 'contain') -}, 2000) - -// 5. Clean up when the component unmounts -// unsubscribe(); -// layout.clearAllCache(); +layout.setContainerRect({ x: 0, y: 0, width: 1200, height: 800 }) +layout.moveTab('terminal', { type: 'root', position: 'bottom' }) ``` ---- - -
- -[![Patreon](https://img.shields.io/badge/Patreon-Support-F96854?style=for-the-badge&logo=patreon)](https://www.patreon.com/akashaman) -[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-Donate-FFDD00?style=for-the-badge&logo=buy-me-a-coffee)](https://www.buymeacoffee.com/akashaman) -[![Hire Me](https://img.shields.io/badge/Hire%20Me-Email-blue?style=for-the-badge&logo=gmail)](mailto:sir.akashaman@gmail.com) +## Documentation -### Made with ❤️ by [Akash Aman](https://linktr.ee/akash_aman) +- [Using the core without React](https://github.com/xcode-studio/dynamix-layout/blob/main/docs/guides/core-without-react.md) +- [`createLayout` API](https://github.com/xcode-studio/dynamix-layout/blob/main/docs/api/create-layout.md) +- [`LayoutJSON`](https://github.com/xcode-studio/dynamix-layout/blob/main/docs/api/layout-json.md) +- [`migrateLayoutFromV1`](https://github.com/xcode-studio/dynamix-layout/blob/main/docs/api/migrate-layout-from-v1.md) +- [Migrating from v1](https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md) -
+MIT © [Akash Aman](https://linktr.ee/akash_aman) diff --git a/packages/core/package.json b/packages/core/package.json index 129297a..ab1326a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -28,21 +28,23 @@ "url": "https://github.com/xcode-studio/dynamix-layout/issues" }, "homepage": "https://github.com/xcode-studio/dynamix-layout#readme", - "main": "./dist/core.cjs", - "module": "./dist/core.es.js", - "types": "./dist/types/index.d.ts", - "typings": "./dist/types/index.d.ts", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "unpkg": "./dist/index.umd.js", "exports": { ".": { "import": { - "types": "./dist/types/index.d.ts", - "default": "./dist/core.es.js" + "types": "./dist/index.d.ts", + "default": "./dist/index.js" }, "require": { - "types": "./dist/types/index.d.cts", - "default": "./dist/core.cjs" + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" } - } + }, + "./package.json": "./package.json" }, "files": [ "dist", @@ -55,7 +57,6 @@ "publishConfig": { "access": "public" }, - "type": "module", "engines": { "node": ">=18" }, diff --git a/packages/core/src/app/comparator.ts b/packages/core/src/app/comparator.ts deleted file mode 100644 index 1496584..0000000 --- a/packages/core/src/app/comparator.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { NodeOptions } from '../type' - -/** @deprecated Internal; removed in 2.0. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -export function areNodeOptionsMapEqual( - mapA: Map, - mapB: Map -): boolean { - if (mapA.size !== mapB.size) return false - - for (const [key, valA] of mapA.entries()) { - const valB = mapB.get(key) - if (!valB) return false - - if (!areNodeOptionsEqual(valA, valB)) { - return false - } - } - - return true -} - -/** @deprecated Internal; removed in 2.0. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -export function areNodeOptionsEqual(a: NodeOptions, b: NodeOptions): boolean { - if (a.typNode !== b.typNode) return false - if (a.nodName !== b.nodName) return false - if (a.uidNode !== b.uidNode) return false - if (a.nodPart !== b.nodPart) return false - if (a.nodFold !== b.nodFold) return false - if (a.nodMaxd !== b.nodMaxd) return false - if (a.nodHidden !== b.nodHidden) return false - if (a.nodLocked !== b.nodLocked) return false - - const dimA = a.nodDims - const dimB = b.nodDims - - return ( - dimA.x === dimB.x && - dimA.y === dimB.y && - dimA.w === dimB.w && - dimA.h === dimB.h - ) -} diff --git a/packages/core/src/app/dom.ts b/packages/core/src/app/dom.ts deleted file mode 100644 index 49057b7..0000000 --- a/packages/core/src/app/dom.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { Dimension, NodeOptions } from '../type' - -/** @deprecated Replaced in 2.0 by `applyRect(element, rect)`. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -export function setElementRect(el: HTMLElement, { x, y, w, h }: Dimension) { - el.style.left = `${x}px` - el.style.top = `${y}px` - el.style.width = `${w}px` - el.style.height = `${h}px` -} - -/** - * Area of a tabset below its tab bar; never negative when squeezed. - * @deprecated Replaced in 2.0 by `getTabContentRect(tabsetRect, tabBarHeight)`. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md - */ -export function getTabBodyRect( - tabset: Dimension, - tabbarHeight: number -): Dimension { - return { - x: tabset.x, - y: tabset.y + tabbarHeight, - w: tabset.w, - h: Math.max(0, tabset.h - tabbarHeight), - } -} - -export interface TabbarPlacement { - /** Box to give the tab bar element before `rotated` is applied. */ - rect: Dimension - /** Folded tabset in a side-by-side row: the bar is drawn as a vertical strip. */ - rotated: boolean -} - -/** - * Where a tabset's tab bar goes. A folded tabset in a side-by-side row shows - * its tab bar rotated 90 degrees: an element `h` wide and `w` tall, rotated - * around its top-left corner and placed at `x + w`, covers exactly the strip. - */ -export function getTabbarPlacement( - tabset: Pick, - tabbarHeight: number -): TabbarPlacement { - const { x, y, w, h } = tabset.nodDims - // A tabset's own direction is the opposite of its row's. - const rowIsHorizontal = tabset.nodeDir === false - if (tabset.nodFold && rowIsHorizontal) { - return { rect: { x: x + w, y, w: h, h: w }, rotated: true } - } - return { rect: { x, y, w, h: tabbarHeight }, rotated: false } -} - -export function placeTabbar(el: HTMLElement, placement: TabbarPlacement) { - setElementRect(el, placement.rect) - el.style.transformOrigin = '0 0' - el.style.transform = placement.rotated ? 'rotate(90deg)' : '' -} - -export interface FrameScheduler { - /** Remember `value`; `onFlush` runs with the latest value on the next frame. */ - schedule: (value: T) => void - /** Run a pending flush now (e.g. on pointer up). */ - flush: () => void - /** Drop a pending flush (e.g. on unmount). */ - cancel: () => void -} - -/** - * Coalesces bursts of updates (pointer events can fire several times per - * frame) into at most one `onFlush` call per animation frame. - */ -export function createFrameScheduler( - onFlush: (value: T) => void -): FrameScheduler { - let pending: { value: T } | null = null - let frame: number | null = null - - const run = () => { - frame = null - const next = pending - pending = null - if (next) onFlush(next.value) - } - - return { - schedule(value) { - pending = { value } - if (frame === null) frame = requestAnimationFrame(run) - }, - flush() { - if (frame === null) return - cancelAnimationFrame(frame) - run() - }, - cancel() { - if (frame !== null) cancelAnimationFrame(frame) - frame = null - pending = null - }, - } -} diff --git a/packages/core/src/app/drop-preview.ts b/packages/core/src/app/drop-preview.ts deleted file mode 100644 index cb206cc..0000000 --- a/packages/core/src/app/drop-preview.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { Dimension } from '../type' - -export type DropArea = 'top' | 'bottom' | 'left' | 'right' | 'contain' -export type RootSide = Exclude - -/** Where a dragged tab or tabset would land, in viewport coordinates. */ -export interface DropPreview { - area: DropArea - left: number - top: number - width: number - height: number -} - -export type PreviewRect = Pick< - DOMRect, - 'left' | 'top' | 'right' | 'bottom' | 'width' | 'height' -> - -const DEFAULT_TAB_GAP = 6 - -/** - * Splits a tabset into thirds: the outer thirds drop beside it, the middle - * drops into it as a tab. - * @deprecated Replaced in 2.0 by `layout.getDropTarget()` and `layout.getDropIndicatorRect()`. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md - */ -export function getTabsetDropPreview( - rect: PreviewRect, - clientX: number, - clientY: number -): DropPreview { - const { left, top, width: w, height: h } = rect - const x = clientX - left - const y = clientY - top - - if (x < w / 3) return { area: 'left', left, top, width: w / 2, height: h } - if (x > (2 * w) / 3) - return { - area: 'right', - left: left + w / 2, - top, - width: w / 2, - height: h, - } - if (y < h / 3) return { area: 'top', left, top, width: w, height: h / 2 } - if (y > (2 * h) / 3) - return { - area: 'bottom', - left, - top: top + h / 2, - width: w, - height: h / 2, - } - - return { - area: 'contain', - left: left + w * 0.1, - top: top + h * 0.1, - width: w * 0.8, - height: h * 0.8, - } -} - -/** - * The half of the whole layout a root-edge drop would take. - * @deprecated Replaced in 2.0 by `layout.getDropTarget()` and `layout.getDropIndicatorRect()`. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md - */ -export function getRootSplitPreview( - container: Dimension, - side: RootSide -): DropPreview { - const { x, y, w, h } = container - switch (side) { - case 'left': - return { area: side, left: x, top: y, width: w / 2, height: h } - case 'right': - return { - area: side, - left: x + w / 2, - top: y, - width: w / 2, - height: h, - } - case 'top': - return { area: side, left: x, top: y, width: w, height: h / 2 } - case 'bottom': - return { - area: side, - left: x, - top: y + h / 2, - width: w, - height: h / 2, - } - } -} - -/** - * Insertion marker between the tabs of a tab bar. `index` is the tab the - * marker sits next to. Returns null when the pointer is above or below the bar. - * @deprecated Replaced in 2.0 by `layout.getDropTarget()` and `layout.getDropIndicatorRect()`. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md - */ -export function getNavbarDropPreview( - navbarRect: PreviewRect, - tabRects: PreviewRect[], - clientX: number, - clientY: number -): (DropPreview & { index: number }) | null { - if (tabRects.length === 0) return null - if (clientY < navbarRect.top || clientY > navbarRect.bottom) return null - - const first = tabRects[0] - const last = tabRects[tabRects.length - 1] - let gap = DEFAULT_TAB_GAP - - if (clientX > last.right) { - return { - index: tabRects.length - 1, - area: 'right', - left: last.right + 1, - top: last.top, - width: gap - 2, - height: last.height, - } - } - - if (clientX < first.left) { - return { - index: 0, - area: 'left', - left: first.left - gap + 1, - top: first.top, - width: gap - 2, - height: first.height, - } - } - - let index = 0 - let minDistance = Infinity - let isLeftSide = false - tabRects.forEach((rect, i) => { - const center = rect.left + rect.width / 2 - const distance = Math.abs(clientX - center) - if (distance < minDistance) { - minDistance = distance - index = i - isLeftSide = clientX < center - } - }) - - const hasNeighbour = isLeftSide ? index > 0 : index < tabRects.length - 1 - if (tabRects.length > 1 && hasNeighbour) - gap = tabRects[1].left - first.right - - const rect = tabRects[index] - return { - index, - area: isLeftSide ? 'left' : 'right', - left: isLeftSide ? rect.left - gap + 1 : rect.right + 1, - top: rect.top, - width: gap - 2, - height: rect.height, - } -} - -/** @deprecated Replaced in 2.0 by `layout.getDropTarget()` and `layout.getDropIndicatorRect()`. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -export function isSameDropPreview( - a: Partial, - b: DropPreview -): boolean { - return ( - a.area === b.area && - a.left === b.left && - a.top === b.top && - a.width === b.width && - a.height === b.height - ) -} diff --git a/packages/core/src/app/dynamix.ts b/packages/core/src/app/dynamix.ts deleted file mode 100644 index 7cfe551..0000000 --- a/packages/core/src/app/dynamix.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { Queue } from './queue' -import { createReactiveState } from './reactive-state' -import { areNodeOptionsMapEqual } from './comparator' -import { Node, Bond } from './node' -import { layoutState, defaultCreateId, defaultTimer } from './state' -import type { Timer } from './state' -import type { - Dimension, - LayoutTree, - NodeOptions, - RootAdjustment, - ReactiveValue, - TabsIds, -} from '../type' -import * as treeBuilder from './tree-builder' -import * as geometry from './geometry' -import * as slider from './slider' -import * as treeMutations from './tree-mutations' -import * as viewState from './view-state' - -/** @deprecated Replaced in 2.0 by `createLayout(options)`, which returns an independent instance (no static state). See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -class Layout { - static get _root(): Node { - return layoutState.root - } - - static set _root(value: Node) { - layoutState.root = value - } - - static get _tree(): LayoutTree | null { - return layoutState.tree - } - - static set _tree(value: LayoutTree | null) { - layoutState.tree = value - } - - static get _minW(): number { - return layoutState.minW - } - - static set _minW(value: number) { - layoutState.minW = value - } - - static get _minH(): number { - return layoutState.minH - } - - static set _minH(value: number) { - layoutState.minH = value - } - - static get _bond(): number { - return layoutState.bond - } - - static set _bond(value: number) { - layoutState.bond = value - } - - static get _inst(): Layout | null { - return layoutState.inst - } - - static set _inst(value: Layout | null) { - layoutState.inst = value - } - - nodeOps: ReactiveValue> - cntdown: ReturnType | null = null - tabsIds: TabsIds = new Map() - constructor( - options: { - tabs?: string[] - tree?: LayoutTree | null - minW?: number - minH?: number - bond?: number - uqid?: string - tabsIds?: TabsIds - /** Generates ids for new nodes and bonds (default `crypto.randomUUID`). */ - createId?: () => string - /** Schedules deferred updates (default `setTimeout`/`clearTimeout`). */ - timer?: Timer - /** Size of a folded tabset along its row; pass the tab bar height. */ - collapsedSize?: number - } = {} - ) { - const config = { - tabs: [], - tree: null, - minW: 40, - minH: 40, - bond: 10, - uqid: 'dynamix-layout-root', - tabsIds: new Map(), - ...options, - } - - this.tabsIds = config.tabsIds - layoutState.createId = config.createId ?? defaultCreateId - layoutState.timer = config.timer ?? defaultTimer - layoutState.collapsedSize = config.collapsedSize ?? config.minH - - // Reset on every construction so a tabs-only layout never reuses the - // tree of a previously created layout. - Layout._tree = config.tree ?? null - layoutState.maximized = config.tree?.nodMaxd ?? null - - Layout._root = new Node({ - unId: config.uqid, - name: config.uqid, - type: 'row', - }) - - this.nodeOps = createReactiveState( - new Map(), - areNodeOptionsMapEqual - ) - - Layout._minW = config.minW - Layout._minH = config.minH - Layout._bond = config.bond - - const tabsQueue = new Queue(config.tabs.length, config.tabs) - if (Layout._tree) this.createNodeLayout() - else this.createBinaryNodeTreeFromQueue(tabsQueue) - - Layout._inst = this - } - - *JSONLayoutIterator(root: LayoutTree): IterableIterator { - yield* treeBuilder.JSONLayoutIterator(root) - } - - *NodeLayoutIterator(root: Node): IterableIterator { - yield* treeBuilder.NodeLayoutIterator(root) - } - - *NodeLayoutRecursiveIterator(root: Node): IterableIterator { - yield* treeBuilder.NodeLayoutRecursiveIterator(this, root) - } - - updateChildDirections(parent: Node) { - return treeBuilder.updateChildDirections(this, parent) - } - - createNodeLayout(root: LayoutTree | null = Layout._tree) { - return treeBuilder.createNodeLayout(this, root) - } - - createNodeTreeFromJSONTree(json: LayoutTree, host: Node) { - return treeBuilder.createNodeTreeFromJSONTree(this, json, host) - } - - createBinaryNodeTreeFromQueue( - queue: Queue, - host: Node = Layout._root - ) { - return treeBuilder.createBinaryNodeTreeFromQueue(this, queue, host) - } - - createNodeFromJSON(json: LayoutTree): Node { - return treeBuilder.createNodeFromJSON(this, json) - } - - calcTabsetCountAndMinDim(root: Node = Layout._root, clear: boolean = true) { - return geometry.calcTabsetCountAndMinDim(this, root, clear) - } - - pruneStaleDirections() { - return geometry.pruneStaleDirections() - } - - calculateRootAdjustment(): RootAdjustment { - return geometry.calculateRootAdjustment() - } - - calcDimensions(root: Node = Layout._root, supress: boolean = false) { - return geometry.calcDimensions(this, root, supress) - } - - updateDimension( - dim: Dimension, - disableTimeout: boolean = false, - timeout: number = 2 - ) { - return geometry.updateDimension(this, dim, disableTimeout, timeout) - } - - updateSlider( - id: string, - dim: { x: number; y: number }, - disableTimeout: boolean = false, - timeout: number = 2 - ) { - return slider.updateSlider(this, id, dim, disableTimeout, timeout) - } - - updateSliderDimension(id: string, dim: { x: number; y: number }) { - return slider.updateSliderDimension(this, id, dim) - } - - updateTree( - src: string, - des: string, - layout: 'top' | 'bottom' | 'left' | 'right' | 'contain' - ): boolean { - return treeMutations.updateTree(this, src, des, layout) - } - - isOnlyContent(node: Node): boolean { - return treeMutations.isOnlyContent(this, node) - } - - moveNodeAsTab(src: Node, des: Node, layout: 'left' | 'right' | 'contain') { - return treeMutations.moveNodeAsTab(this, src, des, layout) - } - - moveRelativeToRoot( - src: Node, - des: Node, - layout: 'top' | 'bottom' | 'left' | 'right' - ) { - return treeMutations.moveRelativeToRoot(this, src, des, layout) - } - - moveRelativeToTabset( - src: Node, - des: Node, - layout: 'top' | 'bottom' | 'left' | 'right' - ) { - return treeMutations.moveRelativeToTabset(this, src, des, layout) - } - - insertNode(des: Node, src: Node, flag: boolean) { - return treeMutations.insertNode(this, des, src, flag) - } - - insertNodeWithNewParent(src: Node, des: Node, flag: boolean) { - return treeMutations.insertNodeWithNewParent(this, src, des, flag) - } - - moveAdjacentNodeToGrandParent(src: Node) { - return treeMutations.moveAdjacentNodeToGrandParent(this, src) - } - - removeRelation(src: Node, fix: boolean = true) { - return treeMutations.removeRelation(src, fix) - } - - removeNode(src: Node) { - return treeMutations.removeNode(this, src) - } - - removeKid(kid: Node) { - return treeMutations.removeKid(kid) - } - - clearAllCache() { - return treeMutations.clearAllCache() - } - - /** Uid of the maximized tabset, or null. */ - get maximizedId(): string | null { - return viewState.getMaximizedTabset()?.unId ?? null - } - - /** Show one tabset over the whole layout; the others stay mounted. */ - maximize(id: string): boolean { - return viewState.maximize(this, id) - } - - /** Leave maximized mode; the previous split is restored exactly. */ - restore(): boolean { - return viewState.restore(this) - } - - toggleMaximize(id: string): boolean { - return viewState.toggleMaximize(this, id) - } - - /** Fold a tabset to a strip along its row (its tab bar). */ - collapse(id: string): boolean { - return viewState.collapse(this, id) - } - - /** Unfold a tabset; it returns to its previous size. */ - expand(id: string): boolean { - return viewState.expand(this, id) - } - - toggleCollapse(id: string): boolean { - return viewState.toggleCollapse(this, id) - } -} - -export { Node, Bond } -export { Layout as DynamixLayoutCore } diff --git a/packages/core/src/app/geometry.ts b/packages/core/src/app/geometry.ts deleted file mode 100644 index b10b07e..0000000 --- a/packages/core/src/app/geometry.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { Node } from './node' -import { layoutState } from './state' -import { - canCollapse, - canMaximize, - getMaximizedTabset, - normalizeViewState, -} from './view-state' -import type { - Dimension, - NodeOptions, - RootAdjustment, - ReactiveValue, -} from '../type' -import type { DynamixLayoutCore as Layout } from './dynamix' - -/** Minimum sizes and the position/size of every tabset, tab and bond. */ - -export function calcTabsetCountAndMinDim( - engine: Layout, - root: Node = layoutState.root, - clear: boolean = true -) { - if (root === layoutState.root && clear) { - normalizeViewState(root) - Node.cache.tabCnts.clear() - Node.cache.dimMins.clear() - Node.cache.mapElem.clear() - Node.cache.nodOpts.get().clear() - Node.cache.bndOpts.get().clear() - } - - for (const node of engine.NodeLayoutRecursiveIterator(root)) { - const tabCnts = Node.cache.tabCnts - const dimMins = Node.cache.dimMins - - Node.cache.mapElem.set(node.unId, node) - - const hostCnt = tabCnts - .set(node.unId, { - horizontal: 0, - vertical: 0, - }) - .get(node.unId) - - const hostDim = dimMins - .set(node.unId, { - minWidth: 0, - minHeight: 0, - }) - .get(node.unId) - - const nodeDir = Node.cache.mapDirs.get(node.unId) - - if (node.type === 'tab') { - Node.cache.mapElem.set(node.unId, node) - dimMins.set(node.unId, { - minWidth: 0, - minHeight: 0, - }) - } - - if (node.next) Node.cache.mapElem.set(node.next.unId, node.next) - - if (node.type === 'tabset') { - Node.cache.mapElem.set(node.unId, node) - dimMins.set(node.unId, { - minWidth: layoutState.minW, - minHeight: layoutState.minH, - }) - - if (nodeDir) { - tabCnts.set(node.unId, { - horizontal: 0, - vertical: 1, - }) - } else { - tabCnts.set(node.unId, { - horizontal: 1, - vertical: 0, - }) - } - - // A folded tabset is only its tab bar along the parent row. - if (node.collapsed && node.host) { - const rowIsHorizontal = Node.cache.mapDirs.get(node.host.unId) - if (rowIsHorizontal) { - dimMins.set(node.unId, { - minWidth: layoutState.collapsedSize, - minHeight: layoutState.minH, - }) - tabCnts.get(node.unId)!.horizontal = 0 - } else { - dimMins.set(node.unId, { - minWidth: layoutState.minW, - minHeight: layoutState.collapsedSize, - }) - tabCnts.get(node.unId)!.vertical = 0 - } - } - } - - if (node.type === 'row') { - for (const child of node.kids) { - const kidCnt = tabCnts.get(child.unId) - const kidDim = dimMins.get(child.unId) - - if (!kidCnt) continue - hostCnt!.horizontal += kidCnt!.horizontal - hostCnt!.vertical += kidCnt!.vertical - - if (nodeDir) { - hostDim!.minHeight = Math.max( - hostDim!.minHeight, - kidDim!.minHeight! - ) - hostDim!.minWidth += kidDim!.minWidth! - } else { - hostDim!.minHeight += kidDim!.minHeight! - hostDim!.minWidth = Math.max( - hostDim!.minWidth, - kidDim!.minWidth! - ) - } - } - - if (nodeDir) { - hostDim!.minWidth += (node.kids.size() - 1) * layoutState.bond - } else { - hostDim!.minHeight += (node.kids.size() - 1) * layoutState.bond - } - } - } - - if (root === layoutState.root && clear) engine.pruneStaleDirections() -} - -export function pruneStaleDirections() { - for (const id of Node.cache.mapDirs.keys()) { - if (!Node.cache.mapElem.has(id)) Node.cache.mapDirs.delete(id) - } -} - -export function calculateRootAdjustment(): RootAdjustment { - const reqDims = layoutState.root.getReqDimension() - - let wasAdjusted = false - let adjustedW = layoutState.root.dims.w - let adjustedH = layoutState.root.dims.h - - if (layoutState.root.dims.w < reqDims.width) { - adjustedW = reqDims.width - wasAdjusted = true - } - if (layoutState.root.dims.h < reqDims.height) { - adjustedH = reqDims.height - wasAdjusted = true - } - - if (wasAdjusted) { - layoutState.root.dims.w = adjustedW - layoutState.root.dims.h = adjustedH - } - - return { - adjustedW, - adjustedH, - wasAdjusted, - } -} - -export function calcDimensions( - engine: Layout, - root: Node = layoutState.root, - supress: boolean = false -) { - const isRoot = root === layoutState.root - - // A subtree pass (e.g. slider drag) only recomputes part of the tree, so - // it starts from the current maps to keep entries outside the subtree. - const seed = (current: ReactiveValue>) => - isRoot ? new Map() : new Map(current.get()) - - const nodeOpts = seed(Node.cache.nodOpts) - const bondOpts = seed(Node.cache.bndOpts) - const tabsIds = seed(Node.cache.tabOpts) - - if (isRoot) engine.calculateRootAdjustment() - - // While a tabset is maximized it takes the whole layout and everything - // else is hidden. Node dims are still computed normally, so restoring is - // exact. - const maximized = getMaximizedTabset() - const maximizable = canMaximize() - - for (const node of engine.NodeLayoutIterator(root)) { - node.calcDimensions() - - const nodeDir = Node.cache.mapDirs.get(node.unId)! - - if (node.type === 'tabset') { - const isMaximized = node === maximized - const hidden = !!maximized && !isMaximized - const dims = isMaximized - ? { ...layoutState.root.dims } - : { ...node.dims } - - const nodeOption: NodeOptions = { - typNode: node.type, - nodName: node.name, - uidNode: node.unId, - nodPart: node.part, - nodOpen: node.open, - nodeDir: nodeDir, - nodFold: node.collapsed && !isMaximized, - nodMaxd: isMaximized, - nodHidden: hidden, - nodFoldable: canCollapse(node), - nodMaximizable: maximizable, - - nodKids: Array.from(node.kids).map((kid) => { - const KidNode: NodeOptions = { - typNode: kid.type, - nodName: kid.name, - uidNode: kid.unId, - nodPart: kid.part, - nodOpen: node.open === kid.name ? true : false, - nodeDir: !nodeDir, - nodHidden: hidden, - nodFold: node.collapsed && !isMaximized, - nodDims: { ...dims }, - } - tabsIds.set(kid.unId, KidNode) - return KidNode - }), - nodDims: dims, - } - nodeOpts.set(node.unId, nodeOption) - } - - if (node.next && node.type !== 'tab') { - const bondOption: NodeOptions = { - typNode: 'bond', - nodName: node.name, - uidNode: node.next.unId, - nodeDir: nodeDir, - nodPart: 0, - nodHidden: !!maximized, - nodLocked: node.collapsed || !!node.next.next?.collapsed, - nodDims: { - ...node.next.dims, - }, - } - - bondOpts.set(node.next.unId, bondOption) - } - } - Node.cache.nodOpts.set(nodeOpts, supress) - Node.cache.bndOpts.set(bondOpts, supress) - Node.cache.tabOpts.set(tabsIds, supress) -} - -export function updateDimension( - engine: Layout, - dim: Dimension, - disableTimeout: boolean = false, - timeout: number = 2 -) { - layoutState.root.dims = dim - - if (disableTimeout) { - engine.calcDimensions(layoutState.root) - return - } - - if (engine.cntdown) { - layoutState.timer.clear(engine.cntdown) - engine.cntdown = null - } - - engine.cntdown = layoutState.timer.set(() => { - engine.cntdown = null - engine.calcDimensions() - }, timeout) -} diff --git a/packages/core/src/app/index.ts b/packages/core/src/app/index.ts deleted file mode 100644 index 3f203e1..0000000 --- a/packages/core/src/app/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './dynamix' -export * from './comparator' -export * from './queue' -export * from './reactive-state' -export * from './drop-preview' -export * from './dom' diff --git a/packages/core/src/app/node.ts b/packages/core/src/app/node.ts deleted file mode 100644 index 159bb9f..0000000 --- a/packages/core/src/app/node.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { Queue } from './queue' -import { createReactiveState } from './reactive-state' -import { areNodeOptionsMapEqual } from './comparator' -import { layoutState } from './state' -import type { Dimension, LayoutTree, NodeCache, NodeOptions } from '../type' - -/** @deprecated Internal; removed in 2.0. Read layout state from `layout.getSnapshot()` / `subscribe()`. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -export class Node { - static cache: NodeCache = { - dimMins: new Map(), - mapDirs: new Map(), - tabCnts: new Map(), - mapElem: new Map(), - tabOpts: createReactiveState( - new Map(), - areNodeOptionsMapEqual - ), - nodOpts: createReactiveState( - new Map(), - areNodeOptionsMapEqual - ), - bndOpts: createReactiveState( - new Map(), - areNodeOptionsMapEqual - ), - } - - kids: Queue = new Queue() - dims: Dimension = { w: 0, h: 0, x: 0, y: 0 } - unId: string - next: Bond | null = null - prev: Bond | null = null - type: 'row' | 'tabset' | 'tab' | 'bond' = 'row' - name: string = '' - part: number = 0 - host: Node | null = null - open: string = '' - /** Tabset only: folded to a strip along its parent row. */ - collapsed: boolean = false - /** Fold order, used to pick which sibling to unfold. */ - foldedAt: number = 0 - - constructor( - options: { - type?: 'row' | 'tabset' | 'tab' | 'bond' - host?: Node | null - name?: string - part?: number - dims?: Dimension - unId?: string - open?: string - } = {} - ) { - const defaults = { - type: 'row' as 'row' | 'tabset' | 'tab' | 'bond', - host: null, - name: '', - part: 100, - dims: { w: 0, h: 0, x: 0, y: 0 }, - unId: layoutState.createId(), - open: '', - } - - this.type = options.type ?? defaults.type - this.host = options.host ?? defaults.host - this.name = options.name ?? defaults.name - this.part = options.part ?? defaults.part - this.dims = options.dims ?? defaults.dims - this.open = options.open ?? defaults.open - - this.unId = options.unId || defaults.unId - } - - addBond(parent: Node | null, prev: Node | null = null) { - if (!prev) return - - const bond = new Bond(parent, this, prev) - Node.cache.mapElem.set(bond.unId, bond) - } - - getReqDimension(): { width: number; height: number } { - const minDims = Node.cache.dimMins.get(this.unId) - const tabCnts = Node.cache.tabCnts.get(this.unId) - - const reqW = Math.max( - minDims!.minWidth, - tabCnts!.horizontal * layoutState.minW + - Math.max(0, tabCnts!.horizontal - 1) * layoutState.bond - ) - - const reqH = Math.max( - minDims!.minHeight, - tabCnts!.vertical * layoutState.minH + - Math.max(0, tabCnts!.vertical - 1) * layoutState.bond - ) - - return { - width: reqW, - height: reqH, - } - } - - calcDimensions() { - const nodeDir = Node.cache.mapDirs.get(this.unId) - - if (!this.kids) return - - let totalWeight = 0 - const totlMinSize = Node.cache.dimMins.get(this.unId) || { - minWidth: 0, - minHeight: 0, - } - let curntOffset = 0 - - // Folded tabsets keep their strip size; only open kids share the - // extra space. Their parts are left untouched so unfolding restores - // the previous split. - let lastFlexKid: Node | undefined - for (const kid of this.kids) { - if (kid.collapsed) continue - totalWeight += kid.part - lastFlexKid = kid - } - - if (totalWeight === 0) return - - let extraSpace - if (nodeDir) { - extraSpace = this.dims.w - totlMinSize.minWidth - } else { - extraSpace = this.dims.h - totlMinSize.minHeight - } - - const isBelowMinSize = extraSpace < 0 - - // Round cumulative boundaries instead of individual sizes. Each edge - // stays within half a pixel of its exact position, moves at most one - // pixel per pixel of resize, and edges outside a dragged pair never move - // (their cumulative part is unchanged). - let cumWeight = 0 - let prevBoundary = 0 - - for (const kid of this.kids) { - const kidDims = Node.cache.dimMins.get(kid.unId) || { - minWidth: 0, - minHeight: 0, - } - - let extraChildSpace = 0 - if (!isBelowMinSize && !kid.collapsed) { - cumWeight += kid.part - const boundary = - kid === lastFlexKid - ? extraSpace - : Math.round((extraSpace * cumWeight) / totalWeight) - extraChildSpace = boundary - prevBoundary - prevBoundary = boundary - } - - if (nodeDir) { - kid.dims.w = kidDims.minWidth + extraChildSpace - kid.dims.h = this.dims.h - kid.dims.x = this.dims.x + curntOffset - kid.dims.y = this.dims.y - curntOffset += kid.dims.w + layoutState.bond - } else { - kid.dims.h = kidDims.minHeight + extraChildSpace - kid.dims.w = this.dims.w - kid.dims.y = this.dims.y + curntOffset - kid.dims.x = this.dims.x - curntOffset += kid.dims.h + layoutState.bond - } - - if (kid.next) { - if (nodeDir) { - kid.next!.dims = { - w: layoutState.bond, - h: kid.dims.h, - x: kid.dims.x + kid.dims.w, - y: kid.dims.y, - } - } else { - kid.next!.dims = { - w: kid.dims.w, - h: layoutState.bond, - x: kid.dims.x, - y: kid.dims.y + kid.dims.h, - } - } - } - } - } - - toJSON(): LayoutTree { - const children: LayoutTree[] = [] - - for (const child of this.kids) { - children.push(child.toJSON()) - } - - const result: LayoutTree = { - typNode: this.type, - nodPart: this.part, - nodName: this.name, - uidNode: this.unId, - } - - if (this.open) { - result.nodOpen = this.open - } - - if (this.collapsed) { - result.nodFold = true - } - - if (this === layoutState.root && layoutState.maximized) { - result.nodMaxd = layoutState.maximized - } - - if (children.length > 0) { - result.nodKids = children - } - - return result - } -} - -/** @deprecated Internal; removed in 2.0. Splitters are `snapshot.splitters`. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -export class Bond { - dims: Dimension = { w: 0, h: 0, x: 0, y: 0 } - unId: string = layoutState.createId() - next: Node | null = null - prev: Node | null = null - host: Node | null = null - - constructor( - parent: Node | null = null, - next: Node | null = null, - prev: Node | null = null - ) { - this.host = parent - this.next = next - this.prev = prev - if (this.next) this.next.prev = this - if (this.prev) this.prev.next = this - } - - toJSON(): LayoutTree { - return { - nodName: this.host?.name || '', - typNode: 'bond', - uidNode: this.unId, - nodPart: 0, - nodOpen: '', - nodKids: [], - } - } -} diff --git a/packages/core/src/app/queue.ts b/packages/core/src/app/queue.ts deleted file mode 100644 index c0e7246..0000000 --- a/packages/core/src/app/queue.ts +++ /dev/null @@ -1,703 +0,0 @@ -/** @deprecated Internal; removed in 2.0. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -class Queue { - private buffer: (T | undefined)[] - private head: number = 0 - private tail: number = 0 - private count: number = 0 - private capacity: number - - constructor(initialCapacity: number = 16, array: T[] = []) { - this.capacity = Math.max(initialCapacity, array.length) - this.buffer = new Array(this.capacity) - - array.forEach((element) => { - this.enqueue(element) - }) - } - - // ==================== INSERTION OPERATIONS ==================== - - /** - * Add element to the back of the queue - O(1) amortized - * Used for FIFO and FILO patterns - */ - enqueue(item: T): void { - if (this.count === this.capacity) { - this.resize() - } - - this.buffer[this.tail] = item - this.tail = (this.tail + 1) % this.capacity - this.count++ - } - - /** - * Add element to the front of the queue - O(1) amortized - * Used for LIFO and LILO patterns - */ - enqueueFront(item: T): void { - if (this.count === this.capacity) { - this.resize() - } - - this.head = (this.head - 1 + this.capacity) % this.capacity - this.buffer[this.head] = item - this.count++ - } - - /** - * Insert element at specific logical index - O(n) - * All elements at and after the index are shifted right - * @param index - Logical index (0 = front of queue) - * @param item - Element to insert - * @returns true if insertion successful, false if index out of bounds - */ - insert(index: number, item: T): boolean { - // Validate index bounds - if (index < 0 || index > this.count) { - return false - } - - // Handle edge cases with existing O(1) operations - if (index === 0) { - this.enqueueFront(item) - return true - } - if (index === this.count) { - this.enqueue(item) - return true - } - - // Ensure we have capacity - if (this.count === this.capacity) { - this.resize() - } - - // Determine which direction to shift (minimize operations) - const distanceFromHead = index - const distanceFromTail = this.count - index - - if (distanceFromHead <= distanceFromTail) { - // Shift elements towards head (left shift) - this.shiftLeft(index, item) - } else { - // Shift elements towards tail (right shift) - this.shiftRight(index, item) - } - - this.count++ - return true - } - - /** - * Insert array of elements at specific logical index - O(n + m) where n is elements to shift, m is array length - * All elements at and after the index are shifted right to make room for the entire array - * @param index - Logical index (0 = front of queue) - * @param items - Array of elements to insert - * @returns true if insertion successful, false if index out of bounds - */ - insertArray(index: number, items: T[]): boolean { - // Validate index bounds - if (index < 0 || index > this.count) { - return false - } - - // Handle empty array case - if (items.length === 0) { - return true - } - - // Handle edge cases - append/prepend entire array - if (index === 0) { - // Insert at beginning - add all items to front in reverse order - for (let i = items.length - 1; i >= 0; i--) { - this.enqueueFront(items[i]) - } - return true - } - if (index === this.count) { - // Insert at end - add all items to back - items.forEach((item) => this.enqueue(item)) - return true - } - - // Ensure we have enough capacity - while (this.count + items.length > this.capacity) { - this.resize() - } - - // Determine which direction to shift (minimize operations) - const distanceFromHead = index - const distanceFromTail = this.count - index - const insertLength = items.length - - if (distanceFromHead <= distanceFromTail) { - // Shift elements towards head (left shift) - this.shiftLeftArray(index, items) - } else { - // Shift elements towards tail (right shift) - this.shiftRightArray(index, items) - } - - this.count += insertLength - return true - } - - /** - * Insert another queue at specific logical index - O(n + m) where n is elements to shift, m is queue size - * All elements at and after the index are shifted right to make room for the entire queue - * @param index - Logical index (0 = front of queue) - * @param queue - Queue to insert - * @returns true if insertion successful, false if index out of bounds - */ - insertQueue(index: number, queue: Queue): boolean { - // Validate index bounds - if (index < 0 || index > this.count) { - return false - } - - // Handle empty queue case - if (queue.isEmpty()) { - return true - } - - const queueSize = queue.size() - - // Handle edge cases - append/prepend entire queue - if (index === 0) { - // Insert at beginning - add all items to front in reverse order - // Use reverse iterator to maintain order - const items = queue.toArray() - for (let i = items.length - 1; i >= 0; i--) { - this.enqueueFront(items[i]) - } - return true - } - if (index === this.count) { - // Insert at end - add all items to back - for (const item of queue) { - this.enqueue(item) - } - return true - } - - // Ensure we have enough capacity - while (this.count + queueSize > this.capacity) { - this.resize() - } - - // Convert queue to array for efficient insertion - const queueItems = queue.toArray() - - // Determine which direction to shift (minimize operations) - const distanceFromHead = index - const distanceFromTail = this.count - index - - if (distanceFromHead <= distanceFromTail) { - // Shift elements towards head (left shift) - this.shiftLeftArray(index, queueItems) - } else { - // Shift elements towards tail (right shift) - this.shiftRightArray(index, queueItems) - } - - this.count += queueSize - return true - } - - /** - * Alias for enqueue (add to back) - */ - push(item: T): void { - this.enqueue(item) - } - - /** - * Alias for enqueueFront (add to front) - */ - unshift(item: T): void { - this.enqueueFront(item) - } - - // ==================== REMOVAL OPERATIONS ==================== - - /** - * Remove and return element from front of queue - O(1) - * Used for FIFO and LIFO patterns - */ - dequeue(): T | undefined { - if (this.count === 0) { - return undefined - } - - const item = this.buffer[this.head] - // Don't store undefined - just move the head pointer - this.head = (this.head + 1) % this.capacity - this.count-- - - return item - } - - /** - * Remove and return element from back of queue - O(1) - * Used for FILO and LILO patterns - */ - dequeueBack(): T | undefined { - if (this.count === 0) { - return undefined - } - - this.tail = (this.tail - 1 + this.capacity) % this.capacity - const item = this.buffer[this.tail] - // Don't store undefined - the tail pointer now excludes this position - this.count-- - - return item - } - - indexOf(item: T): number { - for (let i = 0; i < this.count; i++) { - const actualIndex = (this.head + i) % this.capacity - if (this.buffer[actualIndex] === item) { - return i // Return logical index - } - } - return -1 // Not found - } - - remove(item: T): boolean { - const index = this.indexOf(item) - if (index === -1) return false - this.removeAt(index) - return true - } - - /** - * Remove element at specific logical index - O(n) - * All elements after the index are shifted left - * @param index - Logical index (0 = front of queue) - * @returns The removed element, or undefined if index out of bounds - */ - removeAt(index: number): T | undefined { - // Validate index bounds - if (index < 0 || index >= this.count) { - return undefined - } - - // Handle edge cases with existing O(1) operations - if (index === 0) { - return this.dequeue() - } - if (index === this.count - 1) { - return this.dequeueBack() - } - - // Get the element to remove - const actualIndex = (this.head + index) % this.capacity - const removedItem = this.buffer[actualIndex] - - // Determine which direction to shift (minimize operations) - const distanceFromHead = index - const distanceFromTail = this.count - index - 1 - - if (distanceFromHead <= distanceFromTail) { - // Shift elements from head towards the gap (right shift) - this.shiftRightForRemoval(index) - } else { - // Shift elements from tail towards the gap (left shift) - this.shiftLeftForRemoval(index) - } - - this.count-- - return removedItem - } - - /** - * Alias for dequeue (remove from front) - */ - shift(): T | undefined { - return this.dequeue() - } - - /** - * Alias for dequeueBack (remove from back) - */ - pop(): T | undefined { - return this.dequeueBack() - } - - // ==================== PATTERN-SPECIFIC METHODS ==================== - - /** - * FIFO: First In, First Out - * Add to back, remove from front - */ - fifoAdd(item: T): void { - this.enqueue(item) - } - - fifoRemove(): T | undefined { - return this.dequeue() - } - - /** - * FILO: First In, Last Out - * Add to back, remove from back - */ - filoAdd(item: T): void { - this.enqueue(item) - } - - filoRemove(): T | undefined { - return this.dequeueBack() - } - - /** - * LIFO: Last In, First Out - * Add to front, remove from front - */ - lifoAdd(item: T): void { - this.enqueueFront(item) - } - - lifoRemove(): T | undefined { - return this.dequeue() - } - - /** - * LILO: Last In, Last Out - * Add to front, remove from back - */ - liloAdd(item: T): void { - this.enqueueFront(item) - } - - liloRemove(): T | undefined { - return this.dequeueBack() - } - - // ==================== ACCESS OPERATIONS ==================== - - /** - * Get element at logical index (0 = front of queue) - O(1) - */ - get(index: number): T | undefined { - if (index < 0 || index >= this.count) { - return undefined - } - - const actualIndex = (this.head + index) % this.capacity - return this.buffer[actualIndex] - } - - /** - * Set element at logical index - O(1) - */ - set(index: number, item: T): boolean { - if (index < 0 || index >= this.count) { - return false - } - - const actualIndex = (this.head + index) % this.capacity - this.buffer[actualIndex] = item - return true - } - - /** - * Get the front element without removing it - O(1) - */ - peek(): T | undefined { - return this.count > 0 ? this.buffer[this.head] : undefined - } - - /** - * Get the back element without removing it - O(1) - */ - peekBack(): T | undefined { - if (this.count === 0) return undefined - const backIndex = (this.tail - 1 + this.capacity) % this.capacity - return this.buffer[backIndex] - } - - /** - * Alias for peek (front element) - */ - front(): T | undefined { - return this.peek() - } - - /** - * Alias for peekBack (back element) - */ - back(): T | undefined { - return this.peekBack() - } - - // ==================== UTILITY METHODS ==================== - - /** - * Get current size of queue - O(1) - */ - size(): number { - return this.count - } - - /** - * Get current capacity - O(1) - */ - getCapacity(): number { - return this.capacity - } - - /** - * Check if queue is empty - O(1) - */ - isEmpty(): boolean { - return this.count === 0 - } - - /** - * Check if queue is full - O(1) - */ - isFull(): boolean { - return this.count === this.capacity - } - - /** - * Clear all elements from queue - O(1) - */ - clear(): void { - // Don't create new array, just reset pointers - this.head = 0 - this.tail = 0 - this.count = 0 - } - - /** - * Convert queue to array (maintains order from front to back) - O(n) - */ - toArray(): T[] { - const result: T[] = [] - for (let i = 0; i < this.count; i++) { - const actualIndex = (this.head + i) % this.capacity - result.push(this.buffer[actualIndex] as T) - } - return result - } - - /** - * Forward iterator support for for...of loops - O(n) total - * Iterates from front to back - */ - *[Symbol.iterator](): IterableIterator { - for (let i = 0; i < this.count; i++) { - const actualIndex = (this.head + i) % this.capacity - yield this.buffer[actualIndex] as T - } - } - - /** - * Forward iterator (same as Symbol.iterator) - O(n) total - * Iterates from front to back - */ - *forward(): IterableIterator { - for (let i = 0; i < this.count; i++) { - const actualIndex = (this.head + i) % this.capacity - yield this.buffer[actualIndex] as T - } - } - - /** - * Reverse iterator - O(n) total - * Iterates from back to front - */ - *reverse(): IterableIterator { - for (let i = this.count - 1; i >= 0; i--) { - const actualIndex = (this.head + i) % this.capacity - yield this.buffer[actualIndex] as T - } - } - - /** - * Iterator with index information - O(n) total - * Returns [index, value] pairs from front to back - */ - *entries(): IterableIterator<[number, T]> { - for (let i = 0; i < this.count; i++) { - const actualIndex = (this.head + i) % this.capacity - yield [i, this.buffer[actualIndex] as T] - } - } - - /** - * Iterator for keys (indices) - O(n) total - */ - *keys(): IterableIterator { - for (let i = 0; i < this.count; i++) { - yield i - } - } - - /** - * Iterator for values only - O(n) total - * Same as Symbol.iterator but more explicit - */ - *values(): IterableIterator { - for (let i = 0; i < this.count; i++) { - const actualIndex = (this.head + i) % this.capacity - yield this.buffer[actualIndex] as T - } - } - - /** - * Get string representation - O(n) - */ - toString(): string { - return `Queue(${this.toArray().join(', ')})` - } - - // ==================== PRIVATE HELPER METHODS ==================== - - /** - * Private method to resize buffer when capacity is exceeded - */ - private resize(): void { - const newCapacity = this.capacity * 2 - const newBuffer = new Array(newCapacity) - - // Copy elements in correct order to new buffer - for (let i = 0; i < this.count; i++) { - const oldIndex = (this.head + i) % this.capacity - newBuffer[i] = this.buffer[oldIndex] - } - - this.buffer = newBuffer - this.capacity = newCapacity - this.head = 0 - this.tail = this.count - } - - /** - * Shift elements left (towards head) to make room for insertion - * Used when inserting closer to head - */ - private shiftLeft(insertIndex: number, item: T): void { - // Move head one position left - this.head = (this.head - 1 + this.capacity) % this.capacity - - // Shift elements from old head position to insert position - for (let i = 0; i < insertIndex; i++) { - const fromIndex = (this.head + 1 + i) % this.capacity - const toIndex = (this.head + i) % this.capacity - this.buffer[toIndex] = this.buffer[fromIndex] - } - - // Insert the new item - const actualInsertIndex = (this.head + insertIndex) % this.capacity - this.buffer[actualInsertIndex] = item - } - - /** - * Shift elements right (towards tail) to make room for insertion - * Used when inserting closer to tail - */ - private shiftRight(insertIndex: number, item: T): void { - // Shift elements from insert position to tail - for (let i = this.count - 1; i >= insertIndex; i--) { - const fromIndex = (this.head + i) % this.capacity - const toIndex = (this.head + i + 1) % this.capacity - this.buffer[toIndex] = this.buffer[fromIndex] - } - - // Insert the new item - const actualInsertIndex = (this.head + insertIndex) % this.capacity - this.buffer[actualInsertIndex] = item - - // Move tail one position right - this.tail = (this.tail + 1) % this.capacity - } - - /** - * Shift elements right (towards tail) to fill gap after removal - * Used when removing closer to head - */ - private shiftRightForRemoval(removeIndex: number): void { - // Shift elements from head to remove position - for (let i = removeIndex; i > 0; i--) { - const fromIndex = (this.head + i - 1) % this.capacity - const toIndex = (this.head + i) % this.capacity - this.buffer[toIndex] = this.buffer[fromIndex] - } - - // Move head pointer (don't store undefined) - this.head = (this.head + 1) % this.capacity - } - - /** - * Shift elements left (towards head) to fill gap after removal - * Used when removing closer to tail - */ - private shiftLeftForRemoval(removeIndex: number): void { - // Shift elements from remove position to tail - for (let i = removeIndex; i < this.count - 1; i++) { - const fromIndex = (this.head + i + 1) % this.capacity - const toIndex = (this.head + i) % this.capacity - this.buffer[toIndex] = this.buffer[fromIndex] - } - - // Move tail pointer (don't store undefined) - this.tail = (this.tail - 1 + this.capacity) % this.capacity - } - - /** - * Shift elements left (towards head) to make room for array insertion - * Used when inserting array closer to head - */ - private shiftLeftArray(insertIndex: number, items: T[]): void { - const insertLength = items.length - - // Move head left by insertLength positions - this.head = (this.head - insertLength + this.capacity) % this.capacity - - // Shift existing elements from old head position to insert position - for (let i = 0; i < insertIndex; i++) { - const fromIndex = (this.head + insertLength + i) % this.capacity - const toIndex = (this.head + i) % this.capacity - this.buffer[toIndex] = this.buffer[fromIndex] - } - - // Insert the new items - for (let i = 0; i < insertLength; i++) { - const actualInsertIndex = - (this.head + insertIndex + i) % this.capacity - this.buffer[actualInsertIndex] = items[i] - } - } - - /** - * Shift elements right (towards tail) to make room for array insertion - * Used when inserting array closer to tail - */ - private shiftRightArray(insertIndex: number, items: T[]): void { - const insertLength = items.length - - // Shift existing elements from insert position to tail, moving right - for (let i = this.count - 1; i >= insertIndex; i--) { - const fromIndex = (this.head + i) % this.capacity - const toIndex = (this.head + i + insertLength) % this.capacity - this.buffer[toIndex] = this.buffer[fromIndex] - } - - // Insert the new items - for (let i = 0; i < insertLength; i++) { - const actualInsertIndex = - (this.head + insertIndex + i) % this.capacity - this.buffer[actualInsertIndex] = items[i] - } - - // Move tail right by insertLength positions - this.tail = (this.tail + insertLength) % this.capacity - } -} - -export { Queue } diff --git a/packages/core/src/app/reactive-state.ts b/packages/core/src/app/reactive-state.ts deleted file mode 100644 index f071f55..0000000 --- a/packages/core/src/app/reactive-state.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { ReactiveValue, ChangeListener } from '../type' - -/** @deprecated Internal; removed in 2.0. Use `layout.subscribe()`. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -function createReactiveState( - initialValue: T, - comparator: (a: T, b: T) => boolean = (a, b) => a === b -): ReactiveValue { - let value = initialValue - const listeners = new Set>() - let pendingResolvers: Array<(val: T) => void> = [] - - const get = (): T => value - - const set = (newValue: T, flag: boolean = false): void => { - if (flag) { - value = newValue - return - } - - if (!comparator(value, newValue)) { - value = newValue - - listeners.forEach((cb) => cb(value)) - pendingResolvers.forEach((resolve) => resolve(value)) - pendingResolvers = [] - } - } - - const triggerChange = (): void => { - listeners.forEach((cb) => cb(value)) - pendingResolvers.forEach((resolve) => resolve(value)) - pendingResolvers = [] - } - - const onChange = (listener: ChangeListener): (() => void) => { - listeners.add(listener) - return () => listeners.delete(listener) - } - - const nextChange = (): Promise => { - return new Promise((resolve) => { - pendingResolvers.push(resolve) - }) - } - - const onChangePromise = (listener: ChangeListener): (() => void) => { - let active = true - - const loop = async () => { - while (active) { - const val = await nextChange() - if (!active) break - listener(val) - } - } - - loop() - - return () => { - active = false - } - } - - return { - get, - set, - - onChange, - nextChange, - triggerChange, - onChangePromise, - } -} - -export { createReactiveState } diff --git a/packages/core/src/app/slider.ts b/packages/core/src/app/slider.ts deleted file mode 100644 index dfc254c..0000000 --- a/packages/core/src/app/slider.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { Node, Bond } from './node' -import { layoutState } from './state' -import type { DynamixLayoutCore as Layout } from './dynamix' - -/** Moving a bond (slider) between two siblings. */ - -export function updateSlider( - engine: Layout, - id: string, - dim: { x: number; y: number }, - disableTimeout: boolean = false, - timeout: number = 2 -) { - if (disableTimeout) { - engine.updateSliderDimension(id, dim) - return - } - - if (engine.cntdown) { - layoutState.timer.clear(engine.cntdown) - engine.cntdown = null - } - - engine.cntdown = layoutState.timer.set(() => { - engine.cntdown = null - engine.updateSliderDimension(id, dim) - }, timeout) -} - -export function updateSliderDimension( - engine: Layout, - id: string, - dim: { x: number; y: number } -) { - const bnd = Node.cache.mapElem.get(id) - - if (!bnd || !(bnd instanceof Bond)) { - console.warn(`Bond with id ${id} not found or is not a Bond instance`) - return - } - - const dir = Node.cache.mapDirs.get(bnd.host!.unId) - - const prev = bnd.prev - const next = bnd.next - const host = bnd.host - - if (!prev || !next || !host) { - console.warn('Missing prev, next, or host node', prev, next, host) - return - } - - // Bonds next to a folded tabset are locked, and nothing moves while a - // tabset is maximized. - if (prev.collapsed || next.collapsed || layoutState.maximized) { - return - } - - const prevMinDim = Node.cache.dimMins.get(prev.unId) || { - minWidth: 0, - minHeight: 0, - } - const nextMinDim = Node.cache.dimMins.get(next.unId) || { - minWidth: 0, - minHeight: 0, - } - const half = layoutState.bond / 2 - - if (dir) { - const endX = next.dims.x + next.dims.w - - const minX = prev.dims.x + prevMinDim.minWidth + half - const maxX = endX - nextMinDim.minWidth - half - - const clampedX = Math.max(minX, Math.min(maxX, dim.x)) - - bnd.dims.x = clampedX - half - - prev.dims.w = bnd.dims.x - prev.dims.x - - next.dims.x = clampedX + half - next.dims.w = endX - next.dims.x - - const prevExtraSpace = Math.max(0, prev.dims.w - prevMinDim.minWidth) - const nextExtraSpace = Math.max(0, next.dims.w - nextMinDim.minWidth) - const totalExtraSpace = prevExtraSpace + nextExtraSpace - const totalPart = prev.part + next.part - - if (totalExtraSpace > 0) { - prev.part = (prevExtraSpace / totalExtraSpace) * totalPart - next.part = (nextExtraSpace / totalExtraSpace) * totalPart - } else { - prev.part = totalPart / 2 - next.part = totalPart / 2 - } - } else { - const endY = next.dims.y + next.dims.h - - const minY = prev.dims.y + prevMinDim.minHeight + half - const maxY = endY - nextMinDim.minHeight - half - - const clampedY = Math.max(minY, Math.min(maxY, dim.y)) - - bnd.dims.y = clampedY - half - - prev.dims.h = bnd.dims.y - prev.dims.y - - next.dims.y = clampedY + half - next.dims.h = endY - next.dims.y - - const prevExtraSpace = Math.max(0, prev.dims.h - prevMinDim.minHeight) - const nextExtraSpace = Math.max(0, next.dims.h - nextMinDim.minHeight) - const totalExtraSpace = prevExtraSpace + nextExtraSpace - const totalPart = prev.part + next.part - - if (totalExtraSpace > 0) { - prev.part = (prevExtraSpace / totalExtraSpace) * totalPart - next.part = (nextExtraSpace / totalExtraSpace) * totalPart - } else { - prev.part = totalPart / 2 - next.part = totalPart / 2 - } - } - - engine.calcDimensions(host) -} diff --git a/packages/core/src/app/state.ts b/packages/core/src/app/state.ts deleted file mode 100644 index dc4063f..0000000 --- a/packages/core/src/app/state.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Node } from './node' -import type { DynamixLayoutCore } from './dynamix' -import type { LayoutTree } from '../type' - -export type Timer = { - set: (callback: () => void, ms: number) => ReturnType - clear: (handle: ReturnType) => void -} - -export const defaultCreateId = (): string => crypto.randomUUID() - -export const defaultTimer: Timer = { - set: (callback, ms) => setTimeout(callback, ms), - clear: (handle) => clearTimeout(handle), -} - -/** Shared layout settings and the current root, used by every layout module. */ -export const layoutState = { - createId: defaultCreateId, - timer: defaultTimer, - root: undefined as unknown as Node, - tree: null as LayoutTree | null, - minW: 40, - minH: 40, - bond: 10, - inst: null as DynamixLayoutCore | null, - /** Uid of the maximized tabset, if any. */ - maximized: null as string | null, - /** Size of a folded tabset along its parent row (its tab bar height). */ - collapsedSize: 40, - /** Increments on every fold so the most recently folded tabset is known. */ - foldSeq: 0, -} diff --git a/packages/core/src/app/tree-builder.ts b/packages/core/src/app/tree-builder.ts deleted file mode 100644 index 0748a6c..0000000 --- a/packages/core/src/app/tree-builder.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { Queue } from './queue' -import { Node } from './node' -import { layoutState } from './state' -import type { LayoutTree } from '../type' -import type { DynamixLayoutCore as Layout } from './dynamix' - -/** Building the node tree from a saved layout or a list of tabs, and walking it. */ - -export function* JSONLayoutIterator( - root: LayoutTree -): IterableIterator { - const queue = new Queue() - queue.enqueue(root) - - while (!queue.isEmpty()) { - const current = queue.dequeue() - if (!current) continue - - yield current - - if (current.nodKids && current.nodKids.length > 0) { - for (const child of current.nodKids) { - queue.enqueue(child) - } - } - } -} - -export function* NodeLayoutIterator(root: Node): IterableIterator { - const queue = new Queue() - queue.enqueue(root) - - while (!queue.isEmpty()) { - const current = queue.dequeue() - if (!current) continue - - Node.cache.mapElem.set(current.unId, current) - - yield current - - if (current.kids.size() > 0) { - for (const child of current.kids) { - queue.enqueue(child) - } - } - } -} - -export function* NodeLayoutRecursiveIterator( - engine: Layout, - root: Node -): IterableIterator { - for (const child of root.kids) { - yield* engine.NodeLayoutRecursiveIterator(child) - } - - yield root -} - -export function updateChildDirections(engine: Layout, parent: Node) { - const parentDir = Node.cache.mapDirs.get(parent.unId) - if (parentDir === undefined) { - console.warn( - `Cannot update child directions for node ${parent.unId}; its own direction is not set.` - ) - return - } - for (const child of parent.kids) { - Node.cache.mapDirs.set(child.unId, !parentDir) - if (child.kids.size() > 0) { - child.host = parent - engine.updateChildDirections(child) - - if (child.next) { - Node.cache.mapDirs.set(child.next.unId, !parentDir) - } - } - } -} - -export function createNodeLayout( - engine: Layout, - root: LayoutTree | null = layoutState.tree -) { - if (!root) return - - const JSONIterator = engine.JSONLayoutIterator(root) - const nodeIterator = engine.NodeLayoutIterator(layoutState.root) - - Node.cache.mapDirs.set(layoutState.root.unId, true) - - while (true) { - const jsonNode = JSONIterator.next() - const nodeNode = nodeIterator.next() - - if (jsonNode.done || nodeNode.done) { - break - } - - const jsonValue = jsonNode.value - const nodeValue = nodeNode.value - - if (!jsonValue || !nodeValue) { - continue - } - - engine.createNodeTreeFromJSONTree(jsonValue, nodeValue) - } - - engine.calcTabsetCountAndMinDim() -} - -export function createNodeTreeFromJSONTree( - engine: Layout, - json: LayoutTree, - host: Node -) { - const direction = Node.cache.mapDirs.get(host.unId) - - for (const child of json.nodKids || []) { - const node = engine.createNodeFromJSON(child) - const prev = host.kids.peekBack() - node.host = host - node.addBond(node.host, prev) - host.kids.enqueue(node) - - Node.cache.mapDirs.set(node.unId, !direction!) - } - - if (host.type === 'tabset') Node.cache.mapElem.set(host.unId, host) - if (host.type === 'tab') Node.cache.mapElem.set(host.unId, host) -} - -export function createBinaryNodeTreeFromQueue( - engine: Layout, - queue: Queue, - host: Node = layoutState.root -) { - const nodeQueue = new Queue() - nodeQueue.enqueue(host) - - Node.cache.mapDirs.set(layoutState.root.unId, true) - - while (!queue.isEmpty()) { - const host = nodeQueue.dequeue() - if (!host) { - break - } - const dir = Node.cache.mapDirs.get(host.unId) - - // A single tab gets one tabset; splitting would leave an empty row - // sibling taking half of the space. - if (queue.size() === 1) { - const name = queue.dequeue() - const only = new Node({ type: 'tabset', host }) - const tab = new Node({ - type: 'tab', - host: only, - name, - unId: engine.tabsIds.get(name ?? '') ?? layoutState.createId(), - }) - only.open = tab.name - only.kids.enqueue(tab) - host.kids.enqueue(only) - - Node.cache.mapDirs.set(only.unId, !dir!) - Node.cache.mapDirs.set(tab.unId, dir!) - Node.cache.mapElem.set(only.unId, only) - Node.cache.mapElem.set(tab.unId, tab) - break - } - - if (queue.size() == 2) { - const leftName = queue.dequeue() - const rghtName = queue.dequeue() - const left = new Node({ type: 'tabset', host }) - const rght = new Node({ type: 'tabset', host }) - - const leftKid = new Node({ - type: 'tab', - host: left, - name: leftName, - unId: - engine.tabsIds.get(leftName ?? '') ?? - layoutState.createId(), - }) - const rghtKid = new Node({ - type: 'tab', - host: rght, - name: rghtName, - unId: - engine.tabsIds.get(rghtName ?? '') ?? - layoutState.createId(), - }) - left.open = leftKid.name - rght.open = rghtKid.name - left.kids.enqueue(leftKid) - rght.kids.enqueue(rghtKid) - host.kids.enqueue(left) - host.kids.enqueue(rght) - - rght.addBond(host, left) - - Node.cache.mapDirs.set(left.unId, !dir!) - Node.cache.mapDirs.set(rght.unId, !dir!) - Node.cache.mapElem.set(left.unId, left) - Node.cache.mapElem.set(rght.unId, rght) - Node.cache.mapDirs.set(leftKid.unId, dir!) - Node.cache.mapDirs.set(rghtKid.unId, dir!) - Node.cache.mapElem.set(leftKid.unId, leftKid) - Node.cache.mapElem.set(rghtKid.unId, rghtKid) - - break - } - const leftName = queue.dequeue() - const left = new Node({ type: 'tabset', host }) - const rght = new Node({ type: 'row', host }) - const leftKid = new Node({ - type: 'tab', - host: left, - name: leftName, - unId: engine.tabsIds.get(leftName ?? '') ?? layoutState.createId(), - }) - left.open = leftKid.name - - left.kids.enqueue(leftKid) - host.kids.enqueue(left) - host.kids.enqueue(rght) - - rght.addBond(host, left) - - Node.cache.mapDirs.set(left.unId, !dir!) - Node.cache.mapDirs.set(rght.unId, !dir!) - Node.cache.mapElem.set(left.unId, left) - Node.cache.mapDirs.set(leftKid.unId, dir!) - Node.cache.mapElem.set(leftKid.unId, leftKid) - - nodeQueue.enqueue(rght) - } - - engine.calcTabsetCountAndMinDim() -} - -export function createNodeFromJSON(engine: Layout, json: LayoutTree): Node { - const newNode = new Node({ - type: json.typNode as 'row' | 'tabset' | 'tab', - name: json.nodName, - unId: json.uidNode, - part: json.nodPart, - dims: { w: 0, h: 0, x: 0, y: 0 }, - open: typeof json.nodOpen === 'string' ? json.nodOpen : '', - }) - newNode.collapsed = json.typNode === 'tabset' && json.nodFold === true - - if (json.typNode === 'tab') { - newNode.unId = engine.tabsIds.get(json.nodName) || json.uidNode - } - - return newNode -} diff --git a/packages/core/src/app/tree-mutations.ts b/packages/core/src/app/tree-mutations.ts deleted file mode 100644 index 0d3cca0..0000000 --- a/packages/core/src/app/tree-mutations.ts +++ /dev/null @@ -1,640 +0,0 @@ -import { Queue } from './queue' -import { Node } from './node' -import { layoutState } from './state' -import type { DynamixLayoutCore as Layout } from './dynamix' - -/** Drag and drop: moving tabs/tabsets and keeping bonds and caches consistent. */ - -export function updateTree( - engine: Layout, - src: string, - des: string, - layout: 'top' | 'bottom' | 'left' | 'right' | 'contain' -): boolean { - const srcNode = Node.cache.mapElem.get(src) - let desNode = Node.cache.mapElem.get(des) - - if ( - !srcNode || - !(srcNode instanceof Node) || - !desNode || - !(desNode instanceof Node) - ) { - console.warn(`Source node ${src} not found or is not a Node instance`) - return false - } - - if ( - srcNode?.type == 'tab' && - srcNode.host === desNode && - desNode.kids.size() < 2 - ) { - console.warn(`Cannot move to self as it has only one tab`) - return false - } - - if (srcNode?.type == 'tabset' && desNode?.host === srcNode) { - console.warn(`Cannot move tabset to self as it is already a child`) - return false - } - - if (!srcNode || !desNode) { - console.warn(`Source or destination node not found: ${src}, ${des}`) - return false - } - - if (srcNode === desNode) { - console.warn( - 'Source and destination nodes are the same, no action taken.' - ) - return false - } - - if (!srcNode.host || (!desNode.host && desNode.type !== 'row')) { - console.warn( - `Source node or destination node's host is null: ${srcNode.unId}, ${desNode.unId}` - ) - return false - } - - // Only the root row is a valid row target; moveRelativeToRoot assumes it. - if (desNode.type === 'row' && desNode !== layoutState.root) { - console.warn(`Cannot move relative to a nested row: ${desNode.unId}`) - return false - } - - if (desNode.type === 'row' && layout === 'contain') { - console.warn( - `Cannot move to a row with 'contain' layout: ${desNode.unId}` - ) - return false - } - - if (srcNode.type === 'row') { - return false - } - - if (desNode.type === 'tabset' && layout === 'contain') { - desNode = desNode.kids.peekBack() || desNode - } - - // Dropping the last tab of a tabset onto that same tabset is a no-op. - if (srcNode === desNode) return false - - if (engine.isOnlyContent(srcNode)) { - console.warn('Cannot move the only tabset or tab in the layout') - return false - } - - // Any move ends maximize, and a dragged tabset arrives unfolded. - layoutState.maximized = null - srcNode.collapsed = false - - if ( - desNode.type === 'tab' && - (layout === 'contain' || layout === 'left' || layout === 'right') - ) { - engine.moveNodeAsTab(srcNode, desNode, layout) - } else if (desNode.type === 'row' && layout !== 'contain') { - engine.moveRelativeToRoot(srcNode, desNode, layout) - } else if (desNode.type === 'tabset' && layout !== 'contain') { - engine.moveRelativeToTabset(srcNode, desNode, layout) - } - - engine.calcTabsetCountAndMinDim() - engine.calcDimensions(layoutState.root, true) - return true -} - -export function isOnlyContent(engine: Layout, node: Node): boolean { - let tabsets = 0 - for (const n of engine.NodeLayoutRecursiveIterator(layoutState.root)) { - if (n.type === 'tabset') tabsets++ - } - if (tabsets !== 1) return false - - return ( - node.type === 'tabset' || - (node.type === 'tab' && node.host?.kids.size() === 1) - ) -} - -export function moveNodeAsTab( - engine: Layout, - src: Node, - des: Node, - layout: 'left' | 'right' | 'contain' -) { - if (!des || !des.host) { - console.warn(`Destination node ${des.unId} has no host, cannot move.`) - return - } - - engine.removeNode(src) - - const desHst = des.host - const desIdx = desHst.kids.indexOf(des) - - if (desIdx === undefined || desIdx === -1) { - return - } - - const insertFlg = layout === 'contain' || layout === 'left' - const insertIdx = insertFlg ? desIdx : desIdx + 1 - - const desNxt = desHst.kids.get(insertIdx + 1) - const desPre = desHst.kids.get(insertIdx - 1) - const nxtBnd = des.next - const prvBnd = des.prev - - if (!insertFlg && nxtBnd) { - Node.cache.mapElem.delete(nxtBnd.unId) - } else if (insertFlg && prvBnd) { - Node.cache.mapElem.delete(prvBnd.unId) - } - - if (src.type == 'tab') { - if (insertFlg) { - if (desPre) src.addBond(desHst, desPre) - des.addBond(desHst, src) - } else { - src.addBond(desHst, des) - if (desNxt) desNxt.addBond(desHst, src) - } - desHst.kids.insert(insertIdx, src) - src.host = desHst - desHst.open = src.name - desHst.collapsed = false - } else if (src.type == 'tabset') { - const kidFst = src.kids.peek() - const kidLst = src.kids.peekBack() - - if (insertFlg) { - if (kidFst && desPre) kidFst.addBond(desHst, desPre) - if (kidLst && des) des.addBond(desHst, kidLst) - } else { - if (kidFst && des) kidFst.addBond(desHst, des) - if (kidLst && desNxt) desNxt.addBond(desHst, kidLst) - } - - desHst.kids.insertQueue(insertIdx, src.kids) - - for (const kid of src.kids) { - kid.host = desHst - if (kid.next) kid.next.host = desHst - } - - desHst.open = kidLst ? kidLst.name : '' - desHst.collapsed = false - } - - engine.updateChildDirections(desHst) -} - -type Side = 'top' | 'bottom' | 'left' | 'right' - -/** - * Whether dropping on a side of the target needs a new parent (the drop - * crosses the target's layout axis) or can be inserted as a sibling. - * `vertical` means the target lays its children out top-to-bottom. - */ -const needsNewParent = { - root: (vertical: boolean, sideways: boolean) => vertical === sideways, - tabset: (vertical: boolean, sideways: boolean) => vertical !== sideways, -} - -function moveRelative( - engine: Layout, - src: Node, - des: Node, - side: Side, - target: keyof typeof needsNewParent -) { - engine.removeNode(src) - if (src.type !== 'tab' && src.type !== 'tabset') return - - const vertical = Node.cache.mapDirs.get(des.unId) === false - const sideways = side === 'left' || side === 'right' - const before = side === 'top' || side === 'left' - - let moved = src - if (src.type === 'tab') { - moved = new Node({ type: 'tabset', host: des.host }) - moved.kids.enqueue(src) - moved.open = src.name - src.host = moved - } - - if (needsNewParent[target](vertical, sideways)) { - engine.insertNodeWithNewParent(moved, des, before) - } else { - engine.insertNode(des, moved, before) - } -} - -export function moveRelativeToRoot( - engine: Layout, - src: Node, - des: Node, - layout: Side -) { - moveRelative(engine, src, des, layout, 'root') -} - -export function moveRelativeToTabset( - engine: Layout, - src: Node, - des: Node, - layout: Side -) { - moveRelative(engine, src, des, layout, 'tabset') -} - -export function insertNode( - engine: Layout, - des: Node, - src: Node, - flag: boolean -) { - const desHost = des.host - if (des == layoutState.root) { - const kidhead = layoutState.root.kids.peek() - const kidTail = layoutState.root.kids.peekBack() - - if (flag) { - layoutState.root.kids.enqueueFront(src) - if (kidhead) kidhead.addBond(layoutState.root, src) - } else { - layoutState.root.kids.enqueue(src) - if (kidTail) src.addBond(layoutState.root, kidTail) - } - - src!.host = layoutState.root - engine.updateChildDirections(layoutState.root) - return - } - - if (!desHost) { - console.warn(`Destination node ${des.unId} has no host, cannot insert.`) - return - } - - const desIdx = desHost.kids.indexOf(des) - if (desIdx === -1) { - console.warn( - `Destination node ${des.unId} not found in its host's kids.` - ) - return - } - - const hostDir = Node.cache.mapDirs.get(desHost.unId) - if (hostDir) Node.cache.mapDirs.set(src.unId, !hostDir) - - const desNxt = desHost.kids.get(desIdx + 1) - const desPre = desHost.kids.get(desIdx - 1) - const bndNxt = des.next - const bndPre = des.prev - - src.host = desHost - - if (flag) { - desHost.kids.insert(desIdx, src) - - des.addBond(desHost, src) - if (desPre) src.addBond(desHost, desPre) - - if (bndPre) { - Node.cache.mapElem.delete(bndPre.unId) - } - } else { - desHost.kids.insert(desIdx + 1, src) - src.addBond(desHost, des) - if (desNxt) desNxt.addBond(desHost, src) - - if (bndNxt) { - Node.cache.mapElem.delete(bndNxt.unId) - } - } - - engine.updateChildDirections(desHost) -} - -export function insertNodeWithNewParent( - engine: Layout, - src: Node, - des: Node, - flag: boolean -) { - const kids = new Queue() - const node = new Node({ type: 'tabset', host: des.host }) - - if (!des || !src) { - console.warn(`Destination node ${des.unId} has no host, cannot insert.`) - return - } - - if (des === layoutState.root) { - const row = new Node({ type: 'row', host: des }) - if (des.kids.size() === 0) { - des.kids.enqueue(src) - src.host = des - } else if (des.kids.size() == 1 && des.kids.peek()?.type === 'tabset') { - const kid = des.kids.dequeue() - - if (!kid) { - console.warn( - `No kid found in destination node ${des.unId}, cannot insert.` - ) - return - } - - if (flag) { - row.kids.enqueue(src) - row.kids.enqueue(kid) - des.kids.enqueue(row) - kid.addBond(row, src) - } else { - row.kids.enqueue(kid) - row.kids.enqueue(src) - src.addBond(row, kid) - des.kids.enqueue(row) - } - src.host = row - kid.host = row - } else if (des.kids.size() == 1 && des.kids.peek()?.type === 'row') { - const kid = des.kids.peek() - - const kidhead = kid?.kids.peek() - const kidTail = kid?.kids.peekBack() - - if (flag && kid) { - kid.kids.enqueueFront(src) - if (kidhead) kidhead.addBond(kid, src) - } else if (kid) { - kid.kids.enqueue(src) - if (kidTail) src.addBond(kid, kidTail) - } - - src!.host = kid! - } else if (des.kids.size() > 1) { - const rowA = new Node({ type: 'row', host: des }) - const rowB = new Node({ type: 'row', host: rowA }) - - for (const kid of des.kids) { - rowB.kids.enqueue(kid) - kid.host = rowB - if (kid.next) kid.next.host = rowB - } - - src.host = rowA - des.kids.clear() - - if (flag) { - rowA.kids.enqueueFront(src) - rowA.kids.enqueue(rowB) - rowB.addBond(rowA, src) - } else { - rowA.kids.enqueue(rowB) - rowA.kids.enqueue(src) - src.addBond(rowA, rowB) - } - - des.kids.enqueue(rowA) - } - - engine.updateChildDirections(des) - return - } - - src.host = des - - node.host = des - node.kids = des.kids - node.open = des.open - - const desDir = Node.cache.mapDirs.get(des.unId) - - for (const kid of node.kids) { - kid.host = node - if (kid.next) kid.next.host = node - Node.cache.mapDirs.set(kid.unId, desDir!) - } - - for (const kid of src.kids) { - kid.host = src - if (kid.next) kid.next.host = src - Node.cache.mapDirs.set(kid.unId, desDir!) - } - - Node.cache.mapDirs.set(node.unId, !desDir!) - Node.cache.mapDirs.set(src.unId, !desDir!) - - if (flag) { - kids.enqueue(src) - kids.enqueue(node) - des.kids = kids - des.type = 'row' - node.addBond(des, src) - } else { - kids.enqueue(node) - kids.enqueue(src) - des.kids = kids - des.type = 'row' - src.addBond(des, node) - } -} - -export function moveAdjacentNodeToGrandParent(engine: Layout, src: Node) { - if (!src.host || !src.host.host) { - console.warn(`Source node ${src.unId} has no host, cannot move.`) - return - } - - const srcHost = src.host - const gndHost = src.host.host - - const srcHostIndex = gndHost.kids.indexOf(srcHost) - const hostPrev = gndHost.kids.get(srcHostIndex - 1) - const hostNext = gndHost.kids.get(srcHostIndex + 1) - const fstKid = src.kids.peek() - const lstKid = src.kids.peekBack() - - engine.removeKid(srcHost) - - let insrtIndex = srcHostIndex - - if (src.type == 'row' && srcHost) { - if (hostPrev && fstKid) fstKid.addBond(gndHost, hostPrev) - if (hostNext && lstKid) hostNext.addBond(gndHost, lstKid) - - for (const kid of src.kids) { - kid.host = gndHost - if (kid.next) kid.next.host = gndHost - gndHost.kids.insert(insrtIndex, kid) - - Node.cache.mapDirs.set( - kid.unId, - !Node.cache.mapDirs.get(gndHost.unId)! - ) - Node.cache.dimMins.delete(kid.unId) - insrtIndex++ - } - } else if (src.type == 'tabset') { - if (hostPrev) src.addBond(gndHost, hostPrev) - if (hostNext) hostNext.addBond(gndHost, src) - - gndHost.kids.insert(insrtIndex, src) - - src.host = gndHost - - Node.cache.mapDirs.set(src.unId, !Node.cache.mapDirs.get(gndHost.unId)!) - - for (const kid of src.kids) { - Node.cache.mapDirs.set( - kid.unId, - Node.cache.mapDirs.get(gndHost.unId)! - ) - Node.cache.dimMins.delete(kid.unId) - } - } -} - -export function removeRelation(src: Node, fix: boolean = true) { - const host = src.host - const prev = src.prev - const next = src.next - - src.host = null - src.next = null - src.prev = null - - if (prev) prev.next = null - if (next) next.prev = null - - if (!fix) { - return src - } - - if (prev && next && prev.prev && next.next) { - prev.prev.addBond(host, next.next) - } else if (prev && prev.prev) { - prev.prev.next = null - } else if (next && next.next) { - next.next.prev = null - } -} - -export function removeNode(engine: Layout, src: Node) { - if (!src.host) { - console.warn(`Kid node has no host, cannot remove.`) - return - } - - const papa = src?.host - const dada = papa?.host - - engine.removeKid(src) - - if (src.type === 'tabset' && papa.kids.size() === 1) { - /** - * - */ - const kid = papa.kids.peek() - if (kid) engine.moveAdjacentNodeToGrandParent(kid) - } else if ( - /** - * - */ - src.type === 'tab' && - papa!.kids.size() === 0 && - dada!.kids.size() === 2 - ) { - engine.removeKid(papa) - const kid = dada?.kids.peek() - if (kid) engine.moveAdjacentNodeToGrandParent(kid) - } else if ( - /** - * - */ - src.type === 'tab' && - papa!.kids.size() === 0 && - dada!.kids.size() > 2 - ) { - engine.removeKid(papa) - } else if ( - /** - * - */ - src.type === 'tab' && - papa!.open === src.name && - papa!.kids.size() > 0 - ) { - const kid = papa.kids.peek() - if (kid) papa.open = kid.name - } -} - -export function removeKid(kid: Node) { - if (!kid.host) { - console.warn(`Kid node has no host, cannot remove.`) - return - } - - const host = kid.host - const idx = host.kids.indexOf(kid) - - const nextNode = host.kids.get(idx + 1) - const prevNode = host.kids.get(idx - 1) - const nextBond = kid.next - const prevBond = kid.prev - - if (idx === -1) { - console.warn(`Kid node not found in its host's kids.`) - return - } - - if (nextBond) { - Node.cache.mapElem.delete(nextBond.unId) - } - - if (prevBond) { - Node.cache.mapElem.delete(prevBond.unId) - } - - if (nextNode && prevNode) { - nextNode.addBond(host, prevNode) - } else if (nextNode) { - nextNode.prev = null - } else if (prevNode) { - prevNode.next = null - } - - host.kids.removeAt(idx) - - Node.cache.mapElem.delete(kid.unId) - Node.cache.dimMins.delete(kid.unId) - Node.cache.mapDirs.delete(kid.unId) - Node.cache.tabCnts.delete(kid.unId) - - if (kid.type === 'tabset') { - Node.cache.mapElem.delete(kid.unId) - } - - if (kid.type === 'tab') { - Node.cache.mapElem.delete(kid.unId) - } - - kid.host = null - kid.next = null - kid.prev = null -} - -export function clearAllCache() { - Node.cache.dimMins.clear() - Node.cache.mapDirs.clear() - Node.cache.tabCnts.clear() - Node.cache.mapElem.clear() - - Node.cache.nodOpts.get().clear() - Node.cache.bndOpts.get().clear() -} diff --git a/packages/core/src/app/view-state.ts b/packages/core/src/app/view-state.ts deleted file mode 100644 index 7aee166..0000000 --- a/packages/core/src/app/view-state.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Node } from './node' -import { layoutState } from './state' -import type { DynamixLayoutCore as Layout } from './dynamix' - -/** Maximize and fold: view state on top of the tree that never changes parts. */ - -function findTabset(id: string): Node | null { - const node = Node.cache.mapElem.get(id) - return node instanceof Node && node.type === 'tabset' ? node : null -} - -function countTabsets(root: Node = layoutState.root): number { - let count = 0 - const walk = (node: Node) => { - if (node.type === 'tabset') count++ - for (const kid of node.kids) walk(kid) - } - walk(root) - return count -} - -/** A tabset can fold when its row has another child to take the space. */ -export function canCollapse(node: Node): boolean { - return node.type === 'tabset' && !!node.host && node.host.kids.size() > 1 -} - -export function canMaximize(): boolean { - return countTabsets() > 1 -} - -/** The maximized tabset, or null when nothing is maximized. */ -export function getMaximizedTabset(): Node | null { - const id = layoutState.maximized - return id ? findTabset(id) : null -} - -/** Unfold the most recently folded child of a row other than `except`. */ -function unfoldMostRecent(row: Node, except?: Node) { - let pick: Node | null = null - for (const kid of row.kids) { - if (kid === except || !kid.collapsed) continue - if (!pick || kid.foldedAt > pick.foldedAt) pick = kid - } - if (pick) pick.collapsed = false -} - -/** - * Keeps view state valid after any tree change: a tabset alone in its row - * cannot stay folded, every row keeps at least one open child, and a - * maximized tabset that left the tree is forgotten. - */ -export function normalizeViewState(root: Node = layoutState.root) { - let maximizedFound = false - - const walk = (node: Node) => { - if (node.type === 'tabset') { - if (node.unId === layoutState.maximized) maximizedFound = true - if (node.collapsed && !canCollapse(node)) node.collapsed = false - } - - if (node.type !== 'tab' && node.kids.size() > 0) { - const allFolded = [...node.kids].every((kid) => kid.collapsed) - if (allFolded) unfoldMostRecent(node) - } - - for (const kid of node.kids) walk(kid) - } - walk(root) - - if (!maximizedFound) layoutState.maximized = null -} - -export function maximize(engine: Layout, id: string): boolean { - if (!findTabset(id) || !canMaximize()) return false - if (layoutState.maximized === id) return false - - layoutState.maximized = id - engine.calcDimensions() - return true -} - -export function restore(engine: Layout): boolean { - if (!layoutState.maximized) return false - - layoutState.maximized = null - engine.calcDimensions() - return true -} - -export function toggleMaximize(engine: Layout, id: string): boolean { - return layoutState.maximized === id ? restore(engine) : maximize(engine, id) -} - -/** Folding the last open child of a row unfolds its most recently folded sibling. */ -export function collapse(engine: Layout, id: string): boolean { - const node = findTabset(id) - if (!node || node.collapsed || !canCollapse(node)) return false - - layoutState.maximized = null - node.collapsed = true - node.foldedAt = ++layoutState.foldSeq - - const row = node.host! - if ([...row.kids].every((kid) => kid.collapsed)) unfoldMostRecent(row, node) - - engine.calcTabsetCountAndMinDim() - engine.calcDimensions() - return true -} - -export function expand(engine: Layout, id: string): boolean { - const node = findTabset(id) - if (!node || !node.collapsed) return false - - node.collapsed = false - engine.calcTabsetCountAndMinDim() - engine.calcDimensions() - return true -} - -export function toggleCollapse(engine: Layout, id: string): boolean { - const node = findTabset(id) - if (!node) return false - return node.collapsed ? expand(engine, id) : collapse(engine, id) -} diff --git a/packages/core/src/create-layout.ts b/packages/core/src/create-layout.ts new file mode 100644 index 0000000..572283a --- /dev/null +++ b/packages/core/src/create-layout.ts @@ -0,0 +1,260 @@ +import { defaultWarningHandler, isDev } from './dev' +import { createDragActions } from './drag-actions' +import { getDropIndicatorRect } from './drop/drop-indicator' +import { getDropTarget } from './drop/drop-target' +import { listDropTargets } from './drop/drop-targets' +import { DynamixLayoutError, type WarningHandler } from './errors' +import { DEFAULT_GEOMETRY, type GeometryConfig } from './geometry/config' +import { getSplitterBounds, resizeSplitterWeights } from './geometry/splitter' +import { createIdGenerator } from './ids' +import type { Layout, LayoutOptions, LayoutSizeOptions } from './layout-types' +import type { LayoutModel } from './model/types' +import { parseLayout } from './serialize/parse' +import type { LayoutJSON, LayoutTreeV1 } from './serialize/schema' +import { createLayoutStore } from './store/layout-store' +import { buildDefaultTree } from './tree/build' +import { collectNodeIds, findTabsetOfTab } from './tree/find' +import { moveNode } from './tree/move' +import { + addTabToModel, + reconcileTabs, + type TabInit, +} from './tree/reconcile-tabs' +import { removeTab } from './tree/remove' +import { replaceNode } from './tree/update' +import * as viewState from './tree/view-state' + +const ROOT_ID = 'root' + +/** + * Creates a layout instance. + * + * @param options - Open tabs, an optional saved layout (v1 or v2), sizes and callbacks. + * @returns An instance that shares nothing with other instances. It never + * touches the DOM; call `setContainerRect` to give it a size. + * @throws {DynamixLayoutError} `INVALID_LAYOUT` or `UNSUPPORTED_VERSION` for a + * bad `initialLayout`; `DUPLICATE_TAB_ID` for duplicate tab ids in development. + * @example + * const layout = createLayout({ + * tabs: [{ id: 'editor' }, { id: 'terminal' }], + * onLayoutChange: (json) => localStorage.setItem('layout', JSON.stringify(json)), + * }) + * layout.subscribe((snapshot) => render(snapshot)) + * layout.setContainerRect({ x: 0, y: 0, width: 1200, height: 800 }) + */ +export function createLayout(options: LayoutOptions): Layout { + const warn = options.onWarning ?? defaultWarningHandler + const ids = createIdGenerator(options.createId) + const { createId, reserve } = ids + let tabs = uniqueTabs(options.tabs, warn) + + const fromLayout = (input: LayoutJSON | LayoutTreeV1): LayoutModel => { + const parsed = parseLayout(input, warn) + reserve(collectNodeIds(parsed.root)) + const loaded = { ...parsed, lastActiveTabsetId: null, foldOrder: [] } + return reconcileTabs(loaded, tabs, createId, false) + } + const initialModel = (): LayoutModel => { + if (options.initialLayout) return fromLayout(options.initialLayout) + reserve([ROOT_ID]) + const tabIds = tabs.map((tab) => tab.id) + return { + root: buildDefaultTree(tabIds, createId, ROOT_ID), + maximizedTabsetId: null, + lastActiveTabsetId: null, + foldOrder: [], + } + } + + const store = createLayoutStore( + initialModel(), + configFrom(options, DEFAULT_GEOMETRY), + options.onLayoutChange, + warn + ) + const unknown = (kind: string, id: string) => { + warn({ code: 'UNKNOWN_ID', message: `Unknown ${kind} "${id}"` }) + return false + } + + const layout: Layout = { + getSnapshot: () => store.snapshot, + subscribe: (listener) => store.subscribe(listener), + setContainerRect: (rect) => store.setContainer(rect), + setOptions: (next) => store.setConfig(configFrom(next, store.config)), + setTabs(next) { + tabs = uniqueTabs(next, warn) + store.commit( + reconcileTabs(store.model, tabs, createId, true), + 'tabs' + ) + }, + + moveTab: (tabId, target) => + store.commit( + moveNode(store.model, { type: 'tab', tabId }, target, createId), + 'move' + ), + moveTabset: (tabsetId, target) => + store.commit( + moveNode( + store.model, + { type: 'tabset', tabsetId }, + target, + createId + ), + 'move' + ), + selectTab(tabId) { + const { model } = store + const tabset = findTabsetOfTab(model.root, tabId) + if (!tabset) return unknown('tab', tabId) + if ( + tabset.activeTabId === tabId && + model.lastActiveTabsetId === tabset.id + ) + return false + const root = replaceNode(model.root, tabset.id, () => ({ + ...tabset, + activeTabId: tabId, + })) + return store.commit( + { ...model, root, lastActiveTabsetId: tabset.id }, + 'select' + ) + }, + addTab(tab, target) { + if (tabs.some((t) => t.id === tab.id)) return false + const init = target ? { ...tab, target } : tab + tabs = [...tabs, init] + return store.commit( + addTabToModel(store.model, init, createId, true), + 'tabs' + ) + }, + removeTab(tabId) { + if (!tabs.some((t) => t.id === tabId)) return unknown('tab', tabId) + tabs = tabs.filter((t) => t.id !== tabId) + const { model } = store + const root = removeTab(model.root, tabId) + return store.commit( + viewState.normalizeViewState({ ...model, root }), + 'tabs' + ) + }, + + resizeSplitter(id, point) { + const { model, rects, config } = store + if (model.maximizedTabsetId) return false + const root = resizeSplitterWeights( + model.root, + rects, + config, + id, + point + ) + return store.commit(root && { ...model, root }, 'resize') + }, + moveSplitterBy(id, delta) { + const rect = store.rects.splitters.get(id) + const splitter = store.snapshot.splitters.get(id) + if (!rect || !splitter) return unknown('splitter', id) + const x = rect.x + rect.width / 2 + const y = rect.y + rect.height / 2 + const point = + splitter.direction === 'horizontal' + ? { x: x + delta, y } + : { x, y: y + delta } + return layout.resizeSplitter(id, point) + }, + + maximize: (id) => + store.commit(viewState.maximize(store.model, id), 'maximize'), + restore: () => store.commit(viewState.restore(store.model), 'maximize'), + toggleMaximize: (id) => + store.model.maximizedTabsetId === id + ? layout.restore() + : layout.maximize(id), + fold: (id) => store.commit(viewState.fold(store.model, id), 'fold'), + unfold: (id) => store.commit(viewState.unfold(store.model, id), 'fold'), + toggleFold(id) { + const tabset = store.snapshot.tabsets.get(id) + if (!tabset) return unknown('tabset', id) + return tabset.isFolded ? layout.unfold(id) : layout.fold(id) + }, + + ...createDragActions(store, createId), + + getDropTarget: (point, source, measurements) => + source.type === 'splitter' + ? null + : getDropTarget( + store.model, + store.rects, + point, + source, + measurements + ), + getDropIndicatorRect: (target, measurements) => + getDropIndicatorRect( + store.model, + store.rects, + target, + measurements + ), + listDropTargets: (source) => + source.type === 'splitter' + ? [] + : listDropTargets(store.model, source), + getSplitterBounds: (id) => + getSplitterBounds(store.model.root, store.rects, store.config, id), + + load: (input) => void store.commit(fromLayout(input)), + reset() { + ids.reset() + store.commit(initialModel(), 'reset') + }, + toJSON: () => store.toJSON(), + destroy: () => store.destroy(), + } + return layout +} + +/** + * `foldedSize` follows `minPanelSize.height` unless set; adapters pass it + * explicitly (the tab bar height). + */ +function configFrom( + options: LayoutSizeOptions, + fallback: GeometryConfig +): GeometryConfig { + return { + minPanelSize: options.minPanelSize ?? fallback.minPanelSize, + splitterSize: options.splitterSize ?? fallback.splitterSize, + foldedSize: + options.foldedSize ?? + options.minPanelSize?.height ?? + fallback.foldedSize, + } +} + +/** Duplicate ids throw in development; in production the first one wins. */ +function uniqueTabs(tabs: readonly TabInit[], warn: WarningHandler): TabInit[] { + const seen = new Set() + return tabs.filter((tab) => { + if (!seen.has(tab.id)) { + seen.add(tab.id) + return true + } + if (isDev()) + throw new DynamixLayoutError( + 'DUPLICATE_TAB_ID', + `Duplicate tab id "${tab.id}"` + ) + warn({ + code: 'DUPLICATE_TAB_ID', + message: `Duplicate tab id "${tab.id}" ignored`, + }) + return false + }) +} diff --git a/packages/core/src/dev.ts b/packages/core/src/dev.ts new file mode 100644 index 0000000..1bcfa25 --- /dev/null +++ b/packages/core/src/dev.ts @@ -0,0 +1,21 @@ +import type { WarningHandler } from './errors' + +declare const process: { env: { NODE_ENV?: string } } | undefined + +/** + * Development unless the bundler set `process.env.NODE_ENV` to "production". + * Written so bundlers can replace the expression; never evaluated at import. + */ +export function isDev(): boolean { + return ( + typeof process === 'undefined' || + // Read at runtime by consumers' bundlers, not a build input of this repo. + // eslint-disable-next-line turbo/no-undeclared-env-vars + process.env.NODE_ENV !== 'production' + ) +} + +/** Default warning handler: `console.warn` in development, silent in production. */ +export const defaultWarningHandler: WarningHandler = (warning) => { + if (isDev()) console.warn(`[dynamix-layout] ${warning.message}`) +} diff --git a/packages/core/src/dom/apply-rect.ts b/packages/core/src/dom/apply-rect.ts new file mode 100644 index 0000000..8213b8c --- /dev/null +++ b/packages/core/src/dom/apply-rect.ts @@ -0,0 +1,17 @@ +import type { Rect } from '../model/types' + +/** + * Positions an absolutely positioned element. Adapters use this on their fast + * path (drags), so pointer moves never need a framework re-render. + * @param element - An absolutely positioned element inside the layout root. + * @param rect - Where to put it, in layout-root coordinates. + * @example + * layout.subscribe((s) => s.rects.tabsets.forEach((rect, id) => applyRect(panels.get(id)!, rect))) + */ +export function applyRect(element: HTMLElement, rect: Rect): void { + const { style } = element + style.left = `${rect.x}px` + style.top = `${rect.y}px` + style.width = `${rect.width}px` + style.height = `${rect.height}px` +} diff --git a/packages/core/src/dom/frame-scheduler.ts b/packages/core/src/dom/frame-scheduler.ts new file mode 100644 index 0000000..ac76b68 --- /dev/null +++ b/packages/core/src/dom/frame-scheduler.ts @@ -0,0 +1,49 @@ +export interface FrameScheduler { + /** Remember `value`; `onFlush` runs with the latest value on the next frame. */ + schedule: (value: T) => void + /** Run a pending flush now (e.g. on pointer up). */ + flush: () => void + /** Drop a pending flush (e.g. on unmount). */ + cancel: () => void +} + +/** + * Coalesces bursts of updates (pointer events can fire several times per + * frame) into at most one `onFlush` call per animation frame. + * @param onFlush - Called with the latest scheduled value, at most once per frame. + * @returns `schedule`, `flush` and `cancel`. + * @example + * const frame = createFrameScheduler((point: Point) => layout.updateDrag(point)) + * element.addEventListener('pointermove', (e) => frame.schedule(toRootPoint(e))) + * element.addEventListener('pointerup', () => { frame.flush(); layout.endDrag() }) + */ +export function createFrameScheduler( + onFlush: (value: T) => void +): FrameScheduler { + let pending: { value: T } | null = null + let frame: number | null = null + + const run = () => { + frame = null + const next = pending + pending = null + if (next) onFlush(next.value) + } + + return { + schedule(value) { + pending = { value } + if (frame === null) frame = requestAnimationFrame(run) + }, + flush() { + if (frame === null) return + cancelAnimationFrame(frame) + run() + }, + cancel() { + if (frame !== null) cancelAnimationFrame(frame) + frame = null + pending = null + }, + } +} diff --git a/packages/core/src/drag-actions.ts b/packages/core/src/drag-actions.ts new file mode 100644 index 0000000..a1559ba --- /dev/null +++ b/packages/core/src/drag-actions.ts @@ -0,0 +1,115 @@ +import { getDropIndicatorRect } from './drop/drop-indicator' +import { getDropTarget } from './drop/drop-target' +import { resizeSplitterWeights } from './geometry/splitter' +import type { Layout } from './layout-types' +import type { CreateId } from './model/types' +import { isSameTarget, type LayoutStore } from './store/layout-store' +import { moveNode } from './tree/move' +import { restore } from './tree/view-state' + +type DragActions = Pick< + Layout, + 'startDrag' | 'updateDrag' | 'setDragTarget' | 'endDrag' | 'cancelDrag' +> + +/** + * One drag model for tabs, tabsets and splitters. Frames are transient + * (published but not reported); `endDrag` commits with a single + * `onLayoutChange`, and `cancelDrag` puts a dragged splitter back. + */ +export function createDragActions( + store: LayoutStore, + createId: CreateId +): DragActions { + const actions: DragActions = { + startDrag(source) { + if (store.isDestroyed) return false + const { snapshot, model } = store + if (source.type === 'splitter') { + const splitter = snapshot.splitters.get(source.splitterId) + if (!splitter || splitter.isLocked || model.maximizedTabsetId) + return false + } else { + const exists = + source.type === 'tab' + ? snapshot.tabs.has(source.tabId) + : snapshot.tabsets.has(source.tabsetId) + if (!exists) return false + // Every target must be visible while dragging. + store.commit(restore(model), 'maximize') + } + store.update({ + drag: { + source, + target: null, + indicator: null, + startRoot: store.model.root, + }, + }) + return true + }, + + updateDrag(point, measurements) { + const { drag, model, rects, config } = store + if (!drag) return + if (drag.source.type === 'splitter') { + const root = resizeSplitterWeights( + model.root, + rects, + config, + drag.source.splitterId, + point + ) + if (root) store.update({ model: { ...model, root } }) + return + } + actions.setDragTarget( + getDropTarget(model, rects, point, drag.source, measurements), + measurements + ) + }, + + setDragTarget(target, measurements) { + const { drag, model, rects } = store + if ( + !drag || + drag.source.type === 'splitter' || + isSameTarget(drag.target, target) + ) + return + const indicator = + target && + getDropIndicatorRect(model, rects, target, measurements) + store.update({ drag: { ...drag, target, indicator } }) + }, + + endDrag() { + const { drag, model } = store + if (!drag) return false + store.update({ drag: null }) + if (drag.source.type === 'splitter') { + if (model.root === drag.startRoot) return false + store.report('resize') + return true + } + const moved = + drag.target && + moveNode(model, drag.source, drag.target, createId) + return store.commit(moved, 'move') + }, + + cancelDrag() { + const { drag, model } = store + if (!drag) return + const putBack = + drag.source.type === 'splitter' && model.root !== drag.startRoot + store.update({ + drag: null, + ...(putBack + ? { model: { ...model, root: drag.startRoot } } + : {}), + }) + }, + } + return actions +} diff --git a/packages/core/src/drop/drop-indicator.ts b/packages/core/src/drop/drop-indicator.ts new file mode 100644 index 0000000..abfe1e0 --- /dev/null +++ b/packages/core/src/drop/drop-indicator.ts @@ -0,0 +1,72 @@ +import type { LayoutRects } from '../geometry/compute-rects' +import type { DropTarget, LayoutModel, Rect } from '../model/types' +import { findTabset, findTabsetOfTab } from '../tree/find' +import type { DropMeasurements } from './drop-target' + +const DEFAULT_TAB_GAP = 6 + +/** + * Rect of the drop indicator for a target (ported from v1 drop previews): + * half of a tabset or of the layout for side drops, the middle 80% of a + * tabset for center drops (the whole strip when folded), and a thin marker + * between tabs for tab bar drops. + * + * @returns The rect, or `null` when the target's geometry is unknown. + */ +export function getDropIndicatorRect( + model: LayoutModel, + rects: LayoutRects, + target: DropTarget, + measurements?: DropMeasurements +): Rect | null { + if (target.type === 'root') return half(rects.container, target.position) + + if (target.type === 'tabset') { + const rect = rects.tabsets.get(target.tabsetId) + if (!rect) return null + if (target.position !== 'center') return half(rect, target.position) + if (findTabset(model.root, target.tabsetId)?.isFolded) return rect + return { + x: rect.x + rect.width * 0.1, + y: rect.y + rect.height * 0.1, + width: rect.width * 0.8, + height: rect.height * 0.8, + } + } + + const tabset = findTabsetOfTab(model.root, target.tabId) + const tabs = tabset && measurements?.tabBars.get(tabset.id)?.tabs + const index = tabs?.findIndex((tab) => tab.id === target.tabId) ?? -1 + if (!tabs || index < 0) return null + const gap = + tabs.length > 1 + ? Math.max( + 2, + tabs[1].rect.x - (tabs[0].rect.x + tabs[0].rect.width) + ) + : DEFAULT_TAB_GAP + const { rect } = tabs[index] + return { + x: + target.position === 'before' + ? rect.x - gap + 1 + : rect.x + rect.width + 1, + y: rect.y, + width: Math.max(2, gap - 2), + height: rect.height, + } +} + +function half(rect: Rect, side: 'top' | 'bottom' | 'left' | 'right'): Rect { + const { x, y, width, height } = rect + switch (side) { + case 'left': + return { x, y, width: width / 2, height } + case 'right': + return { x: x + width / 2, y, width: width / 2, height } + case 'top': + return { x, y, width, height: height / 2 } + case 'bottom': + return { x, y: y + height / 2, width, height: height / 2 } + } +} diff --git a/packages/core/src/drop/drop-target.ts b/packages/core/src/drop/drop-target.ts new file mode 100644 index 0000000..6a8dca9 --- /dev/null +++ b/packages/core/src/drop/drop-target.ts @@ -0,0 +1,174 @@ +import type { LayoutRects } from '../geometry/compute-rects' +import type { DropTarget, LayoutModel, Point, Rect, Side } from '../model/types' +import { collectTabsets } from '../tree/find' +import { canMove, type MoveSource } from '../tree/move' + +/** A tab bar and its tabs as rendered, in layout-root coordinates. */ +export interface TabBarMeasurement { + readonly rect: Rect + /** Drawn as a rotated strip (a folded tabset in a side-by-side row). */ + readonly isRotated?: boolean + readonly tabs: readonly { readonly id: string; readonly rect: Rect }[] +} + +/** Tab bar geometry the engine can't know (label widths), keyed by tabset id. */ +export interface DropMeasurements { + readonly tabBars: ReadonlyMap +} + +/** Size of the root-edge drop zones (v1 `RootSplitterHoverEl`). */ +export interface RootDropZoneOptions { + /** Share of the edge the zone covers. @default 0.25 */ + readonly length?: number + /** Thickness in px. @default 8 */ + readonly thickness?: number +} + +const SIDES: readonly Side[] = ['left', 'right', 'top', 'bottom'] + +const contains = (rect: Rect, point: Point) => + point.x >= rect.x && + point.x <= rect.x + rect.width && + point.y >= rect.y && + point.y <= rect.y + rect.height + +/** + * Rect of the drop zone on one edge of the layout, centred on that edge. + * @param container - The layout's container rect (`snapshot.rects.container`). + * @param side - The edge. + * @param options - Zone length (share of the edge) and thickness. + * @returns The zone rect, in layout-root coordinates. + * @example + * const zone = getRootDropZoneRect(snapshot.rects.container, 'left') + */ +export function getRootDropZoneRect( + container: Rect, + side: Side, + options: RootDropZoneOptions = {} +): Rect { + const { length = 0.25, thickness = 8 } = options + const { x, y, width, height } = container + switch (side) { + case 'left': + return { + x, + y: y + (height * (1 - length)) / 2, + width: thickness, + height: height * length, + } + case 'right': + return { + x: x + width - thickness, + y: y + (height * (1 - length)) / 2, + width: thickness, + height: height * length, + } + case 'top': + return { + x: x + (width * (1 - length)) / 2, + y, + width: width * length, + height: thickness, + } + case 'bottom': + return { + x: x + (width * (1 - length)) / 2, + y: y + height - thickness, + width: width * length, + height: thickness, + } + } +} + +/** Thirds of a tabset: the outer thirds drop beside it, the middle into it. */ +export function getTabsetDropPosition( + rect: Rect, + point: Point +): Side | 'center' { + const x = point.x - rect.x + const y = point.y - rect.y + if (x < rect.width / 3) return 'left' + if (x > (2 * rect.width) / 3) return 'right' + if (y < rect.height / 3) return 'top' + if (y > (2 * rect.height) / 3) return 'bottom' + return 'center' +} + +/** The tab a pointer over a tab bar would insert next to. */ +function getTabBarTarget( + tabs: readonly { readonly id: string; readonly rect: Rect }[], + point: Point +): DropTarget | null { + if (tabs.length === 0) return null + const last = tabs[tabs.length - 1] + if (point.x > last.rect.x + last.rect.width) + return { type: 'tab', tabId: last.id, position: 'after' } + if (point.x < tabs[0].rect.x) + return { type: 'tab', tabId: tabs[0].id, position: 'before' } + + let closest = tabs[0] + let distance = Infinity + for (const tab of tabs) { + const center = tab.rect.x + tab.rect.width / 2 + if (Math.abs(point.x - center) < distance) { + distance = Math.abs(point.x - center) + closest = tab + } + } + const center = closest.rect.x + closest.rect.width / 2 + return { + type: 'tab', + tabId: closest.id, + position: point.x < center ? 'before' : 'after', + } +} + +/** + * Where a drag source would land at `point`, or `null` when nothing valid is + * under the pointer. Checked in the order the adapters stack their elements: + * root-edge zones, then tab bars (a folded strip always means "into it"), + * then tabset areas split into thirds. + */ +export function getDropTarget( + model: LayoutModel, + rects: LayoutRects, + point: Point, + source: MoveSource, + measurements?: DropMeasurements, + options?: RootDropZoneOptions +): DropTarget | null { + const valid = (target: DropTarget | null) => + target && canMove(model.root, source, target) ? target : null + + for (const side of SIDES) { + if ( + contains(getRootDropZoneRect(rects.container, side, options), point) + ) + return valid({ type: 'root', position: side }) + } + + const tabsets = collectTabsets(model.root) + for (const tabset of tabsets) { + const bar = measurements?.tabBars.get(tabset.id) + if (!bar || !contains(bar.rect, point)) continue + if (bar.isRotated || tabset.isFolded) + return valid({ + type: 'tabset', + tabsetId: tabset.id, + position: 'center', + }) + return valid(getTabBarTarget(bar.tabs, point)) + } + + for (const tabset of tabsets) { + if (model.maximizedTabsetId && model.maximizedTabsetId !== tabset.id) + continue + const rect = rects.tabsets.get(tabset.id) + if (!rect || !contains(rect, point)) continue + const position = tabset.isFolded + ? 'center' + : getTabsetDropPosition(rect, point) + return valid({ type: 'tabset', tabsetId: tabset.id, position }) + } + return null +} diff --git a/packages/core/src/drop/drop-targets.ts b/packages/core/src/drop/drop-targets.ts new file mode 100644 index 0000000..85e7114 --- /dev/null +++ b/packages/core/src/drop/drop-targets.ts @@ -0,0 +1,29 @@ +import type { DropTarget, LayoutModel, Side } from '../model/types' +import { collectTabsets } from '../tree/find' +import { canMove, type MoveSource } from '../tree/move' + +const POSITIONS: readonly (Side | 'center')[] = [ + 'center', + 'left', + 'right', + 'top', + 'bottom', +] +const ROOT_SIDES: readonly Side[] = ['left', 'right', 'top', 'bottom'] + +/** + * Every valid target for a source, in reading order: each tabset's center and + * sides, then the layout edges. Used by keyboard move mode to cycle targets. + */ +export function listDropTargets( + model: LayoutModel, + source: MoveSource +): DropTarget[] { + const targets: DropTarget[] = [] + for (const tabset of collectTabsets(model.root)) { + for (const position of POSITIONS) + targets.push({ type: 'tabset', tabsetId: tabset.id, position }) + } + for (const position of ROOT_SIDES) targets.push({ type: 'root', position }) + return targets.filter((target) => canMove(model.root, source, target)) +} diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 0000000..3273e36 --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,46 @@ +/** Codes of errors thrown by the layout. */ +export type DynamixLayoutErrorCode = + 'INVALID_LAYOUT' | 'UNSUPPORTED_VERSION' | 'DUPLICATE_TAB_ID' + +/** + * Thrown for invalid input: malformed saved layouts, unsupported versions, + * and duplicate tab ids in development. Actions never throw. + * @example + * try { + * layout.load(JSON.parse(stored)) + * } catch (error) { + * if (error instanceof DynamixLayoutError) console.warn(error.code, error.path) + * else throw error + * } + */ +export class DynamixLayoutError extends Error { + readonly code: DynamixLayoutErrorCode + /** Where in the input the problem is, e.g. `root.children[1].weight`. */ + readonly path?: string + + constructor(code: DynamixLayoutErrorCode, message: string, path?: string) { + super(path ? `${message} (at ${path})` : message) + this.name = 'DynamixLayoutError' + this.code = code + this.path = path + } +} + +/** Codes of non-fatal problems reported through `onWarning`. */ +export type LayoutWarningCode = + | 'MIGRATED_FROM_V1' + | 'DUPLICATE_TAB_ID' + | 'INVALID_ACTIVE_TAB' + | 'INVALID_WEIGHT' + | 'UNKNOWN_ID' + | 'DESTROYED' + +/** A non-fatal problem, such as a repaired saved layout. */ +export interface LayoutWarning { + readonly code: LayoutWarningCode + readonly message: string + readonly path?: string +} + +/** Receives warnings. */ +export type WarningHandler = (warning: LayoutWarning) => void diff --git a/packages/core/src/geometry/compute-rects.ts b/packages/core/src/geometry/compute-rects.ts new file mode 100644 index 0000000..91ad6e5 --- /dev/null +++ b/packages/core/src/geometry/compute-rects.ts @@ -0,0 +1,184 @@ +import { isRow } from '../model/guards' +import type { Rect, RowNode } from '../model/types' +import type { GeometryConfig } from './config' +import { computeMinSizes, requiredRootSize, type MinSize } from './min-size' + +/** Every rect of a layout, in layout-root coordinates. */ +export interface LayoutRects { + /** Area the layout fills; larger than the container when it can't fit. */ + readonly container: Rect + readonly rows: ReadonlyMap + /** As displayed: a maximized tabset gets the whole container. */ + readonly tabsets: ReadonlyMap + readonly splitters: ReadonlyMap +} + +/** Id of the splitter between two adjacent children of a row. */ +export const splitterId = (beforeId: string, afterId: string) => + `${beforeId}~${afterId}` + +/** Same keys in the same order, each mapped to the same object. */ +const sameEntries = ( + a: ReadonlyMap, + b: ReadonlyMap +) => { + if (a.size !== b.size) return false + const other = b.entries() + for (const [id, rect] of a) { + const [otherId, otherRect] = other.next().value! + if (id !== otherId || rect !== otherRect) return false + } + return true +} + +const sameRect = (a: Rect | undefined, b: Rect) => + !!a && + a.x === b.x && + a.y === b.y && + a.width === b.width && + a.height === b.height + +/** + * Positions every row, tabset and splitter. Each row gives its children their + * minimum size plus a share of the remaining space proportional to their + * weights (folded tabsets get no share). Boundaries are rounded cumulatively, + * so every edge is within half a pixel of its exact position and edges outside + * a resized pair never move. Ported from v1 `Node.calcDimensions`. + * + * When the container is smaller than the layout's minimum, the layout grows + * past it (and is clipped by the adapter), as in v1. + * + * @param previous - Rects to reuse by reference when unchanged. + */ +export function computeLayoutRects( + root: RowNode, + container: Rect, + config: GeometryConfig, + maximizedTabsetId: string | null, + previous?: LayoutRects +): LayoutRects { + const minSizes = computeMinSizes(root, config) + const required = requiredRootSize(minSizes.get(root.id)!, config) + const rootRect: Rect = { + x: container.x, + y: container.y, + width: Math.max(container.width, required.width), + height: Math.max(container.height, required.height), + } + + const rows = new Map() + const tabsets = new Map() + const splitters = new Map() + const keep = ( + map: Map, + old: ReadonlyMap | undefined, + id: string, + rect: Rect + ) => map.set(id, sameRect(old?.get(id), rect) ? old!.get(id)! : rect) + + const layoutRow = (row: RowNode, rect: Rect) => { + keep(rows, previous?.rows, row.id, rect) + const horizontal = row.direction === 'horizontal' + const rowMin = minSizes.get(row.id)! + const extraSpace = horizontal + ? rect.width - rowMin.width + : rect.height - rowMin.height + const isBelowMin = extraSpace < 0 + + let totalWeight = 0 + let lastFlexId: string | undefined + let flexCount = 0 + for (const child of row.children) { + if (!isRow(child) && child.isFolded) continue + totalWeight += child.weight + lastFlexId = child.id + flexCount++ + } + // Every open child at weight 0 (each dragged to its minimum): share + // equally rather than leaving the space unassigned. + const allZero = totalWeight === 0 + const weightOf = (weight: number) => (allZero ? 1 : weight) + if (allZero) totalWeight = flexCount + + let offset = 0 + let cumulativeWeight = 0 + let previousBoundary = 0 + row.children.forEach((child, index) => { + const childMin: MinSize = minSizes.get(child.id)! + const isFlexible = isRow(child) || !child.isFolded + let extra = 0 + if (!isBelowMin && isFlexible && totalWeight > 0) { + cumulativeWeight += weightOf(child.weight) + const boundary = + child.id === lastFlexId + ? extraSpace + : Math.round( + (extraSpace * cumulativeWeight) / totalWeight + ) + extra = boundary - previousBoundary + previousBoundary = boundary + } + + const childRect: Rect = horizontal + ? { + x: rect.x + offset, + y: rect.y, + width: childMin.width + extra, + height: rect.height, + } + : { + x: rect.x, + y: rect.y + offset, + width: rect.width, + height: childMin.height + extra, + } + offset += + (horizontal ? childRect.width : childRect.height) + + config.splitterSize + + if (isRow(child)) layoutRow(child, childRect) + else keep(tabsets, previous?.tabsets, child.id, childRect) + + const next = row.children[index + 1] + if (next) { + const splitter: Rect = horizontal + ? { + x: childRect.x + childRect.width, + y: childRect.y, + width: config.splitterSize, + height: childRect.height, + } + : { + x: childRect.x, + y: childRect.y + childRect.height, + width: childRect.width, + height: config.splitterSize, + } + keep( + splitters, + previous?.splitters, + splitterId(child.id, next.id), + splitter + ) + } + }) + } + + layoutRow(root, rootRect) + if (maximizedTabsetId && tabsets.has(maximizedTabsetId)) { + keep(tabsets, previous?.tabsets, maximizedTabsetId, rootRect) + } + + const containerRect = sameRect(previous?.container, rootRect) + ? previous!.container + : rootRect + if ( + previous && + containerRect === previous.container && + sameEntries(rows, previous.rows) && + sameEntries(tabsets, previous.tabsets) && + sameEntries(splitters, previous.splitters) + ) + return previous + return { container: containerRect, rows, tabsets, splitters } +} diff --git a/packages/core/src/geometry/config.ts b/packages/core/src/geometry/config.ts new file mode 100644 index 0000000..beb8f97 --- /dev/null +++ b/packages/core/src/geometry/config.ts @@ -0,0 +1,16 @@ +/** Sizes the geometry depends on. */ +export interface GeometryConfig { + /** Smallest width and height of a tabset. */ + readonly minPanelSize: { readonly width: number; readonly height: number } + /** Thickness of the splitters between siblings. */ + readonly splitterSize: number + /** Size of a folded tabset along its row (usually the tab bar height). */ + readonly foldedSize: number +} + +/** Default sizes, the same as v1. */ +export const DEFAULT_GEOMETRY: GeometryConfig = { + minPanelSize: { width: 40, height: 40 }, + splitterSize: 10, + foldedSize: 40, +} diff --git a/packages/core/src/geometry/min-size.ts b/packages/core/src/geometry/min-size.ts new file mode 100644 index 0000000..7e627f2 --- /dev/null +++ b/packages/core/src/geometry/min-size.ts @@ -0,0 +1,101 @@ +import { isRow } from '../model/guards' +import type { Direction, RowChild, RowNode } from '../model/types' +import type { GeometryConfig } from './config' + +/** Minimum size of a node, plus how many tabsets it stacks along each axis. */ +export interface MinSize { + readonly width: number + readonly height: number + /** Tabsets inside rows of each direction (v1 `tabCnts`). */ + readonly horizontalCount: number + readonly verticalCount: number +} + +/** + * Minimum sizes of every row and tabset, keyed by id. A tabset needs + * `minPanelSize` (a folded one only `foldedSize` along its row); a row needs + * the sum of its children along its direction plus splitters, and the largest + * child across it. Ported from v1 `calcTabsetCountAndMinDim`. + */ +export function computeMinSizes( + root: RowNode, + config: GeometryConfig +): Map { + const sizes = new Map() + const { width: minWidth, height: minHeight } = config.minPanelSize + + const visit = (node: RowChild, parentDirection: Direction): MinSize => { + let size: MinSize + if (!isRow(node)) { + const alongHorizontal = parentDirection === 'horizontal' + size = node.isFolded + ? alongHorizontal + ? { + width: config.foldedSize, + height: minHeight, + horizontalCount: 0, + verticalCount: 0, + } + : { + width: minWidth, + height: config.foldedSize, + horizontalCount: 0, + verticalCount: 0, + } + : { + width: minWidth, + height: minHeight, + horizontalCount: alongHorizontal ? 1 : 0, + verticalCount: alongHorizontal ? 0 : 1, + } + } else { + let width = 0 + let height = 0 + let horizontalCount = 0 + let verticalCount = 0 + for (const child of node.children) { + const childSize = visit(child, node.direction) + horizontalCount += childSize.horizontalCount + verticalCount += childSize.verticalCount + if (node.direction === 'horizontal') { + width += childSize.width + height = Math.max(height, childSize.height) + } else { + height += childSize.height + width = Math.max(width, childSize.width) + } + } + const splitters = (node.children.length - 1) * config.splitterSize + if (node.direction === 'horizontal') width += splitters + else height += splitters + size = { width, height, horizontalCount, verticalCount } + } + sizes.set(node.id, size) + return size + } + + visit(root, root.direction) + return sizes +} + +/** + * Size the root needs. Like v1 `getReqDimension`, this also requires room for + * every tabset counted along each axis. + */ +export function requiredRootSize( + rootMin: MinSize, + config: GeometryConfig +): { width: number; height: number } { + const along = (count: number, size: number) => + count * size + Math.max(0, count - 1) * config.splitterSize + return { + width: Math.max( + rootMin.width, + along(rootMin.horizontalCount, config.minPanelSize.width) + ), + height: Math.max( + rootMin.height, + along(rootMin.verticalCount, config.minPanelSize.height) + ), + } +} diff --git a/packages/core/src/geometry/splitter.ts b/packages/core/src/geometry/splitter.ts new file mode 100644 index 0000000..6d4dc26 --- /dev/null +++ b/packages/core/src/geometry/splitter.ts @@ -0,0 +1,141 @@ +import { isRow } from '../model/guards' +import type { Point, RowChild, RowNode } from '../model/types' +import { replaceNode, withChildren } from '../tree/update' +import type { GeometryConfig } from './config' +import { splitterId, type LayoutRects } from './compute-rects' +import { computeMinSizes } from './min-size' + +/** The row and the two children a splitter sits between. */ +export interface SplitterPair { + readonly row: RowNode + readonly before: RowChild + readonly after: RowChild +} + +/** Finds the row and neighbours of a splitter id (`before~after`). */ +export function findSplitterPair( + root: RowNode, + id: string +): SplitterPair | null { + const visit = (row: RowNode): SplitterPair | null => { + for (let i = 0; i < row.children.length; i++) { + const child = row.children[i] + const next = row.children[i + 1] + if (next && splitterId(child.id, next.id) === id) + return { row, before: child, after: next } + if (isRow(child)) { + const found = visit(child) + if (found) return found + } + } + return null + } + return visit(root) +} + +const isFolded = (node: RowChild) => !isRow(node) && node.isFolded + +/** Whether a splitter can't move because a neighbour is folded. */ +export function isSplitterLocked(pair: SplitterPair): boolean { + return isFolded(pair.before) || isFolded(pair.after) +} + +const rectOf = (rects: LayoutRects, node: RowChild) => + (isRow(node) ? rects.rows : rects.tabsets).get(node.id) + +/** + * Splitter position and limits along its row, in px: `value` is the size of + * the panel before it, `min`/`max` the limits set by both neighbours' minimum + * sizes. + */ +export function getSplitterBounds( + root: RowNode, + rects: LayoutRects, + config: GeometryConfig, + id: string +): { value: number; min: number; max: number } | null { + const pair = findSplitterPair(root, id) + if (!pair) return null + const before = rectOf(rects, pair.before) + const after = rectOf(rects, pair.after) + if (!before || !after) return null + const minSizes = computeMinSizes(root, config) + const horizontal = pair.row.direction === 'horizontal' + const size = (rect: { width: number; height: number }) => + horizontal ? rect.width : rect.height + const minOf = (node: RowChild) => { + const min = minSizes.get(node.id)! + return horizontal ? min.width : min.height + } + return { + value: size(before), + min: minOf(pair.before), + max: size(before) + size(after) - minOf(pair.after), + } +} + +/** + * Moves a splitter so its centre is at `point`, clamped so both neighbours + * keep their minimum size. Only the two neighbours' weights change, split in + * proportion to their space above the minimum, so nothing outside the pair + * moves. Ported from v1 `updateSliderDimension`. + * + * @returns The new root, or `null` when the splitter doesn't exist or is locked. + */ +export function resizeSplitterWeights( + root: RowNode, + rects: LayoutRects, + config: GeometryConfig, + id: string, + point: Point +): RowNode | null { + const pair = findSplitterPair(root, id) + if (!pair || isSplitterLocked(pair)) return null + const beforeRect = rectOf(rects, pair.before) + const afterRect = rectOf(rects, pair.after) + if (!beforeRect || !afterRect) return null + + const minSizes = computeMinSizes(root, config) + const horizontal = pair.row.direction === 'horizontal' + const start = horizontal ? beforeRect.x : beforeRect.y + const end = horizontal + ? afterRect.x + afterRect.width + : afterRect.y + afterRect.height + const beforeMinSize = minSizes.get(pair.before.id)! + const afterMinSize = minSizes.get(pair.after.id)! + const beforeMin = horizontal ? beforeMinSize.width : beforeMinSize.height + const afterMin = horizontal ? afterMinSize.width : afterMinSize.height + const half = config.splitterSize / 2 + + const position = horizontal ? point.x : point.y + const clamped = Math.max( + start + beforeMin + half, + Math.min(end - afterMin - half, position) + ) + const beforeSize = clamped - half - start + const afterSize = end - (clamped + half) + + const beforeExtra = Math.max(0, beforeSize - beforeMin) + const afterExtra = Math.max(0, afterSize - afterMin) + const totalExtra = beforeExtra + afterExtra + const totalWeight = pair.before.weight + pair.after.weight + const beforeWeight = + totalExtra > 0 + ? (beforeExtra / totalExtra) * totalWeight + : totalWeight / 2 + const afterWeight = + totalExtra > 0 + ? (afterExtra / totalExtra) * totalWeight + : totalWeight / 2 + + const children = pair.row.children.map((child) => + child === pair.before + ? { ...child, weight: beforeWeight } + : child === pair.after + ? { ...child, weight: afterWeight } + : child + ) + return replaceNode(root, pair.row.id, () => + withChildren(pair.row, children) + ) +} diff --git a/packages/core/src/geometry/tab-bar.ts b/packages/core/src/geometry/tab-bar.ts new file mode 100644 index 0000000..525f757 --- /dev/null +++ b/packages/core/src/geometry/tab-bar.ts @@ -0,0 +1,57 @@ +import type { Direction, Rect } from '../model/types' + +/** Where a tabset's tab bar goes. */ +export interface TabBarPlacement { + /** Box to give the tab bar element before `isRotated` is applied. */ + readonly rect: Rect + /** + * A folded tabset in a side-by-side row shows its tab bar as a vertical + * strip: an element `height` wide and `width` tall, rotated 90° around its + * top-left corner and placed at the strip's right edge. + */ + readonly isRotated: boolean +} + +/** + * @param tabset - The tabset's rect and state. + * @param tabBarHeight - Height of a horizontal tab bar. + * @returns The rect to give the tab bar element, and whether to rotate it. + * @example + * const { rect, isRotated } = getTabBarPlacement(snapshot.rects.tabsets.get(id)!, snapshot.tabsets.get(id)!, 40) + * applyRect(tabBar, rect) + * tabBar.style.transform = isRotated ? 'rotate(90deg)' : '' + */ +export function getTabBarPlacement( + tabsetRect: Rect, + tabset: { readonly isFolded: boolean; readonly parentDirection: Direction }, + tabBarHeight: number +): TabBarPlacement { + const { x, y, width, height } = tabsetRect + if (tabset.isFolded && tabset.parentDirection === 'horizontal') { + return { + rect: { x: x + width, y, width: height, height: width }, + isRotated: true, + } + } + return { rect: { x, y, width, height: tabBarHeight }, isRotated: false } +} + +/** + * Area of a tabset below its tab bar; never negative when squeezed. + * @param tabsetRect - The tabset's rect. + * @param tabBarHeight - Height of its tab bar (0 without one). + * @returns The content area. + * @example + * applyRect(content, getTabContentRect(snapshot.rects.tabsets.get(tab.tabsetId)!, 40)) + */ +export function getTabContentRect( + tabsetRect: Rect, + tabBarHeight: number +): Rect { + return { + x: tabsetRect.x, + y: tabsetRect.y + tabBarHeight, + width: tabsetRect.width, + height: Math.max(0, tabsetRect.height - tabBarHeight), + } +} diff --git a/packages/core/src/ids.ts b/packages/core/src/ids.ts new file mode 100644 index 0000000..b1ccee9 --- /dev/null +++ b/packages/core/src/ids.ts @@ -0,0 +1,45 @@ +import type { CreateId } from './model/types' + +/** An id factory that also knows which ids are taken. */ +export interface IdGenerator { + readonly createId: CreateId + /** Marks ids from a loaded layout as taken. */ + reserve(ids: Iterable): void + /** Forgets every id, so rebuilding a layout produces the same ids again. */ + reset(): void +} + +const PREFIX = { row: 'row', tabset: 'ts' } as const + +/** + * Deterministic ids: the same calls produce the same ids on the server and + * the client. A tabset is named after a tab when a hint is given (`ts-editor`); + * otherwise a per-instance counter is used. Taken ids are always skipped. + */ +export function createIdGenerator(custom?: CreateId): IdGenerator { + const taken = new Set() + let counter = 0 + + const createId: CreateId = (kind, hint) => { + if (custom) { + const id = custom(kind, hint) + taken.add(id) + return id + } + let id = hint === undefined ? '' : `${PREFIX[kind]}-${hint}` + while (id === '' || taken.has(id)) id = `${PREFIX[kind]}-${++counter}` + taken.add(id) + return id + } + + return { + createId, + reserve(ids) { + for (const id of ids) taken.add(id) + }, + reset() { + taken.clear() + counter = 0 + }, + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 33546ca..d2333d1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,54 +1,21 @@ -import { DynamixLayoutCore } from './app/dynamix' - /** - * Self-executing function to log a message to the console when the module is imported. - * This provides information and a link back to the project for developers. + * @dynamix-layout/core: a framework-agnostic docking layout engine. + * + * Importing this module has no side effects. Everything here is public API; + * the tree, geometry and store modules are internal. */ -declare const __LICENSE__: string -;(() => { - if (typeof window === 'undefined') return - - const titleStyle = `color: #03A9F4; font-weight: bold; line-height: 1.2;` - const textStyle = 'color: #E0E0E0; font-size: 12px;' - const linkStyle = 'color: #42a5f5; font-size: 12px;' - const licenseStyle = 'color: #9E9E9E; font-size: 10px;' - - const asciiArt = ` -██████╗ ██╗ ██╗ ███╗ ██╗ █████╗ ███╗ ███╗ ██████╗ ██╗ ██╗ -██╔══██╗╚██╗ ██╔╝ ████╗ ██║ ██╔══██╗ ████╗ ████║ ╚═██╔═╝ ╚██╗██╔╝ -██║ ██║ ╚████╔╝ ██╔██╗ ██║ ███████║ ██╔████╔██║ ██║ ╚███╔╝ -██║ ██║ ╚██╔╝ ██║╚██╗██║ ██╔══██║ ██║╚██╔╝██║ ██║ ██╔██╗ -██████╔╝ ██║ ██║ ╚████║ ██║ ██║ ██║ ╚═╝ ██║ ██████╗ ██╔╝ ██╗ -╚═════╝ ╚═╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ -██╗ █████╗ ██╗ ██╗ ██████╗ ██╗ ██╗████████╗ -██║ ██╔══██╗ ╚██╗ ██╔╝ ██╔═══██╗ ██║ ██║╚══██╔══╝ -██║ ███████║ ╚████╔╝ ██║ ██║ ██║ ██║ ██║ -██║ ██╔══██║ ╚██╔╝ ██║ ██║ ██║ ██║ ██║ -███████╗ ██║ ██║ ██║ ╚██████╔╝ ╚█████╔╝ ██║ -╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚════╝ ╚═╝ - ` +export * from './public-api' +export { + createFrameScheduler, + type FrameScheduler, +} from './dom/frame-scheduler' - console.groupCollapsed( - `%cDYNAMIX LAYOUT (click to expand)`, - 'color: #03A9F4; font-weight: bold; font-size: 14px;' - ) - console.log(`%c${asciiArt}`, titleStyle) - console.log('%c A powerful, dynamic layout system 🚀', textStyle) - console.log( - '%c ⚡ For docs and more info, \n ⚡ visit: https://github.com/xcode-studio/dynamix-layout', - linkStyle - ) - console.log(`%c${__LICENSE__}`, licenseStyle) - console.groupEnd() +declare const __VERSION__: string - if (typeof window !== 'undefined') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(window as any).__DYNAMIX_LAYOUT__ = { - __LICENSE__: __LICENSE__, - __LAYOUT__: DynamixLayoutCore, - } - } -})() - -export * from './app/index' -export type * from './type' +/** + * The version of this package, for debugging (replaces v1's console banner). + * @example + * console.info('dynamix-layout', version) + */ +export const version: string = + typeof __VERSION__ === 'undefined' ? 'dev' : __VERSION__ diff --git a/packages/core/src/layout-types.ts b/packages/core/src/layout-types.ts new file mode 100644 index 0000000..96d231b --- /dev/null +++ b/packages/core/src/layout-types.ts @@ -0,0 +1,121 @@ +import type { DropMeasurements } from './drop/drop-target' +import type { WarningHandler } from './errors' +import type { CreateId, DropTarget, Point, Rect } from './model/types' +import type { LayoutJSON, LayoutTreeV1 } from './serialize/schema' +import type { DragSource, LayoutSnapshot } from './store/snapshot' +import type { TabInit } from './tree/reconcile-tabs' + +/** Why `onLayoutChange` was called. */ +export type LayoutChangeReason = + 'move' | 'resize' | 'select' | 'fold' | 'maximize' | 'tabs' | 'reset' + +/** Sizes that can change after creation. */ +export interface LayoutSizeOptions { + /** Smallest size of a tabset. @default { width: 40, height: 40 } */ + minPanelSize?: { width: number; height: number } + /** Thickness of splitters. @default 10 */ + splitterSize?: number + /** Size of a folded tabset along its row. @default minPanelSize.height */ + foldedSize?: number +} + +/** Options for `createLayout`. */ +export interface LayoutOptions extends LayoutSizeOptions { + /** Open tabs. Their order only matters for the default layout. */ + tabs: readonly TabInit[] + /** + * A saved layout (v2, or a v1 tree that is migrated automatically). + * @default the default layout built from `tabs` + */ + initialLayout?: LayoutJSON | LayoutTreeV1 | null + /** + * Id factory for rows and tabsets. + * @default deterministic per instance (`ts-`, `row-`) + */ + createId?: CreateId + /** Called after every committed change; never for drag frames or `load`. */ + onLayoutChange?: (layout: LayoutJSON, reason: LayoutChangeReason) => void + /** @default `console.warn` in development, silent in production */ + onWarning?: WarningHandler +} + +/** Splitter position and limits in px along its row. */ +export interface SplitterBounds { + /** Size of the panel before the splitter. */ + readonly value: number + readonly min: number + readonly max: number +} + +/** + * A layout instance. Independent of every other instance; holds no timers and + * never touches the DOM. Actions return `true` when they changed the layout and + * `false` when they were refused or did nothing; they never throw. + */ +export interface Layout { + /** The current immutable snapshot. */ + getSnapshot(): LayoutSnapshot + /** Calls `listener` after every change. @returns A function that unsubscribes. */ + subscribe(listener: (snapshot: LayoutSnapshot) => void): () => void + /** Area to fill, in the coordinates every rect uses (`x`/`y` is usually the padding). */ + setContainerRect(rect: Rect): void + /** Replaces the open tabs: unlisted tabs are removed, new ones added and activated. */ + setTabs(tabs: readonly TabInit[]): void + /** Changes sizes without rebuilding anything. */ + setOptions(options: LayoutSizeOptions): void + + moveTab(tabId: string, target: DropTarget): boolean + moveTabset(tabsetId: string, target: DropTarget): boolean + selectTab(tabId: string): boolean + addTab(tab: TabInit, target?: DropTarget): boolean + removeTab(tabId: string): boolean + + /** Moves a splitter so its centre is at `point`, clamped to the neighbours' minimum sizes. */ + resizeSplitter(splitterId: string, point: Point): boolean + /** Moves a splitter by `delta` px along its row (keyboard resizing). */ + moveSplitterBy(splitterId: string, delta: number): boolean + + maximize(tabsetId: string): boolean + restore(): boolean + toggleMaximize(tabsetId: string): boolean + fold(tabsetId: string): boolean + unfold(tabsetId: string): boolean + toggleFold(tabsetId: string): boolean + + /** Starts a pointer or keyboard drag. Tab and tabset drags leave maximized mode first. */ + startDrag(source: DragSource): boolean + /** Updates the drop target (tabs) or splitter position (splitters); never calls `onLayoutChange`. */ + updateDrag(point: Point, measurements?: DropMeasurements): void + /** Sets the drop target directly (keyboard move mode). */ + setDragTarget( + target: DropTarget | null, + measurements?: DropMeasurements + ): void + /** Commits the drag with one `onLayoutChange`. @returns Whether the layout changed. */ + endDrag(): boolean + /** Ends the drag and undoes any splitter movement. */ + cancelDrag(): void + + getDropTarget( + point: Point, + source: DragSource, + measurements?: DropMeasurements + ): DropTarget | null + getDropIndicatorRect( + target: DropTarget, + measurements?: DropMeasurements + ): Rect | null + /** Every valid target for `source`, in reading order. */ + listDropTargets(source: DragSource): readonly DropTarget[] + /** For `aria-valuenow`, `aria-valuemin` and `aria-valuemax`. */ + getSplitterBounds(splitterId: string): SplitterBounds | null + + /** Replaces the layout (v1 or v2) without calling `onLayoutChange`. */ + load(layout: LayoutJSON | LayoutTreeV1): void + /** Back to `initialLayout`, or the default layout of the current tabs. */ + reset(): void + /** The current layout. The same object is returned until the layout changes. */ + toJSON(): LayoutJSON + /** Removes every listener; later actions are ignored with a warning. */ + destroy(): void +} diff --git a/packages/core/src/model/guards.ts b/packages/core/src/model/guards.ts new file mode 100644 index 0000000..8f7118c --- /dev/null +++ b/packages/core/src/model/guards.ts @@ -0,0 +1,11 @@ +import type { LayoutNode, RowNode, TabNode, TabsetNode } from './types' + +/** @returns `true` when `node` is a row. */ +export const isRow = (node: LayoutNode): node is RowNode => node.type === 'row' + +/** @returns `true` when `node` is a tabset. */ +export const isTabset = (node: LayoutNode): node is TabsetNode => + node.type === 'tabset' + +/** @returns `true` when `node` is a tab. */ +export const isTab = (node: LayoutNode): node is TabNode => node.type === 'tab' diff --git a/packages/core/src/model/types.ts b/packages/core/src/model/types.ts new file mode 100644 index 0000000..793d07f --- /dev/null +++ b/packages/core/src/model/types.ts @@ -0,0 +1,89 @@ +/** + * Direction a row lays out its children in. + * `'horizontal'` places them side by side; `'vertical'` stacks them. + */ +export type Direction = 'horizontal' | 'vertical' + +/** A point in layout-root coordinates (the root's top-left corner is 0,0). */ +export interface Point { + readonly x: number + readonly y: number +} + +/** A rectangle in layout-root coordinates. */ +export interface Rect { + readonly x: number + readonly y: number + readonly width: number + readonly height: number +} + +/** A side of a tabset or of the whole layout. */ +export type Side = 'top' | 'bottom' | 'left' | 'right' + +/** A tab. Its `id` is the id the application gave the tab. */ +export interface TabNode { + readonly type: 'tab' + readonly id: string +} + +/** A group of tabs that shows one of them at a time. */ +export interface TabsetNode { + readonly type: 'tabset' + readonly id: string + /** + * Share of the parent row's space above the minimum sizes, like CSS + * `flex-grow` with `flex-basis` set to the minimum size. + */ + readonly weight: number + /** Always the id of one of `children`. */ + readonly activeTabId: string + /** Folded to a strip along its row. */ + readonly isFolded: boolean + /** Never empty. */ + readonly children: readonly TabNode[] +} + +/** A row of tabsets and rows separated by splitters. */ +export interface RowNode { + readonly type: 'row' + readonly id: string + /** See {@link TabsetNode.weight}. Ignored on the root. */ + readonly weight: number + readonly direction: Direction + readonly children: readonly (RowNode | TabsetNode)[] +} + +/** Any node of the layout tree. */ +export type LayoutNode = RowNode | TabsetNode | TabNode + +/** A child of a row. */ +export type RowChild = RowNode | TabsetNode + +/** The whole layout: the tree plus view state that is not part of any node. */ +export interface LayoutModel { + readonly root: RowNode + /** Id of the maximized tabset, or `null`. */ + readonly maximizedTabsetId: string | null + /** Most recently focused or used tabset; new tabs go here. */ + readonly lastActiveTabsetId: string | null + /** Fold order, so the most recently folded tabset can be unfolded first. */ + readonly foldOrder: readonly string[] +} + +/** Generates ids for new rows and tabsets. */ +export type CreateId = (kind: 'row' | 'tabset', hint?: string) => string + +/** Where a dragged tab or tabset is dropped. */ +export type DropTarget = + | { + readonly type: 'tabset' + readonly tabsetId: string + readonly position: Side | 'center' + } + | { + readonly type: 'tab' + readonly tabId: string + readonly position: 'before' | 'after' + } + | { readonly type: 'root'; readonly position: Side } diff --git a/packages/core/src/public-api.ts b/packages/core/src/public-api.ts new file mode 100644 index 0000000..dac7f48 --- /dev/null +++ b/packages/core/src/public-api.ts @@ -0,0 +1,60 @@ +/** The public API. Internal modules (tree, geometry, store) are not exported. */ + +export { createLayout } from './create-layout' +export type { + Layout, + LayoutChangeReason, + LayoutOptions, + LayoutSizeOptions, + SplitterBounds, +} from './layout-types' +export type { + CreateId, + Direction, + DropTarget, + LayoutNode, + Point, + Rect, + RowNode, + Side, + TabNode, + TabsetNode, +} from './model/types' +export type { + DragSource, + DragState, + LayoutSnapshot, + SplitterState, + TabState, + TabsetState, +} from './store/snapshot' +export type { LayoutRects } from './geometry/compute-rects' +export type { TabInit } from './tree/reconcile-tabs' +export type { + DropMeasurements, + RootDropZoneOptions, + TabBarMeasurement, +} from './drop/drop-target' +export { getRootDropZoneRect } from './drop/drop-target' +export { + getTabBarPlacement, + getTabContentRect, + type TabBarPlacement, +} from './geometry/tab-bar' +export { applyRect } from './dom/apply-rect' +export { + DynamixLayoutError, + type DynamixLayoutErrorCode, + type LayoutWarning, + type LayoutWarningCode, + type WarningHandler, +} from './errors' +export { + LAYOUT_VERSION, + type LayoutJSON, + type LayoutTreeV1, + type RowJSON, + type TabJSON, + type TabsetJSON, +} from './serialize/schema' +export { isLayoutV1, migrateLayoutFromV1 } from './serialize/migrate-v1' diff --git a/packages/core/src/serialize/from-json.ts b/packages/core/src/serialize/from-json.ts new file mode 100644 index 0000000..72f863c --- /dev/null +++ b/packages/core/src/serialize/from-json.ts @@ -0,0 +1,151 @@ +import { DynamixLayoutError, type WarningHandler } from '../errors' +import type { + Direction, + LayoutModel, + RowChild, + RowNode, + TabsetNode, +} from '../model/types' +import { normalizeTree } from '../tree/normalize' +import { normalizeViewState } from '../tree/view-state' +import type { LayoutJSON } from './schema' + +type Json = Record + +const isObject = (value: unknown): value is Json => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const fail = (message: string, path: string): never => { + throw new DynamixLayoutError('INVALID_LAYOUT', message, path) +} + +const DIRECTIONS: readonly Direction[] = ['horizontal', 'vertical'] + +/** + * Validates a v2 layout and turns it into a normalized model. Problems that + * can be repaired (invalid weights or active tabs, duplicate tabs) are + * repaired and reported; anything else throws. + * + * @throws {DynamixLayoutError} `INVALID_LAYOUT` with the path of the problem. + */ +export function layoutFromJSON( + input: LayoutJSON, + onWarning: WarningHandler +): Pick { + const seenNodes = new Set() + const seenTabs = new Set() + + const readId = (node: Json, path: string) => { + if (typeof node.id !== 'string' || node.id === '') + fail('Expected a non-empty string id', `${path}.id`) + const id = node.id as string + if (seenNodes.has(id)) fail(`Duplicate node id "${id}"`, `${path}.id`) + seenNodes.add(id) + return id + } + + const readWeight = (node: Json, path: string) => { + const weight = node.weight + if ( + typeof weight === 'number' && + Number.isFinite(weight) && + weight >= 0 + ) + return weight + onWarning({ + code: 'INVALID_WEIGHT', + message: `Invalid weight replaced by 100`, + path: `${path}.weight`, + }) + return 100 + } + + const readTabset = (node: Json, path: string): TabsetNode => { + const id = readId(node, path) + if (!Array.isArray(node.children)) + fail('Expected a children array', `${path}.children`) + const children = (node.children as unknown[]).flatMap((tab, i) => { + const tabPath = `${path}.children[${i}]` + if ( + !isObject(tab) || + tab.type !== 'tab' || + typeof tab.id !== 'string' || + tab.id === '' + ) + fail('Expected { type: "tab", id: string }', tabPath) + const tabId = (tab as Json).id as string + if (seenTabs.has(tabId)) { + onWarning({ + code: 'DUPLICATE_TAB_ID', + message: `Duplicate tab "${tabId}" dropped`, + path: tabPath, + }) + return [] + } + seenTabs.add(tabId) + return [{ type: 'tab' as const, id: tabId }] + }) + const active = node.activeTabId + if (active !== undefined && !children.some((tab) => tab.id === active)) + onWarning({ + code: 'INVALID_ACTIVE_TAB', + message: `Active tab "${String(active)}" is not in the tabset`, + path: `${path}.activeTabId`, + }) + return { + type: 'tabset', + id, + weight: readWeight(node, path), + activeTabId: + typeof active === 'string' ? active : (children[0]?.id ?? ''), + isFolded: node.isFolded === true, + children, + } + } + + const readRow = (node: Json, path: string): RowNode => { + const id = readId(node, path) + if (!DIRECTIONS.includes(node.direction as Direction)) + fail( + 'Expected direction "horizontal" or "vertical"', + `${path}.direction` + ) + if (!Array.isArray(node.children)) + fail('Expected a children array', `${path}.children`) + const children = (node.children as unknown[]).map( + (child, i): RowChild => { + const childPath = `${path}.children[${i}]` + if (!isObject(child)) + return fail('Expected a row or tabset', childPath) + if (child.type === 'row') return readRow(child, childPath) + if (child.type === 'tabset') return readTabset(child, childPath) + return fail( + 'Expected type "row" or "tabset"', + `${childPath}.type` + ) + } + ) + return { + type: 'row', + id, + weight: readWeight(node, path), + direction: node.direction as Direction, + children, + } + } + + if (!isObject(input.root) || input.root.type !== 'row') + fail('Expected a root row', 'root') + const root = normalizeTree(readRow(input.root as unknown as Json, 'root')) + const maximized = + typeof input.maximizedTabsetId === 'string' + ? input.maximizedTabsetId + : null + const model = normalizeViewState({ + root, + maximizedTabsetId: maximized, + lastActiveTabsetId: null, + foldOrder: [], + }) + return { root: model.root, maximizedTabsetId: model.maximizedTabsetId } +} diff --git a/packages/core/src/serialize/migrate-v1.ts b/packages/core/src/serialize/migrate-v1.ts new file mode 100644 index 0000000..266fada --- /dev/null +++ b/packages/core/src/serialize/migrate-v1.ts @@ -0,0 +1,185 @@ +import { DynamixLayoutError, type WarningHandler } from '../errors' +import type { Direction } from '../model/types' +import { layoutFromJSON } from './from-json' +import type { LayoutJSON, LayoutTreeV1, RowJSON, TabsetJSON } from './schema' +import { toLayoutJSON } from './to-json' + +/** + * @returns `true` when `input` looks like a layout saved by v1. + * @param input - Anything, e.g. parsed JSON from storage. + * @example + * const saved = JSON.parse(localStorage.getItem('layout') ?? 'null') + * const layout = isLayoutV1(saved) ? migrateLayoutFromV1(saved) : saved + */ +export function isLayoutV1(input: unknown): input is LayoutTreeV1 { + return ( + typeof input === 'object' && + input !== null && + !('version' in input) && + typeof (input as { typNode?: unknown }).typNode === 'string' + ) +} + +const isValidWeight = (part: unknown): part is number => + typeof part === 'number' && Number.isFinite(part) && part >= 0 + +/** + * Converts a layout saved by v1 to the v2 format, losslessly: + * - a tab's identity was its label (`nodName`), which becomes its `id`; v1 + * tab `uidNode`s were per-session and are dropped; + * - `nodPart` becomes `weight` (same meaning), so the layout renders + * pixel-identically; + * - directions, implicit in v1 (the root is horizontal and every level flips), + * become explicit; + * - `nodOpen`, `nodFold` and `nodMaxd` become `activeTabId`, `isFolded` and + * `maximizedTabsetId`. + * + * Damaged input is repaired where possible (see the migration guide): + * duplicate tabs keep their first occurrence, unknown active tabs fall back to + * the first tab, empty tabsets and rows are removed, stray tabs are wrapped in + * a tabset, and the result is normalized. + * + * @param tree - The v1 layout (`DynamixLayoutCore._root.toJSON()`). + * @returns The equivalent v2 layout. + * @throws {DynamixLayoutError} `INVALID_LAYOUT` when `tree` is not a v1 layout. + * @example + * const saved = JSON.parse(localStorage.getItem('layout')!) + * const layout = isLayoutV1(saved) ? migrateLayoutFromV1(saved) : saved + */ +export function migrateLayoutFromV1( + tree: LayoutTreeV1, + options: { onWarning?: WarningHandler } = {} +): LayoutJSON { + const warn: WarningHandler = options.onWarning ?? (() => {}) + const weightOf = (node: LayoutTreeV1, path: string) => { + if (isValidWeight(node.nodPart)) return node.nodPart + warn({ + code: 'INVALID_WEIGHT', + message: 'Invalid weight replaced by 100', + path: `${path}.nodPart`, + }) + return 100 + } + if (!isLayoutV1(tree)) + throw new DynamixLayoutError('INVALID_LAYOUT', 'Not a v1 layout', '') + + const usedIds = new Set() + const collect = (node: LayoutTreeV1) => { + if (typeof node.uidNode === 'string') usedIds.add(node.uidNode) + node.nodKids?.forEach(collect) + } + collect(tree) + const freshId = (base: string) => { + let id = base + for (let n = 1; usedIds.has(id); n++) id = `${base}-${n}` + usedIds.add(id) + return id + } + const nodeId = (node: LayoutTreeV1, fallback: string) => + typeof node.uidNode === 'string' && node.uidNode !== '' + ? node.uidNode + : freshId(fallback) + + const seenTabs = new Set() + const readTabs = (kids: readonly LayoutTreeV1[], path: string) => + kids.flatMap((kid, i) => { + if (kid?.typNode !== 'tab') return [] + const id = kid.nodName + if (typeof id !== 'string' || id === '' || seenTabs.has(id)) { + warn({ + code: 'DUPLICATE_TAB_ID', + message: `Duplicate or unnamed tab "${String(id)}" dropped`, + path: `${path}.nodKids[${i}]`, + }) + return [] + } + seenTabs.add(id) + return [{ type: 'tab' as const, id }] + }) + + const toTabset = (node: LayoutTreeV1, path: string): TabsetJSON => { + const children = readTabs(node.nodKids ?? [], path) + const open = typeof node.nodOpen === 'string' ? node.nodOpen : '' + const hasOpen = children.some((tab) => tab.id === open) + if (open && !hasOpen && children.length > 0) + warn({ + code: 'INVALID_ACTIVE_TAB', + message: `Active tab "${open}" is not in the tabset`, + path: `${path}.nodOpen`, + }) + return { + type: 'tabset', + id: nodeId(node, 'ts'), + weight: weightOf(node, path), + ...(children.length > 0 + ? { activeTabId: hasOpen ? open : children[0].id } + : {}), + ...(node.nodFold === true ? { isFolded: true } : {}), + children, + } + } + + const toRow = ( + node: LayoutTreeV1, + depth: number, + path: string + ): RowJSON => { + const direction: Direction = depth % 2 === 0 ? 'horizontal' : 'vertical' + const children: RowJSON['children'] = [] + ;(node.nodKids ?? []).forEach((kid, i) => { + const kidPath = `${path}.nodKids[${i}]` + if (kid?.typNode === 'row') + children.push(toRow(kid, depth + 1, kidPath)) + else if (kid?.typNode === 'tabset') + children.push(toTabset(kid, kidPath)) + else if (kid?.typNode === 'tab') + children.push({ + ...toTabset( + { + ...kid, + typNode: 'tabset', + uidNode: freshId(`ts-${kid.nodName}`), + nodKids: [kid], + nodOpen: kid.nodName, + }, + kidPath + ), + }) + }) + return { + type: 'row', + id: nodeId(node, 'row'), + weight: weightOf(node, path), + direction, + children, + } + } + + const root: RowJSON = + tree.typNode === 'row' + ? toRow(tree, 0, 'root') + : tree.typNode === 'tabset' + ? { + type: 'row', + id: freshId('root'), + weight: 100, + direction: 'horizontal', + children: [toTabset(tree, 'root')], + } + : (() => { + throw new DynamixLayoutError( + 'INVALID_LAYOUT', + `A v1 layout root must be a row, not "${tree.typNode}"`, + 'root' + ) + })() + + const json: LayoutJSON = { + version: 2, + root, + ...(typeof tree.nodMaxd === 'string' + ? { maximizedTabsetId: tree.nodMaxd } + : {}), + } + return toLayoutJSON(layoutFromJSON(json, warn)) +} diff --git a/packages/core/src/serialize/parse.ts b/packages/core/src/serialize/parse.ts new file mode 100644 index 0000000..693493d --- /dev/null +++ b/packages/core/src/serialize/parse.ts @@ -0,0 +1,44 @@ +import { DynamixLayoutError, type WarningHandler } from '../errors' +import type { LayoutModel } from '../model/types' +import { layoutFromJSON } from './from-json' +import { isLayoutV1, migrateLayoutFromV1 } from './migrate-v1' +import { LAYOUT_VERSION, type LayoutJSON } from './schema' + +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +/** + * Accepts a v2 layout or a v1 tree (migrated, with a `MIGRATED_FROM_V1` + * warning) and returns a normalized model. + * + * @throws {DynamixLayoutError} `UNSUPPORTED_VERSION` for other versions, + * `INVALID_LAYOUT` for anything malformed. + */ +export function parseLayout( + input: unknown, + onWarning: WarningHandler +): Pick { + if (isLayoutV1(input)) { + onWarning({ + code: 'MIGRATED_FROM_V1', + message: + 'Loaded a v1 layout; save it again to store the v2 format.', + }) + return layoutFromJSON( + migrateLayoutFromV1(input, { onWarning }), + onWarning + ) + } + if (!isObject(input)) + throw new DynamixLayoutError( + 'INVALID_LAYOUT', + 'Expected a layout object' + ) + if (input.version !== LAYOUT_VERSION) + throw new DynamixLayoutError( + 'UNSUPPORTED_VERSION', + `Unsupported layout version ${String(input.version)}; expected ${LAYOUT_VERSION}`, + 'version' + ) + return layoutFromJSON(input as unknown as LayoutJSON, onWarning) +} diff --git a/packages/core/src/serialize/schema.ts b/packages/core/src/serialize/schema.ts new file mode 100644 index 0000000..a73947f --- /dev/null +++ b/packages/core/src/serialize/schema.ts @@ -0,0 +1,54 @@ +import type { Direction } from '../model/types' + +/** Version written by {@link toLayoutJSON}. */ +export const LAYOUT_VERSION = 2 + +/** A saved layout. Splitters and pixel sizes are not stored. */ +export interface LayoutJSON { + version: 2 + root: RowJSON + /** Id of the maximized tabset, if any. */ + maximizedTabsetId?: string +} + +/** A saved row. */ +export interface RowJSON { + type: 'row' + id: string + /** Share of the parent's space above the minimum sizes (like CSS flex-grow). */ + weight: number + direction: Direction + children: (RowJSON | TabsetJSON)[] +} + +/** A saved tabset. */ +export interface TabsetJSON { + type: 'tabset' + id: string + weight: number + activeTabId?: string + /** Written only when true. */ + isFolded?: boolean + children: TabJSON[] +} + +/** A saved tab: only the id the application gave it. */ +export interface TabJSON { + type: 'tab' + id: string +} + +/** + * A layout saved by v1 (`DynamixLayoutCore._root.toJSON()`). Accepted + * everywhere a {@link LayoutJSON} is, and migrated automatically. + */ +export interface LayoutTreeV1 { + typNode: 'row' | 'tabset' | 'tab' | 'bond' + nodName: string + uidNode: string + nodPart: number + nodOpen?: string | boolean + nodKids?: readonly LayoutTreeV1[] + nodFold?: boolean + nodMaxd?: string +} diff --git a/packages/core/src/serialize/to-json.ts b/packages/core/src/serialize/to-json.ts new file mode 100644 index 0000000..f1278db --- /dev/null +++ b/packages/core/src/serialize/to-json.ts @@ -0,0 +1,38 @@ +import { isRow } from '../model/guards' +import type { LayoutModel, RowChild, RowNode, TabsetNode } from '../model/types' +import type { LayoutJSON, RowJSON, TabsetJSON } from './schema' + +const tabsetToJSON = (tabset: TabsetNode): TabsetJSON => ({ + type: 'tabset', + id: tabset.id, + weight: tabset.weight, + activeTabId: tabset.activeTabId, + ...(tabset.isFolded ? { isFolded: true } : {}), + children: tabset.children.map((tab) => ({ type: 'tab', id: tab.id })), +}) + +const rowToJSON = (row: RowNode): RowJSON => ({ + type: 'row', + id: row.id, + weight: row.weight, + direction: row.direction, + children: row.children.map((child: RowChild) => + isRow(child) ? rowToJSON(child) : tabsetToJSON(child) + ), +}) + +/** + * Serializes a model. Weights are kept as they are, so a container resize + * never changes the result. + */ +export function toLayoutJSON( + model: Pick +): LayoutJSON { + return { + version: 2, + root: rowToJSON(model.root), + ...(model.maximizedTabsetId + ? { maximizedTabsetId: model.maximizedTabsetId } + : {}), + } +} diff --git a/packages/core/src/store/layout-store.ts b/packages/core/src/store/layout-store.ts new file mode 100644 index 0000000..6d4b471 --- /dev/null +++ b/packages/core/src/store/layout-store.ts @@ -0,0 +1,177 @@ +import type { WarningHandler } from '../errors' +import { computeLayoutRects, type LayoutRects } from '../geometry/compute-rects' +import type { GeometryConfig } from '../geometry/config' +import type { LayoutChangeReason } from '../layout-types' +import type { DropTarget, LayoutModel, Rect, RowNode } from '../model/types' +import type { LayoutJSON } from '../serialize/schema' +import { toLayoutJSON } from '../serialize/to-json' +import { deriveSnapshot, type DragState, type LayoutSnapshot } from './snapshot' + +/** A drag in progress, plus what's needed to cancel it. */ +export interface Drag extends DragState { + /** Root when the drag started, restored on cancel. */ + readonly startRoot: RowNode +} + +/** Everything a layout instance holds, with one place that publishes changes. */ +export interface LayoutStore { + readonly model: LayoutModel + readonly rects: LayoutRects + readonly config: GeometryConfig + readonly snapshot: LayoutSnapshot + readonly drag: Drag | null + readonly isDestroyed: boolean + subscribe(listener: (snapshot: LayoutSnapshot) => void): () => void + setContainer(rect: Rect): void + setConfig(config: GeometryConfig): void + /** A transient change (drag frames): published, but not reported as a layout change. */ + update(change: { model?: LayoutModel; drag?: Drag | null }): void + /** + * Applies a committed change and reports it through `onLayoutChange`. + * @returns Whether anything changed. + */ + commit(next: LayoutModel | null, reason?: LayoutChangeReason): boolean + /** Reports the current layout (after a splitter drag already applied its frames). */ + report(reason: LayoutChangeReason): void + toJSON(): LayoutJSON + destroy(): void +} + +/** Creates the state holder of one layout instance. */ +export function createLayoutStore( + initial: LayoutModel, + initialConfig: GeometryConfig, + onLayoutChange: + ((layout: LayoutJSON, reason: LayoutChangeReason) => void) | undefined, + warn: WarningHandler +): LayoutStore { + const listeners = new Set<(snapshot: LayoutSnapshot) => void>() + let model = initial + let config = initialConfig + let container: Rect = { x: 0, y: 0, width: 0, height: 0 } + let rects = computeLayoutRects( + model.root, + container, + config, + model.maximizedTabsetId + ) + let drag: Drag | null = null + let snapshot = deriveSnapshot(model, rects, null) + let destroyed = false + let json: { model: LayoutModel; value: LayoutJSON } | null = null + + const publish = () => { + rects = computeLayoutRects( + model.root, + container, + config, + model.maximizedTabsetId, + rects + ) + const dragState = sameDrag(snapshot.drag, drag) + ? snapshot.drag + : drag && toDragState(drag) + const next = deriveSnapshot(model, rects, dragState, snapshot) + if (next === snapshot) return + snapshot = next + listeners.forEach((listener) => listener(snapshot)) + } + + const toJSON = () => { + const unchanged = + json && + json.model.root === model.root && + json.model.maximizedTabsetId === model.maximizedTabsetId + if (!unchanged) json = { model, value: toLayoutJSON(model) } + return json!.value + } + + return { + get model() { + return model + }, + get rects() { + return rects + }, + get config() { + return config + }, + get snapshot() { + return snapshot + }, + get drag() { + return drag + }, + get isDestroyed() { + return destroyed + }, + subscribe(listener) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + setContainer(rect) { + container = rect + if (!destroyed) publish() + }, + setConfig(next) { + config = next + if (!destroyed) publish() + }, + update(change) { + if (destroyed) return + if (change.model) model = change.model + if (change.drag !== undefined) drag = change.drag + publish() + }, + commit(next, reason) { + if (destroyed) { + warn({ + code: 'DESTROYED', + message: 'This layout was destroyed; the call was ignored.', + }) + return false + } + if (!next || next === model) return false + model = next + publish() + if (reason) onLayoutChange?.(toJSON(), reason) + return true + }, + report(reason) { + if (!destroyed) onLayoutChange?.(toJSON(), reason) + }, + toJSON, + destroy() { + destroyed = true + drag = null + listeners.clear() + }, + } +} + +const toDragState = ({ source, target, indicator }: Drag): DragState => ({ + source, + target, + indicator, +}) + +function sameDrag(state: DragState | null, drag: Drag | null): boolean { + if (!state || !drag) return state === drag + return ( + state.source === drag.source && + state.target === drag.target && + state.indicator === drag.indicator + ) +} + +/** @returns Whether two drop targets point at the same place. */ +export function isSameTarget( + a: DropTarget | null, + b: DropTarget | null +): boolean { + if (a === b) return true + if (!a || !b || a.type !== b.type || a.position !== b.position) return false + if (a.type === 'tab') return a.tabId === (b as typeof a).tabId + if (a.type === 'tabset') return a.tabsetId === (b as typeof a).tabsetId + return true +} diff --git a/packages/core/src/store/snapshot.ts b/packages/core/src/store/snapshot.ts new file mode 100644 index 0000000..a46dfa5 --- /dev/null +++ b/packages/core/src/store/snapshot.ts @@ -0,0 +1,201 @@ +import type { LayoutRects } from '../geometry/compute-rects' +import { splitterId } from '../geometry/compute-rects' +import { isRow } from '../model/guards' +import type { + Direction, + DropTarget, + LayoutModel, + Rect, + RowNode, +} from '../model/types' +import { isDev } from '../dev' + +/** Derived state of one tabset. */ +export interface TabsetState { + readonly id: string + readonly tabIds: readonly string[] + readonly activeTabId: string + /** Direction of the row this tabset sits in. */ + readonly parentDirection: Direction + readonly isFolded: boolean + readonly isMaximized: boolean + /** Hidden because another tabset is maximized (its content stays mounted). */ + readonly isHidden: boolean + readonly canFold: boolean + readonly canMaximize: boolean +} + +/** Derived state of one splitter. */ +export interface SplitterState { + readonly id: string + readonly rowId: string + /** The row's direction: a `'horizontal'` row has a vertical splitter bar. */ + readonly direction: Direction + readonly beforeId: string + readonly afterId: string + /** Next to a folded tabset, so it can't be dragged. */ + readonly isLocked: boolean + readonly isHidden: boolean +} + +/** Derived state of one tab. */ +export interface TabState { + readonly id: string + readonly tabsetId: string + readonly isActive: boolean + /** Active and its tabset neither folded nor hidden. */ + readonly isVisible: boolean +} + +/** What is being dragged. */ +export type DragSource = + | { readonly type: 'tab'; readonly tabId: string } + | { readonly type: 'tabset'; readonly tabsetId: string } + | { readonly type: 'splitter'; readonly splitterId: string } + +/** The current drag, if any. */ +export interface DragState { + readonly source: DragSource + /** Where a tab or tabset would land; `null` over nothing valid. */ + readonly target: DropTarget | null + /** Rect of the drop indicator for `target`. */ + readonly indicator: Rect | null +} + +/** + * An immutable view of the layout. Every field keeps its previous reference + * unless it changed, and so does every entry of every map, so consumers can + * compare by reference at any depth. + */ +export interface LayoutSnapshot { + readonly root: RowNode + readonly tabsets: ReadonlyMap + readonly splitters: ReadonlyMap + readonly tabs: ReadonlyMap + readonly rects: LayoutRects + readonly maximizedTabsetId: string | null + readonly drag: DragState | null +} + +const shallowEqual = (a: T, b: T) => { + for (const key of Object.keys(a) as (keyof T)[]) { + const x = a[key] + const y = b[key] + if (Array.isArray(x) && Array.isArray(y)) { + if (x.length !== y.length || x.some((item, i) => item !== y[i])) + return false + } else if (x !== y) return false + } + return true +} + +const freeze = (value: T): T => (isDev() ? Object.freeze(value) : value) + +/** Reuses `previous` entries (and the map itself) when nothing changed. */ +function share( + next: Map, + previous: ReadonlyMap | undefined +): ReadonlyMap { + if (!previous) { + next.forEach((value) => freeze(value)) + return next + } + let changed = next.size !== previous.size + const previousKeys = [...previous.keys()] + let index = 0 + for (const [id, value] of next) { + const old = previous.get(id) + if (old && shallowEqual(old, value)) next.set(id, old) + else { + freeze(value) + changed = true + } + if (previousKeys[index++] !== id) changed = true + } + return changed ? next : previous +} + +/** Builds the next snapshot from the model, sharing everything unchanged with `previous`. */ +export function deriveSnapshot( + model: LayoutModel, + rects: LayoutRects, + drag: DragState | null, + previous?: LayoutSnapshot +): LayoutSnapshot { + if ( + previous && + previous.root === model.root && + previous.maximizedTabsetId === model.maximizedTabsetId && + previous.rects === rects && + previous.drag === drag + ) + return previous + + const tabsets = new Map() + const splitters = new Map() + const tabs = new Map() + const maximized = model.maximizedTabsetId + let tabsetCount = 0 + const countTabsets = (row: RowNode) => + row.children.forEach((child) => + isRow(child) ? countTabsets(child) : tabsetCount++ + ) + countTabsets(model.root) + + const visit = (row: RowNode) => { + row.children.forEach((child, index) => { + if (isRow(child)) visit(child) + else { + const isMaximized = child.id === maximized + const isHidden = !!maximized && !isMaximized + const isFolded = child.isFolded && !isMaximized + tabsets.set(child.id, { + id: child.id, + tabIds: child.children.map((tab) => tab.id), + activeTabId: child.activeTabId, + parentDirection: row.direction, + isFolded, + isMaximized, + isHidden, + canFold: row.children.length > 1, + canMaximize: tabsetCount > 1, + }) + for (const tab of child.children) { + const isActive = tab.id === child.activeTabId + tabs.set(tab.id, { + id: tab.id, + tabsetId: child.id, + isActive, + isVisible: isActive && !isFolded && !isHidden, + }) + } + } + const next = row.children[index + 1] + if (next) { + const id = splitterId(child.id, next.id) + const folded = (node: typeof child) => + !isRow(node) && node.isFolded + splitters.set(id, { + id, + rowId: row.id, + direction: row.direction, + beforeId: child.id, + afterId: next.id, + isLocked: folded(child) || folded(next), + isHidden: !!maximized, + }) + } + }) + } + visit(model.root) + + return freeze({ + root: model.root, + tabsets: share(tabsets, previous?.tabsets), + splitters: share(splitters, previous?.splitters), + tabs: share(tabs, previous?.tabs), + rects, + maximizedTabsetId: maximized, + drag, + }) +} diff --git a/packages/core/src/tree/build.ts b/packages/core/src/tree/build.ts new file mode 100644 index 0000000..a2a5267 --- /dev/null +++ b/packages/core/src/tree/build.ts @@ -0,0 +1,48 @@ +import type { CreateId, Direction, RowNode, TabsetNode } from '../model/types' +import { DEFAULT_WEIGHT } from './normalize' + +const flip = (direction: Direction): Direction => + direction === 'horizontal' ? 'vertical' : 'horizontal' + +/** A tabset holding a single tab. */ +export function createTabset(tabId: string, createId: CreateId): TabsetNode { + return { + type: 'tabset', + id: createId('tabset', tabId), + weight: DEFAULT_WEIGHT, + activeTabId: tabId, + isFolded: false, + children: [{ type: 'tab', id: tabId }], + } +} + +/** + * The layout used when there is no saved one: each tab gets its own tabset, + * nested so that directions alternate (`t1 | (t2 / (t3 | …))`). This matches + * v1's default layout exactly. + * + * @param tabIds - Tabs in display order. + * @param createId - Id factory for the rows and tabsets. + * @param rootId - Id of the root row. + */ +export function buildDefaultTree( + tabIds: readonly string[], + createId: CreateId, + rootId = 'root' +): RowNode { + const build = ( + ids: readonly string[], + direction: Direction, + id: string + ): RowNode => { + const children: RowNode['children'] = + ids.length <= 2 + ? ids.map((tabId) => createTabset(tabId, createId)) + : [ + createTabset(ids[0], createId), + build(ids.slice(1), flip(direction), createId('row')), + ] + return { type: 'row', id, weight: DEFAULT_WEIGHT, direction, children } + } + return build(tabIds, 'horizontal', rootId) +} diff --git a/packages/core/src/tree/find.ts b/packages/core/src/tree/find.ts new file mode 100644 index 0000000..8921503 --- /dev/null +++ b/packages/core/src/tree/find.ts @@ -0,0 +1,72 @@ +import { isRow } from '../model/guards' +import type { RowChild, RowNode, TabsetNode } from '../model/types' + +/** Every tabset in reading order (depth first, left to right). */ +export function collectTabsets(root: RowNode): TabsetNode[] { + const tabsets: TabsetNode[] = [] + const visit = (node: RowChild) => { + if (isRow(node)) node.children.forEach(visit) + else tabsets.push(node) + } + root.children.forEach(visit) + return tabsets +} + +/** Every tab id in reading order. */ +export function collectTabIds(root: RowNode): string[] { + return collectTabsets(root).flatMap((tabset) => + tabset.children.map((tab) => tab.id) + ) +} + +/** Every row and tabset id, including the root's. */ +export function collectNodeIds(root: RowNode): string[] { + const ids: string[] = [] + const visit = (node: RowChild) => { + ids.push(node.id) + if (isRow(node)) node.children.forEach(visit) + } + visit(root) + return ids +} + +export function findTabset( + root: RowNode, + tabsetId: string +): TabsetNode | undefined { + return collectTabsets(root).find((tabset) => tabset.id === tabsetId) +} + +export function findTabsetOfTab( + root: RowNode, + tabId: string +): TabsetNode | undefined { + return collectTabsets(root).find((tabset) => + tabset.children.some((tab) => tab.id === tabId) + ) +} + +/** The row that directly contains the row or tabset `childId`. */ +export function findParentRow( + root: RowNode, + childId: string +): RowNode | undefined { + for (const child of root.children) { + if (child.id === childId) return root + if (isRow(child)) { + const found = findParentRow(child, childId) + if (found) return found + } + } + return undefined +} + +/** Id of the first tab inside a row or tabset. */ +export function firstTabId(node: RowChild): string | undefined { + if (!isRow(node)) return node.children[0]?.id + for (const child of node.children) { + const id = firstTabId(child) + if (id !== undefined) return id + } + return undefined +} diff --git a/packages/core/src/tree/insert.ts b/packages/core/src/tree/insert.ts new file mode 100644 index 0000000..a4750f8 --- /dev/null +++ b/packages/core/src/tree/insert.ts @@ -0,0 +1,124 @@ +import type { + CreateId, + Direction, + RowNode, + Side, + TabNode, + TabsetNode, +} from '../model/types' +import { findParentRow, findTabset } from './find' +import { DEFAULT_WEIGHT } from './normalize' +import { replaceNode, withChildren } from './update' + +const flip = (direction: Direction): Direction => + direction === 'horizontal' ? 'vertical' : 'horizontal' + +const isBefore = (side: Side) => side === 'top' || side === 'left' + +/** Whether `side` lies along a row of `direction` (left/right in a horizontal row). */ +const isAlong = (side: Side, direction: Direction) => + (side === 'left' || side === 'right') === (direction === 'horizontal') + +/** + * Inserts tabs into a tabset at `index` and makes `activeTabId` active. The + * tabset unfolds, since it just received content. + */ +export function insertTabs( + root: RowNode, + tabsetId: string, + tabs: readonly TabNode[], + index: number, + activeTabId: string +): RowNode { + return replaceNode(root, tabsetId, (node) => { + const tabset = node as TabsetNode + const children = [...tabset.children] + children.splice(index, 0, ...tabs) + return { ...tabset, children, activeTabId, isFolded: false } + }) +} + +/** + * Places `moved` on one side of a tabset. Along the tabset's row it becomes a + * sibling; across it, the target is wrapped in a new row with `moved`. The new + * row takes the target's weight, and the target restarts at the default + * weight, as in v1. A folded target unfolds (v1 left the new row stuck at its + * minimum size here; audit B27). + */ +export function insertBeside( + root: RowNode, + targetId: string, + moved: TabsetNode, + side: Side, + createId: CreateId +): RowNode { + const parent = findParentRow(root, targetId) + const target = findTabset(root, targetId) + if (!parent || !target) return root + + if (isAlong(side, parent.direction)) { + const index = parent.children.indexOf(target) + (isBefore(side) ? 0 : 1) + const children = [...parent.children] + children.splice(index, 0, moved) + return replaceNode(root, parent.id, () => + withChildren(parent, children) + ) + } + + const resized: TabsetNode = { + ...target, + weight: DEFAULT_WEIGHT, + isFolded: false, + } + const wrapper: RowNode = { + type: 'row', + id: createId('row'), + weight: target.weight, + direction: flip(parent.direction), + children: isBefore(side) ? [moved, resized] : [resized, moved], + } + return replaceNode(root, targetId, () => wrapper) +} + +/** + * Docks `moved` at an edge of the whole layout. Along the root's direction it + * becomes the first or last child; across it, the current content is wrapped + * in a row and the root flips direction. + * + * The root's `weight` stands in for v1's single child row (see + * `normalizeTree`), so the wrapped row reuses it to stay pixel-identical. + */ +export function dockAtRoot( + root: RowNode, + moved: TabsetNode, + side: Side, + createId: CreateId +): RowNode { + const before = isBefore(side) + if (root.children.length === 0) return withChildren(root, [moved]) + + if (isAlong(side, root.direction)) { + return withChildren( + root, + before ? [moved, ...root.children] : [...root.children, moved] + ) + } + + const content = + root.children.length === 1 + ? root.children[0] + : ({ + type: 'row', + id: createId('row'), + weight: root.weight, + direction: root.direction, + children: root.children, + } satisfies RowNode) + + return { + ...root, + weight: DEFAULT_WEIGHT, + direction: flip(root.direction), + children: before ? [moved, content] : [content, moved], + } +} diff --git a/packages/core/src/tree/move.ts b/packages/core/src/tree/move.ts new file mode 100644 index 0000000..c6300af --- /dev/null +++ b/packages/core/src/tree/move.ts @@ -0,0 +1,155 @@ +import type { + CreateId, + DropTarget, + LayoutModel, + RowNode, + TabNode, + TabsetNode, +} from '../model/types' +import { createTabset } from './build' +import { collectTabsets, findTabset, findTabsetOfTab } from './find' +import { dockAtRoot, insertBeside, insertTabs } from './insert' +import { normalizeTree } from './normalize' +import { removeTab, removeTabset } from './remove' +import { normalizeViewState } from './view-state' + +/** What is being moved. */ +export type MoveSource = + | { readonly type: 'tab'; readonly tabId: string } + | { readonly type: 'tabset'; readonly tabsetId: string } + +/** The tabset a source currently lives in (the tabset itself for a tabset). */ +function sourceTabset( + root: RowNode, + source: MoveSource +): TabsetNode | undefined { + return source.type === 'tab' + ? findTabsetOfTab(root, source.tabId) + : findTabset(root, source.tabsetId) +} + +/** + * Whether a move is allowed. Mirrors v1's guards (`tree-mutations.ts:17-87`): + * nothing moves onto itself, a lone tab can't be dropped on its own tabset, + * and the only tabset in the layout can't move. + */ +export function canMove( + root: RowNode, + source: MoveSource, + target: DropTarget +): boolean { + const from = sourceTabset(root, source) + if (!from) return false + + const tabsets = collectTabsets(root) + const isOnlyContent = + tabsets.length === 1 && + (source.type === 'tabset' || from.children.length === 1) + if (isOnlyContent) return false + + switch (target.type) { + case 'root': + return true + case 'tab': { + const targetTabset = findTabsetOfTab(root, target.tabId) + if (!targetTabset) return false + if (source.type === 'tab') return source.tabId !== target.tabId + return targetTabset !== from + } + case 'tabset': { + const targetTabset = findTabset(root, target.tabsetId) + if (!targetTabset) return false + if (source.type === 'tabset') return targetTabset !== from + if (targetTabset !== from) return true + if (from.children.length < 2) return false + // Center on its own tabset moves the tab to the end; already there is a no-op. + return ( + target.position !== 'center' || + from.children[from.children.length - 1].id !== source.tabId + ) + } + } +} + +/** + * Moves a tab or a whole tabset. The source is removed first (dissolving rows + * left with one child), then inserted at the target: + * - `tab` target: into that tab's tabset, before or after it; + * - `tabset` + `center`: appended to that tabset (v1 inserted before its last + * tab; audit B26); + * - `tabset` + side: beside that tabset (see `insertBeside`); + * - `root` + side: docked at a layout edge (see `dockAtRoot`). + * + * Moving ends maximized mode; the moved tabs arrive unfolded and the receiving + * tabset becomes the last active one. + * + * @returns The new model, or `null` when the move isn't allowed. + */ +export function moveNode( + model: LayoutModel, + source: MoveSource, + target: DropTarget, + createId: CreateId +): LayoutModel | null { + if (!canMove(model.root, source, target)) return null + const from = sourceTabset(model.root, source)! + + const tabs: readonly TabNode[] = + source.type === 'tab' + ? [{ type: 'tab', id: source.tabId }] + : from.children + const activeTabId = + source.type === 'tab' ? source.tabId : tabs[tabs.length - 1].id + const asTabset = (): TabsetNode => + source.type === 'tab' + ? createTabset(source.tabId, createId) + : { ...from, isFolded: false } + + let root = + source.type === 'tab' + ? removeTab(model.root, source.tabId) + : removeTabset(model.root, from.id) + + switch (target.type) { + case 'tab': { + const tabset = findTabsetOfTab(root, target.tabId)! + const index = + tabset.children.findIndex((tab) => tab.id === target.tabId) + + (target.position === 'after' ? 1 : 0) + root = insertTabs(root, tabset.id, tabs, index, activeTabId) + break + } + case 'tabset': { + if (target.position === 'center') { + const tabset = findTabset(root, target.tabsetId)! + root = insertTabs( + root, + tabset.id, + tabs, + tabset.children.length, + activeTabId + ) + } else { + root = insertBeside( + root, + target.tabsetId, + asTabset(), + target.position, + createId + ) + } + break + } + case 'root': + root = dockAtRoot(root, asTabset(), target.position, createId) + break + } + + root = normalizeTree(root) + return normalizeViewState({ + ...model, + root, + maximizedTabsetId: null, + lastActiveTabsetId: findTabsetOfTab(root, activeTabId)?.id ?? null, + }) +} diff --git a/packages/core/src/tree/normalize.ts b/packages/core/src/tree/normalize.ts new file mode 100644 index 0000000..33b33e2 --- /dev/null +++ b/packages/core/src/tree/normalize.ts @@ -0,0 +1,85 @@ +import { isRow } from '../model/guards' +import type { RowChild, RowNode, TabsetNode } from '../model/types' +import { withChildren } from './update' + +/** Weight given to new nodes and to nodes with an invalid weight. */ +export const DEFAULT_WEIGHT = 100 + +const validWeight = (weight: number) => + Number.isFinite(weight) && weight >= 0 ? weight : DEFAULT_WEIGHT + +function normalizeTabset(tabset: TabsetNode): TabsetNode | null { + if (tabset.children.length === 0) return null + const weight = validWeight(tabset.weight) + const hasActive = tabset.children.some( + (tab) => tab.id === tabset.activeTabId + ) + if (weight === tabset.weight && hasActive) return tabset + return { + ...tabset, + weight, + activeTabId: hasActive ? tabset.activeTabId : tabset.children[0].id, + } +} + +/** + * Children of a dissolved row move up **keeping their own weights**. This is + * what v1 does (`moveAdjacentNodeToGrandParent`), and keeping it means saved + * v1 layouts and v1 interactions give pixel-identical results. + */ +function normalizeRow(row: RowNode): RowNode { + const children: RowChild[] = [] + for (const child of row.children) { + if (!isRow(child)) { + const tabset = normalizeTabset(child) + if (tabset) children.push(tabset) + continue + } + let next: RowChild = normalizeRow(child) + if (isRow(next) && next.children.length === 1) next = next.children[0] + if (isRow(next) && next.children.length === 0) continue + if (isRow(next) && next.direction === row.direction) + children.push(...next.children) + else children.push(next) + } + const weight = validWeight(row.weight) + const normalized = withChildren(row, children) + return weight === row.weight ? normalized : { ...normalized, weight } +} + +/** + * Restores the tree invariants after any change: + * - tabsets are never empty and their `activeTabId` is one of their tabs; + * - non-root rows have at least two children and never share their parent's + * direction; + * - weights are finite and not negative (0 is valid: a panel dragged to its + * minimum has no share of the extra space). + * + * A root with a single row child takes over that row, which renders + * identically. The root's own weight stands for that absorbed row: v1 kept a + * horizontal root with one vertical row child there, and the row's weight + * matters again if content is later docked beside it. A root that is + * horizontal, or has at most one child, is a plain root (weight 100, and + * horizontal, since its direction then doesn't matter). Unchanged nodes keep + * their identity. + */ +export function normalizeTree(root: RowNode): RowNode { + let result = normalizeRow(root) + while (result.children.length === 1 && isRow(result.children[0])) { + const only: RowNode = result.children[0] + result = { + ...result, + weight: only.weight, + direction: only.direction, + children: only.children, + } + } + const isPlain = + result.children.length <= 1 || result.direction === 'horizontal' + if ( + isPlain && + (result.direction !== 'horizontal' || result.weight !== DEFAULT_WEIGHT) + ) + result = { ...result, direction: 'horizontal', weight: DEFAULT_WEIGHT } + return result +} diff --git a/packages/core/src/tree/reconcile-tabs.ts b/packages/core/src/tree/reconcile-tabs.ts new file mode 100644 index 0000000..c8ad228 --- /dev/null +++ b/packages/core/src/tree/reconcile-tabs.ts @@ -0,0 +1,129 @@ +import type { CreateId, DropTarget, LayoutModel, RowNode } from '../model/types' +import { createTabset } from './build' +import { + collectTabIds, + collectTabsets, + findTabset, + findTabsetOfTab, +} from './find' +import { dockAtRoot, insertBeside, insertTabs } from './insert' +import { normalizeTree } from './normalize' +import { removeTab } from './remove' +import { normalizeViewState } from './view-state' + +/** A tab the layout should contain. */ +export interface TabInit { + /** Unique, stable id. It's also what saved layouts store. */ + readonly id: string + /** Where the tab goes the first time it appears. @default the last active tabset */ + readonly target?: DropTarget +} + +/** The tabset new tabs go to: the last active one, else the first. */ +function defaultTabsetId( + model: LayoutModel, + root: RowNode +): string | undefined { + if (model.lastActiveTabsetId && findTabset(root, model.lastActiveTabsetId)) + return model.lastActiveTabsetId + return collectTabsets(root)[0]?.id +} + +/** + * Adds one tab at `target` (or the default tabset). A target that no longer + * exists falls back to the default tabset. + * + * @param activate - Make the new tab the active one in its tabset. + */ +export function addTabToModel( + model: LayoutModel, + tab: TabInit, + createId: CreateId, + activate = true +): LayoutModel { + if (findTabsetOfTab(model.root, tab.id)) return model + const node = { type: 'tab', id: tab.id } as const + const target = tab.target + let root = model.root + + const appendTo = (tabsetId: string) => { + const tabset = findTabset(root, tabsetId)! + const activeTabId = activate ? tab.id : tabset.activeTabId + return insertTabs( + root, + tabsetId, + [node], + tabset.children.length, + activeTabId + ) + } + + if (target?.type === 'tab' && findTabsetOfTab(root, target.tabId)) { + const tabset = findTabsetOfTab(root, target.tabId)! + const index = + tabset.children.findIndex((t) => t.id === target.tabId) + + (target.position === 'after' ? 1 : 0) + root = insertTabs( + root, + tabset.id, + [node], + index, + activate ? tab.id : tabset.activeTabId + ) + } else if (target?.type === 'tabset' && findTabset(root, target.tabsetId)) { + root = + target.position === 'center' + ? appendTo(target.tabsetId) + : insertBeside( + root, + target.tabsetId, + createTabset(tab.id, createId), + target.position, + createId + ) + } else if (target?.type === 'root' || collectTabsets(root).length === 0) { + root = dockAtRoot( + root, + createTabset(tab.id, createId), + target?.type === 'root' ? target.position : 'right', + createId + ) + } else { + root = appendTo(defaultTabsetId(model, root)!) + } + + root = normalizeTree(root) + return normalizeViewState({ + ...model, + root, + lastActiveTabsetId: activate + ? (findTabsetOfTab(root, tab.id)?.id ?? null) + : model.lastActiveTabsetId, + }) +} + +/** + * Makes the layout contain exactly `tabs`: tabs that are no longer listed are + * removed, and new ones are added at their `target` or the default tabset. + * Order of `tabs` doesn't reorder tabs already placed. + * + * @param activate - Make newly added tabs active (true for runtime additions, + * false when filling gaps in a just-loaded layout). + */ +export function reconcileTabs( + model: LayoutModel, + tabs: readonly TabInit[], + createId: CreateId, + activate = true +): LayoutModel { + const wanted = new Set(tabs.map((tab) => tab.id)) + let root = model.root + for (const id of collectTabIds(root)) { + if (!wanted.has(id)) root = removeTab(root, id) + } + let next = + root === model.root ? model : normalizeViewState({ ...model, root }) + + for (const tab of tabs) next = addTabToModel(next, tab, createId, activate) + return next +} diff --git a/packages/core/src/tree/remove.ts b/packages/core/src/tree/remove.ts new file mode 100644 index 0000000..801b059 --- /dev/null +++ b/packages/core/src/tree/remove.ts @@ -0,0 +1,35 @@ +import type { RowNode } from '../model/types' +import { findTabsetOfTab } from './find' +import { normalizeTree } from './normalize' +import { replaceNode } from './update' + +/** + * Removes a tab. An emptied tabset is removed too, and rows left with one + * child are dissolved. If the removed tab was active, the first remaining tab + * becomes active. + * + * @returns The new root, or `root` itself when the tab does not exist. + */ +export function removeTab(root: RowNode, tabId: string): RowNode { + const tabset = findTabsetOfTab(root, tabId) + if (!tabset) return root + const children = tabset.children.filter((tab) => tab.id !== tabId) + const next = replaceNode(root, tabset.id, () => + children.length === 0 + ? [] + : { + ...tabset, + children, + activeTabId: + tabset.activeTabId === tabId + ? children[0].id + : tabset.activeTabId, + } + ) + return normalizeTree(next) +} + +/** Removes a whole tabset and dissolves rows left with one child. */ +export function removeTabset(root: RowNode, tabsetId: string): RowNode { + return normalizeTree(replaceNode(root, tabsetId, () => [])) +} diff --git a/packages/core/src/tree/update.ts b/packages/core/src/tree/update.ts new file mode 100644 index 0000000..874f9c8 --- /dev/null +++ b/packages/core/src/tree/update.ts @@ -0,0 +1,45 @@ +import { isRow } from '../model/guards' +import type { RowChild, RowNode } from '../model/types' + +const sameItems = (a: readonly T[], b: readonly T[]) => + a.length === b.length && a.every((item, i) => item === b[i]) + +/** A copy of `row` with new children, or `row` itself when nothing changed. */ +export function withChildren( + row: RowNode, + children: readonly RowChild[] +): RowNode { + return sameItems(row.children, children) ? row : { ...row, children } +} + +/** + * Replaces the row or tabset `id` with the result of `replace` (an array to + * splice several nodes in its place, or an empty array to remove it). Only the + * path to the node is copied; every other node keeps its identity. + */ +export function replaceNode( + root: RowNode, + id: string, + replace: (node: RowChild) => RowChild | readonly RowChild[] +): RowNode { + if (root.id === id) { + const result = replace(root) + if (Array.isArray(result) || !isRow(result as RowChild)) + throw new Error('The root can only be replaced by a row.') + return result as RowNode + } + const visit = (row: RowNode): RowNode => { + const children: RowChild[] = [] + for (const child of row.children) { + if (child.id === id) { + const result = replace(child) + if (Array.isArray(result)) children.push(...result) + else children.push(result as RowChild) + } else { + children.push(isRow(child) ? visit(child) : child) + } + } + return withChildren(row, children) + } + return visit(root) +} diff --git a/packages/core/src/tree/view-state.ts b/packages/core/src/tree/view-state.ts new file mode 100644 index 0000000..49216b3 --- /dev/null +++ b/packages/core/src/tree/view-state.ts @@ -0,0 +1,147 @@ +import { isRow } from '../model/guards' +import type { LayoutModel, RowChild, RowNode, TabsetNode } from '../model/types' +import { + collectNodeIds, + collectTabsets, + findParentRow, + findTabset, +} from './find' +import { replaceNode, withChildren } from './update' + +/** Maximize and fold: view state that never changes weights, so both restore exactly. */ + +const rankOf = (model: LayoutModel, id: string) => + model.foldOrder.indexOf(id) + 1 + +/** Unfolds the most recently folded child of `children` (other than `exceptId`). */ +function unfoldMostRecent( + model: LayoutModel, + children: readonly RowChild[], + exceptId?: string +): RowChild[] { + let pick: TabsetNode | null = null + for (const child of children) { + if (child.id === exceptId || isRow(child) || !child.isFolded) continue + if (!pick || rankOf(model, child.id) > rankOf(model, pick.id)) + pick = child + } + return children.map((child) => + child === pick ? { ...pick, isFolded: false } : child + ) +} + +const isFoldedChild = (child: RowChild) => !isRow(child) && child.isFolded + +/** A tabset can fold when its row has another child to take the space. */ +export function canFold(root: RowNode, tabsetId: string): boolean { + const parent = findParentRow(root, tabsetId) + return !!parent && parent.children.length > 1 +} + +/** Maximizing needs at least two tabsets. */ +export function canMaximize(root: RowNode): boolean { + return collectTabsets(root).length > 1 +} + +/** + * Keeps view state valid after any change: a tabset alone in its row can't + * stay folded, every row keeps at least one unfolded child, a maximized + * tabset that left the tree is forgotten, and the fold order only lists + * existing tabsets. + */ +export function normalizeViewState(model: LayoutModel): LayoutModel { + const visit = (row: RowNode): RowNode => { + let children: readonly RowChild[] = row.children + if (children.length > 0 && children.every(isFoldedChild)) { + children = unfoldMostRecent(model, children) + } + children = children.map((child) => { + if (isRow(child)) return visit(child) + return child.isFolded && children.length < 2 + ? { ...child, isFolded: false } + : child + }) + return withChildren(row, children) + } + + const root = visit(model.root) + const ids = new Set(collectNodeIds(root)) + const maximizedTabsetId = + model.maximizedTabsetId && findTabset(root, model.maximizedTabsetId) + ? model.maximizedTabsetId + : null + const foldOrder = model.foldOrder.filter((id) => ids.has(id)) + const lastActiveTabsetId = + model.lastActiveTabsetId && findTabset(root, model.lastActiveTabsetId) + ? model.lastActiveTabsetId + : null + + if ( + root === model.root && + maximizedTabsetId === model.maximizedTabsetId && + foldOrder.length === model.foldOrder.length && + lastActiveTabsetId === model.lastActiveTabsetId + ) + return model + return { root, maximizedTabsetId, foldOrder, lastActiveTabsetId } +} + +/** @returns The new model, or `null` when the tabset can't be maximized. */ +export function maximize( + model: LayoutModel, + tabsetId: string +): LayoutModel | null { + if (model.maximizedTabsetId === tabsetId) return null + if (!findTabset(model.root, tabsetId) || !canMaximize(model.root)) + return null + return { ...model, maximizedTabsetId: tabsetId } +} + +/** @returns The new model, or `null` when nothing is maximized. */ +export function restore(model: LayoutModel): LayoutModel | null { + return model.maximizedTabsetId + ? { ...model, maximizedTabsetId: null } + : null +} + +/** + * Folds a tabset to a strip. Folding the last unfolded child of a row + * unfolds its most recently folded sibling instead (LeetCode-style swap). + * Folding leaves maximized mode. + */ +export function fold(model: LayoutModel, tabsetId: string): LayoutModel | null { + const tabset = findTabset(model.root, tabsetId) + const parent = findParentRow(model.root, tabsetId) + if (!tabset || !parent || tabset.isFolded || parent.children.length < 2) + return null + + const foldOrder = [ + ...model.foldOrder.filter((id) => id !== tabsetId), + tabsetId, + ] + const next: LayoutModel = { ...model, foldOrder, maximizedTabsetId: null } + let children = parent.children.map((child) => + child === tabset ? { ...tabset, isFolded: true } : child + ) + if (children.every(isFoldedChild)) + children = unfoldMostRecent(next, children, tabsetId) + + const root = replaceNode(model.root, parent.id, () => + withChildren(parent, children) + ) + return normalizeViewState({ ...next, root }) +} + +/** @returns The new model, or `null` when the tabset isn't folded. */ +export function unfold( + model: LayoutModel, + tabsetId: string +): LayoutModel | null { + const tabset = findTabset(model.root, tabsetId) + if (!tabset || !tabset.isFolded) return null + const root = replaceNode(model.root, tabsetId, () => ({ + ...tabset, + isFolded: false, + })) + return normalizeViewState({ ...model, root }) +} diff --git a/packages/core/src/type/index.ts b/packages/core/src/type/index.ts deleted file mode 100644 index e53663e..0000000 --- a/packages/core/src/type/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Node, Bond } from '../app/dynamix' - -/** @deprecated Replaced in 2.0 by `Rect { x, y, width, height }`. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -export type Dimension = { - w: number - h: number - x: number - y: number -} - -export interface NodeCache { - dimMins: Map - mapDirs: Map - tabCnts: Map - mapElem: Map - nodOpts: ReactiveValue> - bndOpts: ReactiveValue> - tabOpts: ReactiveValue> -} - -export type NodeType = 'row' | 'tabset' | 'tab' -export type NodeTypeWithBond = NodeType | 'bond' - -export type TabsIds = Map - -export interface BaseNode { - typNode: NodeTypeWithBond - nodName: string - uidNode: string - nodPart: number - nodeDir: boolean - nodDims: Dimension - nodOpen?: string | boolean -} - -/** @deprecated Replaced in 2.0 by `LayoutJSON` (v1 trees load automatically; `LayoutTreeV1` describes this shape). See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -export type LayoutTree = Omit & { - nodKids?: LayoutTree[] - /** Tabset only: folded to a strip along its parent row. */ - nodFold?: boolean - /** Root only: uid of the maximized tabset. */ - nodMaxd?: string -} - -/** @deprecated Replaced in 2.0 by `TabsetState`, `SplitterState`, `TabState` and `snapshot.rects`. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -export interface NodeOptions extends BaseNode { - nodKids?: BaseNode[] - /** Tabset: folded to a strip (ignored while it is maximized). */ - nodFold?: boolean - /** Tabset: currently maximized over the whole layout. */ - nodMaxd?: boolean - /** Hidden because another tabset is maximized (content stays mounted). */ - nodHidden?: boolean - /** Bond: next to a folded tabset, so it cannot be dragged. */ - nodLocked?: boolean - /** Tabset: can be folded (it has siblings in its row). */ - nodFoldable?: boolean - /** Tabset: can be maximized (the layout has more than one tabset). */ - nodMaximizable?: boolean -} - -export interface RootAdjustment { - adjustedW: number - adjustedH: number - wasAdjusted: boolean -} - -export type ChangeListener = (newValue: T) => void - -export interface ReactiveValue { - get: () => T - set: (newValue: T, flag?: boolean) => void - onChange: (listener: ChangeListener) => () => void - nextChange: () => Promise - onChangePromise: (listener: ChangeListener) => () => void - triggerChange: () => void -} diff --git a/packages/core/test/characterization/fixtures.ts b/packages/core/test/characterization/fixtures.ts new file mode 100644 index 0000000..fa0d125 --- /dev/null +++ b/packages/core/test/characterization/fixtures.ts @@ -0,0 +1,103 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import type { Observation, Scenario } from './scenario' + +const FIXTURE_DIR = resolve(__dirname, '../fixtures/v1') + +/** All stored v1 scenarios. */ +export function loadScenarios(): Scenario[] { + return Array.from({ length: 16 }, (_, i) => { + const file = `scenario-${String(i + 1).padStart(2, '0')}.json` + return JSON.parse( + readFileSync(resolve(FIXTURE_DIR, file), 'utf8') + ) as Scenario + }) +} + +/** Each saved v1 layout with the container size and v1's observation at that moment. */ +export function savedStates(scenario: Scenario) { + const states: { + saved: unknown + container: { width: number; height: number } + observation: Observation + label: string + }[] = [ + { + saved: scenario.initialSavedV1, + container: scenario.container, + observation: scenario.initial, + label: 'initial', + }, + ] + let container = scenario.container + scenario.steps.forEach((step, i) => { + if (step.operation.op === 'resize') + container = { + width: step.operation.width, + height: step.operation.height, + } + if (step.savedV1) + states.push({ + saved: step.savedV1, + container, + observation: step.observation, + label: `step ${i + 1}`, + }) + }) + return states +} + +/** + * v1 never shrinks the root back after it outgrew a too-small container until + * the next window resize (audit B29), so its rects in that state depend on + * history that isn't saved. Those states are compared by structure only. + */ +export function overflows( + observation: Observation, + container: { width: number; height: number } +) { + return Object.values(observation.tabsets).some( + ([x, y, width, height]) => + x + width > container.width || y + height > container.height + ) +} + +/** + * v1 kept the fold flag when a folded tabset turned into a row (a tab dropped + * beside it), leaving that row stuck at its minimum size (audit B27). v2 + * doesn't reproduce the bug, so these states are compared by structure only. + */ +export function hasFoldedRow(saved: unknown): boolean { + const visit = (node: { + typNode?: string + nodFold?: boolean + nodKids?: unknown[] + }): boolean => + (node.typNode === 'row' && node.nodFold === true) || + (node.nodKids ?? []).some((kid) => visit(kid as typeof node)) + return visit(saved as Parameters[0]) +} + +/** + * When every unfolded child of a row has weight 0, v1 skips the row and its + * children keep stale positions, sometimes outside the row (audit B28). v2 + * shares the space equally, so these states are compared by structure only. + */ +export function hasZeroWeightRow(saved: unknown): boolean { + type V1 = { + typNode?: string + nodPart?: number + nodFold?: boolean + nodKids?: V1[] + } + const visit = (node: V1): boolean => { + const kids = node.typNode === 'row' ? (node.nodKids ?? []) : [] + const open = kids.filter( + (kid) => !(kid.typNode === 'tabset' && kid.nodFold) + ) + if (open.length > 0 && open.every((kid) => kid.nodPart === 0)) + return true + return kids.some(visit) + } + return visit(saved as V1) +} diff --git a/packages/core/test/characterization/scenario.ts b/packages/core/test/characterization/scenario.ts new file mode 100644 index 0000000..895fa93 --- /dev/null +++ b/packages/core/test/characterization/scenario.ts @@ -0,0 +1,128 @@ +/** + * Engine-neutral description of layout behaviour. + * + * The scenarios in `../fixtures/v1` were recorded from the v1 engine (see the + * README there). The v2 engine replays the same operations and must produce + * the same canonical structure and pixel-identical rects. Everything here is + * keyed by tab ids so it does not depend on either engine's internal node ids. + */ + +export type Direction = 'horizontal' | 'vertical' +export type Side = 'top' | 'bottom' | 'left' | 'right' +export type RectTuple = [x: number, y: number, width: number, height: number] + +/** A tabset is addressed by any tab inside it. */ +export type TabsetRef = { tabsetOf: string } + +export type Operation = + | { + op: 'move' + source: { tab: string } | TabsetRef + target: + | { tab: string; position: 'left' | 'right' } + | (TabsetRef & { area: Side | 'contain' }) + | { root: Side } + } + | { + op: 'splitter' + before: string + after: string + point: { x: number; y: number } + } + | ({ op: 'fold' } & TabsetRef) + | ({ op: 'maximize' } & TabsetRef) + | { op: 'resize'; width: number; height: number } + +export interface CanonicalTabset { + tabs: string[] + active: string + folded?: true +} +export interface CanonicalRow { + direction: Direction + children: CanonicalNode[] +} +export type CanonicalNode = CanonicalRow | CanonicalTabset + +export interface Observation { + tree: CanonicalRow + /** Tabset rects keyed by `tabs.join('|')`. */ + tabsets: Record + /** Splitter rects keyed by `firstTab(before) + '>' + firstTab(after)`. */ + splitters: Record + /** Key of the maximized tabset, if any. */ + maximized: string | null +} + +export interface Step { + operation: Operation + /** Whether the engine accepted the operation (moves, fold, maximize). */ + accepted?: boolean + observation: Observation + /** v1 saved layout after this step (every few steps), for migration checks. */ + savedV1?: unknown +} + +export interface Scenario { + name: string + tabs: string[] + container: { width: number; height: number } + initial: Observation + initialSavedV1: unknown + steps: Step[] +} + +export interface Driver { + create( + tabs: string[], + container: { width: number; height: number }, + saved?: unknown + ): void + apply(operation: Operation): boolean | undefined + observe(): Observation + save(): unknown +} + +export const isRow = (node: CanonicalNode): node is CanonicalRow => + 'direction' in node + +export const firstTab = (node: CanonicalNode): string => + isRow(node) ? firstTab(node.children[0]) : node.tabs[0] + +export const tabsetKey = (tabs: readonly string[]) => tabs.join('|') + +/** + * Flattens single-child rows and same-direction nesting, which v2 does on + * every change and v1 sometimes leaves behind (audit B7). Both engines are + * compared in this normalized form. + */ +export function normalizeCanonical(root: CanonicalRow): CanonicalRow { + const normalizeRow = (row: CanonicalRow): CanonicalRow => { + const children: CanonicalNode[] = [] + for (const child of row.children) { + if (!isRow(child)) { + if (child.tabs.length > 0) children.push(child) + continue + } + let next: CanonicalNode = normalizeRow(child) + if (isRow(next) && next.children.length === 1) + next = next.children[0] + if (isRow(next) && next.children.length === 0) continue + if (isRow(next) && next.direction === row.direction) { + children.push(...next.children) + } else { + children.push(next) + } + } + return { direction: row.direction, children } + } + + let result = normalizeRow(root) + while (result.children.length === 1 && isRow(result.children[0])) { + result = result.children[0] + } + return result +} + +/** Settings the fixtures were recorded with (the v1 React adapter's defaults). */ +export const V1_SETTINGS = { bond: 10, minW: 40, minH: 40, collapsedSize: 40 } diff --git a/packages/core/test/characterization/v2-driver.ts b/packages/core/test/characterization/v2-driver.ts new file mode 100644 index 0000000..f50ab08 --- /dev/null +++ b/packages/core/test/characterization/v2-driver.ts @@ -0,0 +1,109 @@ +import { createLayout, type Layout } from '../../src/create-layout' +import type { DropTarget, Side } from '../../src/model/types' +import type { LayoutTreeV1 } from '../../src/serialize/schema' +import { findSplitterPair } from '../../src/geometry/splitter' +import { firstTabId } from '../../src/tree/find' +import { observeV2 } from './v2-observe' +import { V1_SETTINGS, type Driver, type Operation } from './scenario' + +/** + * Drives the v2 `createLayout` through the public API. v1's center drop + * inserted the tab before the target's last tab (audit B26); to compare like + * with like, `contain` is replayed as exactly that. + */ +export function createV2Driver(): Driver { + let layout: Layout + + const tabsetOf = (tab: string) => + layout.getSnapshot().tabs.get(tab)?.tabsetId + + const toTarget = ( + target: Extract['target'] + ): DropTarget | null => { + if ('root' in target) return { type: 'root', position: target.root } + if ('tab' in target) + return { + type: 'tab', + tabId: target.tab, + position: target.position === 'left' ? 'before' : 'after', + } + const tabsetId = tabsetOf(target.tabsetOf) + if (!tabsetId) return null + if (target.area !== 'contain') + return { type: 'tabset', tabsetId, position: target.area as Side } + const tabIds = layout.getSnapshot().tabsets.get(tabsetId)!.tabIds + return { + type: 'tab', + tabId: tabIds[tabIds.length - 1], + position: 'before', + } + } + + return { + create(tabs, size, saved) { + layout = createLayout({ + tabs: tabs.map((id) => ({ id })), + initialLayout: (saved as LayoutTreeV1 | undefined) ?? null, + minPanelSize: { + width: V1_SETTINGS.minW, + height: V1_SETTINGS.minH, + }, + splitterSize: V1_SETTINGS.bond, + foldedSize: V1_SETTINGS.collapsedSize, + onWarning: () => {}, + }) + layout.setContainerRect({ x: 0, y: 0, ...size }) + }, + apply(operation) { + switch (operation.op) { + case 'resize': + layout.setContainerRect({ + x: 0, + y: 0, + width: operation.width, + height: operation.height, + }) + return undefined + case 'fold': + return layout.toggleFold(tabsetOf(operation.tabsetOf)!) + case 'maximize': + return layout.toggleMaximize(tabsetOf(operation.tabsetOf)!) + case 'splitter': { + const { root } = layout.getSnapshot() + const id = [...layout.getSnapshot().splitters.keys()].find( + (key) => { + const pair = findSplitterPair(root, key)! + return ( + firstTabId(pair.before) === operation.before && + firstTabId(pair.after) === operation.after + ) + } + ) + if (id) layout.resizeSplitter(id, operation.point) + return undefined + } + case 'move': { + const target = toTarget(operation.target) + if (!target) return false + const { source } = operation + return 'tab' in source + ? layout.moveTab(source.tab, target) + : layout.moveTabset(tabsetOf(source.tabsetOf)!, target) + } + } + }, + observe() { + const snapshot = layout.getSnapshot() + return observeV2( + { + root: snapshot.root, + maximizedTabsetId: snapshot.maximizedTabsetId, + lastActiveTabsetId: null, + foldOrder: [], + }, + snapshot.rects + ) + }, + save: () => layout.toJSON(), + } +} diff --git a/packages/core/test/characterization/v2-observe.ts b/packages/core/test/characterization/v2-observe.ts new file mode 100644 index 0000000..9990fbd --- /dev/null +++ b/packages/core/test/characterization/v2-observe.ts @@ -0,0 +1,63 @@ +import { isRow } from '../../src/model/guards' +import type { + LayoutModel, + Rect, + RowChild, + RowNode, +} from '../../src/model/types' +import type { LayoutRects } from '../../src/geometry/compute-rects' +import { splitterId } from '../../src/geometry/compute-rects' +import { firstTabId } from '../../src/tree/find' +import { + normalizeCanonical, + tabsetKey, + type CanonicalNode, + type CanonicalRow, + type Observation, + type RectTuple, +} from './scenario' + +const tuple = (r: Rect): RectTuple => [r.x, r.y, r.width, r.height] + +/** Describes a v2 model and its rects in the engine-neutral fixture format. */ +export function observeV2(model: LayoutModel, rects: LayoutRects): Observation { + const toCanonical = (node: RowChild): CanonicalNode => { + if (isRow(node)) + return { + direction: node.direction, + children: node.children.map(toCanonical), + } + const tabset = { + tabs: node.children.map((t) => t.id), + active: node.activeTabId, + } + return node.isFolded ? { ...tabset, folded: true } : tabset + } + + const tabsets: Record = {} + const splitters: Record = {} + let maximized: string | null = null + const visit = (row: RowNode) => { + row.children.forEach((child, i) => { + if (isRow(child)) visit(child) + else { + const key = tabsetKey(child.children.map((t) => t.id)) + tabsets[key] = tuple(rects.tabsets.get(child.id)!) + if (child.id === model.maximizedTabsetId) maximized = key + } + const next = row.children[i + 1] + if (next) + splitters[`${firstTabId(child)}>${firstTabId(next)}`] = tuple( + rects.splitters.get(splitterId(child.id, next.id))! + ) + }) + } + visit(model.root) + + return { + tree: normalizeCanonical(toCanonical(model.root) as CanonicalRow), + tabsets, + splitters, + maximized, + } +} diff --git a/packages/core/test/characterization/v2.test.ts b/packages/core/test/characterization/v2.test.ts new file mode 100644 index 0000000..50f6e2c --- /dev/null +++ b/packages/core/test/characterization/v2.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest' +import { isDeepStrictEqual } from 'node:util' +import { loadScenarios } from './fixtures' +import type { Observation, Operation } from './scenario' +import { createV2Driver } from './v2-driver' + +/** + * Replays every v1 fixture through the v2 public API. Each step must match + * v1 exactly (structure, every rect, accepted or refused), except where v1 + * hits one of its known bugs. There the engines' states legitimately differ, + * so the replay checks the structure still matches and stops that scenario. + */ + +const FOLDED_SIZE = 40 + +/** Why v1 diverges at this step, or `null` when it should match exactly. */ +function knownV1Bug( + operation: Operation, + before: Observation, + after: Observation, + container: { width: number; height: number } +): string | null { + const overflows = Object.values(after.tabsets).some( + ([x, y, w, h]) => x + w > container.width || y + h > container.height + ) + if (overflows) + return 'B29: v1 keeps a grown root after the container shrank' + + if ( + operation.op === 'move' && + 'tabsetOf' in operation.target && + operation.target.area !== 'contain' + ) { + const tab = operation.target.tabsetOf + const folded = findTabset(before.tree, tab)?.folded + if (folded) + return 'B27: splitting beside a folded tabset leaves a row stuck at its minimum' + } + + const stale = Object.entries(after.tabsets).some(([key, [, , w, h]]) => { + const tabset = findTabset(after.tree, key.split('|')[0]) + return tabset?.folded && w !== FOLDED_SIZE && h !== FOLDED_SIZE + }) + if (stale) + return 'B28: a row whose open children all weigh 0 keeps stale sizes' + return null +} + +type Tree = Observation['tree'] +function findTabset( + node: Tree | Tree['children'][number], + tab: string +): { folded?: true } | undefined { + if ('tabs' in node) return node.tabs.includes(tab) ? node : undefined + for (const child of node.children) { + const found = findTabset(child, tab) + if (found) return found + } + return undefined +} + +describe('v2 replays the v1 fixtures', () => { + const scenarios = loadScenarios() + const stops: string[] = [] + let compared = 0 + + it.each(scenarios.map((s) => [s.name, s] as const))('%s', (_, scenario) => { + const driver = createV2Driver() + driver.create(scenario.tabs, scenario.container) + expect(driver.observe(), 'initial').toEqual(scenario.initial) + + let container = scenario.container + let before = scenario.initial + for (const [i, step] of scenario.steps.entries()) { + const { operation } = step + if (operation.op === 'resize') + container = { width: operation.width, height: operation.height } + const accepted = driver.apply(operation) + const observed = driver.observe() + const label = `step ${i + 1}: ${JSON.stringify(operation)}` + if (step.accepted !== undefined) + expect(accepted, label).toBe(step.accepted) + + if (!isDeepStrictEqual(observed, step.observation)) { + const bug = knownV1Bug( + operation, + before, + step.observation, + container + ) + if (!bug) expect(observed, label).toEqual(step.observation) + expect(observed.tree, label).toEqual(step.observation.tree) + stops.push(`${scenario.name} ${label} → ${bug}`) + return + } + compared++ + before = step.observation + } + }) + + it('compares most steps exactly', () => { + expect(compared).toBeGreaterThan(400) + expect(stops.length).toBeLessThanOrEqual(4) + }) +}) diff --git a/packages/core/test/drop-preview.test.ts b/packages/core/test/drop-preview.test.ts deleted file mode 100644 index 73b42c7..0000000 --- a/packages/core/test/drop-preview.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest' -import { - createFrameScheduler, - getNavbarDropPreview, - getRootSplitPreview, - getTabBodyRect, - getTabsetDropPreview, - isSameDropPreview, -} from '../src' - -const rect = (left: number, top: number, width: number, height: number) => ({ - left, - top, - width, - height, - right: left + width, - bottom: top + height, -}) - -describe('getTabsetDropPreview', () => { - const tabset = rect(100, 50, 300, 600) - - it.each([ - ['left', 120, 300, { left: 100, top: 50, width: 150, height: 600 }], - ['right', 380, 300, { left: 250, top: 50, width: 150, height: 600 }], - ['top', 250, 100, { left: 100, top: 50, width: 300, height: 300 }], - ['bottom', 250, 600, { left: 100, top: 350, width: 300, height: 300 }], - ['contain', 250, 350, { left: 130, top: 110, width: 240, height: 480 }], - ])('previews the %s zone', (area, x, y, box) => { - expect(getTabsetDropPreview(tabset, x, y)).toEqual({ area, ...box }) - }) -}) - -describe('getRootSplitPreview', () => { - const container = { x: 0, y: 0, w: 1000, h: 800 } - - it.each([ - ['left', { left: 0, top: 0, width: 500, height: 800 }], - ['right', { left: 500, top: 0, width: 500, height: 800 }], - ['top', { left: 0, top: 0, width: 1000, height: 400 }], - ['bottom', { left: 0, top: 400, width: 1000, height: 400 }], - ] as const)('previews the %s half', (side, box) => { - expect(getRootSplitPreview(container, side)).toEqual({ - area: side, - ...box, - }) - }) -}) - -describe('getNavbarDropPreview', () => { - const navbar = rect(0, 0, 500, 40) - // Three tabs, 60px wide, 10px apart. - const tabs = [rect(8, 5, 60, 30), rect(78, 5, 60, 30), rect(148, 5, 60, 30)] - - it('returns null outside the bar vertically or without tabs', () => { - expect(getNavbarDropPreview(navbar, tabs, 100, 60)).toBeNull() - expect(getNavbarDropPreview(navbar, [], 100, 20)).toBeNull() - }) - - it('marks after the last tab past its right edge', () => { - expect(getNavbarDropPreview(navbar, tabs, 300, 20)).toEqual({ - index: 2, - area: 'right', - left: 209, - top: 5, - width: 4, - height: 30, - }) - }) - - it('marks before the first tab left of it', () => { - expect(getNavbarDropPreview(navbar, tabs, 2, 20)).toEqual({ - index: 0, - area: 'left', - left: 3, - top: 5, - width: 4, - height: 30, - }) - }) - - it('uses the measured gap between neighbouring tabs', () => { - expect(getNavbarDropPreview(navbar, tabs, 100, 20)).toEqual({ - index: 1, - area: 'left', - left: 69, - top: 5, - width: 8, - height: 30, - }) - }) -}) - -describe('isSameDropPreview', () => { - it('compares area and box', () => { - const a = { - area: 'left' as const, - left: 1, - top: 2, - width: 3, - height: 4, - } - expect(isSameDropPreview(a, { ...a })).toBe(true) - expect(isSameDropPreview(a, { ...a, width: 5 })).toBe(false) - expect(isSameDropPreview({}, a)).toBe(false) - }) -}) - -describe('getTabBodyRect', () => { - it('sits below the tab bar and never goes negative', () => { - expect(getTabBodyRect({ x: 10, y: 20, w: 300, h: 200 }, 40)).toEqual({ - x: 10, - y: 60, - w: 300, - h: 160, - }) - expect(getTabBodyRect({ x: 0, y: 0, w: 100, h: 20 }, 40).h).toBe(0) - }) -}) - -describe('createFrameScheduler', () => { - afterEach(() => vi.unstubAllGlobals()) - - const stubFrames = () => { - const callbacks = new Map void>() - let next = 1 - vi.stubGlobal('requestAnimationFrame', (cb: () => void) => { - callbacks.set(next, cb) - return next++ - }) - vi.stubGlobal('cancelAnimationFrame', (id: number) => - callbacks.delete(id) - ) - return () => { - const pending = [...callbacks.values()] - callbacks.clear() - pending.forEach((cb) => cb()) - } - } - - it('runs once per frame with the latest value', () => { - const runFrame = stubFrames() - const onFlush = vi.fn() - const scheduler = createFrameScheduler(onFlush) - - scheduler.schedule(1) - scheduler.schedule(2) - scheduler.schedule(3) - runFrame() - - expect(onFlush).toHaveBeenCalledTimes(1) - expect(onFlush).toHaveBeenCalledWith(3) - }) - - it('flushes immediately and cancels pending work', () => { - const runFrame = stubFrames() - const onFlush = vi.fn() - const scheduler = createFrameScheduler(onFlush) - - scheduler.schedule(1) - scheduler.flush() - expect(onFlush).toHaveBeenCalledWith(1) - - scheduler.schedule(2) - scheduler.cancel() - runFrame() - expect(onFlush).toHaveBeenCalledTimes(1) - }) -}) diff --git a/packages/core/test/dynamic.test.ts b/packages/core/test/dynamic.test.ts deleted file mode 100644 index ebbc055..0000000 --- a/packages/core/test/dynamic.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { DynamixLayoutCore, LayoutTree, NodeTypeWithBond } from '../src' -import { assertNodeTreeIsValid } from './utils/test.utils' - -/** - * Extracts all nodes from a LayoutTree and returns them in a flat array. - * Each node is represented by its id, name, and type. - * @param tree The root of the LayoutTree to extract nodes from. - * @returns An array of objects containing id, name, and type of each node. - */ -const getNodesFromTree = ( - tree: LayoutTree -): { id: string; name: string; type: NodeTypeWithBond }[] => { - const nodes = [ - { - id: tree.uidNode, - name: tree.nodName, - type: tree.typNode, - }, - ] - if (tree.nodKids) { - for (const kid of tree.nodKids) { - nodes.push(...getNodesFromTree(kid)) - } - } - return nodes -} - -/** - * Test suite for dynamic layout management functionality. - * - * This suite tests the Layout class's ability to handle various node configurations - * and relocation operations. It dynamically generates layout structures with an - * increasing number of tabs (1 to n), converts each layout to JSON, and then - * performs extensive node movement tests. - * - * For each layout configuration, it: - * 1. Creates a layout with a subset of tabs - * 2. Extracts all nodes (tabs, tabsets, etc.) from the layout tree - * 3. Tests every possible movement combination: - * - Every draggable node (tabs and tabsets) - * - Every potential drop target (tabs, tabsets, and root) - * - Every position (top, bottom, left, right, contain) - * 4. Validates that each successful movement results in a valid tree structure - * - * This comprehensive approach ensures the layout engine correctly handles - * node relocation under a wide variety of scenarios and maintains tree integrity - * after each operation. - */ - -describe('Dynamic Layout Management Test Suite', () => { - beforeEach(() => { - const layout = new DynamixLayoutCore() - layout.clearAllCache() - }) - - describe('Layout Initialization', () => { - it('should create a valid layout structure from tabs list', () => { - const tabs = ['dashboard', 'profile', 'settings'] - const layoutInstance = new DynamixLayoutCore({ tabs }) - expect(layoutInstance).toBeInstanceOf(DynamixLayoutCore) - expect(() => - assertNodeTreeIsValid(DynamixLayoutCore._root) - ).not.toThrow() - }) - }) - - describe('Node Relocation Operations', () => { - const tabs = [ - 'dashboard', - 'profile', - 'settings', - 'analytics', - 'messages', - 'notifications', - // 'activity', - // 'projects', - // 'tasks', - // 'calendar', - // 'files', - // 'contacts', - // 'inbox', - // 'reports', - // 'billing', - // 'support', - // 'integrations', - // 'api-keys', - // 'logs', - // 'themes', - // 'permissions', - // 'account', - // 'devices', - // 'security', - // 'feedback' - ] - - const layouts: LayoutTree[] = [] - const tabsets: string[][] = [] - for (let i = 0; i < tabs.length; i++) { - const subsetTabs = tabs.slice(0, i + 1) - tabsets.push(subsetTabs) - new DynamixLayoutCore({ - tabs: subsetTabs, - }) - layouts.push(DynamixLayoutCore._root.toJSON()) - } - - for (let i = 0; i < layouts.length; i++) { - const initialLayoutTree = layouts[i] - const tabset = tabsets[i] - describe(`Layout with ${tabset.length} Tabs: Node Movement Tests`, () => { - const nodes = getNodesFromTree(initialLayoutTree) - const draggableNodes = nodes.filter( - (n) => n.type === 'tab' || n.type === 'tabset' - ) - const droppableNodes = nodes.filter( - (n) => - n.type === 'tab' || - n.type === 'tabset' || - n.id === 'dynamix-layout-root' - ) - const positions: ( - | 'top' - | 'bottom' - | 'left' - | 'right' - | 'contain' - )[] = ['top', 'bottom', 'left', 'right', 'contain'] - - for (const srcNode of draggableNodes) { - for (const desNode of droppableNodes) { - for (const position of positions) { - if (srcNode.id === desNode.id) continue - - it(`should handle ${srcNode.type} '${srcNode.id}' moving to '${position}' of ${desNode.type} '${desNode.id}'`, () => { - const testInstance = new DynamixLayoutCore({ - tree: initialLayoutTree, - }) - const moveSucceeded = testInstance.updateTree( - srcNode.id, - desNode.id, - position - ) - - if (moveSucceeded) { - expect(() => - assertNodeTreeIsValid( - DynamixLayoutCore._root - ) - ).not.toThrow() - } else { - expect(moveSucceeded).toBe(false) - } - }) - } - } - } - }) - } - }) -}) diff --git a/packages/core/test/fixtures/v1/README.md b/packages/core/test/fixtures/v1/README.md new file mode 100644 index 0000000..66ad1e6 --- /dev/null +++ b/packages/core/test/fixtures/v1/README.md @@ -0,0 +1,18 @@ +# v1 fixtures + +Recorded from the v1 engine (`DynamixLayoutCore`, core 1.x) before it was +removed. They are the reference v2 is checked against and must not be +regenerated. + +- `scenario-*.json`: 16 seeded scenarios of 30 UI-reachable operations each + (moves, splitter drags, fold, maximize, resize), with the canonical tree, + every tabset and splitter rect, and the saved v1 layout every few steps. + Recorded by `test/characterization/v1.test.ts` and `v1-driver.ts` + (commit `dc654bd`). +- `default-layouts.json`: default layouts of 1–9 tabs at five container + sizes. +- `drop-previews.json`: outputs of the v1 drop-preview helpers for sampled + pointer positions. + +Replayed by `test/characterization/v2.test.ts`, `test/v2/serialize.test.ts`, +`test/v2/geometry.test.ts` and `test/v2/drop.test.ts`. diff --git a/packages/core/test/fixtures/v1/default-layouts.json b/packages/core/test/fixtures/v1/default-layouts.json new file mode 100644 index 0000000..813bc0c --- /dev/null +++ b/packages/core/test/fixtures/v1/default-layouts.json @@ -0,0 +1,2259 @@ +[ + { + "tabs": ["a"], + "container": { "width": 1200, "height": 800 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["a"], "active": "a" }] + }, + "tabsets": { "a": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + } + }, + { + "tabs": ["a"], + "container": { "width": 1357, "height": 911 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["a"], "active": "a" }] + }, + "tabsets": { "a": [0, 0, 1357, 911] }, + "splitters": {}, + "maximized": null + } + }, + { + "tabs": ["a"], + "container": { "width": 640, "height": 420 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["a"], "active": "a" }] + }, + "tabsets": { "a": [0, 0, 640, 420] }, + "splitters": {}, + "maximized": null + } + }, + { + "tabs": ["a"], + "container": { "width": 300, "height": 200 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["a"], "active": "a" }] + }, + "tabsets": { "a": [0, 0, 300, 200] }, + "splitters": {}, + "maximized": null + } + }, + { + "tabs": ["a"], + "container": { "width": 97, "height": 61 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["a"], "active": "a" }] + }, + "tabsets": { "a": [0, 0, 97, 61] }, + "splitters": {}, + "maximized": null + } + }, + { + "tabs": ["a", "b"], + "container": { "width": 1200, "height": 800 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { "tabs": ["b"], "active": "b" } + ] + }, + "tabsets": { "a": [0, 0, 595, 800], "b": [605, 0, 595, 800] }, + "splitters": { "a>b": [595, 0, 10, 800] }, + "maximized": null + } + }, + { + "tabs": ["a", "b"], + "container": { "width": 1357, "height": 911 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { "tabs": ["b"], "active": "b" } + ] + }, + "tabsets": { "a": [0, 0, 674, 911], "b": [684, 0, 673, 911] }, + "splitters": { "a>b": [674, 0, 10, 911] }, + "maximized": null + } + }, + { + "tabs": ["a", "b"], + "container": { "width": 640, "height": 420 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { "tabs": ["b"], "active": "b" } + ] + }, + "tabsets": { "a": [0, 0, 315, 420], "b": [325, 0, 315, 420] }, + "splitters": { "a>b": [315, 0, 10, 420] }, + "maximized": null + } + }, + { + "tabs": ["a", "b"], + "container": { "width": 300, "height": 200 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { "tabs": ["b"], "active": "b" } + ] + }, + "tabsets": { "a": [0, 0, 145, 200], "b": [155, 0, 145, 200] }, + "splitters": { "a>b": [145, 0, 10, 200] }, + "maximized": null + } + }, + { + "tabs": ["a", "b"], + "container": { "width": 97, "height": 61 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { "tabs": ["b"], "active": "b" } + ] + }, + "tabsets": { "a": [0, 0, 44, 61], "b": [54, 0, 43, 61] }, + "splitters": { "a>b": [44, 0, 10, 61] }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c"], + "container": { "width": 1200, "height": 800 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { "tabs": ["c"], "active": "c" } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 595, 800], + "b": [605, 0, 595, 395], + "c": [605, 405, 595, 395] + }, + "splitters": { + "a>b": [595, 0, 10, 800], + "b>c": [605, 395, 595, 10] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c"], + "container": { "width": 1357, "height": 911 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { "tabs": ["c"], "active": "c" } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 674, 911], + "b": [684, 0, 673, 451], + "c": [684, 461, 673, 450] + }, + "splitters": { + "a>b": [674, 0, 10, 911], + "b>c": [684, 451, 673, 10] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c"], + "container": { "width": 640, "height": 420 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { "tabs": ["c"], "active": "c" } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 315, 420], + "b": [325, 0, 315, 205], + "c": [325, 215, 315, 205] + }, + "splitters": { + "a>b": [315, 0, 10, 420], + "b>c": [325, 205, 315, 10] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c"], + "container": { "width": 300, "height": 200 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { "tabs": ["c"], "active": "c" } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 145, 200], + "b": [155, 0, 145, 95], + "c": [155, 105, 145, 95] + }, + "splitters": { + "a>b": [145, 0, 10, 200], + "b>c": [155, 95, 145, 10] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c"], + "container": { "width": 97, "height": 61 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { "tabs": ["c"], "active": "c" } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 44, 90], + "b": [54, 0, 43, 40], + "c": [54, 50, 43, 40] + }, + "splitters": { "a>b": [44, 0, 10, 90], "b>c": [54, 40, 43, 10] }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d"], + "container": { "width": 1200, "height": 800 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { "tabs": ["d"], "active": "d" } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 570, 800], + "b": [580, 0, 620, 395], + "c": [580, 405, 305, 395], + "d": [895, 405, 305, 395] + }, + "splitters": { + "a>b": [570, 0, 10, 800], + "b>c": [580, 395, 620, 10], + "c>d": [885, 405, 10, 395] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d"], + "container": { "width": 1357, "height": 911 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { "tabs": ["d"], "active": "d" } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 649, 911], + "b": [659, 0, 698, 451], + "c": [659, 461, 344, 450], + "d": [1013, 461, 344, 450] + }, + "splitters": { + "a>b": [649, 0, 10, 911], + "b>c": [659, 451, 698, 10], + "c>d": [1003, 461, 10, 450] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d"], + "container": { "width": 640, "height": 420 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { "tabs": ["d"], "active": "d" } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 290, 420], + "b": [300, 0, 340, 205], + "c": [300, 215, 165, 205], + "d": [475, 215, 165, 205] + }, + "splitters": { + "a>b": [290, 0, 10, 420], + "b>c": [300, 205, 340, 10], + "c>d": [465, 215, 10, 205] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d"], + "container": { "width": 300, "height": 200 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { "tabs": ["d"], "active": "d" } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 120, 200], + "b": [130, 0, 170, 95], + "c": [130, 105, 80, 95], + "d": [220, 105, 80, 95] + }, + "splitters": { + "a>b": [120, 0, 10, 200], + "b>c": [130, 95, 170, 10], + "c>d": [210, 105, 10, 95] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d"], + "container": { "width": 97, "height": 61 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { "tabs": ["d"], "active": "d" } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 40, 90], + "b": [50, 0, 90, 40], + "c": [50, 50, 40, 40], + "d": [100, 50, 40, 40] + }, + "splitters": { + "a>b": [40, 0, 10, 90], + "b>c": [50, 40, 90, 10], + "c>d": [90, 50, 10, 40] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e"], + "container": { "width": 1200, "height": 800 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { "tabs": ["e"], "active": "e" } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 570, 800], + "b": [580, 0, 620, 370], + "c": [580, 380, 305, 420], + "d": [895, 380, 305, 205], + "e": [895, 595, 305, 205] + }, + "splitters": { + "a>b": [570, 0, 10, 800], + "b>c": [580, 370, 620, 10], + "c>d": [885, 380, 10, 420], + "d>e": [895, 585, 305, 10] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e"], + "container": { "width": 1357, "height": 911 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { "tabs": ["e"], "active": "e" } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 649, 911], + "b": [659, 0, 698, 426], + "c": [659, 436, 344, 475], + "d": [1013, 436, 344, 233], + "e": [1013, 679, 344, 232] + }, + "splitters": { + "a>b": [649, 0, 10, 911], + "b>c": [659, 426, 698, 10], + "c>d": [1003, 436, 10, 475], + "d>e": [1013, 669, 344, 10] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e"], + "container": { "width": 640, "height": 420 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { "tabs": ["e"], "active": "e" } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 290, 420], + "b": [300, 0, 340, 180], + "c": [300, 190, 165, 230], + "d": [475, 190, 165, 110], + "e": [475, 310, 165, 110] + }, + "splitters": { + "a>b": [290, 0, 10, 420], + "b>c": [300, 180, 340, 10], + "c>d": [465, 190, 10, 230], + "d>e": [475, 300, 165, 10] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e"], + "container": { "width": 300, "height": 200 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { "tabs": ["e"], "active": "e" } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 120, 200], + "b": [130, 0, 170, 70], + "c": [130, 80, 80, 120], + "d": [220, 80, 80, 55], + "e": [220, 145, 80, 55] + }, + "splitters": { + "a>b": [120, 0, 10, 200], + "b>c": [130, 70, 170, 10], + "c>d": [210, 80, 10, 120], + "d>e": [220, 135, 80, 10] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e"], + "container": { "width": 97, "height": 61 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { "tabs": ["e"], "active": "e" } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 40, 140], + "b": [50, 0, 90, 40], + "c": [50, 50, 40, 90], + "d": [100, 50, 40, 40], + "e": [100, 100, 40, 40] + }, + "splitters": { + "a>b": [40, 0, 10, 140], + "b>c": [50, 40, 90, 10], + "c>d": [90, 50, 10, 90], + "d>e": [100, 90, 40, 10] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e", "f"], + "container": { "width": 1200, "height": 800 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["e"], + "active": "e" + }, + { + "tabs": ["f"], + "active": "f" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 545, 800], + "b": [555, 0, 645, 370], + "c": [555, 380, 293, 420], + "d": [858, 380, 342, 205], + "e": [858, 595, 166, 205], + "f": [1034, 595, 166, 205] + }, + "splitters": { + "a>b": [545, 0, 10, 800], + "b>c": [555, 370, 645, 10], + "c>d": [848, 380, 10, 420], + "d>e": [858, 585, 342, 10], + "e>f": [1024, 595, 10, 205] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e", "f"], + "container": { "width": 1357, "height": 911 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["e"], + "active": "e" + }, + { + "tabs": ["f"], + "active": "f" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 624, 911], + "b": [634, 0, 723, 426], + "c": [634, 436, 332, 475], + "d": [976, 436, 381, 233], + "e": [976, 679, 186, 232], + "f": [1172, 679, 185, 232] + }, + "splitters": { + "a>b": [624, 0, 10, 911], + "b>c": [634, 426, 723, 10], + "c>d": [966, 436, 10, 475], + "d>e": [976, 669, 381, 10], + "e>f": [1162, 679, 10, 232] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e", "f"], + "container": { "width": 640, "height": 420 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["e"], + "active": "e" + }, + { + "tabs": ["f"], + "active": "f" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 265, 420], + "b": [275, 0, 365, 180], + "c": [275, 190, 153, 230], + "d": [438, 190, 202, 110], + "e": [438, 310, 96, 110], + "f": [544, 310, 96, 110] + }, + "splitters": { + "a>b": [265, 0, 10, 420], + "b>c": [275, 180, 365, 10], + "c>d": [428, 190, 10, 230], + "d>e": [438, 300, 202, 10], + "e>f": [534, 310, 10, 110] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e", "f"], + "container": { "width": 300, "height": 200 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["e"], + "active": "e" + }, + { + "tabs": ["f"], + "active": "f" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 95, 200], + "b": [105, 0, 195, 70], + "c": [105, 80, 68, 120], + "d": [183, 80, 117, 55], + "e": [183, 145, 54, 55], + "f": [247, 145, 53, 55] + }, + "splitters": { + "a>b": [95, 0, 10, 200], + "b>c": [105, 70, 195, 10], + "c>d": [173, 80, 10, 120], + "d>e": [183, 135, 117, 10], + "e>f": [237, 145, 10, 55] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e", "f"], + "container": { "width": 97, "height": 61 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["e"], + "active": "e" + }, + { + "tabs": ["f"], + "active": "f" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 40, 140], + "b": [50, 0, 140, 40], + "c": [50, 50, 40, 90], + "d": [100, 50, 90, 40], + "e": [100, 100, 40, 40], + "f": [150, 100, 40, 40] + }, + "splitters": { + "a>b": [40, 0, 10, 140], + "b>c": [50, 40, 140, 10], + "c>d": [90, 50, 10, 90], + "d>e": [100, 90, 90, 10], + "e>f": [140, 100, 10, 40] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e", "f", "g"], + "container": { "width": 1200, "height": 800 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["e"], + "active": "e" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["f"], + "active": "f" + }, + { + "tabs": ["g"], + "active": "g" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 545, 800], + "b": [555, 0, 645, 345], + "c": [555, 355, 293, 445], + "d": [858, 355, 342, 193], + "e": [858, 558, 166, 242], + "f": [1034, 558, 166, 116], + "g": [1034, 684, 166, 116] + }, + "splitters": { + "a>b": [545, 0, 10, 800], + "b>c": [555, 345, 645, 10], + "c>d": [848, 355, 10, 445], + "d>e": [858, 548, 342, 10], + "e>f": [1024, 558, 10, 242], + "f>g": [1034, 674, 166, 10] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e", "f", "g"], + "container": { "width": 1357, "height": 911 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["e"], + "active": "e" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["f"], + "active": "f" + }, + { + "tabs": ["g"], + "active": "g" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 624, 911], + "b": [634, 0, 723, 401], + "c": [634, 411, 332, 500], + "d": [976, 411, 381, 220], + "e": [976, 641, 186, 270], + "f": [1172, 641, 185, 130], + "g": [1172, 781, 185, 130] + }, + "splitters": { + "a>b": [624, 0, 10, 911], + "b>c": [634, 401, 723, 10], + "c>d": [966, 411, 10, 500], + "d>e": [976, 631, 381, 10], + "e>f": [1162, 641, 10, 270], + "f>g": [1172, 771, 185, 10] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e", "f", "g"], + "container": { "width": 640, "height": 420 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["e"], + "active": "e" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["f"], + "active": "f" + }, + { + "tabs": ["g"], + "active": "g" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 265, 420], + "b": [275, 0, 365, 155], + "c": [275, 165, 153, 255], + "d": [438, 165, 202, 98], + "e": [438, 273, 96, 147], + "f": [544, 273, 96, 69], + "g": [544, 352, 96, 68] + }, + "splitters": { + "a>b": [265, 0, 10, 420], + "b>c": [275, 155, 365, 10], + "c>d": [428, 165, 10, 255], + "d>e": [438, 263, 202, 10], + "e>f": [534, 273, 10, 147], + "f>g": [544, 342, 96, 10] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e", "f", "g"], + "container": { "width": 300, "height": 200 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["e"], + "active": "e" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["f"], + "active": "f" + }, + { + "tabs": ["g"], + "active": "g" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 95, 200], + "b": [105, 0, 195, 45], + "c": [105, 55, 68, 145], + "d": [183, 55, 117, 43], + "e": [183, 108, 54, 92], + "f": [247, 108, 53, 41], + "g": [247, 159, 53, 41] + }, + "splitters": { + "a>b": [95, 0, 10, 200], + "b>c": [105, 45, 195, 10], + "c>d": [173, 55, 10, 145], + "d>e": [183, 98, 117, 10], + "e>f": [237, 108, 10, 92], + "f>g": [247, 149, 53, 10] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e", "f", "g"], + "container": { "width": 97, "height": 61 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["e"], + "active": "e" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["f"], + "active": "f" + }, + { + "tabs": ["g"], + "active": "g" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 40, 190], + "b": [50, 0, 140, 40], + "c": [50, 50, 40, 140], + "d": [100, 50, 90, 40], + "e": [100, 100, 40, 90], + "f": [150, 100, 40, 40], + "g": [150, 150, 40, 40] + }, + "splitters": { + "a>b": [40, 0, 10, 190], + "b>c": [50, 40, 140, 10], + "c>d": [90, 50, 10, 140], + "d>e": [100, 90, 90, 10], + "e>f": [140, 100, 10, 90], + "f>g": [150, 140, 40, 10] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e", "f", "g", "h"], + "container": { "width": 1200, "height": 800 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["e"], + "active": "e" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["f"], + "active": "f" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "g" + ], + "active": "g" + }, + { + "tabs": [ + "h" + ], + "active": "h" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 520, 800], + "b": [530, 0, 670, 345], + "c": [530, 355, 280, 445], + "d": [820, 355, 380, 193], + "e": [820, 558, 160, 242], + "f": [990, 558, 210, 116], + "g": [990, 684, 100, 116], + "h": [1100, 684, 100, 116] + }, + "splitters": { + "a>b": [520, 0, 10, 800], + "b>c": [530, 345, 670, 10], + "c>d": [810, 355, 10, 445], + "d>e": [820, 548, 380, 10], + "e>f": [980, 558, 10, 242], + "f>g": [990, 674, 210, 10], + "g>h": [1090, 684, 10, 116] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e", "f", "g", "h"], + "container": { "width": 1357, "height": 911 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["e"], + "active": "e" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["f"], + "active": "f" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "g" + ], + "active": "g" + }, + { + "tabs": [ + "h" + ], + "active": "h" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 599, 911], + "b": [609, 0, 748, 401], + "c": [609, 411, 319, 500], + "d": [938, 411, 419, 220], + "e": [938, 641, 180, 270], + "f": [1128, 641, 229, 130], + "g": [1128, 781, 110, 130], + "h": [1248, 781, 109, 130] + }, + "splitters": { + "a>b": [599, 0, 10, 911], + "b>c": [609, 401, 748, 10], + "c>d": [928, 411, 10, 500], + "d>e": [938, 631, 419, 10], + "e>f": [1118, 641, 10, 270], + "f>g": [1128, 771, 229, 10], + "g>h": [1238, 781, 10, 130] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e", "f", "g", "h"], + "container": { "width": 640, "height": 420 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["e"], + "active": "e" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["f"], + "active": "f" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "g" + ], + "active": "g" + }, + { + "tabs": [ + "h" + ], + "active": "h" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 240, 420], + "b": [250, 0, 390, 155], + "c": [250, 165, 140, 255], + "d": [400, 165, 240, 98], + "e": [400, 273, 90, 147], + "f": [500, 273, 140, 69], + "g": [500, 352, 65, 68], + "h": [575, 352, 65, 68] + }, + "splitters": { + "a>b": [240, 0, 10, 420], + "b>c": [250, 155, 390, 10], + "c>d": [390, 165, 10, 255], + "d>e": [400, 263, 240, 10], + "e>f": [490, 273, 10, 147], + "f>g": [500, 342, 140, 10], + "g>h": [565, 352, 10, 68] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e", "f", "g", "h"], + "container": { "width": 300, "height": 200 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["e"], + "active": "e" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["f"], + "active": "f" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "g" + ], + "active": "g" + }, + { + "tabs": [ + "h" + ], + "active": "h" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 70, 200], + "b": [80, 0, 220, 45], + "c": [80, 55, 55, 145], + "d": [145, 55, 155, 43], + "e": [145, 108, 48, 92], + "f": [203, 108, 97, 41], + "g": [203, 159, 44, 41], + "h": [257, 159, 43, 41] + }, + "splitters": { + "a>b": [70, 0, 10, 200], + "b>c": [80, 45, 220, 10], + "c>d": [135, 55, 10, 145], + "d>e": [145, 98, 155, 10], + "e>f": [193, 108, 10, 92], + "f>g": [203, 149, 97, 10], + "g>h": [247, 159, 10, 41] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e", "f", "g", "h"], + "container": { "width": 97, "height": 61 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["e"], + "active": "e" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["f"], + "active": "f" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "g" + ], + "active": "g" + }, + { + "tabs": [ + "h" + ], + "active": "h" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 40, 190], + "b": [50, 0, 190, 40], + "c": [50, 50, 40, 140], + "d": [100, 50, 140, 40], + "e": [100, 100, 40, 90], + "f": [150, 100, 90, 40], + "g": [150, 150, 40, 40], + "h": [200, 150, 40, 40] + }, + "splitters": { + "a>b": [40, 0, 10, 190], + "b>c": [50, 40, 190, 10], + "c>d": [90, 50, 10, 140], + "d>e": [100, 90, 140, 10], + "e>f": [140, 100, 10, 90], + "f>g": [150, 140, 90, 10], + "g>h": [190, 150, 10, 40] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e", "f", "g", "h", "i"], + "container": { "width": 1200, "height": 800 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["e"], + "active": "e" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["f"], + "active": "f" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "g" + ], + "active": "g" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": [ + "h" + ], + "active": "h" + }, + { + "tabs": [ + "i" + ], + "active": "i" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 520, 800], + "b": [530, 0, 670, 320], + "c": [530, 330, 280, 470], + "d": [820, 330, 380, 180], + "e": [820, 520, 160, 280], + "f": [990, 520, 210, 110], + "g": [990, 640, 100, 160], + "h": [1100, 640, 100, 75], + "i": [1100, 725, 100, 75] + }, + "splitters": { + "a>b": [520, 0, 10, 800], + "b>c": [530, 320, 670, 10], + "c>d": [810, 330, 10, 470], + "d>e": [820, 510, 380, 10], + "e>f": [980, 520, 10, 280], + "f>g": [990, 630, 210, 10], + "g>h": [1090, 640, 10, 160], + "h>i": [1100, 715, 100, 10] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e", "f", "g", "h", "i"], + "container": { "width": 1357, "height": 911 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["e"], + "active": "e" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["f"], + "active": "f" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "g" + ], + "active": "g" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": [ + "h" + ], + "active": "h" + }, + { + "tabs": [ + "i" + ], + "active": "i" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 599, 911], + "b": [609, 0, 748, 376], + "c": [609, 386, 319, 525], + "d": [938, 386, 419, 208], + "e": [938, 604, 180, 307], + "f": [1128, 604, 229, 124], + "g": [1128, 738, 110, 173], + "h": [1248, 738, 109, 82], + "i": [1248, 830, 109, 81] + }, + "splitters": { + "a>b": [599, 0, 10, 911], + "b>c": [609, 376, 748, 10], + "c>d": [928, 386, 10, 525], + "d>e": [938, 594, 419, 10], + "e>f": [1118, 604, 10, 307], + "f>g": [1128, 728, 229, 10], + "g>h": [1238, 738, 10, 173], + "h>i": [1248, 820, 109, 10] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e", "f", "g", "h", "i"], + "container": { "width": 640, "height": 420 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["e"], + "active": "e" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["f"], + "active": "f" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "g" + ], + "active": "g" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": [ + "h" + ], + "active": "h" + }, + { + "tabs": [ + "i" + ], + "active": "i" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 240, 420], + "b": [250, 0, 390, 130], + "c": [250, 140, 140, 280], + "d": [400, 140, 240, 85], + "e": [400, 235, 90, 185], + "f": [500, 235, 140, 63], + "g": [500, 308, 65, 112], + "h": [575, 308, 65, 51], + "i": [575, 369, 65, 51] + }, + "splitters": { + "a>b": [240, 0, 10, 420], + "b>c": [250, 130, 390, 10], + "c>d": [390, 140, 10, 280], + "d>e": [400, 225, 240, 10], + "e>f": [490, 235, 10, 185], + "f>g": [500, 298, 140, 10], + "g>h": [565, 308, 10, 112], + "h>i": [575, 359, 65, 10] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e", "f", "g", "h", "i"], + "container": { "width": 300, "height": 200 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["e"], + "active": "e" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["f"], + "active": "f" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "g" + ], + "active": "g" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": [ + "h" + ], + "active": "h" + }, + { + "tabs": [ + "i" + ], + "active": "i" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 70, 240], + "b": [80, 0, 220, 40], + "c": [80, 50, 55, 190], + "d": [145, 50, 155, 40], + "e": [145, 100, 48, 140], + "f": [203, 100, 97, 40], + "g": [203, 150, 44, 90], + "h": [257, 150, 43, 40], + "i": [257, 200, 43, 40] + }, + "splitters": { + "a>b": [70, 0, 10, 240], + "b>c": [80, 40, 220, 10], + "c>d": [135, 50, 10, 190], + "d>e": [145, 90, 155, 10], + "e>f": [193, 100, 10, 140], + "f>g": [203, 140, 97, 10], + "g>h": [247, 150, 10, 90], + "h>i": [257, 190, 43, 10] + }, + "maximized": null + } + }, + { + "tabs": ["a", "b", "c", "d", "e", "f", "g", "h", "i"], + "container": { "width": 97, "height": 61 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["a"], "active": "a" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["b"], "active": "b" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["c"], "active": "c" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["d"], "active": "d" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["e"], + "active": "e" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["f"], + "active": "f" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "g" + ], + "active": "g" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": [ + "h" + ], + "active": "h" + }, + { + "tabs": [ + "i" + ], + "active": "i" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "a": [0, 0, 40, 240], + "b": [50, 0, 190, 40], + "c": [50, 50, 40, 190], + "d": [100, 50, 140, 40], + "e": [100, 100, 40, 140], + "f": [150, 100, 90, 40], + "g": [150, 150, 40, 90], + "h": [200, 150, 40, 40], + "i": [200, 200, 40, 40] + }, + "splitters": { + "a>b": [40, 0, 10, 240], + "b>c": [50, 40, 190, 10], + "c>d": [90, 50, 10, 190], + "d>e": [100, 90, 140, 10], + "e>f": [140, 100, 10, 140], + "f>g": [150, 140, 90, 10], + "g>h": [190, 150, 10, 90], + "h>i": [200, 190, 40, 10] + }, + "maximized": null + } + } +] diff --git a/packages/core/test/fixtures/v1/drop-previews.json b/packages/core/test/fixtures/v1/drop-previews.json new file mode 100644 index 0000000..4d50d60 --- /dev/null +++ b/packages/core/test/fixtures/v1/drop-previews.json @@ -0,0 +1,3092 @@ +{ + "tabsetRect": { "x": 610, "y": 0, "width": 600, "height": 800 }, + "tabset": [ + { + "point": { "x": 631, "y": 217 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 935, "y": 537 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 753, "y": 706 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1042, "y": 350 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1085, "y": 563 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1083, "y": 498 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 992, "y": 136 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 1171, "y": 312 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1097, "y": 457 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1083, "y": 766 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 672, "y": 743 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 690, "y": 587 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1196, "y": 582 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 907, "y": 657 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 1011, "y": 161 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1007, "y": 314 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 946, "y": 511 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 746, "y": 649 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 900, "y": 623 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 653, "y": 606 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1111, "y": 272 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 695, "y": 584 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 618, "y": 685 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1047, "y": 509 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1192, "y": 594 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 719, "y": 342 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 934, "y": 382 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 1167, "y": 760 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 735, "y": 704 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1106, "y": 199 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1082, "y": 141 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 810, "y": 262 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 719, "y": 162 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1141, "y": 94 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 800, "y": 289 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 657, "y": 66 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 715, "y": 635 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1158, "y": 405 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 905, "y": 238 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 791, "y": 443 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1061, "y": 712 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 697, "y": 299 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1067, "y": 588 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1031, "y": 128 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 869, "y": 410 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 1174, "y": 199 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 678, "y": 729 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 819, "y": 614 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 711, "y": 767 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 724, "y": 375 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 666, "y": 714 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 823, "y": 34 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 1021, "y": 597 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 647, "y": 181 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 645, "y": 435 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 743, "y": 89 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 798, "y": 521 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1068, "y": 50 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1036, "y": 247 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 620, "y": 646 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 790, "y": 181 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1192, "y": 627 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1195, "y": 680 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1055, "y": 700 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 628, "y": 376 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1142, "y": 618 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 856, "y": 392 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 864, "y": 486 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 648, "y": 466 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1176, "y": 179 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 933, "y": 29 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 777, "y": 481 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 764, "y": 633 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 730, "y": 120 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1015, "y": 321 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 879, "y": 350 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 895, "y": 67 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 772, "y": 372 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 877, "y": 243 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 678, "y": 194 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 979, "y": 261 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 1002, "y": 640 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 724, "y": 218 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1023, "y": 142 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 614, "y": 705 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 688, "y": 698 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1099, "y": 364 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 742, "y": 573 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1060, "y": 727 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 785, "y": 204 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 918, "y": 654 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 932, "y": 233 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 1104, "y": 265 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1004, "y": 551 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 679, "y": 536 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 795, "y": 747 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 768, "y": 521 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 791, "y": 405 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 842, "y": 276 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 1200, "y": 32 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 848, "y": 281 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 770, "y": 524 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 992, "y": 295 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 936, "y": 762 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 817, "y": 654 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 822, "y": 197 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 1038, "y": 746 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 902, "y": 89 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 740, "y": 407 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1199, "y": 565 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1121, "y": 687 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 948, "y": 178 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 617, "y": 420 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 938, "y": 149 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 1078, "y": 359 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 932, "y": 536 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 645, "y": 322 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 828, "y": 143 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 631, "y": 588 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1189, "y": 510 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 956, "y": 168 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 1128, "y": 14 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 852, "y": 736 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 944, "y": 214 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 1110, "y": 36 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 742, "y": 124 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 740, "y": 167 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 763, "y": 630 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 725, "y": 89 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1003, "y": 458 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 773, "y": 401 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 637, "y": 70 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1103, "y": 372 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 772, "y": 10 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1021, "y": 618 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1067, "y": 671 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 963, "y": 238 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 1208, "y": 241 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 860, "y": 368 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 813, "y": 280 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 1138, "y": 766 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1072, "y": 287 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 922, "y": 299 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 1054, "y": 438 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 897, "y": 547 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 743, "y": 757 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1175, "y": 19 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 751, "y": 211 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1129, "y": 669 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1031, "y": 524 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 875, "y": 428 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 806, "y": 321 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 610, "y": 435 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1120, "y": 505 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 764, "y": 424 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1181, "y": 522 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 834, "y": 111 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 1144, "y": 494 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1081, "y": 103 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1197, "y": 758 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 773, "y": 174 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1115, "y": 163 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 970, "y": 789 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 709, "y": 474 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1056, "y": 627 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1177, "y": 162 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1194, "y": 435 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 837, "y": 123 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 772, "y": 70 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 708, "y": 331 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 956, "y": 26 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 828, "y": 554 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 1037, "y": 170 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 963, "y": 254 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 715, "y": 54 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 702, "y": 225 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 942, "y": 603 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 1178, "y": 184 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1187, "y": 18 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 750, "y": 468 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1205, "y": 16 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 961, "y": 552 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 1020, "y": 77 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1051, "y": 383 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1195, "y": 425 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 935, "y": 72 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 1160, "y": 385 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 784, "y": 713 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 734, "y": 33 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1035, "y": 512 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 635, "y": 125 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 901, "y": 476 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 1060, "y": 403 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1203, "y": 559 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 910, "y": 176 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 1115, "y": 230 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 972, "y": 418 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 813, "y": 465 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 1150, "y": 780 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1059, "y": 232 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 810, "y": 189 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 1110, "y": 149 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 693, "y": 67 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 665, "y": 333 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 836, "y": 499 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 760, "y": 103 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1050, "y": 770 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1048, "y": 67 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 700, "y": 673 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 644, "y": 562 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 742, "y": 537 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 651, "y": 36 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1155, "y": 10 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1204, "y": 29 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1136, "y": 12 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1169, "y": 733 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 625, "y": 416 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1017, "y": 412 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 863, "y": 109 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 723, "y": 618 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 999, "y": 65 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 680, "y": 294 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 634, "y": 100 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1088, "y": 655 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1134, "y": 636 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 633, "y": 568 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 993, "y": 95 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 822, "y": 131 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 1196, "y": 256 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 619, "y": 219 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1037, "y": 359 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 940, "y": 116 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 713, "y": 624 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1123, "y": 444 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 652, "y": 685 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 733, "y": 174 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1054, "y": 496 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 708, "y": 765 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 745, "y": 575 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1023, "y": 509 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 727, "y": 578 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 925, "y": 397 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 1122, "y": 83 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 614, "y": 478 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 675, "y": 476 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 657, "y": 572 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1076, "y": 764 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 656, "y": 211 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1066, "y": 667 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 657, "y": 666 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 996, "y": 531 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 883, "y": 163 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 747, "y": 163 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 899, "y": 85 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 970, "y": 783 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 1084, "y": 755 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1044, "y": 693 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1049, "y": 559 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 945, "y": 275 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 744, "y": 511 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 948, "y": 696 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 842, "y": 418 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 981, "y": 701 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 735, "y": 133 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 654, "y": 719 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 919, "y": 502 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 687, "y": 764 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 959, "y": 535 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 940, "y": 358 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 678, "y": 229 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 906, "y": 550 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 689, "y": 150 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1158, "y": 151 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1124, "y": 267 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1091, "y": 308 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 617, "y": 23 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1191, "y": 614 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 891, "y": 441 }, + "preview": { + "area": "contain", + "left": 670, + "top": 80, + "width": 480, + "height": 640 + } + }, + { + "point": { "x": 1164, "y": 123 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 915, "y": 583 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 640, "y": 657 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 996, "y": 262 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 964, "y": 604 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 1118, "y": 558 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 617, "y": 263 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 664, "y": 458 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 685, "y": 117 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 694, "y": 450 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 712, "y": 257 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1083, "y": 232 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1030, "y": 111 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 768, "y": 619 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 860, "y": 265 }, + "preview": { + "area": "top", + "left": 610, + "top": 0, + "width": 600, + "height": 400 + } + }, + { + "point": { "x": 749, "y": 392 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 802, "y": 291 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 721, "y": 408 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 745, "y": 573 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 761, "y": 752 }, + "preview": { + "area": "left", + "left": 610, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 1046, "y": 631 }, + "preview": { + "area": "right", + "left": 910, + "top": 0, + "width": 300, + "height": 800 + } + }, + { + "point": { "x": 827, "y": 600 }, + "preview": { + "area": "bottom", + "left": 610, + "top": 400, + "width": 600, + "height": 400 + } + } + ], + "root": [ + { + "side": "left", + "preview": { + "area": "left", + "left": 0, + "top": 0, + "width": 605, + "height": 800 + } + }, + { + "side": "right", + "preview": { + "area": "right", + "left": 605, + "top": 0, + "width": 605, + "height": 800 + } + }, + { + "side": "top", + "preview": { + "area": "top", + "left": 0, + "top": 0, + "width": 1210, + "height": 400 + } + }, + { + "side": "bottom", + "preview": { + "area": "bottom", + "left": 0, + "top": 400, + "width": 1210, + "height": 400 + } + } + ], + "navbar": [ + { "x": 0, "index": 0, "area": "left" }, + { "x": 7, "index": 0, "area": "left" }, + { "x": 14, "index": 0, "area": "left" }, + { "x": 21, "index": 0, "area": "left" }, + { "x": 28, "index": 0, "area": "left" }, + { "x": 35, "index": 0, "area": "left" }, + { "x": 42, "index": 0, "area": "right" }, + { "x": 49, "index": 0, "area": "right" }, + { "x": 56, "index": 0, "area": "right" }, + { "x": 63, "index": 0, "area": "right" }, + { "x": 70, "index": 0, "area": "right" }, + { "x": 77, "index": 1, "area": "left" }, + { "x": 84, "index": 1, "area": "left" }, + { "x": 91, "index": 1, "area": "left" }, + { "x": 98, "index": 1, "area": "left" }, + { "x": 105, "index": 1, "area": "left" }, + { "x": 112, "index": 1, "area": "right" }, + { "x": 119, "index": 1, "area": "right" }, + { "x": 126, "index": 1, "area": "right" }, + { "x": 133, "index": 1, "area": "right" }, + { "x": 140, "index": 1, "area": "right" }, + { "x": 147, "index": 2, "area": "left" }, + { "x": 154, "index": 2, "area": "left" }, + { "x": 161, "index": 2, "area": "left" }, + { "x": 168, "index": 2, "area": "left" }, + { "x": 175, "index": 2, "area": "left" }, + { "x": 182, "index": 2, "area": "right" }, + { "x": 189, "index": 2, "area": "right" }, + { "x": 196, "index": 2, "area": "right" }, + { "x": 203, "index": 2, "area": "right" }, + { "x": 210, "index": 2, "area": "right" }, + { "x": 217, "index": 2, "area": "right" }, + { "x": 224, "index": 2, "area": "right" }, + { "x": 231, "index": 2, "area": "right" }, + { "x": 238, "index": 2, "area": "right" }, + { "x": 245, "index": 2, "area": "right" }, + { "x": 252, "index": 2, "area": "right" }, + { "x": 259, "index": 2, "area": "right" }, + { "x": 266, "index": 2, "area": "right" }, + { "x": 273, "index": 2, "area": "right" }, + { "x": 280, "index": 2, "area": "right" }, + { "x": 287, "index": 2, "area": "right" }, + { "x": 294, "index": 2, "area": "right" } + ] +} diff --git a/packages/core/test/fixtures/v1/scenario-01.json b/packages/core/test/fixtures/v1/scenario-01.json new file mode 100644 index 0000000..6322110 --- /dev/null +++ b/packages/core/test/fixtures/v1/scenario-01.json @@ -0,0 +1,637 @@ +{ + "name": "1 tabs, seed 1", + "tabs": ["editor"], + "container": { "width": 1200, "height": 800 }, + "initial": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "initialSavedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-3" + } + ] + } + ] + }, + "steps": [ + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "editor", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "root": "top" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tabsetOf": "editor", "area": "top" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tabsetOf": "editor", "area": "top" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-3" + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tab": "editor", "position": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "editor", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "root": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-3" + } + ] + } + ] + } + }, + { + "operation": { "op": "maximize", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "editor", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "maximize", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "editor", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "resize", "width": 224, "height": 851 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 224, 851] }, + "splitters": {}, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-3" + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tabsetOf": "editor", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 224, 851] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "root": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 224, 851] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tabsetOf": "editor", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 224, 851] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "resize", "width": 501, "height": 467 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 501, 467] }, + "splitters": {}, + "maximized": null + } + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 501, 467] }, + "splitters": {}, + "maximized": null + }, + "accepted": false, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-3" + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tabsetOf": "editor", "area": "top" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 501, 467] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tab": "editor", "position": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 501, 467] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tab": "editor", "position": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 501, 467] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "maximize", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 501, 467] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 501, 467] }, + "splitters": {}, + "maximized": null + }, + "accepted": false, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-3" + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "root": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 501, 467] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 501, 467] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "editor", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 501, 467] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "editor", "area": "top" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 501, 467] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 501, 467] }, + "splitters": {}, + "maximized": null + }, + "accepted": false, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-3" + } + ] + } + ] + } + } + ] +} diff --git a/packages/core/test/fixtures/v1/scenario-02.json b/packages/core/test/fixtures/v1/scenario-02.json new file mode 100644 index 0000000..231d9e4 --- /dev/null +++ b/packages/core/test/fixtures/v1/scenario-02.json @@ -0,0 +1,920 @@ +{ + "name": "2 tabs, seed 2", + "tabs": ["editor", "terminal"], + "container": { "width": 1200, "height": 800 }, + "initial": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 595, 800], + "terminal": [605, 0, 595, 800] + }, + "splitters": { "editor>terminal": [595, 0, 10, 800] }, + "maximized": null + }, + "initialSavedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-6" + } + ] + } + ] + }, + "steps": [ + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tab": "terminal", "position": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 595, 800], + "terminal": [605, 0, 595, 800] + }, + "splitters": { "editor>terminal": [595, 0, 10, 800] }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "maximize", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 595, 800], + "terminal": [0, 0, 1200, 800] + }, + "splitters": { "editor>terminal": [595, 0, 10, 800] }, + "maximized": "terminal" + }, + "accepted": true + }, + { + "operation": { "op": "resize", "width": 971, "height": 330 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 481, 330], + "terminal": [0, 0, 971, 330] + }, + "splitters": { "editor>terminal": [481, 0, 10, 330] }, + "maximized": "terminal" + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "terminal", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 481, 330], + "terminal": [491, 0, 480, 330] + }, + "splitters": { "editor>terminal": [481, 0, 10, 330] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 971, 330], + "terminal": [491, 0, 480, 330] + }, + "splitters": { "editor>terminal": [481, 0, 10, 330] }, + "maximized": "editor" + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodMaxd": "id-9", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-9", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-6" + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "terminal" }, + "target": { "tab": "terminal", "position": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 971, 330], + "terminal": [491, 0, 480, 330] + }, + "splitters": { "editor>terminal": [481, 0, 10, 330] }, + "maximized": "editor" + }, + "accepted": false + }, + { + "operation": { "op": "resize", "width": 1598, "height": 959 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 1598, 959], + "terminal": [804, 0, 794, 959] + }, + "splitters": { "editor>terminal": [794, 0, 10, 959] }, + "maximized": "editor" + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "root": "top" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "terminal": [0, 0, 1598, 475], + "editor": [0, 485, 1598, 474] + }, + "splitters": { "terminal>editor": [0, 475, 1598, 10] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "terminal" }, + "target": { "tab": "editor", "position": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor", "terminal"], "active": "terminal" } + ] + }, + "tabsets": { "editor|terminal": [0, 0, 1598, 959] }, + "splitters": {}, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "terminal", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 794, 959], + "terminal": [804, 0, 794, 959] + }, + "splitters": { "editor>terminal": [794, 0, 10, 959] }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-9", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-16", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-6" + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "terminal" }, + "target": { "tabsetOf": "editor", "area": "top" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "terminal": [0, 0, 1598, 475], + "editor": [0, 485, 1598, 474] + }, + "splitters": { "terminal>editor": [0, 475, 1598, 10] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "editor", + "point": { "x": 799, "y": 568 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "terminal": [0, 0, 1598, 563], + "editor": [0, 573, 1598, 386] + }, + "splitters": { "terminal>editor": [0, 563, 1598, 10] }, + "maximized": null + } + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "editor", + "point": { "x": 799, "y": 616 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "terminal": [0, 0, 1598, 611], + "editor": [0, 621, 1598, 338] + }, + "splitters": { "terminal>editor": [0, 611, 1598, 10] }, + "maximized": null + } + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "editor", + "point": { "x": 799, "y": 622 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "terminal": [0, 0, 1598, 617], + "editor": [0, 627, 1598, 332] + }, + "splitters": { "terminal>editor": [0, 617, 1598, 10] }, + "maximized": null + } + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["editor"], + "active": "editor", + "folded": true + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1598, 909], + "editor": [0, 919, 1598, 40] + }, + "splitters": { "terminal>editor": [0, 909, 1598, 10] }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-9", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 132.7963176064442, + "nodName": "", + "uidNode": "id-16", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-6" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 67.20368239355581, + "nodName": "", + "uidNode": "id-18", + "nodOpen": "editor", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tab": "terminal", "position": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" } + ] + }, + "tabsets": { "editor|terminal": [0, 0, 1598, 959] }, + "splitters": {}, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "resize", "width": 1466, "height": 499 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" } + ] + }, + "tabsets": { "editor|terminal": [0, 0, 1466, 499] }, + "splitters": {}, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "editor", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" } + ] + }, + "tabsets": { "editor|terminal": [0, 0, 1466, 499] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "terminal" }, + "target": { "tabsetOf": "editor", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" } + ] + }, + "tabsets": { "editor|terminal": [0, 0, 1466, 499] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "fold", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" } + ] + }, + "tabsets": { "editor|terminal": [0, 0, 1466, 499] }, + "splitters": {}, + "maximized": null + }, + "accepted": false, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 132.7963176064442, + "nodName": "", + "uidNode": "id-16", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-6" + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "root": "bottom" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 1466, 273], + "terminal": [0, 283, 1466, 216] + }, + "splitters": { "editor>terminal": [0, 273, 1466, 10] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "terminal", + "point": { "x": 733, "y": 288 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 1466, 283], + "terminal": [0, 293, 1466, 206] + }, + "splitters": { "editor>terminal": [0, 283, 1466, 10] }, + "maximized": null + } + }, + { + "operation": { "op": "fold", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 1466, 449], + "terminal": [0, 459, 1466, 40] + }, + "splitters": { "editor>terminal": [0, 449, 1466, 10] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "terminal", + "point": { "x": 733, "y": 481 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 1466, 449], + "terminal": [0, 459, 1466, 40] + }, + "splitters": { "editor>terminal": [0, 449, 1466, 10] }, + "maximized": null + } + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "terminal", + "point": { "x": 733, "y": 409 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 1466, 449], + "terminal": [0, 459, 1466, 40] + }, + "splitters": { "editor>terminal": [0, 449, 1466, 10] }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-23", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 138.31174860236172, + "nodName": "", + "uidNode": "id-16", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 94.48456900408249, + "nodName": "", + "uidNode": "id-21", + "nodOpen": "terminal", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-6" + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "terminal", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 1416, 499], + "terminal": [1426, 0, 40, 499] + }, + "splitters": { "editor>terminal": [1416, 0, 10, 499] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tab": "terminal", "position": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["terminal", "editor"], "active": "editor" } + ] + }, + "tabsets": { "terminal|editor": [0, 0, 1466, 499] }, + "splitters": {}, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "terminal", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["terminal", "editor"], "active": "editor" } + ] + }, + "tabsets": { "terminal|editor": [0, 0, 1466, 499] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "terminal" }, + "target": { "tabsetOf": "terminal", "area": "top" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["terminal", "editor"], "active": "editor" } + ] + }, + "tabsets": { "terminal|editor": [0, 0, 1466, 499] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "resize", "width": 379, "height": 1010 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["terminal", "editor"], "active": "editor" } + ] + }, + "tabsets": { "terminal|editor": [0, 0, 379, 1010] }, + "splitters": {}, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 94.48456900408249, + "nodName": "", + "uidNode": "id-21", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-6" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + } + ] + } + } + ] +} diff --git a/packages/core/test/fixtures/v1/scenario-03.json b/packages/core/test/fixtures/v1/scenario-03.json new file mode 100644 index 0000000..39e049b --- /dev/null +++ b/packages/core/test/fixtures/v1/scenario-03.json @@ -0,0 +1,1155 @@ +{ + "name": "3 tabs, seed 3", + "tabs": ["editor", "terminal", "preview"], + "container": { "width": 1200, "height": 800 }, + "initial": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["preview"], "active": "preview" } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 595, 800], + "terminal": [605, 0, 595, 395], + "preview": [605, 405, 595, 395] + }, + "splitters": { + "editor>terminal": [595, 0, 10, 800], + "terminal>preview": [605, 395, 595, 10] + }, + "maximized": null + }, + "initialSavedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-11" + } + ] + } + ] + } + ] + }, + "steps": [ + { + "operation": { + "op": "move", + "source": { "tabsetOf": "terminal" }, + "target": { "tabsetOf": "preview", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 595, 800], + "preview": [605, 0, 595, 395], + "terminal": [605, 405, 595, 395] + }, + "splitters": { + "editor>preview": [595, 0, 10, 800], + "preview>terminal": [605, 395, 595, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "tabsetOf": "terminal", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "editor": [0, 0, 393, 800], + "terminal": [403, 0, 394, 800], + "preview": [807, 0, 393, 800] + }, + "splitters": { + "editor>terminal": [393, 0, 10, 800], + "terminal>preview": [797, 0, 10, 800] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tabsetOf": "terminal", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "editor": [0, 0, 393, 800], + "terminal": [403, 0, 394, 800], + "preview": [807, 0, 393, 800] + }, + "splitters": { + "editor>terminal": [393, 0, 10, 800], + "terminal>preview": [797, 0, 10, 800] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "tabsetOf": "preview", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "editor": [0, 0, 393, 800], + "terminal": [403, 0, 394, 800], + "preview": [807, 0, 393, 800] + }, + "splitters": { + "editor>terminal": [393, 0, 10, 800], + "terminal>preview": [797, 0, 10, 800] + }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "terminal", + "point": { "x": 295, "y": 400 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "editor": [0, 0, 290, 800], + "terminal": [300, 0, 497, 800], + "preview": [807, 0, 393, 800] + }, + "splitters": { + "editor>terminal": [290, 0, 10, 800], + "terminal>preview": [797, 0, 10, 800] + }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 70.72135785007072, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 129.27864214992928, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-11" + } + ] + } + ] + } + }, + { + "operation": { "op": "fold", "tabsetOf": "preview" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 415, 800], + "terminal": [425, 0, 725, 800], + "preview": [1160, 0, 40, 800] + }, + "splitters": { + "editor>terminal": [415, 0, 10, 800], + "terminal>preview": [1150, 0, 10, 800] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tab": "editor", "position": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "editor"], + "active": "terminal" + }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + }, + "tabsets": { + "terminal|editor": [0, 0, 1150, 800], + "preview": [1160, 0, 40, 800] + }, + "splitters": { "terminal>preview": [1150, 0, 10, 800] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "resize", "width": 290, "height": 403 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "editor"], + "active": "terminal" + }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + }, + "tabsets": { + "terminal|editor": [0, 0, 240, 403], + "preview": [250, 0, 40, 403] + }, + "splitters": { "terminal>preview": [240, 0, 10, 403] }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "editor", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "editor"], + "active": "terminal" + }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + }, + "tabsets": { + "terminal|editor": [0, 0, 240, 403], + "preview": [250, 0, 40, 403] + }, + "splitters": { "terminal>preview": [240, 0, 10, 403] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "editor"], + "active": "terminal" + }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + }, + "tabsets": { + "terminal|editor": [0, 0, 290, 403], + "preview": [250, 0, 40, 403] + }, + "splitters": { "terminal>preview": [240, 0, 10, 403] }, + "maximized": "terminal|editor" + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodMaxd": "id-2", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 70.72135785007072, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodOpen": "preview", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-11" + } + ] + } + ] + } + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "editor"], + "active": "terminal", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "terminal|editor": [0, 0, 40, 403], + "preview": [50, 0, 240, 403] + }, + "splitters": { "terminal>preview": [40, 0, 10, 403] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "terminal", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "editor": [0, 0, 115, 403], + "terminal": [125, 0, 40, 403], + "preview": [175, 0, 115, 403] + }, + "splitters": { + "editor>terminal": [115, 0, 10, 403], + "terminal>preview": [165, 0, 10, 403] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "editor": [0, 0, 40, 403], + "terminal": [50, 0, 40, 403], + "preview": [100, 0, 190, 403] + }, + "splitters": { + "editor>terminal": [40, 0, 10, 403], + "terminal>preview": [90, 0, 10, 403] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "terminal", + "point": { "x": -59, "y": 201.5 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "editor": [0, 0, 40, 403], + "terminal": [50, 0, 40, 403], + "preview": [100, 0, 190, 403] + }, + "splitters": { + "editor>terminal": [40, 0, 10, 403], + "terminal>preview": [90, 0, 10, 403] + }, + "maximized": null + } + }, + { + "operation": { "op": "resize", "width": 472, "height": 701 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "editor": [0, 0, 40, 701], + "terminal": [50, 0, 40, 701], + "preview": [100, 0, 372, 701] + }, + "splitters": { + "editor>terminal": [40, 0, 10, 701], + "terminal>preview": [90, 0, 10, 701] + }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-24", + "nodOpen": "editor", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 70.72135785007072, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "terminal", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-11" + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "terminal" }, + "target": { "tabsetOf": "editor", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "editor"], + "active": "terminal" + }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "terminal|editor": [0, 0, 231, 701], + "preview": [241, 0, 231, 701] + }, + "splitters": { "terminal>preview": [231, 0, 10, 701] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "tab": "terminal", "position": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "preview", "editor"], + "active": "preview" + } + ] + }, + "tabsets": { "terminal|preview|editor": [0, 0, 472, 701] }, + "splitters": {}, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "preview", "editor"], + "active": "preview" + } + ] + }, + "tabsets": { "terminal|preview|editor": [0, 0, 472, 701] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "fold", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "preview", "editor"], + "active": "preview" + } + ] + }, + "tabsets": { "terminal|preview|editor": [0, 0, 472, 701] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "tabsetOf": "terminal", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "preview", "editor"], + "active": "preview" + } + ] + }, + "tabsets": { "terminal|preview|editor": [0, 0, 472, 701] }, + "splitters": {}, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-24", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-11" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + } + ] + } + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "preview", "editor"], + "active": "preview" + } + ] + }, + "tabsets": { "terminal|preview|editor": [0, 0, 472, 701] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "root": "top" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["terminal", "editor"], "active": "terminal" } + ] + }, + "tabsets": { + "preview": [0, 0, 472, 346], + "terminal|editor": [0, 356, 472, 345] + }, + "splitters": { "preview>terminal": [0, 346, 472, 10] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "resize", "width": 1268, "height": 449 }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["terminal", "editor"], "active": "terminal" } + ] + }, + "tabsets": { + "preview": [0, 0, 1268, 220], + "terminal|editor": [0, 230, 1268, 219] + }, + "splitters": { "preview>terminal": [0, 220, 1268, 10] }, + "maximized": null + } + }, + { + "operation": { "op": "resize", "width": 804, "height": 251 }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["terminal", "editor"], "active": "terminal" } + ] + }, + "tabsets": { + "preview": [0, 0, 804, 121], + "terminal|editor": [0, 131, 804, 120] + }, + "splitters": { "preview>terminal": [0, 121, 804, 10] }, + "maximized": null + } + }, + { + "operation": { "op": "fold", "tabsetOf": "preview" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "tabs": ["preview"], + "active": "preview", + "folded": true + }, + { "tabs": ["terminal", "editor"], "active": "terminal" } + ] + }, + "tabsets": { + "preview": [0, 0, 804, 40], + "terminal|editor": [0, 50, 804, 201] + }, + "splitters": { "preview>terminal": [0, 40, 804, 10] }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-35", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-33", + "nodOpen": "preview", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-11" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-24", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "splitter", + "before": "preview", + "after": "terminal", + "point": { "x": 402, "y": 89 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "tabs": ["preview"], + "active": "preview", + "folded": true + }, + { "tabs": ["terminal", "editor"], "active": "terminal" } + ] + }, + "tabsets": { + "preview": [0, 0, 804, 40], + "terminal|editor": [0, 50, 804, 201] + }, + "splitters": { "preview>terminal": [0, 40, 804, 10] }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "tabsetOf": "terminal", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "tabs": ["terminal", "editor"], + "active": "terminal" + }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "terminal|editor": [0, 0, 804, 121], + "preview": [0, 131, 804, 120] + }, + "splitters": { "terminal>preview": [0, 121, 804, 10] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "preview", + "point": { "x": 402, "y": 259 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "tabs": ["terminal", "editor"], + "active": "terminal" + }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "terminal|editor": [0, 0, 804, 201], + "preview": [0, 211, 804, 40] + }, + "splitters": { "terminal>preview": [0, 201, 804, 10] }, + "maximized": null + } + }, + { + "operation": { "op": "resize", "width": 1188, "height": 481 }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "tabs": ["terminal", "editor"], + "active": "terminal" + }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "terminal|editor": [0, 0, 1188, 431], + "preview": [0, 441, 1188, 40] + }, + "splitters": { "terminal>preview": [0, 431, 1188, 10] }, + "maximized": null + } + }, + { + "operation": { "op": "maximize", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "tabs": ["terminal", "editor"], + "active": "terminal" + }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "terminal|editor": [0, 0, 1188, 481], + "preview": [0, 441, 1188, 40] + }, + "splitters": { "terminal>preview": [0, 431, 1188, 10] }, + "maximized": "terminal|editor" + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodMaxd": "id-37", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-24", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 200, + "nodName": "", + "uidNode": "id-37", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 0, + "nodName": "", + "uidNode": "id-33", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-11" + } + ] + } + ] + } + ] + } + } + ] +} diff --git a/packages/core/test/fixtures/v1/scenario-04.json b/packages/core/test/fixtures/v1/scenario-04.json new file mode 100644 index 0000000..a6d2c7f --- /dev/null +++ b/packages/core/test/fixtures/v1/scenario-04.json @@ -0,0 +1,1681 @@ +{ + "name": "4 tabs, seed 4", + "tabs": ["editor", "terminal", "preview", "console"], + "container": { "width": 640, "height": 420 }, + "initial": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 290, 420], + "terminal": [300, 0, 340, 205], + "preview": [300, 215, 165, 205], + "console": [475, 215, 165, 205] + }, + "splitters": { + "editor>terminal": [290, 0, 10, 420], + "terminal>preview": [300, 205, 340, 10], + "preview>console": [465, 215, 10, 205] + }, + "maximized": null + }, + "initialSavedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-16" + } + ] + } + ] + } + ] + } + ] + }, + "steps": [ + { + "operation": { "op": "maximize", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["console"], + "active": "console" + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 640, 420], + "terminal": [300, 0, 340, 205], + "preview": [300, 215, 165, 205], + "console": [475, 215, 165, 205] + }, + "splitters": { + "editor>terminal": [290, 0, 10, 420], + "terminal>preview": [300, 205, 340, 10], + "preview>console": [465, 215, 10, 205] + }, + "maximized": "editor" + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "tabsetOf": "editor", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["console"], "active": "console" } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 207, 420], + "preview": [217, 0, 206, 420], + "terminal": [433, 0, 207, 205], + "console": [433, 215, 207, 205] + }, + "splitters": { + "editor>preview": [207, 0, 10, 420], + "preview>terminal": [423, 0, 10, 420], + "terminal>console": [433, 205, 207, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "console" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["console"], "active": "console" } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 207, 420], + "preview": [217, 0, 206, 420], + "terminal": [433, 0, 207, 205], + "console": [0, 0, 640, 420] + }, + "splitters": { + "editor>preview": [207, 0, 10, 420], + "preview>terminal": [423, 0, 10, 420], + "terminal>console": [433, 205, 207, 10] + }, + "maximized": "console" + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "console" }, + "target": { "tabsetOf": "editor", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 153, 420], + "console": [163, 0, 152, 420], + "preview": [325, 0, 153, 420], + "terminal": [488, 0, 152, 420] + }, + "splitters": { + "editor>console": [153, 0, 10, 420], + "console>preview": [315, 0, 10, 420], + "preview>terminal": [478, 0, 10, 420] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "console" }, + "target": { "tabsetOf": "console", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 153, 420], + "console": [163, 0, 152, 420], + "preview": [325, 0, 153, 420], + "terminal": [488, 0, 152, 420] + }, + "splitters": { + "editor>console": [153, 0, 10, 420], + "console>preview": [315, 0, 10, 420], + "preview>terminal": [478, 0, 10, 420] + }, + "maximized": null + }, + "accepted": false, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-16" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-20", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "editor", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "editor": [0, 0, 153, 420], + "terminal": [163, 0, 152, 420], + "console": [325, 0, 153, 420], + "preview": [488, 0, 152, 420] + }, + "splitters": { + "editor>terminal": [153, 0, 10, 420], + "terminal>console": [315, 0, 10, 420], + "console>preview": [478, 0, 10, 420] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "console" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "editor": [0, 0, 190, 420], + "terminal": [200, 0, 190, 420], + "console": [400, 0, 40, 420], + "preview": [450, 0, 190, 420] + }, + "splitters": { + "editor>terminal": [190, 0, 10, 420], + "terminal>console": [390, 0, 10, 420], + "console>preview": [440, 0, 10, 420] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "preview" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 265, 420], + "terminal": [275, 0, 265, 420], + "console": [550, 0, 40, 420], + "preview": [600, 0, 40, 420] + }, + "splitters": { + "editor>terminal": [265, 0, 10, 420], + "terminal>console": [540, 0, 10, 420], + "console>preview": [590, 0, 10, 420] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "console" }, + "target": { "root": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + }, + "tabsets": { + "console": [0, 0, 190, 420], + "editor": [200, 0, 190, 420], + "terminal": [400, 0, 190, 420], + "preview": [600, 0, 40, 420] + }, + "splitters": { + "console>editor": [190, 0, 10, 420], + "editor>terminal": [390, 0, 10, 420], + "terminal>preview": [590, 0, 10, 420] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "console", + "after": "editor", + "point": { "x": 32, "y": 210 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + }, + "tabsets": { + "console": [0, 0, 40, 420], + "editor": [50, 0, 340, 420], + "terminal": [400, 0, 190, 420], + "preview": [600, 0, 40, 420] + }, + "splitters": { + "console>editor": [40, 0, 10, 420], + "editor>terminal": [390, 0, 10, 420], + "terminal>preview": [590, 0, 10, 420] + }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 0, + "nodName": "", + "uidNode": "id-30", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-16" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 200, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-26", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-20", + "nodOpen": "preview", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + } + }, + { + "operation": { "op": "maximize", "tabsetOf": "preview" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + }, + "tabsets": { + "console": [0, 0, 40, 420], + "editor": [50, 0, 340, 420], + "terminal": [400, 0, 190, 420], + "preview": [0, 0, 640, 420] + }, + "splitters": { + "console>editor": [40, 0, 10, 420], + "editor>terminal": [390, 0, 10, 420], + "terminal>preview": [590, 0, 10, 420] + }, + "maximized": "preview" + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "preview" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + }, + "tabsets": { + "console": [0, 0, 40, 420], + "editor": [50, 0, 340, 420], + "terminal": [400, 0, 190, 420], + "preview": [600, 0, 40, 420] + }, + "splitters": { + "console>editor": [40, 0, 10, 420], + "editor>terminal": [390, 0, 10, 420], + "terminal>preview": [590, 0, 10, 420] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "preview", + "point": { "x": 413, "y": 210 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + }, + "tabsets": { + "console": [0, 0, 40, 420], + "editor": [50, 0, 340, 420], + "terminal": [400, 0, 190, 420], + "preview": [600, 0, 40, 420] + }, + "splitters": { + "console>editor": [40, 0, 10, 420], + "editor>terminal": [390, 0, 10, 420], + "terminal>preview": [590, 0, 10, 420] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "editor", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + }, + "tabsets": { + "console": [0, 0, 40, 420], + "editor": [50, 0, 340, 420], + "terminal": [400, 0, 190, 420], + "preview": [600, 0, 40, 420] + }, + "splitters": { + "console>editor": [40, 0, 10, 420], + "editor>terminal": [390, 0, 10, 420], + "terminal>preview": [590, 0, 10, 420] + }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "resize", "width": 1025, "height": 710 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + }, + "tabsets": { + "console": [0, 0, 40, 710], + "editor": [50, 0, 597, 710], + "terminal": [657, 0, 318, 710], + "preview": [985, 0, 40, 710] + }, + "splitters": { + "console>editor": [40, 0, 10, 710], + "editor>terminal": [647, 0, 10, 710], + "terminal>preview": [975, 0, 10, 710] + }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 0, + "nodName": "", + "uidNode": "id-30", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-16" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 200, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-26", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-20", + "nodOpen": "preview", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + } + }, + { + "operation": { "op": "resize", "width": 1443, "height": 429 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + }, + "tabsets": { + "console": [0, 0, 40, 429], + "editor": [50, 0, 875, 429], + "terminal": [935, 0, 458, 429], + "preview": [1403, 0, 40, 429] + }, + "splitters": { + "console>editor": [40, 0, 10, 429], + "editor>terminal": [925, 0, 10, 429], + "terminal>preview": [1393, 0, 10, 429] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "editor", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + }, + "tabsets": { + "console": [0, 0, 40, 429], + "editor": [50, 0, 875, 429], + "terminal": [935, 0, 458, 429], + "preview": [1403, 0, 40, 429] + }, + "splitters": { + "console>editor": [40, 0, 10, 429], + "editor>terminal": [925, 0, 10, 429], + "terminal>preview": [1393, 0, 10, 429] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "preview", + "point": { "x": 1230, "y": 214.5 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + }, + "tabsets": { + "console": [0, 0, 40, 429], + "editor": [50, 0, 875, 429], + "terminal": [935, 0, 458, 429], + "preview": [1403, 0, 40, 429] + }, + "splitters": { + "console>editor": [40, 0, 10, 429], + "editor>terminal": [925, 0, 10, 429], + "terminal>preview": [1393, 0, 10, 429] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "editor", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + }, + "tabsets": { + "console": [0, 0, 40, 429], + "preview": [1403, 0, 40, 429], + "editor": [50, 0, 1343, 210], + "terminal": [50, 220, 1343, 209] + }, + "splitters": { + "console>editor": [40, 0, 10, 429], + "editor>preview": [1393, 0, 10, 429], + "editor>terminal": [50, 210, 1343, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "console", + "after": "editor", + "point": { "x": -44, "y": 214.5 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + }, + "tabsets": { + "console": [0, 0, 40, 429], + "preview": [1403, 0, 40, 429], + "editor": [50, 0, 1343, 210], + "terminal": [50, 220, 1343, 209] + }, + "splitters": { + "console>editor": [40, 0, 10, 429], + "editor>preview": [1393, 0, 10, 429], + "editor>terminal": [50, 210, 1343, 10] + }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 0, + "nodName": "", + "uidNode": "id-30", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-16" + } + ] + }, + { + "typNode": "row", + "nodPart": 200, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-38", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-37", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-20", + "nodOpen": "preview", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "console", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["console"], "active": "console" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + }, + "tabsets": { + "editor": [50, 0, 1343, 429], + "preview": [1403, 0, 40, 429], + "console": [0, 0, 40, 210], + "terminal": [0, 220, 40, 209] + }, + "splitters": { + "console>editor": [40, 0, 10, 429], + "editor>preview": [1393, 0, 10, 429], + "console>terminal": [0, 210, 40, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "console" }, + "target": { "tab": "console", "position": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["console"], "active": "console" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + }, + "tabsets": { + "editor": [50, 0, 1343, 429], + "preview": [1403, 0, 40, 429], + "console": [0, 0, 40, 210], + "terminal": [0, 220, 40, 209] + }, + "splitters": { + "console>editor": [40, 0, 10, 429], + "editor>preview": [1393, 0, 10, 429], + "console>terminal": [0, 210, 40, 10] + }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "tabsetOf": "console", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["preview", "console"], + "active": "preview" + }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "editor": [50, 0, 1393, 429], + "preview|console": [0, 0, 40, 210], + "terminal": [0, 220, 40, 209] + }, + "splitters": { + "preview>editor": [40, 0, 10, 429], + "preview>terminal": [0, 210, 40, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "terminal" }, + "target": { "root": "bottom" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview", "console"], + "active": "preview" + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "terminal": [0, 220, 1443, 209], + "preview|console": [0, 0, 717, 210], + "editor": [727, 0, 716, 210] + }, + "splitters": { + "preview>terminal": [0, 210, 1443, 10], + "preview>editor": [717, 0, 10, 210] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "tabsetOf": "terminal", "area": "right" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["preview"], "active": "preview" } + ] + } + ] + }, + "tabsets": { + "console": [0, 0, 717, 210], + "editor": [727, 0, 716, 210], + "terminal": [0, 220, 717, 209], + "preview": [727, 220, 716, 209] + }, + "splitters": { + "console>terminal": [0, 210, 1443, 10], + "console>editor": [717, 0, 10, 210], + "terminal>preview": [717, 220, 10, 209] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-50", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-51", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-44", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-16" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-38", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-43", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-54", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-53", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { "op": "fold", "tabsetOf": "console" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["preview"], "active": "preview" } + ] + } + ] + }, + "tabsets": { + "console": [0, 0, 40, 210], + "editor": [50, 0, 1393, 210], + "terminal": [0, 220, 717, 209], + "preview": [727, 220, 716, 209] + }, + "splitters": { + "console>terminal": [0, 210, 1443, 10], + "console>editor": [40, 0, 10, 210], + "terminal>preview": [717, 220, 10, 209] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "root": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "tabs": ["editor"], + "active": "editor" + } + ] + }, + { "tabs": ["terminal"], "active": "terminal" } + ] + } + ] + }, + "tabsets": { + "preview": [0, 0, 692, 429], + "terminal": [702, 220, 741, 209], + "console": [702, 0, 40, 210], + "editor": [752, 0, 691, 210] + }, + "splitters": { + "preview>console": [692, 0, 10, 429], + "console>terminal": [702, 210, 741, 10], + "console>editor": [742, 0, 10, 210] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "resize", "width": 1246, "height": 401 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "tabs": ["editor"], + "active": "editor" + } + ] + }, + { "tabs": ["terminal"], "active": "terminal" } + ] + } + ] + }, + "tabsets": { + "preview": [0, 0, 593, 401], + "terminal": [603, 206, 643, 195], + "console": [603, 0, 40, 196], + "editor": [653, 0, 593, 196] + }, + "splitters": { + "preview>console": [593, 0, 10, 401], + "console>terminal": [603, 196, 643, 10], + "console>editor": [643, 0, 10, 196] + }, + "maximized": null + } + }, + { + "operation": { "op": "maximize", "tabsetOf": "preview" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "tabs": ["editor"], + "active": "editor" + } + ] + }, + { "tabs": ["terminal"], "active": "terminal" } + ] + } + ] + }, + "tabsets": { + "preview": [0, 0, 1246, 401], + "terminal": [603, 206, 643, 195], + "console": [603, 0, 40, 196], + "editor": [653, 0, 593, 196] + }, + "splitters": { + "preview>console": [593, 0, 10, 401], + "console>terminal": [603, 196, 643, 10], + "console>editor": [643, 0, 10, 196] + }, + "maximized": "preview" + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "preview" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "tabs": ["editor"], + "active": "editor" + } + ] + }, + { "tabs": ["terminal"], "active": "terminal" } + ] + } + ] + }, + "tabsets": { + "preview": [0, 0, 593, 401], + "terminal": [603, 206, 643, 195], + "console": [603, 0, 40, 196], + "editor": [653, 0, 593, 196] + }, + "splitters": { + "preview>console": [593, 0, 10, 401], + "console>terminal": [603, 196, 643, 10], + "console>editor": [643, 0, 10, 196] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-57", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-50", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-51", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-44", + "nodOpen": "console", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-16" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-38", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-54", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + } + ] + } + ] + } + } + ] +} diff --git a/packages/core/test/fixtures/v1/scenario-05.json b/packages/core/test/fixtures/v1/scenario-05.json new file mode 100644 index 0000000..ea0259c --- /dev/null +++ b/packages/core/test/fixtures/v1/scenario-05.json @@ -0,0 +1,2016 @@ +{ + "name": "5 tabs, seed 5", + "tabs": ["editor", "terminal", "preview", "console", "files"], + "container": { "width": 1200, "height": 800 }, + "initial": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 570, 800], + "terminal": [580, 0, 620, 370], + "preview": [580, 380, 305, 420], + "console": [895, 380, 305, 205], + "files": [895, 595, 305, 205] + }, + "splitters": { + "editor>terminal": [570, 0, 10, 800], + "terminal>preview": [580, 370, 620, 10], + "preview>console": [885, 380, 10, 420], + "console>files": [895, 585, 305, 10] + }, + "maximized": null + }, + "initialSavedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-21" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "steps": [ + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "preview", + "point": { "x": 890, "y": 399 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "tabs": ["files"], + "active": "files" + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 570, 800], + "terminal": [580, 0, 620, 394], + "preview": [580, 404, 305, 396], + "console": [895, 404, 305, 193], + "files": [895, 607, 305, 193] + }, + "splitters": { + "editor>terminal": [570, 0, 10, 800], + "terminal>preview": [580, 394, 620, 10], + "preview>console": [885, 404, 10, 396], + "console>files": [895, 597, 305, 10] + }, + "maximized": null + } + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "terminal", + "point": { "x": 688, "y": 400 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "tabs": ["files"], + "active": "files" + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 683, 800], + "terminal": [693, 0, 507, 394], + "preview": [693, 404, 249, 396], + "console": [952, 404, 248, 193], + "files": [952, 607, 248, 193] + }, + "splitters": { + "editor>terminal": [683, 0, 10, 800], + "terminal>preview": [693, 394, 507, 10], + "preview>console": [942, 404, 10, 396], + "console>files": [952, 597, 248, 10] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "files" }, + "target": { "tabsetOf": "console", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "tabs": ["files"], + "active": "files" + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 683, 800], + "terminal": [693, 0, 507, 394], + "preview": [693, 404, 249, 396], + "console": [952, 404, 248, 193], + "files": [952, 607, 248, 193] + }, + "splitters": { + "editor>terminal": [683, 0, 10, 800], + "terminal>preview": [693, 394, 507, 10], + "preview>console": [942, 404, 10, 396], + "console>files": [952, 597, 248, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tab": "files", "position": "right" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "tabs": ["files", "editor"], + "active": "editor" + } + ] + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1200, 394], + "preview": [0, 404, 595, 396], + "console": [605, 404, 595, 193], + "files|editor": [605, 607, 595, 193] + }, + "splitters": { + "terminal>preview": [0, 394, 1200, 10], + "preview>console": [595, 404, 10, 396], + "console>files": [605, 597, 595, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "tabsetOf": "preview", "area": "left" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "tabs": ["files", "editor"], + "active": "editor" + } + ] + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1200, 394], + "preview": [0, 404, 595, 396], + "console": [605, 404, 595, 193], + "files|editor": [605, 607, 595, 193] + }, + "splitters": { + "terminal>preview": [0, 394, 1200, 10], + "preview>console": [595, 404, 10, 396], + "console>files": [605, 597, 595, 10] + }, + "maximized": null + }, + "accepted": false, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 78.67924528301887, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 107.27272727272728, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 92.72727272727272, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-26", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-25", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-21" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "preview", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "preview"], + "active": "terminal" + }, + { + "direction": "vertical", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["files", "editor"], + "active": "editor" + } + ] + } + ] + }, + "tabsets": { + "terminal|preview": [0, 0, 595, 800], + "console": [605, 0, 595, 395], + "files|editor": [605, 405, 595, 395] + }, + "splitters": { + "terminal>console": [595, 0, 10, 800], + "console>files": [605, 395, 595, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "preview"], + "active": "terminal" + }, + { + "direction": "vertical", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["files", "editor"], + "active": "editor", + "folded": true + } + ] + } + ] + }, + "tabsets": { + "terminal|preview": [0, 0, 595, 800], + "console": [605, 0, 595, 750], + "files|editor": [605, 760, 595, 40] + }, + "splitters": { + "terminal>console": [595, 0, 10, 800], + "console>files": [605, 750, 595, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "editor", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "preview"], + "active": "terminal" + }, + { + "direction": "vertical", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["files", "editor"], + "active": "editor", + "folded": true + } + ] + } + ] + }, + "tabsets": { + "terminal|preview": [0, 0, 595, 800], + "console": [605, 0, 595, 750], + "files|editor": [605, 760, 595, 40] + }, + "splitters": { + "terminal>console": [595, 0, 10, 800], + "console>files": [605, 750, 595, 10] + }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "fold", "tabsetOf": "files" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "preview"], + "active": "terminal" + }, + { + "direction": "vertical", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["files", "editor"], + "active": "editor" + } + ] + } + ] + }, + "tabsets": { + "terminal|preview": [0, 0, 595, 800], + "console": [605, 0, 595, 395], + "files|editor": [605, 405, 595, 395] + }, + "splitters": { + "terminal>console": [595, 0, 10, 800], + "console>files": [605, 395, 595, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "console" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "preview"], + "active": "terminal" + }, + { + "direction": "vertical", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["files", "editor"], + "active": "editor" + } + ] + } + ] + }, + "tabsets": { + "terminal|preview": [0, 0, 595, 800], + "console": [0, 0, 1200, 800], + "files|editor": [605, 405, 595, 395] + }, + "splitters": { + "terminal>console": [595, 0, 10, 800], + "console>files": [605, 395, 595, 10] + }, + "maximized": "console" + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodMaxd": "id-26", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-26", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-25", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-21" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "root": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "preview"], + "active": "terminal" + }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["files", "editor"], "active": "editor" } + ] + }, + "tabsets": { + "terminal|preview": [0, 0, 393, 800], + "console": [403, 0, 394, 800], + "files|editor": [807, 0, 393, 800] + }, + "splitters": { + "terminal>console": [393, 0, 10, 800], + "console>files": [797, 0, 10, 800] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "files" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "preview"], + "active": "terminal" + }, + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["files", "editor"], + "active": "editor", + "folded": true + } + ] + }, + "tabsets": { + "terminal|preview": [0, 0, 570, 800], + "console": [580, 0, 570, 800], + "files|editor": [1160, 0, 40, 800] + }, + "splitters": { + "terminal>console": [570, 0, 10, 800], + "console>files": [1150, 0, 10, 800] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "tabsetOf": "files", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["files", "editor"], + "active": "editor", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "terminal": [0, 0, 377, 800], + "console": [387, 0, 376, 800], + "files|editor": [773, 0, 40, 800], + "preview": [823, 0, 377, 800] + }, + "splitters": { + "terminal>console": [377, 0, 10, 800], + "console>files": [763, 0, 10, 800], + "files>preview": [813, 0, 10, 800] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "files" }, + "target": { "tabsetOf": "terminal", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "files": [0, 0, 280, 800], + "terminal": [290, 0, 280, 800], + "console": [580, 0, 280, 800], + "editor": [870, 0, 40, 800], + "preview": [920, 0, 280, 800] + }, + "splitters": { + "files>terminal": [280, 0, 10, 800], + "terminal>console": [570, 0, 10, 800], + "console>editor": [860, 0, 10, 800], + "editor>preview": [910, 0, 10, 800] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "preview", + "point": { "x": 788, "y": 400 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "files": [0, 0, 280, 800], + "terminal": [290, 0, 280, 800], + "console": [580, 0, 280, 800], + "editor": [870, 0, 40, 800], + "preview": [920, 0, 280, 800] + }, + "splitters": { + "files>terminal": [280, 0, 10, 800], + "terminal>console": [570, 0, 10, 800], + "console>editor": [860, 0, 10, 800], + "editor>preview": [910, 0, 10, 800] + }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-34", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-21" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-26", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-25", + "nodOpen": "editor", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + } + }, + { + "operation": { "op": "fold", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + }, + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "files": [0, 0, 360, 800], + "terminal": [370, 0, 40, 800], + "console": [420, 0, 360, 800], + "editor": [790, 0, 40, 800], + "preview": [840, 0, 360, 800] + }, + "splitters": { + "files>terminal": [360, 0, 10, 800], + "terminal>console": [410, 0, 10, 800], + "console>editor": [780, 0, 10, 800], + "editor>preview": [830, 0, 10, 800] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "files" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + }, + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "files": [0, 0, 1200, 800], + "terminal": [370, 0, 40, 800], + "console": [420, 0, 360, 800], + "editor": [790, 0, 40, 800], + "preview": [840, 0, 360, 800] + }, + "splitters": { + "files>terminal": [360, 0, 10, 800], + "terminal>console": [410, 0, 10, 800], + "console>editor": [780, 0, 10, 800], + "editor>preview": [830, 0, 10, 800] + }, + "maximized": "files" + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "editor", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + "tabsets": { + "files": [0, 0, 280, 800], + "console": [290, 0, 280, 800], + "terminal": [580, 0, 280, 800], + "editor": [870, 0, 40, 800], + "preview": [920, 0, 280, 800] + }, + "splitters": { + "files>console": [280, 0, 10, 800], + "console>terminal": [570, 0, 10, 800], + "terminal>editor": [860, 0, 10, 800], + "editor>preview": [910, 0, 10, 800] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "terminal" }, + "target": { "root": "top" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1200, 395], + "files": [0, 405, 377, 395], + "console": [387, 405, 376, 395], + "editor": [773, 405, 40, 395], + "preview": [823, 405, 377, 395] + }, + "splitters": { + "terminal>files": [0, 395, 1200, 10], + "files>console": [377, 405, 10, 395], + "console>editor": [763, 405, 10, 395], + "editor>preview": [813, 405, 10, 395] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "root": "top" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1200, 395], + "files": [0, 405, 377, 395], + "console": [387, 405, 376, 395], + "editor": [773, 405, 40, 395], + "preview": [823, 405, 377, 395] + }, + "splitters": { + "terminal>files": [0, 395, 1200, 10], + "files>console": [377, 405, 10, 395], + "console>editor": [763, 405, 10, 395], + "editor>preview": [813, 405, 10, 395] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-49", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-46", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-50", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-34", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-21" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-26", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-25", + "nodOpen": "editor", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { "op": "fold", "tabsetOf": "preview" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1200, 395], + "files": [0, 405, 545, 395], + "console": [555, 405, 545, 395], + "editor": [1110, 405, 40, 395], + "preview": [1160, 405, 40, 395] + }, + "splitters": { + "terminal>files": [0, 395, 1200, 10], + "files>console": [545, 405, 10, 395], + "console>editor": [1100, 405, 10, 395], + "editor>preview": [1150, 405, 10, 395] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "console" }, + "target": { "tabsetOf": "editor", "area": "right" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1200, 395], + "files": [0, 405, 545, 395], + "editor": [555, 405, 40, 395], + "console": [605, 405, 545, 395], + "preview": [1160, 405, 40, 395] + }, + "splitters": { + "terminal>files": [0, 395, 1200, 10], + "files>editor": [545, 405, 10, 395], + "editor>console": [595, 405, 10, 395], + "console>preview": [1150, 405, 10, 395] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "files" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1200, 395], + "files": [0, 0, 1200, 800], + "editor": [555, 405, 40, 395], + "console": [605, 405, 545, 395], + "preview": [1160, 405, 40, 395] + }, + "splitters": { + "terminal>files": [0, 395, 1200, 10], + "files>editor": [545, 405, 10, 395], + "editor>console": [595, 405, 10, 395], + "console>preview": [1150, 405, 10, 395] + }, + "maximized": "files" + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "console", + "point": { "x": 686, "y": 602.5 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1200, 395], + "files": [0, 0, 1200, 800], + "editor": [555, 405, 40, 395], + "console": [605, 405, 545, 395], + "preview": [1160, 405, 40, 395] + }, + "splitters": { + "terminal>files": [0, 395, 1200, 10], + "files>editor": [545, 405, 10, 395], + "editor>console": [595, 405, 10, 395], + "console>preview": [1150, 405, 10, 395] + }, + "maximized": "files" + } + }, + { + "operation": { "op": "fold", "tabsetOf": "console" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1200, 395], + "files": [0, 405, 1050, 395], + "editor": [1060, 405, 40, 395], + "console": [1110, 405, 40, 395], + "preview": [1160, 405, 40, 395] + }, + "splitters": { + "terminal>files": [0, 395, 1200, 10], + "files>editor": [1050, 405, 10, 395], + "editor>console": [1100, 405, 10, 395], + "console>preview": [1150, 405, 10, 395] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-49", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-46", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-50", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-34", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-21" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-25", + "nodOpen": "editor", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-53", + "nodOpen": "console", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "preview", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "splitter", + "before": "console", + "after": "preview", + "point": { "x": 1346, "y": 602.5 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1200, 395], + "files": [0, 405, 1050, 395], + "editor": [1060, 405, 40, 395], + "console": [1110, 405, 40, 395], + "preview": [1160, 405, 40, 395] + }, + "splitters": { + "terminal>files": [0, 395, 1200, 10], + "files>editor": [1050, 405, 10, 395], + "editor>console": [1100, 405, 10, 395], + "console>preview": [1150, 405, 10, 395] + }, + "maximized": null + } + }, + { + "operation": { "op": "maximize", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1200, 800], + "files": [0, 405, 1050, 395], + "editor": [1060, 405, 40, 395], + "console": [1110, 405, 40, 395], + "preview": [1160, 405, 40, 395] + }, + "splitters": { + "terminal>files": [0, 395, 1200, 10], + "files>editor": [1050, 405, 10, 395], + "editor>console": [1100, 405, 10, 395], + "console>preview": [1150, 405, 10, 395] + }, + "maximized": "terminal" + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "files", + "point": { "x": 600, "y": 297 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1200, 800], + "files": [0, 405, 1050, 395], + "editor": [1060, 405, 40, 395], + "console": [1110, 405, 40, 395], + "preview": [1160, 405, 40, 395] + }, + "splitters": { + "terminal>files": [0, 395, 1200, 10], + "files>editor": [1050, 405, 10, 395], + "editor>console": [1100, 405, 10, 395], + "console>preview": [1150, 405, 10, 395] + }, + "maximized": "terminal" + } + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "files", + "point": { "x": 600, "y": 426 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "tabs": ["preview"], + "active": "preview", + "folded": true + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1200, 800], + "files": [0, 405, 1050, 395], + "editor": [1060, 405, 40, 395], + "console": [1110, 405, 40, 395], + "preview": [1160, 405, 40, 395] + }, + "splitters": { + "terminal>files": [0, 395, 1200, 10], + "files>editor": [1050, 405, 10, 395], + "editor>console": [1100, 405, 10, 395], + "console>preview": [1150, 405, 10, 395] + }, + "maximized": "terminal" + } + }, + { + "operation": { "op": "fold", "tabsetOf": "preview" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1200, 800], + "files": [0, 405, 545, 395], + "editor": [555, 405, 40, 395], + "console": [605, 405, 40, 395], + "preview": [655, 405, 545, 395] + }, + "splitters": { + "terminal>files": [0, 395, 1200, 10], + "files>editor": [545, 405, 10, 395], + "editor>console": [595, 405, 10, 395], + "console>preview": [645, 405, 10, 395] + }, + "maximized": "terminal" + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodMaxd": "id-46", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-49", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-46", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-50", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-34", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-21" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-25", + "nodOpen": "editor", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-53", + "nodOpen": "console", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + } + ] + } + ] + } + } + ] +} diff --git a/packages/core/test/fixtures/v1/scenario-06.json b/packages/core/test/fixtures/v1/scenario-06.json new file mode 100644 index 0000000..7f010a9 --- /dev/null +++ b/packages/core/test/fixtures/v1/scenario-06.json @@ -0,0 +1,2117 @@ +{ + "name": "6 tabs, seed 6", + "tabs": ["editor", "terminal", "preview", "console", "files", "search"], + "container": { "width": 1200, "height": 800 }, + "initial": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 545, 800], + "terminal": [555, 0, 645, 370], + "preview": [555, 380, 293, 420], + "console": [858, 380, 342, 205], + "files": [858, 595, 166, 205], + "search": [1034, 595, 166, 205] + }, + "splitters": { + "editor>terminal": [545, 0, 10, 800], + "terminal>preview": [555, 370, 645, 10], + "preview>console": [848, 380, 10, 420], + "console>files": [858, 585, 342, 10], + "files>search": [1024, 595, 10, 205] + }, + "maximized": null + }, + "initialSavedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-23", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-26" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "steps": [ + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "tabsetOf": "console", "area": "top" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 570, 800], + "terminal": [580, 0, 620, 193], + "preview": [580, 203, 620, 192], + "console": [580, 405, 620, 193], + "files": [580, 608, 305, 192], + "search": [895, 608, 305, 192] + }, + "splitters": { + "editor>terminal": [570, 0, 10, 800], + "terminal>preview": [580, 193, 620, 10], + "preview>console": [580, 395, 620, 10], + "console>files": [580, 598, 620, 10], + "files>search": [885, 608, 10, 192] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "tabsetOf": "files", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["console"], "active": "console" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview", "files"], + "active": "preview" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 570, 800], + "terminal": [580, 0, 620, 260], + "console": [580, 270, 620, 260], + "preview|files": [580, 540, 305, 260], + "search": [895, 540, 305, 260] + }, + "splitters": { + "editor>terminal": [570, 0, 10, 800], + "terminal>console": [580, 260, 620, 10], + "console>preview": [580, 530, 620, 10], + "preview>search": [885, 540, 10, 260] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tabsetOf": "console", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["editor", "console"], "active": "editor" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview", "files"], + "active": "preview" + }, + { "tabs": ["search"], "active": "search" } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1200, 260], + "editor|console": [0, 270, 1200, 260], + "preview|files": [0, 540, 595, 260], + "search": [605, 540, 595, 260] + }, + "splitters": { + "terminal>editor": [0, 260, 1200, 10], + "editor>preview": [0, 530, 1200, 10], + "preview>search": [595, 540, 10, 260] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "editor", + "point": { "x": 600, "y": 189 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["editor", "console"], "active": "editor" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview", "files"], + "active": "preview" + }, + { "tabs": ["search"], "active": "search" } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1200, 184], + "editor|console": [0, 194, 1200, 336], + "preview|files": [0, 540, 595, 260], + "search": [605, 540, 595, 260] + }, + "splitters": { + "terminal>editor": [0, 184, 1200, 10], + "editor>preview": [0, 530, 1200, 10], + "preview>search": [595, 540, 10, 260] + }, + "maximized": null + } + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "editor", + "point": { "x": 600, "y": 296 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["editor", "console"], "active": "editor" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview", "files"], + "active": "preview" + }, + { "tabs": ["search"], "active": "search" } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1200, 291], + "editor|console": [0, 301, 1200, 229], + "preview|files": [0, 540, 595, 260], + "search": [605, 540, 595, 260] + }, + "splitters": { + "terminal>editor": [0, 291, 1200, 10], + "editor>preview": [0, 530, 1200, 10], + "preview>search": [595, 540, 10, 260] + }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 114.0909090909091, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 85.9090909090909, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-23", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-26" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "editor", + "point": { "x": 600, "y": 290 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["editor", "console"], "active": "editor" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview", "files"], + "active": "preview" + }, + { "tabs": ["search"], "active": "search" } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1200, 285], + "editor|console": [0, 295, 1200, 235], + "preview|files": [0, 540, 595, 260], + "search": [605, 540, 595, 260] + }, + "splitters": { + "terminal>editor": [0, 285, 1200, 10], + "editor>preview": [0, 530, 1200, 10], + "preview>search": [595, 540, 10, 260] + }, + "maximized": null + } + }, + { + "operation": { "op": "fold", "tabsetOf": "files" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["editor", "console"], "active": "editor" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview", "files"], + "active": "preview", + "folded": true + }, + { "tabs": ["search"], "active": "search" } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1200, 285], + "editor|console": [0, 295, 1200, 235], + "preview|files": [0, 540, 40, 260], + "search": [50, 540, 1150, 260] + }, + "splitters": { + "terminal>editor": [0, 285, 1200, 10], + "editor>preview": [0, 530, 1200, 10], + "preview>search": [40, 540, 10, 260] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "console" }, + "target": { "root": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview", "files"], + "active": "preview", + "folded": true + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + }, + "tabsets": { + "console": [0, 0, 570, 800], + "terminal": [580, 0, 620, 285], + "editor": [580, 295, 620, 235], + "preview|files": [580, 540, 40, 260], + "search": [630, 540, 570, 260] + }, + "splitters": { + "console>terminal": [570, 0, 10, 800], + "terminal>editor": [580, 285, 620, 10], + "editor>preview": [580, 530, 620, 10], + "preview>search": [620, 540, 10, 260] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "tab": "terminal", "position": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["preview", "terminal"], + "active": "preview" + }, + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files", + "folded": true + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + }, + "tabsets": { + "console": [0, 0, 570, 800], + "preview|terminal": [580, 0, 620, 285], + "editor": [580, 295, 620, 235], + "files": [580, 540, 40, 260], + "search": [630, 540, 570, 260] + }, + "splitters": { + "console>preview": [570, 0, 10, 800], + "preview>editor": [580, 285, 620, 10], + "editor>files": [580, 530, 620, 10], + "files>search": [620, 540, 10, 260] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "preview", + "after": "editor", + "point": { "x": 890, "y": 260 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["preview", "terminal"], + "active": "preview" + }, + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files", + "folded": true + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + }, + "tabsets": { + "console": [0, 0, 570, 800], + "preview|terminal": [580, 0, 620, 255], + "editor": [580, 265, 620, 265], + "files": [580, 540, 40, 260], + "search": [630, 540, 570, 260] + }, + "splitters": { + "console>preview": [570, 0, 10, 800], + "preview>editor": [580, 255, 620, 10], + "editor>files": [580, 530, 620, 10], + "files>search": [620, 540, 10, 260] + }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-35", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 97.72727272727273, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 102.27272727272727, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-23", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-26" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { "op": "maximize", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["preview", "terminal"], + "active": "preview" + }, + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files", + "folded": true + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + }, + "tabsets": { + "console": [0, 0, 570, 800], + "preview|terminal": [580, 0, 620, 255], + "editor": [0, 0, 1200, 800], + "files": [580, 540, 40, 260], + "search": [630, 540, 570, 260] + }, + "splitters": { + "console>preview": [570, 0, 10, 800], + "preview>editor": [580, 255, 620, 10], + "editor>files": [580, 530, 620, 10], + "files>search": [620, 540, 10, 260] + }, + "maximized": "editor" + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "tabsetOf": "files", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files", + "folded": true + }, + { + "tabs": ["preview", "terminal"], + "active": "preview" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + }, + "tabsets": { + "console": [0, 0, 545, 800], + "editor": [555, 0, 645, 399], + "files": [555, 409, 40, 391], + "preview|terminal": [605, 409, 290, 391], + "search": [905, 409, 295, 391] + }, + "splitters": { + "console>editor": [545, 0, 10, 800], + "editor>files": [555, 399, 645, 10], + "files>preview": [595, 409, 10, 391], + "preview>search": [895, 409, 10, 391] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "search" }, + "target": { "tabsetOf": "console", "area": "top" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["search"], "active": "search" }, + { "tabs": ["console"], "active": "console" } + ] + }, + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files", + "folded": true + }, + { + "tabs": ["preview", "terminal"], + "active": "preview" + } + ] + } + ] + } + ] + }, + "tabsets": { + "search": [0, 0, 570, 395], + "console": [0, 405, 570, 395], + "editor": [580, 0, 620, 399], + "files": [580, 409, 40, 391], + "preview|terminal": [630, 409, 570, 391] + }, + "splitters": { + "search>editor": [570, 0, 10, 800], + "search>console": [0, 395, 570, 10], + "editor>files": [580, 399, 620, 10], + "files>preview": [620, 409, 10, 391] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "search" }, + "target": { "tabsetOf": "files", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files", + "folded": true + }, + { + "tabs": ["search"], + "active": "search" + }, + { + "tabs": ["preview", "terminal"], + "active": "preview" + } + ] + } + ] + } + ] + }, + "tabsets": { + "console": [0, 0, 545, 800], + "editor": [555, 0, 645, 399], + "files": [555, 409, 40, 391], + "search": [605, 409, 295, 391], + "preview|terminal": [910, 409, 290, 391] + }, + "splitters": { + "console>editor": [545, 0, 10, 800], + "editor>files": [555, 399, 645, 10], + "files>search": [595, 409, 10, 391], + "search>preview": [900, 409, 10, 391] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files", + "folded": true + }, + { + "tabs": ["search"], + "active": "search" + }, + { + "tabs": ["preview", "terminal"], + "active": "preview" + } + ] + } + ] + } + ] + }, + "tabsets": { + "console": [0, 0, 545, 800], + "editor": [555, 0, 645, 40], + "files": [555, 50, 40, 750], + "search": [605, 50, 295, 750], + "preview|terminal": [910, 50, 290, 750] + }, + "splitters": { + "console>editor": [545, 0, 10, 800], + "editor>files": [555, 40, 645, 10], + "files>search": [595, 50, 10, 750], + "search>preview": [900, 50, 10, 750] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-41", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 102.27272727272727, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "editor", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-44", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-26" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 97.72727272727273, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "console" }, + "target": { "tabsetOf": "console", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files", + "folded": true + }, + { + "tabs": ["search"], + "active": "search" + }, + { + "tabs": ["preview", "terminal"], + "active": "preview" + } + ] + } + ] + } + ] + }, + "tabsets": { + "console": [0, 0, 545, 800], + "editor": [555, 0, 645, 40], + "files": [555, 50, 40, 750], + "search": [605, 50, 295, 750], + "preview|terminal": [910, 50, 290, 750] + }, + "splitters": { + "console>editor": [545, 0, 10, 800], + "editor>files": [555, 40, 645, 10], + "files>search": [595, 50, 10, 750], + "search>preview": [900, 50, 10, 750] + }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "preview", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["files"], + "active": "files", + "folded": true + }, + { "tabs": ["search"], "active": "search" }, + { + "tabs": ["preview", "terminal"], + "active": "preview" + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "console": [0, 0, 281, 800], + "files": [291, 0, 40, 800], + "search": [341, 0, 282, 800], + "preview|terminal": [633, 0, 276, 800], + "editor": [919, 0, 281, 800] + }, + "splitters": { + "console>files": [281, 0, 10, 800], + "files>search": [331, 0, 10, 800], + "search>preview": [623, 0, 10, 800], + "preview>editor": [909, 0, 10, 800] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "search", + "after": "preview", + "point": { "x": 654, "y": 400 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["files"], + "active": "files", + "folded": true + }, + { "tabs": ["search"], "active": "search" }, + { + "tabs": ["preview", "terminal"], + "active": "preview" + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "console": [0, 0, 281, 800], + "files": [291, 0, 40, 800], + "search": [341, 0, 308, 800], + "preview|terminal": [659, 0, 250, 800], + "editor": [919, 0, 281, 800] + }, + "splitters": { + "console>files": [281, 0, 10, 800], + "files>search": [331, 0, 10, 800], + "search>preview": [649, 0, 10, 800], + "preview>editor": [909, 0, 10, 800] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "console" }, + "target": { "tabsetOf": "files", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console", "files"], "active": "console" }, + { "tabs": ["search"], "active": "search" }, + { + "tabs": ["preview", "terminal"], + "active": "preview" + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "console|files": [0, 0, 294, 800], + "search": [304, 0, 321, 800], + "preview|terminal": [635, 0, 261, 800], + "editor": [906, 0, 294, 800] + }, + "splitters": { + "console>search": [294, 0, 10, 800], + "search>preview": [625, 0, 10, 800], + "preview>editor": [896, 0, 10, 800] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "files" }, + "target": { "tabsetOf": "editor", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { "tabs": ["search"], "active": "search" }, + { + "tabs": ["preview", "terminal"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + }, + "tabsets": { + "console": [0, 0, 294, 800], + "search": [304, 0, 321, 800], + "preview|terminal": [635, 0, 261, 800], + "editor": [906, 0, 294, 395], + "files": [906, 405, 294, 395] + }, + "splitters": { + "console>search": [294, 0, 10, 800], + "search>preview": [625, 0, 10, 800], + "preview>editor": [896, 0, 10, 800], + "editor>files": [906, 395, 294, 10] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 110.85964244960063, + "nodName": "", + "uidNode": "id-44", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-26" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 86.86763027767212, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-48", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-52", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-51", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "files", + "point": { "x": 1053, "y": 571 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { "tabs": ["search"], "active": "search" }, + { + "tabs": ["preview", "terminal"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + }, + "tabsets": { + "console": [0, 0, 294, 800], + "search": [304, 0, 321, 800], + "preview|terminal": [635, 0, 261, 800], + "editor": [906, 0, 294, 566], + "files": [906, 576, 294, 224] + }, + "splitters": { + "console>search": [294, 0, 10, 800], + "search>preview": [625, 0, 10, 800], + "preview>editor": [896, 0, 10, 800], + "editor>files": [906, 566, 294, 10] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "search" }, + "target": { "tab": "editor", "position": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["preview", "terminal"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor", "search"], + "active": "search" + }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + }, + "tabsets": { + "console": [0, 0, 410, 800], + "preview|terminal": [420, 0, 360, 800], + "editor|search": [790, 0, 410, 566], + "files": [790, 576, 410, 224] + }, + "splitters": { + "console>preview": [410, 0, 10, 800], + "preview>editor": [780, 0, 10, 800], + "editor>files": [790, 566, 410, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "files" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["preview", "terminal"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor", "search"], + "active": "search" + }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + }, + "tabsets": { + "console": [0, 0, 410, 800], + "preview|terminal": [420, 0, 360, 800], + "editor|search": [790, 0, 410, 566], + "files": [0, 0, 1200, 800] + }, + "splitters": { + "console>preview": [410, 0, 10, 800], + "preview>editor": [780, 0, 10, 800], + "editor>files": [790, 566, 410, 10] + }, + "maximized": "files" + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tabsetOf": "console", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor", "search"], "active": "search" }, + { + "tabs": ["preview", "terminal"], + "active": "preview" + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "console": [0, 0, 301, 800], + "editor|search": [311, 0, 427, 800], + "preview|terminal": [748, 0, 267, 800], + "files": [1025, 0, 175, 800] + }, + "splitters": { + "console>editor": [301, 0, 10, 800], + "editor>preview": [738, 0, 10, 800], + "preview>files": [1015, 0, 10, 800] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "search" }, + "target": { "tabsetOf": "editor", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor", "search"], "active": "search" }, + { + "tabs": ["preview", "terminal"], + "active": "preview" + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "console": [0, 0, 301, 800], + "editor|search": [311, 0, 427, 800], + "preview|terminal": [748, 0, 267, 800], + "files": [1025, 0, 175, 800] + }, + "splitters": { + "console>editor": [301, 0, 10, 800], + "editor>preview": [738, 0, 10, 800], + "preview>files": [1015, 0, 10, 800] + }, + "maximized": null + }, + "accepted": false, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 148.16901408450704, + "nodName": "", + "uidNode": "id-52", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-26" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 86.86763027767212, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 51.83098591549295, + "nodName": "", + "uidNode": "id-51", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "files" }, + "target": { "root": "top" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["editor", "search"], + "active": "search" + }, + { + "tabs": ["preview", "terminal"], + "active": "preview" + } + ] + } + ] + }, + "tabsets": { + "files": [0, 0, 1200, 282], + "console": [0, 292, 356, 508], + "editor|search": [366, 292, 509, 508], + "preview|terminal": [885, 292, 315, 508] + }, + "splitters": { + "files>console": [0, 282, 1200, 10], + "console>editor": [356, 292, 10, 508], + "editor>preview": [875, 292, 10, 508] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "files" }, + "target": { "tabsetOf": "files", "area": "top" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["editor", "search"], + "active": "search" + }, + { + "tabs": ["preview", "terminal"], + "active": "preview" + } + ] + } + ] + }, + "tabsets": { + "files": [0, 0, 1200, 282], + "console": [0, 292, 356, 508], + "editor|search": [366, 292, 509, 508], + "preview|terminal": [885, 292, 315, 508] + }, + "splitters": { + "files>console": [0, 282, 1200, 10], + "console>editor": [356, 292, 10, 508], + "editor>preview": [875, 292, 10, 508] + }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "root": "bottom" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["editor", "search"], + "active": "search" + }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "files": [0, 0, 1200, 176], + "terminal": [0, 498, 1200, 302], + "console": [0, 186, 356, 302], + "editor|search": [366, 186, 509, 302], + "preview": [885, 186, 315, 302] + }, + "splitters": { + "files>console": [0, 176, 1200, 10], + "console>terminal": [0, 488, 1200, 10], + "console>editor": [356, 186, 10, 302], + "editor>preview": [875, 186, 10, 302] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "resize", "width": 1492, "height": 674 }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["editor", "search"], + "active": "search" + }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "files": [0, 0, 1492, 150], + "terminal": [0, 422, 1492, 252], + "console": [0, 160, 444, 252], + "editor|search": [454, 160, 637, 252], + "preview": [1101, 160, 391, 252] + }, + "splitters": { + "files>console": [0, 150, 1492, 10], + "console>terminal": [0, 412, 1492, 10], + "console>editor": [444, 160, 10, 252], + "editor>preview": [1091, 160, 10, 252] + }, + "maximized": null + } + }, + { + "operation": { "op": "maximize", "tabsetOf": "search" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["editor", "search"], + "active": "search" + }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "files": [0, 0, 1492, 150], + "terminal": [0, 422, 1492, 252], + "console": [0, 160, 444, 252], + "editor|search": [0, 0, 1492, 674], + "preview": [1101, 160, 391, 252] + }, + "splitters": { + "files>console": [0, 150, 1492, 10], + "console>terminal": [0, 412, 1492, 10], + "console>editor": [444, 160, 10, 252], + "editor>preview": [1091, 160, 10, 252] + }, + "maximized": "editor|search" + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodMaxd": "id-52", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-61", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 51.83098591549295, + "nodName": "", + "uidNode": "id-51", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-62", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 148.16901408450704, + "nodName": "", + "uidNode": "id-52", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-26" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 86.86763027767212, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-64", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + } + ] + } + ] + } + } + ] +} diff --git a/packages/core/test/fixtures/v1/scenario-07.json b/packages/core/test/fixtures/v1/scenario-07.json new file mode 100644 index 0000000..97ebaf7 --- /dev/null +++ b/packages/core/test/fixtures/v1/scenario-07.json @@ -0,0 +1,2699 @@ +{ + "name": "7 tabs, seed 7", + "tabs": [ + "editor", + "terminal", + "preview", + "console", + "files", + "search", + "git" + ], + "container": { "width": 1200, "height": 800 }, + "initial": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["search"], + "active": "search" + }, + { + "tabs": ["git"], + "active": "git" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 545, 800], + "terminal": [555, 0, 645, 345], + "preview": [555, 355, 293, 445], + "console": [858, 355, 342, 193], + "files": [858, 558, 166, 242], + "search": [1034, 558, 166, 116], + "git": [1034, 684, 166, 116] + }, + "splitters": { + "editor>terminal": [545, 0, 10, 800], + "terminal>preview": [555, 345, 645, 10], + "preview>console": [848, 355, 10, 445], + "console>files": [858, 548, 342, 10], + "files>search": [1024, 558, 10, 242], + "search>git": [1034, 674, 166, 10] + }, + "maximized": null + }, + "initialSavedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-23", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-27", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-28", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-31" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "steps": [ + { + "operation": { "op": "resize", "width": 543, "height": 1026 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": [ + "search" + ], + "active": "search" + }, + { + "tabs": [ + "git" + ], + "active": "git" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 217, 1026], + "terminal": [227, 0, 316, 458], + "preview": [227, 468, 128, 558], + "console": [365, 468, 178, 249], + "files": [365, 727, 84, 299], + "search": [459, 727, 84, 145], + "git": [459, 882, 84, 144] + }, + "splitters": { + "editor>terminal": [217, 0, 10, 1026], + "terminal>preview": [227, 458, 316, 10], + "preview>console": [355, 468, 10, 558], + "console>files": [365, 717, 178, 10], + "files>search": [449, 727, 10, 299], + "search>git": [459, 872, 84, 10] + }, + "maximized": null + } + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "preview", + "point": { "x": 385, "y": 320 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": [ + "search" + ], + "active": "search" + }, + { + "tabs": [ + "git" + ], + "active": "git" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 217, 1026], + "terminal": [227, 0, 316, 315], + "preview": [227, 325, 128, 701], + "console": [365, 325, 178, 321], + "files": [365, 656, 84, 370], + "search": [459, 656, 84, 180], + "git": [459, 846, 84, 180] + }, + "splitters": { + "editor>terminal": [217, 0, 10, 1026], + "terminal>preview": [227, 315, 316, 10], + "preview>console": [355, 325, 10, 701], + "console>files": [365, 646, 178, 10], + "files>search": [449, 656, 10, 370], + "search>git": [459, 836, 84, 10] + }, + "maximized": null + } + }, + { + "operation": { "op": "maximize", "tabsetOf": "console" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": [ + "search" + ], + "active": "search" + }, + { + "tabs": [ + "git" + ], + "active": "git" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 217, 1026], + "terminal": [227, 0, 316, 315], + "preview": [227, 325, 128, 701], + "console": [0, 0, 543, 1026], + "files": [365, 656, 84, 370], + "search": [459, 656, 84, 180], + "git": [459, 846, 84, 180] + }, + "splitters": { + "editor>terminal": [217, 0, 10, 1026], + "terminal>preview": [227, 315, 316, 10], + "preview>console": [355, 325, 10, 701], + "console>files": [365, 646, 178, 10], + "files>search": [449, 656, 10, 370], + "search>git": [459, 836, 84, 10] + }, + "maximized": "console" + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "search" }, + "target": { "tabsetOf": "preview", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["search", "preview"], + "active": "search" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["git"], + "active": "git" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 217, 1026], + "terminal": [227, 0, 316, 331], + "search|preview": [227, 341, 128, 685], + "console": [365, 341, 178, 338], + "files": [365, 689, 84, 337], + "git": [459, 689, 84, 337] + }, + "splitters": { + "editor>terminal": [217, 0, 10, 1026], + "terminal>search": [227, 331, 316, 10], + "search>console": [355, 341, 10, 685], + "console>files": [365, 679, 178, 10], + "files>git": [449, 689, 10, 337] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "resize", "width": 1380, "height": 951 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["search", "preview"], + "active": "search" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["git"], + "active": "git" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 635, 951], + "terminal": [645, 0, 735, 307], + "search|preview": [645, 317, 338, 634], + "console": [993, 317, 387, 312], + "files": [993, 639, 189, 312], + "git": [1192, 639, 188, 312] + }, + "splitters": { + "editor>terminal": [635, 0, 10, 951], + "terminal>search": [645, 307, 735, 10], + "search>console": [983, 317, 10, 634], + "console>files": [993, 629, 387, 10], + "files>git": [1182, 639, 10, 312] + }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 65.78947368421053, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 134.21052631578948, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-28", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-31" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "preview", "area": "top" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor"], + "active": "editor" + }, + { + "tabs": ["search", "preview"], + "active": "search" + } + ] + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["git"], + "active": "git" + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1380, 307], + "editor": [0, 317, 660, 312], + "search|preview": [0, 639, 660, 312], + "console": [670, 317, 710, 312], + "files": [670, 639, 350, 312], + "git": [1030, 639, 350, 312] + }, + "splitters": { + "terminal>editor": [0, 307, 1380, 10], + "editor>console": [660, 317, 10, 634], + "editor>search": [0, 629, 660, 10], + "console>files": [670, 629, 710, 10], + "files>git": [1020, 639, 10, 312] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "search" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor"], + "active": "editor" + }, + { + "tabs": ["search", "preview"], + "active": "search", + "folded": true + } + ] + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["git"], + "active": "git" + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1380, 307], + "editor": [0, 317, 660, 584], + "search|preview": [0, 911, 660, 40], + "console": [670, 317, 710, 312], + "files": [670, 639, 350, 312], + "git": [1030, 639, 350, 312] + }, + "splitters": { + "terminal>editor": [0, 307, 1380, 10], + "editor>console": [660, 317, 10, 634], + "editor>search": [0, 901, 660, 10], + "console>files": [670, 629, 710, 10], + "files>git": [1020, 639, 10, 312] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "tabsetOf": "preview", "area": "left" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor"], + "active": "editor" + }, + { + "tabs": ["search", "preview"], + "active": "search", + "folded": true + } + ] + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["git"], + "active": "git" + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1380, 307], + "editor": [0, 317, 660, 584], + "search|preview": [0, 911, 660, 40], + "console": [670, 317, 710, 312], + "files": [670, 639, 350, 312], + "git": [1030, 639, 350, 312] + }, + "splitters": { + "terminal>editor": [0, 307, 1380, 10], + "editor>console": [660, 317, 10, 634], + "editor>search": [0, 901, 660, 10], + "console>files": [670, 629, 710, 10], + "files>git": [1020, 639, 10, 312] + }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "fold", "tabsetOf": "git" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor"], + "active": "editor" + }, + { + "tabs": ["search", "preview"], + "active": "search", + "folded": true + } + ] + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["git"], + "active": "git", + "folded": true + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1380, 307], + "editor": [0, 317, 660, 584], + "search|preview": [0, 911, 660, 40], + "console": [670, 317, 710, 312], + "files": [670, 639, 660, 312], + "git": [1340, 639, 40, 312] + }, + "splitters": { + "terminal>editor": [0, 307, 1380, 10], + "editor>console": [660, 317, 10, 634], + "editor>search": [0, 901, 660, 10], + "console>files": [670, 629, 710, 10], + "files>git": [1330, 639, 10, 312] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "resize", "width": 1035, "height": 787 }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor"], + "active": "editor" + }, + { + "tabs": ["search", "preview"], + "active": "search", + "folded": true + } + ] + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["git"], + "active": "git", + "folded": true + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1035, 253], + "editor": [0, 263, 488, 474], + "search|preview": [0, 747, 488, 40], + "console": [498, 263, 537, 257], + "files": [498, 530, 487, 257], + "git": [995, 530, 40, 257] + }, + "splitters": { + "terminal>editor": [0, 253, 1035, 10], + "editor>console": [488, 263, 10, 524], + "editor>search": [0, 737, 488, 10], + "console>files": [498, 520, 537, 10], + "files>git": [985, 530, 10, 257] + }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 65.78947368421053, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 134.21052631578948, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-36", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-37", + "nodOpen": "search", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-28", + "nodOpen": "git", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-31" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "splitter", + "before": "console", + "after": "files", + "point": { "x": 766.5, "y": 496 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor"], + "active": "editor" + }, + { + "tabs": ["search", "preview"], + "active": "search", + "folded": true + } + ] + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["git"], + "active": "git", + "folded": true + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1035, 253], + "editor": [0, 263, 488, 474], + "search|preview": [0, 747, 488, 40], + "console": [498, 263, 537, 228], + "files": [498, 501, 487, 286], + "git": [995, 501, 40, 286] + }, + "splitters": { + "terminal>editor": [0, 253, 1035, 10], + "editor>console": [488, 263, 10, 524], + "editor>search": [0, 737, 488, 10], + "console>files": [498, 491, 537, 10], + "files>git": [985, 501, 10, 286] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tab": "search", "position": "right" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["search", "editor", "preview"], + "active": "editor" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["git"], + "active": "git", + "folded": true + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1035, 253], + "search|editor|preview": [0, 263, 488, 524], + "console": [498, 263, 537, 228], + "files": [498, 501, 487, 286], + "git": [995, 501, 40, 286] + }, + "splitters": { + "terminal>search": [0, 253, 1035, 10], + "search>console": [488, 263, 10, 524], + "console>files": [498, 491, 537, 10], + "files>git": [985, 501, 10, 286] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "search", + "point": { "x": 517.5, "y": 413 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["search", "editor", "preview"], + "active": "editor" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["git"], + "active": "git", + "folded": true + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1035, 408], + "search|editor|preview": [0, 418, 488, 369], + "console": [498, 418, 537, 161], + "files": [498, 589, 487, 198], + "git": [995, 589, 40, 198] + }, + "splitters": { + "terminal>search": [0, 408, 1035, 10], + "search>console": [488, 418, 10, 369], + "console>files": [498, 579, 537, 10], + "files>git": [985, 589, 10, 198] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "root": "bottom" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["search", "preview"], + "active": "search" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["git"], + "active": "git", + "folded": true + } + ] + } + ] + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "terminal": [0, 0, 1035, 266], + "editor": [0, 548, 1035, 239], + "search|preview": [0, 276, 488, 262], + "console": [498, 276, 537, 115], + "files": [498, 401, 487, 137], + "git": [995, 401, 40, 137] + }, + "splitters": { + "terminal>search": [0, 266, 1035, 10], + "search>editor": [0, 538, 1035, 10], + "search>console": [488, 276, 10, 262], + "console>files": [498, 391, 537, 10], + "files>git": [985, 401, 10, 137] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "git" }, + "target": { "tabsetOf": "editor", "area": "right" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["search", "preview"], + "active": "search" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["git"], "active": "git" } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1035, 266], + "search|preview": [0, 276, 513, 262], + "editor": [0, 548, 513, 239], + "git": [523, 548, 512, 239], + "console": [523, 276, 512, 120], + "files": [523, 406, 512, 132] + }, + "splitters": { + "terminal>search": [0, 266, 1035, 10], + "search>editor": [0, 538, 1035, 10], + "search>console": [513, 276, 10, 262], + "editor>git": [513, 548, 10, 239], + "console>files": [523, 396, 512, 10] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 113.75579598145286, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 86.24420401854714, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-37", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 86.63594470046083, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + } + ] + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-42", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-48", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-47", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-31" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "git" }, + "target": { "tabsetOf": "console", "area": "top" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["search", "preview"], + "active": "search" + }, + { + "direction": "vertical", + "children": [ + { "tabs": ["git"], "active": "git" }, + { + "tabs": ["console"], + "active": "console" + }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "terminal": [0, 0, 1035, 247], + "editor": [0, 565, 1035, 222], + "search|preview": [0, 257, 513, 298], + "git": [523, 257, 512, 95], + "console": [523, 362, 512, 88], + "files": [523, 460, 512, 95] + }, + "splitters": { + "terminal>search": [0, 247, 1035, 10], + "search>editor": [0, 555, 1035, 10], + "search>git": [513, 257, 10, 298], + "git>console": [523, 352, 512, 10], + "console>files": [523, 450, 512, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "terminal", "area": "top" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["search", "preview"], + "active": "search" + }, + { + "direction": "vertical", + "children": [ + { "tabs": ["git"], "active": "git" }, + { + "tabs": ["console"], + "active": "console" + }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "terminal": [0, 0, 1035, 247], + "editor": [0, 565, 1035, 222], + "search|preview": [0, 257, 513, 298], + "git": [523, 257, 512, 95], + "console": [523, 362, 512, 88], + "files": [523, 460, 512, 95] + }, + "splitters": { + "terminal>search": [0, 247, 1035, 10], + "search>editor": [0, 555, 1035, 10], + "search>git": [513, 257, 10, 298], + "git>console": [523, 352, 512, 10], + "console>files": [523, 450, 512, 10] + }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tabsetOf": "git", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["search", "preview"], + "active": "search" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor", "git"], + "active": "editor" + }, + { + "tabs": ["console"], + "active": "console" + }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 1035, 380], + "search|preview": [0, 390, 513, 397], + "editor|git": [523, 390, 512, 130], + "console": [523, 530, 512, 117], + "files": [523, 657, 512, 130] + }, + "splitters": { + "terminal>search": [0, 380, 1035, 10], + "search>editor": [513, 390, 10, 397], + "editor>console": [523, 520, 512, 10], + "console>files": [523, 647, 512, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "root": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["search", "preview"], "active": "search" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["editor", "git"], + "active": "editor" + }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + }, + "tabsets": { + "search|preview": [0, 0, 513, 787], + "terminal": [523, 0, 512, 210], + "editor|git": [523, 220, 512, 189], + "console": [523, 419, 512, 169], + "files": [523, 598, 512, 189] + }, + "splitters": { + "search>terminal": [513, 0, 10, 787], + "terminal>editor": [523, 210, 512, 10], + "editor>console": [523, 409, 512, 10], + "console>files": [523, 588, 512, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "tabsetOf": "terminal", "area": "right" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["search", "preview"], + "active": "search" + } + ] + }, + { "tabs": ["editor", "git"], "active": "editor" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "editor|git": [0, 220, 1035, 189], + "console": [0, 419, 1035, 169], + "files": [0, 598, 1035, 189], + "terminal": [0, 0, 513, 210], + "search|preview": [523, 0, 512, 210] + }, + "splitters": { + "terminal>editor": [0, 210, 1035, 10], + "editor>console": [0, 409, 1035, 10], + "console>files": [0, 588, 1035, 10], + "terminal>search": [513, 0, 10, 210] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "row", + "nodPart": 113.75579598145286, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-56", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-37", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-51", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-31" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 86.63594470046083, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "editor", + "point": { "x": 517.5, "y": 84 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["search", "preview"], + "active": "search" + } + ] + }, + { "tabs": ["editor", "git"], "active": "editor" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "editor|git": [0, 89, 1035, 320], + "console": [0, 419, 1035, 169], + "files": [0, 598, 1035, 189], + "terminal": [0, 0, 513, 79], + "search|preview": [523, 0, 512, 79] + }, + "splitters": { + "terminal>editor": [0, 79, 1035, 10], + "editor>console": [0, 409, 1035, 10], + "console>files": [0, 588, 1035, 10], + "terminal>search": [513, 0, 10, 79] + }, + "maximized": null + } + }, + { + "operation": { "op": "fold", "tabsetOf": "search" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["search", "preview"], + "active": "search", + "folded": true + } + ] + }, + { "tabs": ["editor", "git"], "active": "editor" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "editor|git": [0, 89, 1035, 320], + "console": [0, 419, 1035, 169], + "files": [0, 598, 1035, 189], + "terminal": [0, 0, 985, 79], + "search|preview": [995, 0, 40, 79] + }, + "splitters": { + "terminal>editor": [0, 79, 1035, 10], + "editor>console": [0, 409, 1035, 10], + "console>files": [0, 588, 1035, 10], + "terminal>search": [985, 0, 10, 79] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "console" }, + "target": { "tabsetOf": "console", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["search", "preview"], + "active": "search", + "folded": true + } + ] + }, + { "tabs": ["editor", "git"], "active": "editor" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "editor|git": [0, 89, 1035, 320], + "console": [0, 419, 1035, 169], + "files": [0, 598, 1035, 189], + "terminal": [0, 0, 985, 79], + "search|preview": [995, 0, 40, 79] + }, + "splitters": { + "terminal>editor": [0, 79, 1035, 10], + "editor>console": [0, 409, 1035, 10], + "console>files": [0, 588, 1035, 10], + "terminal>search": [985, 0, 10, 79] + }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "terminal" }, + "target": { "tabsetOf": "preview", "area": "left" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["search", "preview"], + "active": "search" + } + ] + }, + { "tabs": ["editor", "git"], "active": "editor" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "editor|git": [0, 50, 1035, 339], + "console": [0, 399, 1035, 178], + "files": [0, 587, 1035, 200], + "terminal": [0, 0, 513, 40], + "search|preview": [523, 0, 512, 40] + }, + "splitters": { + "terminal>editor": [0, 40, 1035, 10], + "editor>console": [0, 389, 1035, 10], + "console>files": [0, 577, 1035, 10], + "terminal>search": [513, 0, 10, 40] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "console" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["search", "preview"], + "active": "search" + } + ] + }, + { "tabs": ["editor", "git"], "active": "editor" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "editor|git": [0, 50, 1035, 339], + "console": [0, 0, 1035, 787], + "files": [0, 587, 1035, 200], + "terminal": [0, 0, 513, 40], + "search|preview": [523, 0, 512, 40] + }, + "splitters": { + "terminal>editor": [0, 40, 1035, 10], + "editor>console": [0, 389, 1035, 10], + "console>files": [0, 577, 1035, 10], + "terminal>search": [513, 0, 10, 40] + }, + "maximized": "console" + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodMaxd": "id-17", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-37", + "nodOpen": "search", + "nodFold": true, + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-56", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-59", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 187.6226422407737, + "nodName": "", + "uidNode": "id-51", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-31" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 86.63594470046083, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + } + ] + } + ] + } + }, + { + "operation": { "op": "resize", "width": 1317, "height": 537 }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "tabs": ["search", "preview"], + "active": "search" + } + ] + }, + { "tabs": ["editor", "git"], "active": "editor" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "editor|git": [0, 50, 1317, 214], + "console": [0, 0, 1317, 537], + "files": [0, 404, 1317, 133], + "terminal": [0, 0, 654, 40], + "search|preview": [664, 0, 653, 40] + }, + "splitters": { + "terminal>editor": [0, 40, 1317, 10], + "editor>console": [0, 264, 1317, 10], + "console>files": [0, 394, 1317, 10], + "terminal>search": [654, 0, 10, 40] + }, + "maximized": "console" + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "terminal" }, + "target": { "tabsetOf": "files", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["search", "preview"], "active": "search" }, + { "tabs": ["editor", "git"], "active": "editor" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["files"], "active": "files" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "search|preview": [0, 0, 1317, 92], + "editor|git": [0, 102, 1317, 137], + "console": [0, 249, 1317, 85], + "files": [0, 344, 1317, 91], + "terminal": [0, 445, 1317, 92] + }, + "splitters": { + "search>editor": [0, 92, 1317, 10], + "editor>console": [0, 239, 1317, 10], + "console>files": [0, 334, 1317, 10], + "files>terminal": [0, 435, 1317, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["search", "preview"], "active": "search" }, + { "tabs": ["editor", "git"], "active": "editor" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["files"], "active": "files" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "search|preview": [0, 0, 1317, 103], + "editor|git": [0, 113, 1317, 157], + "console": [0, 280, 1317, 94], + "files": [0, 384, 1317, 103], + "terminal": [0, 497, 1317, 40] + }, + "splitters": { + "search>editor": [0, 103, 1317, 10], + "editor>console": [0, 270, 1317, 10], + "console>files": [0, 374, 1317, 10], + "files>terminal": [0, 487, 1317, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "search" }, + "target": { "tabsetOf": "search", "area": "left" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { "tabs": ["search"], "active": "search" }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + { "tabs": ["editor", "git"], "active": "editor" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["files"], "active": "files" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor|git": [0, 113, 1317, 157], + "console": [0, 280, 1317, 94], + "files": [0, 384, 1317, 103], + "terminal": [0, 497, 1317, 40], + "search": [0, 0, 654, 103], + "preview": [664, 0, 653, 103] + }, + "splitters": { + "search>editor": [0, 103, 1317, 10], + "editor>console": [0, 270, 1317, 10], + "console>files": [0, 374, 1317, 10], + "files>terminal": [0, 487, 1317, 10], + "search>preview": [654, 0, 10, 103] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "files", + "after": "terminal", + "point": { "x": 658.5, "y": 601 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { "tabs": ["search"], "active": "search" }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + { "tabs": ["editor", "git"], "active": "editor" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["files"], "active": "files" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor|git": [0, 113, 1317, 157], + "console": [0, 280, 1317, 94], + "files": [0, 384, 1317, 103], + "terminal": [0, 497, 1317, 40], + "search": [0, 0, 654, 103], + "preview": [664, 0, 653, 103] + }, + "splitters": { + "search>editor": [0, 103, 1317, 10], + "editor>console": [0, 270, 1317, 10], + "console>files": [0, 374, 1317, 10], + "files>terminal": [0, 487, 1317, 10], + "search>preview": [654, 0, 10, 103] + }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-59", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-63", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-64", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 187.6226422407737, + "nodName": "", + "uidNode": "id-51", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-31" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 86.63594470046083, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-56", + "nodOpen": "terminal", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + } + ] + } + ] + } + } + ] +} diff --git a/packages/core/test/fixtures/v1/scenario-08.json b/packages/core/test/fixtures/v1/scenario-08.json new file mode 100644 index 0000000..e9dbce5 --- /dev/null +++ b/packages/core/test/fixtures/v1/scenario-08.json @@ -0,0 +1,3020 @@ +{ + "name": "8 tabs, seed 8", + "tabs": [ + "editor", + "terminal", + "preview", + "console", + "files", + "search", + "git", + "output" + ], + "container": { "width": 640, "height": 420 }, + "initial": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["search"], + "active": "search" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "git" + ], + "active": "git" + }, + { + "tabs": [ + "output" + ], + "active": "output" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 240, 420], + "terminal": [250, 0, 390, 155], + "preview": [250, 165, 140, 255], + "console": [400, 165, 240, 98], + "files": [400, 273, 90, 147], + "search": [500, 273, 140, 69], + "git": [500, 352, 65, 68], + "output": [575, 352, 65, 68] + }, + "splitters": { + "editor>terminal": [240, 0, 10, 420], + "terminal>preview": [250, 155, 390, 10], + "preview>console": [390, 165, 10, 255], + "console>files": [400, 263, 240, 10], + "files>search": [490, 273, 10, 147], + "search>git": [500, 342, 140, 10], + "git>output": [565, 352, 10, 68] + }, + "maximized": null + }, + "initialSavedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-23", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-27", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-28", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-34" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-33", + "nodOpen": "output", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "output", + "uidNode": "id-36" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "steps": [ + { + "operation": { + "op": "splitter", + "before": "git", + "after": "output", + "point": { "x": 672, "y": 386 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": [ + "search" + ], + "active": "search" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "git" + ], + "active": "git" + }, + { + "tabs": [ + "output" + ], + "active": "output" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 240, 420], + "terminal": [250, 0, 390, 155], + "preview": [250, 165, 140, 255], + "console": [400, 165, 240, 98], + "files": [400, 273, 90, 147], + "search": [500, 273, 140, 69], + "git": [500, 352, 90, 68], + "output": [600, 352, 40, 68] + }, + "splitters": { + "editor>terminal": [240, 0, 10, 420], + "terminal>preview": [250, 155, 390, 10], + "preview>console": [390, 165, 10, 255], + "console>files": [400, 263, 240, 10], + "files>search": [490, 273, 10, 147], + "search>git": [500, 342, 140, 10], + "git>output": [590, 352, 10, 68] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "search", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": [ + "terminal", + "search" + ], + "active": "terminal" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "tabs": ["output"], + "active": "output" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 173, 420], + "preview": [183, 0, 174, 420], + "console": [367, 0, 273, 180], + "files": [367, 190, 107, 230], + "terminal|search": [484, 190, 156, 110], + "git": [484, 310, 106, 110], + "output": [600, 310, 40, 110] + }, + "splitters": { + "editor>preview": [173, 0, 10, 420], + "preview>console": [357, 0, 10, 420], + "console>files": [367, 180, 273, 10], + "files>terminal": [474, 190, 10, 230], + "terminal>git": [484, 300, 156, 10], + "git>output": [590, 310, 10, 110] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "terminal" }, + "target": { "tabsetOf": "editor", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal", "search"], + "active": "terminal" + } + ] + }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { "tabs": ["git"], "active": "git" }, + { + "tabs": ["output"], + "active": "output" + } + ] + } + ] + } + ] + }, + "tabsets": { + "preview": [183, 0, 174, 420], + "editor": [0, 0, 173, 205], + "terminal|search": [0, 215, 173, 205], + "console": [367, 0, 273, 205], + "files": [367, 215, 84, 205], + "git": [461, 215, 129, 205], + "output": [600, 215, 40, 205] + }, + "splitters": { + "editor>preview": [173, 0, 10, 420], + "preview>console": [357, 0, 10, 420], + "editor>terminal": [0, 205, 173, 10], + "console>files": [367, 205, 273, 10], + "files>git": [451, 215, 10, 205], + "git>output": [590, 215, 10, 205] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "console", + "after": "files", + "point": { "x": 503.5, "y": 242 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal", "search"], + "active": "terminal" + } + ] + }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { "tabs": ["git"], "active": "git" }, + { + "tabs": ["output"], + "active": "output" + } + ] + } + ] + } + ] + }, + "tabsets": { + "preview": [183, 0, 174, 420], + "editor": [0, 0, 173, 205], + "terminal|search": [0, 215, 173, 205], + "console": [367, 0, 273, 237], + "files": [367, 247, 84, 173], + "git": [461, 247, 129, 173], + "output": [600, 247, 40, 173] + }, + "splitters": { + "editor>preview": [173, 0, 10, 420], + "preview>console": [357, 0, 10, 420], + "editor>terminal": [0, 205, 173, 10], + "console>files": [367, 237, 273, 10], + "files>git": [451, 247, 10, 173], + "git>output": [590, 247, 10, 173] + }, + "maximized": null + } + }, + { + "operation": { "op": "resize", "width": 896, "height": 933 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal", "search"], + "active": "terminal" + } + ] + }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { "tabs": ["git"], "active": "git" }, + { + "tabs": ["output"], + "active": "output" + } + ] + } + ] + } + ] + }, + "tabsets": { + "preview": [269, 0, 258, 933], + "editor": [0, 0, 259, 462], + "terminal|search": [0, 472, 259, 461], + "console": [537, 0, 359, 543], + "files": [537, 553, 113, 380], + "git": [660, 553, 186, 380], + "output": [856, 553, 40, 380] + }, + "splitters": { + "editor>preview": [259, 0, 10, 933], + "preview>console": [527, 0, 10, 933], + "editor>terminal": [0, 462, 259, 10], + "console>files": [537, 543, 359, 10], + "files>git": [650, 553, 10, 380], + "git>output": [846, 553, 10, 380] + }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-42", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-27", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 119.39393939393939, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 80.60606060606061, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 200, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-34" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 0, + "nodName": "", + "uidNode": "id-33", + "nodOpen": "output", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "output", + "uidNode": "id-36" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { "op": "fold", "tabsetOf": "console" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal", "search"], + "active": "terminal" + } + ] + }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { "tabs": ["git"], "active": "git" }, + { + "tabs": ["output"], + "active": "output" + } + ] + } + ] + } + ] + }, + "tabsets": { + "preview": [269, 0, 258, 933], + "editor": [0, 0, 259, 462], + "terminal|search": [0, 472, 259, 461], + "console": [537, 0, 359, 40], + "files": [537, 50, 113, 883], + "git": [660, 50, 186, 883], + "output": [856, 50, 40, 883] + }, + "splitters": { + "editor>preview": [259, 0, 10, 933], + "preview>console": [527, 0, 10, 933], + "editor>terminal": [0, 462, 259, 10], + "console>files": [537, 40, 359, 10], + "files>git": [650, 50, 10, 883], + "git>output": [846, 50, 10, 883] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "git" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal", "search"], + "active": "terminal" + } + ] + }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["git"], + "active": "git", + "folded": true + }, + { + "tabs": ["output"], + "active": "output" + } + ] + } + ] + } + ] + }, + "tabsets": { + "preview": [269, 0, 258, 933], + "editor": [0, 0, 259, 462], + "terminal|search": [0, 472, 259, 461], + "console": [537, 0, 359, 40], + "files": [537, 50, 259, 883], + "git": [806, 50, 40, 883], + "output": [856, 50, 40, 883] + }, + "splitters": { + "editor>preview": [259, 0, 10, 933], + "preview>console": [527, 0, 10, 933], + "editor>terminal": [0, 462, 259, 10], + "console>files": [537, 40, 359, 10], + "files>git": [796, 50, 10, 883], + "git>output": [846, 50, 10, 883] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "preview", + "point": { "x": 453, "y": 466.5 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal", "search"], + "active": "terminal" + } + ] + }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["git"], + "active": "git", + "folded": true + }, + { + "tabs": ["output"], + "active": "output" + } + ] + } + ] + } + ] + }, + "tabsets": { + "preview": [458, 0, 69, 933], + "editor": [0, 0, 448, 462], + "terminal|search": [0, 472, 448, 461], + "console": [537, 0, 359, 40], + "files": [537, 50, 259, 883], + "git": [806, 50, 40, 883], + "output": [856, 50, 40, 883] + }, + "splitters": { + "editor>preview": [448, 0, 10, 933], + "preview>console": [527, 0, 10, 933], + "editor>terminal": [0, 462, 448, 10], + "console>files": [537, 40, 359, 10], + "files>git": [796, 50, 10, 883], + "git>output": [846, 50, 10, 883] + }, + "maximized": null + } + }, + { + "operation": { "op": "fold", "tabsetOf": "files" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal", "search"], + "active": "terminal" + } + ] + }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files", + "folded": true + }, + { + "tabs": ["git"], + "active": "git", + "folded": true + }, + { + "tabs": ["output"], + "active": "output" + } + ] + } + ] + } + ] + }, + "tabsets": { + "preview": [458, 0, 69, 933], + "editor": [0, 0, 448, 462], + "terminal|search": [0, 472, 448, 461], + "console": [537, 0, 359, 40], + "files": [537, 50, 259, 883], + "git": [806, 50, 40, 883], + "output": [856, 50, 40, 883] + }, + "splitters": { + "editor>preview": [448, 0, 10, 933], + "preview>console": [527, 0, 10, 933], + "editor>terminal": [0, 462, 448, 10], + "console>files": [537, 40, 359, 10], + "files>git": [796, 50, 10, 883], + "git>output": [846, 50, 10, 883] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "git" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal", "search"], + "active": "terminal" + } + ] + }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files", + "folded": true + }, + { "tabs": ["git"], "active": "git" }, + { + "tabs": ["output"], + "active": "output" + } + ] + } + ] + } + ] + }, + "tabsets": { + "preview": [458, 0, 69, 933], + "editor": [0, 0, 448, 462], + "terminal|search": [0, 472, 448, 461], + "console": [537, 0, 359, 40], + "files": [537, 50, 40, 883], + "git": [587, 50, 259, 883], + "output": [856, 50, 40, 883] + }, + "splitters": { + "editor>preview": [448, 0, 10, 933], + "preview>console": [527, 0, 10, 933], + "editor>terminal": [0, 462, 448, 10], + "console>files": [537, 40, 359, 10], + "files>git": [577, 50, 10, 883], + "git>output": [846, 50, 10, 883] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 186.72768878718534, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-42", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-27", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 13.272311212814644, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 119.39393939393939, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 80.60606060606061, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 200, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-34" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 0, + "nodName": "", + "uidNode": "id-33", + "nodOpen": "output", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "output", + "uidNode": "id-36" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "files" }, + "target": { "root": "bottom" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor"], + "active": "editor" + }, + { + "tabs": ["terminal", "search"], + "active": "terminal" + } + ] + }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "tabs": ["output"], + "active": "output" + } + ] + } + ] + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "files": [0, 497, 896, 436], + "preview": [489, 0, 72, 487], + "editor": [0, 0, 479, 239], + "terminal|search": [0, 249, 479, 238], + "console": [571, 0, 325, 40], + "git": [571, 50, 275, 437], + "output": [856, 50, 40, 437] + }, + "splitters": { + "editor>files": [0, 487, 896, 10], + "editor>preview": [479, 0, 10, 487], + "preview>console": [561, 0, 10, 487], + "editor>terminal": [0, 239, 479, 10], + "console>git": [571, 40, 325, 10], + "git>output": [846, 50, 10, 437] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "output", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "search"], + "active": "terminal" + }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "tabs": [ + "editor", + "output" + ], + "active": "editor" + } + ] + } + ] + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "files": [0, 497, 896, 436], + "terminal|search": [0, 0, 371, 487], + "preview": [381, 0, 84, 487], + "console": [475, 0, 421, 40], + "git": [475, 50, 371, 437], + "editor|output": [856, 50, 40, 437] + }, + "splitters": { + "terminal>files": [0, 487, 896, 10], + "terminal>preview": [371, 0, 10, 487], + "preview>console": [465, 0, 10, 487], + "console>git": [475, 40, 421, 10], + "git>editor": [846, 50, 10, 437] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "files", + "point": { "x": 448, "y": 652 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "search"], + "active": "terminal" + }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "tabs": [ + "editor", + "output" + ], + "active": "editor" + } + ] + } + ] + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "files": [0, 657, 896, 276], + "terminal|search": [0, 0, 371, 647], + "preview": [381, 0, 84, 647], + "console": [475, 0, 421, 40], + "git": [475, 50, 371, 597], + "editor|output": [856, 50, 40, 597] + }, + "splitters": { + "terminal>files": [0, 647, 896, 10], + "terminal>preview": [371, 0, 10, 647], + "preview>console": [465, 0, 10, 647], + "console>git": [475, 40, 421, 10], + "git>editor": [846, 50, 10, 597] + }, + "maximized": null + } + }, + { + "operation": { "op": "resize", "width": 799, "height": 808 }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "search"], + "active": "terminal" + }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "tabs": [ + "editor", + "output" + ], + "active": "editor" + } + ] + } + ] + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "files": [0, 569, 799, 239], + "terminal|search": [0, 0, 326, 559], + "preview": [336, 0, 77, 559], + "console": [423, 0, 376, 40], + "git": [423, 50, 326, 509], + "editor|output": [759, 50, 40, 509] + }, + "splitters": { + "terminal>files": [0, 559, 799, 10], + "terminal>preview": [326, 0, 10, 559], + "preview>console": [413, 0, 10, 559], + "console>git": [423, 40, 376, 10], + "git>editor": [749, 50, 10, 509] + }, + "maximized": null + } + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "preview", + "point": { "x": 414, "y": 279.5 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "search"], + "active": "terminal" + }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "tabs": [ + "editor", + "output" + ], + "active": "editor" + } + ] + } + ] + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "files": [0, 569, 799, 239], + "terminal|search": [0, 0, 363, 559], + "preview": [373, 0, 40, 559], + "console": [423, 0, 376, 40], + "git": [423, 50, 326, 509], + "editor|output": [759, 50, 40, 509] + }, + "splitters": { + "terminal>files": [0, 559, 799, 10], + "terminal>preview": [363, 0, 10, 559], + "preview>console": [413, 0, 10, 559], + "console>git": [423, 40, 376, 10], + "git>editor": [749, 50, 10, 509] + }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-47", + "nodKids": [ + { + "typNode": "row", + "nodPart": 140.47919293820934, + "nodName": "", + "uidNode": "id-48", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 113.27231121281464, + "nodName": "", + "uidNode": "id-27", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 0, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 119.39393939393939, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 80.60606060606061, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 200, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-34" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 0, + "nodName": "", + "uidNode": "id-33", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "output", + "uidNode": "id-36" + } + ] + } + ] + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 59.52080706179067, + "nodName": "", + "uidNode": "id-44", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + } + ] + } + ] + } + }, + { + "operation": { "op": "fold", "tabsetOf": "search" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "search"], + "active": "terminal", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "tabs": [ + "editor", + "output" + ], + "active": "editor" + } + ] + } + ] + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "files": [0, 569, 799, 239], + "terminal|search": [0, 0, 40, 559], + "preview": [50, 0, 40, 559], + "console": [100, 0, 699, 40], + "git": [100, 50, 649, 509], + "editor|output": [759, 50, 40, 509] + }, + "splitters": { + "terminal>files": [0, 559, 799, 10], + "terminal>preview": [40, 0, 10, 559], + "preview>console": [90, 0, 10, 559], + "console>git": [100, 40, 699, 10], + "git>editor": [749, 50, 10, 509] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "tabsetOf": "output", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "search"], + "active": "terminal", + "folded": true + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "tabs": [ + "editor", + "preview", + "output" + ], + "active": "preview" + } + ] + } + ] + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "files": [0, 569, 799, 239], + "terminal|search": [0, 0, 40, 559], + "console": [50, 0, 749, 40], + "git": [50, 50, 699, 509], + "editor|preview|output": [759, 50, 40, 509] + }, + "splitters": { + "terminal>files": [0, 559, 799, 10], + "terminal>console": [40, 0, 10, 559], + "console>git": [50, 40, 749, 10], + "git>editor": [749, 50, 10, 509] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "git" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "search"], + "active": "terminal", + "folded": true + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git", + "folded": true + }, + { + "tabs": [ + "editor", + "preview", + "output" + ], + "active": "preview" + } + ] + } + ] + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "files": [0, 569, 799, 239], + "terminal|search": [0, 0, 40, 559], + "console": [50, 0, 749, 40], + "git": [50, 50, 699, 509], + "editor|preview|output": [759, 50, 40, 509] + }, + "splitters": { + "terminal>files": [0, 559, 799, 10], + "terminal>console": [40, 0, 10, 559], + "console>git": [50, 40, 749, 10], + "git>editor": [749, 50, 10, 509] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "output" }, + "target": { "tabsetOf": "search", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "output", "search"], + "active": "output" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git", + "folded": true + }, + { + "tabs": [ + "editor", + "preview" + ], + "active": "preview" + } + ] + } + ] + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "files": [0, 569, 799, 239], + "terminal|output|search": [0, 0, 390, 559], + "console": [400, 0, 399, 40], + "git": [50, 50, 699, 509], + "editor|preview": [759, 50, 40, 509] + }, + "splitters": { + "terminal>files": [0, 559, 799, 10], + "terminal>console": [390, 0, 10, 559], + "console>git": [400, 40, 399, 10], + "git>editor": [749, 50, 10, 509] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "files", + "point": { "x": 399.5, "y": 560 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "output", "search"], + "active": "output" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git", + "folded": true + }, + { + "tabs": [ + "editor", + "preview" + ], + "active": "preview" + } + ] + } + ] + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "files": [0, 565, 799, 243], + "terminal|output|search": [0, 0, 390, 555], + "console": [400, 0, 399, 40], + "git": [50, 50, 699, 509], + "editor|preview": [759, 50, 40, 509] + }, + "splitters": { + "terminal>files": [0, 555, 799, 10], + "terminal>console": [390, 0, 10, 555], + "console>git": [400, 40, 399, 10], + "git>editor": [749, 50, 10, 509] + }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-47", + "nodKids": [ + { + "typNode": "row", + "nodPart": 139.22155688622755, + "nodName": "", + "uidNode": "id-48", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 113.27231121281464, + "nodName": "", + "uidNode": "id-27", + "nodOpen": "output", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "output", + "uidNode": "id-36" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 119.39393939393939, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 80.60606060606061, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 200, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "git", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-34" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 0, + "nodName": "", + "uidNode": "id-33", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 60.778443113772454, + "nodName": "", + "uidNode": "id-44", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + } + ] + } + ] + } + }, + { + "operation": { "op": "fold", "tabsetOf": "output" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "output", "search"], + "active": "output", + "folded": true + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git", + "folded": true + }, + { + "tabs": [ + "editor", + "preview" + ], + "active": "preview" + } + ] + } + ] + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "files": [0, 565, 799, 243], + "terminal|output|search": [0, 0, 40, 555], + "console": [50, 0, 749, 40], + "git": [50, 50, 699, 509], + "editor|preview": [759, 50, 40, 509] + }, + "splitters": { + "terminal>files": [0, 555, 799, 10], + "terminal>console": [40, 0, 10, 555], + "console>git": [50, 40, 749, 10], + "git>editor": [749, 50, 10, 509] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "files", + "point": { "x": 399.5, "y": 731 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "output", "search"], + "active": "output", + "folded": true + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git", + "folded": true + }, + { + "tabs": [ + "editor", + "preview" + ], + "active": "preview" + } + ] + } + ] + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "files": [0, 736, 799, 72], + "terminal|output|search": [0, 0, 40, 726], + "console": [50, 0, 749, 40], + "git": [50, 50, 699, 509], + "editor|preview": [759, 50, 40, 509] + }, + "splitters": { + "terminal>files": [0, 726, 799, 10], + "terminal>console": [40, 0, 10, 726], + "console>git": [50, 40, 749, 10], + "git>editor": [749, 50, 10, 509] + }, + "maximized": null + } + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "files", + "point": { "x": 399.5, "y": 849 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "output", "search"], + "active": "output", + "folded": true + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git", + "folded": true + }, + { + "tabs": [ + "editor", + "preview" + ], + "active": "preview" + } + ] + } + ] + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "files": [0, 768, 799, 40], + "terminal|output|search": [0, 0, 40, 758], + "console": [50, 0, 749, 40], + "git": [50, 50, 699, 509], + "editor|preview": [759, 50, 40, 509] + }, + "splitters": { + "terminal>files": [0, 758, 799, 10], + "terminal>console": [40, 0, 10, 758], + "console>git": [50, 40, 749, 10], + "git>editor": [749, 50, 10, 509] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "terminal" }, + "target": { "tabsetOf": "git", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "terminal", + "output", + "search", + "git" + ], + "active": "search" + }, + { + "tabs": ["editor", "preview"], + "active": "preview" + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "console": [0, 0, 799, 40], + "files": [0, 768, 799, 40], + "terminal|output|search|git": [0, 50, 749, 708], + "editor|preview": [759, 50, 40, 708] + }, + "splitters": { + "console>terminal": [0, 40, 799, 10], + "terminal>files": [0, 758, 799, 10], + "terminal>editor": [749, 50, 10, 708] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "files", + "point": { "x": 399.5, "y": 760 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "terminal", + "output", + "search", + "git" + ], + "active": "search" + }, + { + "tabs": ["editor", "preview"], + "active": "preview" + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "console": [0, 0, 799, 40], + "files": [0, 765, 799, 43], + "terminal|output|search|git": [0, 50, 749, 705], + "editor|preview": [759, 50, 40, 705] + }, + "splitters": { + "console>terminal": [0, 40, 799, 10], + "terminal>files": [0, 755, 799, 10], + "terminal>editor": [749, 50, 10, 705] + }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-47", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 119.39393939393939, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 80.24405733986573, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 200, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "output", + "uidNode": "id-36" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-34" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 0, + "nodName": "", + "uidNode": "id-33", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 0.362003266194883, + "nodName": "", + "uidNode": "id-44", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + } + ] + } + ] + } + }, + { + "operation": { "op": "resize", "width": 455, "height": 407 }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "terminal", + "output", + "search", + "git" + ], + "active": "search" + }, + { + "tabs": ["editor", "preview"], + "active": "preview" + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "console": [0, 0, 455, 40], + "files": [0, 366, 455, 41], + "terminal|output|search|git": [0, 50, 405, 306], + "editor|preview": [415, 50, 40, 306] + }, + "splitters": { + "console>terminal": [0, 40, 455, 10], + "terminal>files": [0, 356, 455, 10], + "terminal>editor": [405, 50, 10, 306] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "search" }, + "target": { "tabsetOf": "editor", "area": "left" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "output", "git"], + "active": "terminal" + }, + { "tabs": ["search"], "active": "search" }, + { + "tabs": ["editor", "preview"], + "active": "preview" + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "console": [0, 0, 455, 40], + "files": [0, 366, 455, 41], + "terminal|output|git": [0, 50, 250, 306], + "search": [260, 50, 145, 306], + "editor|preview": [415, 50, 40, 306] + }, + "splitters": { + "console>terminal": [0, 40, 455, 10], + "terminal>files": [0, 356, 455, 10], + "terminal>search": [250, 50, 10, 306], + "search>editor": [405, 50, 10, 306] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "output" }, + "target": { "root": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "git"], + "active": "terminal" + }, + { + "tabs": ["search"], + "active": "search" + }, + { + "tabs": ["editor", "preview"], + "active": "preview" + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + { "tabs": ["output"], "active": "output" } + ] + }, + "tabsets": { + "output": [283, 0, 172, 407], + "console": [0, 0, 273, 40], + "files": [0, 366, 273, 41], + "terminal|git": [0, 50, 129, 306], + "search": [139, 50, 84, 306], + "editor|preview": [233, 50, 40, 306] + }, + "splitters": { + "console>output": [273, 0, 10, 407], + "console>terminal": [0, 40, 273, 10], + "terminal>files": [0, 356, 273, 10], + "terminal>search": [129, 50, 10, 306], + "search>editor": [223, 50, 10, 306] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "console" }, + "target": { "tab": "editor", "position": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "git"], + "active": "terminal" + }, + { + "tabs": ["search"], + "active": "search" + }, + { + "tabs": [ + "console", + "editor", + "preview" + ], + "active": "console" + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + { "tabs": ["output"], "active": "output" } + ] + }, + "tabsets": { + "output": [283, 0, 172, 407], + "files": [0, 366, 273, 41], + "terminal|git": [0, 0, 129, 356], + "search": [139, 0, 84, 356], + "console|editor|preview": [233, 0, 40, 356] + }, + "splitters": { + "terminal>output": [273, 0, 10, 407], + "terminal>files": [0, 356, 273, 10], + "terminal>search": [129, 0, 10, 356], + "search>console": [223, 0, 10, 356] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "tabsetOf": "search", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal", "git"], + "active": "terminal" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["search"], + "active": "search" + }, + { + "tabs": [ + "console", + "editor", + "preview" + ], + "active": "console" + } + ] + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + { "tabs": ["output"], "active": "output" } + ] + }, + "tabsets": { + "output": [258, 0, 197, 407], + "files": [0, 366, 248, 41], + "terminal|git": [0, 0, 145, 356], + "search": [155, 0, 93, 306], + "console|editor|preview": [155, 316, 93, 40] + }, + "splitters": { + "terminal>output": [248, 0, 10, 407], + "terminal>files": [0, 356, 248, 10], + "terminal>search": [145, 0, 10, 356], + "search>console": [155, 306, 93, 10] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-47", + "nodKids": [ + { + "typNode": "row", + "nodPart": 80.24405733986573, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 200, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-34" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-60", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-67", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 0, + "nodName": "", + "uidNode": "id-33", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 0.362003266194883, + "nodName": "", + "uidNode": "id-44", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-64", + "nodOpen": "output", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "output", + "uidNode": "id-36" + } + ] + } + ] + } + } + ] +} diff --git a/packages/core/test/fixtures/v1/scenario-09.json b/packages/core/test/fixtures/v1/scenario-09.json new file mode 100644 index 0000000..da8ae06 --- /dev/null +++ b/packages/core/test/fixtures/v1/scenario-09.json @@ -0,0 +1,3699 @@ +{ + "name": "9 tabs, seed 9", + "tabs": [ + "editor", + "terminal", + "preview", + "console", + "files", + "search", + "git", + "output", + "debug" + ], + "container": { "width": 1200, "height": 800 }, + "initial": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["search"], + "active": "search" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "git" + ], + "active": "git" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": [ + "output" + ], + "active": "output" + }, + { + "tabs": [ + "debug" + ], + "active": "debug" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 520, 800], + "terminal": [530, 0, 670, 320], + "preview": [530, 330, 280, 470], + "console": [820, 330, 380, 180], + "files": [820, 520, 160, 280], + "search": [990, 520, 210, 110], + "git": [990, 640, 100, 160], + "output": [1100, 640, 100, 75], + "debug": [1100, 725, 100, 75] + }, + "splitters": { + "editor>terminal": [520, 0, 10, 800], + "terminal>preview": [530, 320, 670, 10], + "preview>console": [810, 330, 10, 470], + "console>files": [820, 510, 380, 10], + "files>search": [980, 520, 10, 280], + "search>git": [990, 630, 210, 10], + "git>output": [1090, 640, 10, 160], + "output>debug": [1100, 715, 100, 10] + }, + "maximized": null + }, + "initialSavedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-23", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-27", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-28", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-34" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-33", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-37", + "nodOpen": "output", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "output", + "uidNode": "id-39" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-38", + "nodOpen": "debug", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "debug", + "uidNode": "id-41" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "steps": [ + { + "operation": { + "op": "move", + "source": { "tab": "console" }, + "target": { "tabsetOf": "search", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["files"], + "active": "files" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["search"], + "active": "search" + }, + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": [ + "output" + ], + "active": "output" + }, + { + "tabs": [ + "debug" + ], + "active": "debug" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 520, 800], + "terminal": [530, 0, 670, 320], + "preview": [530, 330, 200, 470], + "files": [740, 330, 200, 470], + "search": [950, 330, 250, 133], + "console": [950, 473, 250, 134], + "git": [950, 617, 120, 183], + "output": [1080, 617, 120, 87], + "debug": [1080, 714, 120, 86] + }, + "splitters": { + "editor>terminal": [520, 0, 10, 800], + "terminal>preview": [530, 320, 670, 10], + "preview>files": [730, 330, 10, 470], + "files>search": [940, 330, 10, 470], + "search>console": [950, 463, 250, 10], + "console>git": [950, 607, 250, 10], + "git>output": [1070, 617, 10, 183], + "output>debug": [1080, 704, 120, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "search", + "after": "console", + "point": { "x": 1075, "y": 493 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["files"], + "active": "files" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["search"], + "active": "search" + }, + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": [ + "output" + ], + "active": "output" + }, + { + "tabs": [ + "debug" + ], + "active": "debug" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 520, 800], + "terminal": [530, 0, 670, 320], + "preview": [530, 330, 200, 470], + "files": [740, 330, 200, 470], + "search": [950, 330, 250, 158], + "console": [950, 498, 250, 109], + "git": [950, 617, 120, 183], + "output": [1080, 617, 120, 87], + "debug": [1080, 714, 120, 86] + }, + "splitters": { + "editor>terminal": [520, 0, 10, 800], + "terminal>preview": [530, 320, 670, 10], + "preview>files": [730, 330, 10, 470], + "files>search": [940, 330, 10, 470], + "search>console": [950, 488, 250, 10], + "console>git": [950, 607, 250, 10], + "git>output": [1070, 617, 10, 183], + "output>debug": [1080, 704, 120, 10] + }, + "maximized": null + } + }, + { + "operation": { + "op": "splitter", + "before": "files", + "after": "search", + "point": { "x": 842, "y": 565 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["files"], + "active": "files" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["search"], + "active": "search" + }, + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": [ + "output" + ], + "active": "output" + }, + { + "tabs": [ + "debug" + ], + "active": "debug" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 520, 800], + "terminal": [530, 0, 670, 320], + "preview": [530, 330, 200, 470], + "files": [740, 330, 97, 470], + "search": [847, 330, 353, 158], + "console": [847, 498, 353, 109], + "git": [847, 617, 172, 183], + "output": [1029, 617, 171, 87], + "debug": [1029, 714, 171, 86] + }, + "splitters": { + "editor>terminal": [520, 0, 10, 800], + "terminal>preview": [530, 320, 670, 10], + "preview>files": [730, 330, 10, 470], + "files>search": [837, 330, 10, 470], + "search>console": [847, 488, 353, 10], + "console>git": [847, 607, 353, 10], + "git>output": [1019, 617, 10, 183], + "output>debug": [1029, 704, 171, 10] + }, + "maximized": null + } + }, + { + "operation": { "op": "resize", "width": 1178, "height": 933 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["files"], + "active": "files" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["search"], + "active": "search" + }, + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": [ + "output" + ], + "active": "output" + }, + { + "tabs": [ + "debug" + ], + "active": "debug" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 509, 933], + "terminal": [519, 0, 659, 387], + "preview": [519, 397, 196, 536], + "files": [725, 397, 96, 536], + "search": [831, 397, 347, 186], + "console": [831, 593, 347, 125], + "git": [831, 728, 169, 205], + "output": [1010, 728, 168, 98], + "debug": [1010, 836, 168, 97] + }, + "splitters": { + "editor>terminal": [509, 0, 10, 933], + "terminal>preview": [519, 387, 659, 10], + "preview>files": [715, 397, 10, 536], + "files>search": [821, 397, 10, 536], + "search>console": [831, 583, 347, 10], + "console>git": [831, 718, 347, 10], + "git>output": [1000, 728, 10, 205], + "output>debug": [1010, 826, 168, 10] + }, + "maximized": null + } + }, + { + "operation": { "op": "resize", "width": 261, "height": 411 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["files"], + "active": "files" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["search"], + "active": "search" + }, + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": [ + "output" + ], + "active": "output" + }, + { + "tabs": [ + "debug" + ], + "active": "debug" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 51, 411], + "terminal": [61, 0, 200, 126], + "preview": [61, 136, 43, 275], + "files": [114, 136, 42, 275], + "search": [166, 136, 95, 76], + "console": [166, 222, 95, 61], + "git": [166, 293, 43, 118], + "output": [219, 293, 42, 54], + "debug": [219, 357, 42, 54] + }, + "splitters": { + "editor>terminal": [51, 0, 10, 411], + "terminal>preview": [61, 126, 200, 10], + "preview>files": [104, 136, 10, 275], + "files>search": [156, 136, 10, 275], + "search>console": [166, 212, 95, 10], + "console>git": [166, 283, 95, 10], + "git>output": [209, 293, 10, 118], + "output>debug": [219, 347, 42, 10] + }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 35.625, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "row", + "nodPart": 164.375, + "nodName": "", + "uidNode": "id-23", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 126.20320855614973, + "nodName": "", + "uidNode": "id-27", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 73.79679144385027, + "nodName": "", + "uidNode": "id-45", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-28", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-34" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-33", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-37", + "nodOpen": "output", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "output", + "uidNode": "id-39" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-38", + "nodOpen": "debug", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "debug", + "uidNode": "id-41" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { "op": "resize", "width": 1508, "height": 336 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["files"], + "active": "files" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["search"], + "active": "search" + }, + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": [ + "output" + ], + "active": "output" + }, + { + "tabs": [ + "debug" + ], + "active": "debug" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 674, 336], + "terminal": [684, 0, 824, 88], + "preview": [684, 98, 251, 238], + "files": [945, 98, 116, 238], + "search": [1071, 98, 437, 60], + "console": [1071, 168, 437, 52], + "git": [1071, 230, 214, 106], + "output": [1295, 230, 213, 48], + "debug": [1295, 288, 213, 48] + }, + "splitters": { + "editor>terminal": [674, 0, 10, 336], + "terminal>preview": [684, 88, 824, 10], + "preview>files": [935, 98, 10, 238], + "files>search": [1061, 98, 10, 238], + "search>console": [1071, 158, 437, 10], + "console>git": [1071, 220, 437, 10], + "git>output": [1285, 230, 10, 106], + "output>debug": [1295, 278, 213, 10] + }, + "maximized": null + } + }, + { + "operation": { "op": "resize", "width": 946, "height": 187 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["files"], + "active": "files" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["search"], + "active": "search" + }, + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": [ + "output" + ], + "active": "output" + }, + { + "tabs": [ + "debug" + ], + "active": "debug" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 393, 240], + "terminal": [403, 0, 543, 40], + "preview": [403, 50, 158, 190], + "files": [571, 50, 82, 190], + "search": [663, 50, 283, 40], + "console": [663, 100, 283, 40], + "git": [663, 150, 137, 90], + "output": [810, 150, 136, 40], + "debug": [810, 200, 136, 40] + }, + "splitters": { + "editor>terminal": [393, 0, 10, 240], + "terminal>preview": [403, 40, 543, 10], + "preview>files": [561, 50, 10, 190], + "files>search": [653, 50, 10, 190], + "search>console": [663, 90, 283, 10], + "console>git": [663, 140, 283, 10], + "git>output": [800, 150, 10, 90], + "output>debug": [810, 190, 136, 10] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "git", "area": "right" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["files"], "active": "files" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["search"], + "active": "search" + }, + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "tabs": ["editor"], + "active": "editor" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["output"], + "active": "output" + }, + { + "tabs": ["debug"], + "active": "debug" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 946, 40], + "preview": [0, 50, 275, 190], + "files": [285, 50, 124, 190], + "search": [419, 50, 527, 40], + "console": [419, 100, 527, 40], + "git": [419, 150, 169, 90], + "editor": [598, 150, 169, 90], + "output": [777, 150, 169, 40], + "debug": [777, 200, 169, 40] + }, + "splitters": { + "terminal>preview": [0, 40, 946, 10], + "preview>files": [275, 50, 10, 190], + "files>search": [409, 50, 10, 190], + "search>console": [419, 90, 527, 10], + "console>git": [419, 140, 527, 10], + "git>editor": [588, 150, 10, 90], + "editor>output": [767, 150, 10, 90], + "output>debug": [777, 190, 169, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "search" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["files"], "active": "files" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["search"], + "active": "search", + "folded": true + }, + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "tabs": ["editor"], + "active": "editor" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["output"], + "active": "output" + }, + { + "tabs": ["debug"], + "active": "debug" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 946, 40], + "preview": [0, 50, 275, 190], + "files": [285, 50, 124, 190], + "search": [419, 50, 527, 40], + "console": [419, 100, 527, 40], + "git": [419, 150, 169, 90], + "editor": [598, 150, 169, 90], + "output": [777, 150, 169, 40], + "debug": [777, 200, 169, 40] + }, + "splitters": { + "terminal>preview": [0, 40, 946, 10], + "preview>files": [275, 50, 10, 190], + "files>search": [409, 50, 10, 190], + "search>console": [419, 90, 527, 10], + "console>git": [419, 140, 527, 10], + "git>editor": [588, 150, 10, 90], + "editor>output": [767, 150, 10, 90], + "output>debug": [777, 190, 169, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "search" }, + "target": { "tabsetOf": "console", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["files"], "active": "files" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["search", "console"], + "active": "search" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "tabs": ["editor"], + "active": "editor" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["output"], + "active": "output" + }, + { + "tabs": ["debug"], + "active": "debug" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 946, 65], + "preview": [0, 75, 275, 165], + "files": [285, 75, 124, 165], + "search|console": [419, 75, 527, 51], + "git": [419, 136, 169, 104], + "editor": [598, 136, 169, 104], + "output": [777, 136, 169, 47], + "debug": [777, 193, 169, 47] + }, + "splitters": { + "terminal>preview": [0, 65, 946, 10], + "preview>files": [275, 75, 10, 165], + "files>search": [409, 75, 10, 165], + "search>git": [419, 126, 527, 10], + "git>editor": [588, 136, 10, 104], + "editor>output": [767, 136, 10, 104], + "output>debug": [777, 183, 169, 10] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 35.625, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "row", + "nodPart": 164.375, + "nodName": "", + "uidNode": "id-23", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 73.79679144385027, + "nodName": "", + "uidNode": "id-45", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-28", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-34" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-48", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-33", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-37", + "nodOpen": "output", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "output", + "uidNode": "id-39" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-38", + "nodOpen": "debug", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "debug", + "uidNode": "id-41" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "output", + "point": { "x": 581, "y": 188 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["files"], "active": "files" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["search", "console"], + "active": "search" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "tabs": ["editor"], + "active": "editor" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["output"], + "active": "output" + }, + { + "tabs": ["debug"], + "active": "debug" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 946, 65], + "preview": [0, 75, 275, 165], + "files": [285, 75, 124, 165], + "search|console": [419, 75, 527, 51], + "git": [419, 136, 169, 104], + "editor": [598, 136, 40, 104], + "output": [648, 136, 298, 47], + "debug": [648, 193, 298, 47] + }, + "splitters": { + "terminal>preview": [0, 65, 946, 10], + "preview>files": [275, 75, 10, 165], + "files>search": [409, 75, 10, 165], + "search>git": [419, 126, 527, 10], + "git>editor": [588, 136, 10, 104], + "editor>output": [638, 136, 10, 104], + "output>debug": [648, 183, 298, 10] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "search" }, + "target": { "root": "bottom" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["files"], "active": "files" }, + { "tabs": ["git"], "active": "git" }, + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["output"], + "active": "output" + }, + { "tabs": ["debug"], "active": "debug" } + ] + } + ] + }, + { "tabs": ["search", "console"], "active": "search" } + ] + }, + "tabsets": { + "terminal": [0, 0, 946, 58], + "search|console": [0, 187, 946, 53], + "preview": [0, 68, 202, 109], + "files": [212, 68, 98, 109], + "git": [320, 68, 202, 109], + "editor": [532, 68, 40, 109], + "output": [582, 68, 364, 50], + "debug": [582, 128, 364, 49] + }, + "splitters": { + "terminal>preview": [0, 58, 946, 10], + "preview>search": [0, 177, 946, 10], + "preview>files": [202, 68, 10, 109], + "files>git": [310, 68, 10, 109], + "git>editor": [522, 68, 10, 109], + "editor>output": [572, 68, 10, 109], + "output>debug": [582, 118, 364, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "console" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["files"], "active": "files" }, + { "tabs": ["git"], "active": "git" }, + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["output"], + "active": "output" + }, + { "tabs": ["debug"], "active": "debug" } + ] + } + ] + }, + { + "tabs": ["search", "console"], + "active": "search", + "folded": true + } + ] + }, + "tabsets": { + "terminal": [0, 0, 946, 65], + "search|console": [0, 200, 946, 40], + "preview": [0, 75, 202, 115], + "files": [212, 75, 98, 115], + "git": [320, 75, 202, 115], + "editor": [532, 75, 40, 115], + "output": [582, 75, 364, 53], + "debug": [582, 138, 364, 52] + }, + "splitters": { + "terminal>preview": [0, 65, 946, 10], + "preview>search": [0, 190, 946, 10], + "preview>files": [202, 75, 10, 115], + "files>git": [310, 75, 10, 115], + "git>editor": [522, 75, 10, 115], + "editor>output": [572, 75, 10, 115], + "output>debug": [582, 128, 364, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "tabsetOf": "output", "area": "left" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { "tabs": ["git"], "active": "git" }, + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["output"], + "active": "output" + } + ] + }, + { "tabs": ["debug"], "active": "debug" } + ] + } + ] + }, + { + "tabs": ["search", "console"], + "active": "search", + "folded": true + } + ] + }, + "tabsets": { + "terminal": [0, 0, 946, 65], + "search|console": [0, 200, 946, 40], + "files": [0, 75, 115, 115], + "git": [125, 75, 250, 115], + "editor": [385, 75, 40, 115], + "debug": [435, 138, 511, 52], + "preview": [435, 75, 251, 53], + "output": [696, 75, 250, 53] + }, + "splitters": { + "terminal>files": [0, 65, 946, 10], + "files>search": [0, 190, 946, 10], + "files>git": [115, 75, 10, 115], + "git>editor": [375, 75, 10, 115], + "editor>preview": [425, 75, 10, 115], + "preview>debug": [435, 128, 511, 10], + "preview>output": [686, 75, 10, 53] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "search", "area": "left" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { "tabs": ["git"], "active": "git" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["output"], + "active": "output" + } + ] + }, + { "tabs": ["debug"], "active": "debug" } + ] + } + ] + }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["search", "console"], + "active": "search" + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 946, 65], + "files": [0, 75, 120, 115], + "git": [130, 75, 265, 115], + "editor": [0, 200, 468, 40], + "search|console": [478, 200, 468, 40], + "debug": [405, 138, 541, 52], + "preview": [405, 75, 266, 53], + "output": [681, 75, 265, 53] + }, + "splitters": { + "terminal>files": [0, 65, 946, 10], + "files>editor": [0, 190, 946, 10], + "files>git": [120, 75, 10, 115], + "git>preview": [395, 75, 10, 115], + "editor>search": [468, 200, 10, 40], + "preview>debug": [405, 128, 541, 10], + "preview>output": [671, 75, 10, 53] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 35.625, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-34" + } + ] + }, + { + "typNode": "row", + "nodPart": 200, + "nodName": "", + "uidNode": "id-33", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-37", + "nodOpen": "output", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-56", + "nodOpen": "output", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "output", + "uidNode": "id-39" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-38", + "nodOpen": "debug", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "debug", + "uidNode": "id-41" + } + ] + } + ] + } + ] + }, + { + "typNode": "row", + "nodPart": 73.79679144385027, + "nodName": "", + "uidNode": "id-45", + "nodOpen": "search", + "nodFold": true, + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-59", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-60", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "console" }, + "target": { "tabsetOf": "files", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["console", "files"], + "active": "console" + }, + { "tabs": ["git"], "active": "git" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["output"], + "active": "output" + } + ] + }, + { "tabs": ["debug"], "active": "debug" } + ] + } + ] + }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["search"], "active": "search" } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 946, 65], + "console|files": [0, 75, 120, 115], + "git": [130, 75, 265, 115], + "editor": [0, 200, 468, 40], + "search": [478, 200, 468, 40], + "debug": [405, 138, 541, 52], + "preview": [405, 75, 266, 53], + "output": [681, 75, 265, 53] + }, + "splitters": { + "terminal>console": [0, 65, 946, 10], + "console>editor": [0, 190, 946, 10], + "console>git": [120, 75, 10, 115], + "git>preview": [395, 75, 10, 115], + "editor>search": [468, 200, 10, 40], + "preview>debug": [405, 128, 541, 10], + "preview>output": [671, 75, 10, 53] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "resize", "width": 872, "height": 826 }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["console", "files"], + "active": "console" + }, + { "tabs": ["git"], "active": "git" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["output"], + "active": "output" + } + ] + }, + { "tabs": ["debug"], "active": "debug" } + ] + } + ] + }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["search"], "active": "search" } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 872, 358], + "console|files": [0, 368, 112, 408], + "git": [122, 368, 244, 408], + "editor": [0, 786, 431, 40], + "search": [441, 786, 431, 40], + "debug": [376, 577, 496, 199], + "preview": [376, 368, 243, 199], + "output": [629, 368, 243, 199] + }, + "splitters": { + "terminal>console": [0, 358, 872, 10], + "console>editor": [0, 776, 872, 10], + "console>git": [112, 368, 10, 408], + "git>preview": [366, 368, 10, 408], + "editor>search": [431, 786, 10, 40], + "preview>debug": [376, 567, 496, 10], + "preview>output": [619, 368, 10, 199] + }, + "maximized": null + } + }, + { + "operation": { "op": "fold", "tabsetOf": "search" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["console", "files"], + "active": "console" + }, + { "tabs": ["git"], "active": "git" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["output"], + "active": "output" + } + ] + }, + { "tabs": ["debug"], "active": "debug" } + ] + } + ] + }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["search"], + "active": "search", + "folded": true + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 872, 358], + "console|files": [0, 368, 112, 408], + "git": [122, 368, 244, 408], + "editor": [0, 786, 822, 40], + "search": [832, 786, 40, 40], + "debug": [376, 577, 496, 199], + "preview": [376, 368, 243, 199], + "output": [629, 368, 243, 199] + }, + "splitters": { + "terminal>console": [0, 358, 872, 10], + "console>editor": [0, 776, 872, 10], + "console>git": [112, 368, 10, 408], + "git>preview": [366, 368, 10, 408], + "editor>search": [822, 786, 10, 40], + "preview>debug": [376, 567, 496, 10], + "preview>output": [619, 368, 10, 199] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "preview" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["console", "files"], + "active": "console" + }, + { "tabs": ["git"], "active": "git" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["output"], + "active": "output" + } + ] + }, + { "tabs": ["debug"], "active": "debug" } + ] + } + ] + }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["search"], + "active": "search", + "folded": true + } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 872, 358], + "console|files": [0, 368, 112, 408], + "git": [122, 368, 244, 408], + "editor": [0, 786, 822, 40], + "search": [832, 786, 40, 40], + "debug": [376, 577, 496, 199], + "preview": [0, 0, 872, 826], + "output": [629, 368, 243, 199] + }, + "splitters": { + "terminal>console": [0, 358, 872, 10], + "console>editor": [0, 776, 872, 10], + "console>git": [112, 368, 10, 408], + "git>preview": [366, 368, 10, 408], + "editor>search": [822, 786, 10, 40], + "preview>debug": [376, 567, 496, 10], + "preview>output": [619, 368, 10, 199] + }, + "maximized": "preview" + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "search" }, + "target": { "tab": "terminal", "position": "right" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal", "search"], "active": "search" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["console", "files"], + "active": "console" + }, + { "tabs": ["git"], "active": "git" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["output"], + "active": "output" + } + ] + }, + { "tabs": ["debug"], "active": "debug" } + ] + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "terminal|search": [0, 0, 872, 252], + "editor": [0, 574, 872, 252], + "console|files": [0, 262, 112, 302], + "git": [122, 262, 244, 302], + "debug": [376, 418, 496, 146], + "preview": [376, 262, 243, 146], + "output": [629, 262, 243, 146] + }, + "splitters": { + "terminal>console": [0, 252, 872, 10], + "console>editor": [0, 564, 872, 10], + "console>git": [112, 262, 10, 302], + "git>preview": [366, 262, 10, 302], + "preview>debug": [376, 408, 496, 10], + "preview>output": [619, 262, 10, 146] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 35.625, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-34" + } + ] + }, + { + "typNode": "row", + "nodPart": 200, + "nodName": "", + "uidNode": "id-33", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-37", + "nodOpen": "output", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-56", + "nodOpen": "output", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "output", + "uidNode": "id-39" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-38", + "nodOpen": "debug", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "debug", + "uidNode": "id-41" + } + ] + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-59", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + } + ] + } + ] + } + }, + { + "operation": { "op": "resize", "width": 506, "height": 1049 }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal", "search"], "active": "search" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["console", "files"], + "active": "console" + }, + { "tabs": ["git"], "active": "git" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["output"], + "active": "output" + } + ] + }, + { "tabs": ["debug"], "active": "debug" } + ] + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "terminal|search": [0, 0, 506, 326], + "editor": [0, 723, 506, 326], + "console|files": [0, 336, 74, 377], + "git": [84, 336, 134, 377], + "debug": [228, 530, 278, 183], + "preview": [228, 336, 134, 184], + "output": [372, 336, 134, 184] + }, + "splitters": { + "terminal>console": [0, 326, 506, 10], + "console>editor": [0, 713, 506, 10], + "console>git": [74, 336, 10, 377], + "git>preview": [218, 336, 10, 377], + "preview>debug": [228, 520, 278, 10], + "preview>output": [362, 336, 10, 184] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "root": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["terminal", "search"], + "active": "search" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["console", "files"], + "active": "console" + }, + { "tabs": ["git"], "active": "git" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["output"], + "active": "output" + } + ] + }, + { + "tabs": ["debug"], + "active": "debug" + } + ] + } + ] + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "editor": [333, 0, 173, 1049], + "terminal|search": [0, 0, 323, 495], + "console|files": [0, 505, 54, 544], + "git": [64, 505, 80, 544], + "debug": [154, 782, 169, 267], + "preview": [154, 505, 80, 267], + "output": [244, 505, 79, 267] + }, + "splitters": { + "terminal>editor": [323, 0, 10, 1049], + "terminal>console": [0, 495, 323, 10], + "console>git": [54, 505, 10, 544], + "git>preview": [144, 505, 10, 544], + "preview>debug": [154, 772, 169, 10], + "preview>output": [234, 505, 10, 267] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "search" }, + "target": { "tabsetOf": "preview", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["console", "files"], + "active": "console" + }, + { "tabs": ["git"], "active": "git" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "search", + "preview" + ], + "active": "search" + }, + { + "tabs": ["output"], + "active": "output" + } + ] + }, + { + "tabs": ["debug"], + "active": "debug" + } + ] + } + ] + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "editor": [333, 0, 173, 1049], + "terminal": [0, 0, 323, 495], + "console|files": [0, 505, 54, 544], + "git": [64, 505, 80, 544], + "debug": [154, 782, 169, 267], + "search|preview": [154, 505, 80, 267], + "output": [244, 505, 79, 267] + }, + "splitters": { + "terminal>editor": [323, 0, 10, 1049], + "terminal>console": [0, 495, 323, 10], + "console>git": [54, 505, 10, 544], + "git>search": [144, 505, 10, 544], + "search>debug": [154, 772, 169, 10], + "search>output": [234, 505, 10, 267] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "console" }, + "target": { "tabsetOf": "console", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["console"], + "active": "console" + }, + { "tabs": ["git"], "active": "git" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "search", + "preview" + ], + "active": "search" + }, + { + "tabs": ["output"], + "active": "output" + } + ] + }, + { + "tabs": ["debug"], + "active": "debug" + } + ] + } + ] + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "editor": [358, 0, 148, 1049], + "terminal": [0, 0, 348, 495], + "files": [0, 505, 49, 544], + "console": [59, 505, 65, 544], + "git": [134, 505, 64, 544], + "debug": [208, 782, 140, 267], + "search|preview": [208, 505, 65, 267], + "output": [283, 505, 65, 267] + }, + "splitters": { + "terminal>editor": [348, 0, 10, 1049], + "terminal>files": [0, 495, 348, 10], + "files>console": [49, 505, 10, 544], + "console>git": [124, 505, 10, 544], + "git>search": [198, 505, 10, 544], + "search>debug": [208, 772, 140, 10], + "search>output": [273, 505, 10, 267] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "search", + "after": "output", + "point": { "x": 363, "y": 638.5 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["console"], + "active": "console" + }, + { "tabs": ["git"], "active": "git" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "search", + "preview" + ], + "active": "search" + }, + { + "tabs": ["output"], + "active": "output" + } + ] + }, + { + "tabs": ["debug"], + "active": "debug" + } + ] + } + ] + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "editor": [358, 0, 148, 1049], + "terminal": [0, 0, 348, 495], + "files": [0, 505, 49, 544], + "console": [59, 505, 65, 544], + "git": [134, 505, 64, 544], + "debug": [208, 782, 140, 267], + "search|preview": [208, 505, 90, 267], + "output": [308, 505, 40, 267] + }, + "splitters": { + "terminal>editor": [348, 0, 10, 1049], + "terminal>files": [0, 495, 348, 10], + "files>console": [49, 505, 10, 544], + "console>git": [124, 505, 10, 544], + "git>search": [198, 505, 10, 544], + "search>debug": [208, 772, 140, 10], + "search>output": [298, 505, 10, 267] + }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 35.625, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-67", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-34" + } + ] + }, + { + "typNode": "row", + "nodPart": 200, + "nodName": "", + "uidNode": "id-33", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-37", + "nodOpen": "output", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 200, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 0, + "nodName": "", + "uidNode": "id-56", + "nodOpen": "output", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "output", + "uidNode": "id-39" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-38", + "nodOpen": "debug", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "debug", + "uidNode": "id-41" + } + ] + } + ] + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-59", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + } + ] + } + }, + { + "operation": { + "op": "splitter", + "before": "search", + "after": "debug", + "point": { "x": 278, "y": 763 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["console"], + "active": "console" + }, + { "tabs": ["git"], "active": "git" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "search", + "preview" + ], + "active": "search" + }, + { + "tabs": ["output"], + "active": "output" + } + ] + }, + { + "tabs": ["debug"], + "active": "debug" + } + ] + } + ] + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "editor": [358, 0, 148, 1049], + "terminal": [0, 0, 348, 495], + "files": [0, 505, 49, 544], + "console": [59, 505, 65, 544], + "git": [134, 505, 64, 544], + "debug": [208, 768, 140, 281], + "search|preview": [208, 505, 90, 253], + "output": [308, 505, 40, 253] + }, + "splitters": { + "terminal>editor": [348, 0, 10, 1049], + "terminal>files": [0, 495, 348, 10], + "files>console": [49, 505, 10, 544], + "console>git": [124, 505, 10, 544], + "git>search": [198, 505, 10, 544], + "search>debug": [208, 758, 140, 10], + "search>output": [298, 505, 10, 253] + }, + "maximized": null + } + }, + { + "operation": { "op": "fold", "tabsetOf": "files" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files", + "folded": true + }, + { + "tabs": ["console"], + "active": "console" + }, + { "tabs": ["git"], "active": "git" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": [ + "search", + "preview" + ], + "active": "search" + }, + { + "tabs": ["output"], + "active": "output" + } + ] + }, + { + "tabs": ["debug"], + "active": "debug" + } + ] + } + ] + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "editor": [358, 0, 148, 1049], + "terminal": [0, 0, 348, 495], + "files": [0, 505, 40, 544], + "console": [50, 505, 67, 544], + "git": [127, 505, 67, 544], + "debug": [204, 768, 144, 281], + "search|preview": [204, 505, 94, 253], + "output": [308, 505, 40, 253] + }, + "splitters": { + "terminal>editor": [348, 0, 10, 1049], + "terminal>files": [0, 495, 348, 10], + "files>console": [40, 505, 10, 544], + "console>git": [117, 505, 10, 544], + "git>search": [194, 505, 10, 544], + "search>debug": [204, 758, 144, 10], + "search>output": [298, 505, 10, 253] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "debug" }, + "target": { "root": "bottom" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["terminal"], + "active": "terminal" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files", + "folded": true + }, + { + "tabs": ["console"], + "active": "console" + }, + { + "tabs": ["git"], + "active": "git" + }, + { + "tabs": [ + "search", + "preview" + ], + "active": "search" + }, + { + "tabs": ["output"], + "active": "output" + } + ] + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + { "tabs": ["debug"], "active": "debug" } + ] + }, + "tabsets": { + "debug": [0, 555, 506, 494], + "editor": [358, 0, 148, 545], + "terminal": [0, 0, 348, 268], + "files": [0, 278, 40, 267], + "console": [50, 278, 67, 267], + "git": [127, 278, 67, 267], + "search|preview": [204, 278, 94, 267], + "output": [308, 278, 40, 267] + }, + "splitters": { + "terminal>debug": [0, 545, 506, 10], + "terminal>editor": [348, 0, 10, 545], + "terminal>files": [0, 268, 348, 10], + "files>console": [40, 278, 10, 267], + "console>git": [117, 278, 10, 267], + "git>search": [194, 278, 10, 267], + "search>output": [298, 278, 10, 267] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["terminal"], + "active": "terminal" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files", + "folded": true + }, + { + "tabs": ["console"], + "active": "console" + }, + { + "tabs": ["git"], + "active": "git" + }, + { + "tabs": [ + "search", + "preview" + ], + "active": "search" + }, + { + "tabs": ["output"], + "active": "output" + } + ] + } + ] + }, + { + "tabs": ["editor"], + "active": "editor", + "folded": true + } + ] + }, + { "tabs": ["debug"], "active": "debug" } + ] + }, + "tabsets": { + "debug": [0, 555, 506, 494], + "editor": [466, 0, 40, 545], + "terminal": [0, 0, 456, 268], + "files": [0, 278, 40, 267], + "console": [50, 278, 94, 267], + "git": [154, 278, 94, 267], + "search|preview": [258, 278, 148, 267], + "output": [416, 278, 40, 267] + }, + "splitters": { + "terminal>debug": [0, 545, 506, 10], + "terminal>editor": [456, 0, 10, 545], + "terminal>files": [0, 268, 456, 10], + "files>console": [40, 278, 10, 267], + "console>git": [144, 278, 10, 267], + "git>search": [248, 278, 10, 267], + "search>output": [406, 278, 10, 267] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "output" }, + "target": { "tabsetOf": "files", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["terminal"], + "active": "terminal" + }, + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["output"], + "active": "output" + } + ] + }, + { + "tabs": ["console"], + "active": "console" + }, + { + "tabs": ["git"], + "active": "git" + }, + { + "tabs": [ + "search", + "preview" + ], + "active": "search" + } + ] + } + ] + }, + { + "tabs": ["editor"], + "active": "editor", + "folded": true + } + ] + }, + { "tabs": ["debug"], "active": "debug" } + ] + }, + "tabsets": { + "debug": [0, 580, 506, 469], + "editor": [466, 0, 40, 570], + "terminal": [0, 0, 456, 255], + "console": [50, 265, 107, 305], + "git": [167, 265, 106, 305], + "search|preview": [283, 265, 173, 305], + "files": [0, 265, 40, 148], + "output": [0, 423, 40, 147] + }, + "splitters": { + "terminal>debug": [0, 570, 506, 10], + "terminal>editor": [456, 0, 10, 570], + "terminal>files": [0, 255, 456, 10], + "files>console": [40, 265, 10, 305], + "console>git": [157, 265, 10, 305], + "git>search": [273, 265, 10, 305], + "files>output": [0, 413, 40, 10] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-74", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-75", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "row", + "nodPart": 35.625, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodFold": true, + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-78", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-77", + "nodOpen": "output", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "output", + "uidNode": "id-39" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-67", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-34" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 200, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-59", + "nodOpen": "editor", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-71", + "nodOpen": "debug", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "debug", + "uidNode": "id-41" + } + ] + } + ] + } + ] + } + } + ] +} diff --git a/packages/core/test/fixtures/v1/scenario-10.json b/packages/core/test/fixtures/v1/scenario-10.json new file mode 100644 index 0000000..5231b43 --- /dev/null +++ b/packages/core/test/fixtures/v1/scenario-10.json @@ -0,0 +1,616 @@ +{ + "name": "1 tabs, seed 10", + "tabs": ["editor"], + "container": { "width": 1200, "height": 800 }, + "initial": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "initialSavedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-3" + } + ] + } + ] + }, + "steps": [ + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tabsetOf": "editor", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "editor", "area": "top" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "root": "top" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "editor", "area": "top" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-3" + } + ] + } + ] + } + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "maximize", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tab": "editor", "position": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1200, 800] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "resize", "width": 908, "height": 711 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 908, 711] }, + "splitters": {}, + "maximized": null + } + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 908, 711] }, + "splitters": {}, + "maximized": null + }, + "accepted": false, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-3" + } + ] + } + ] + } + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 908, 711] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "root": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 908, 711] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tab": "editor", "position": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 908, 711] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 908, 711] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "editor", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 908, 711] }, + "splitters": {}, + "maximized": null + }, + "accepted": false, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-3" + } + ] + } + ] + } + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 908, 711] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "resize", "width": 1587, "height": 192 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1587, 192] }, + "splitters": {}, + "maximized": null + } + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1587, 192] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1587, 192] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1587, 192] }, + "splitters": {}, + "maximized": null + }, + "accepted": false, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-3" + } + ] + } + ] + } + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1587, 192] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1587, 192] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1587, 192] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "editor", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1587, 192] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tab": "editor", "position": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1587, 192] }, + "splitters": {}, + "maximized": null + }, + "accepted": false, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-3" + } + ] + } + ] + } + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 1587, 192] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "resize", "width": 985, "height": 835 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 985, 835] }, + "splitters": {}, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tab": "editor", "position": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 985, 835] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tabsetOf": "editor", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 985, 835] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "editor", "area": "top" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [{ "tabs": ["editor"], "active": "editor" }] + }, + "tabsets": { "editor": [0, 0, 985, 835] }, + "splitters": {}, + "maximized": null + }, + "accepted": false, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-3" + } + ] + } + ] + } + } + ] +} diff --git a/packages/core/test/fixtures/v1/scenario-11.json b/packages/core/test/fixtures/v1/scenario-11.json new file mode 100644 index 0000000..8ad29ec --- /dev/null +++ b/packages/core/test/fixtures/v1/scenario-11.json @@ -0,0 +1,1004 @@ +{ + "name": "2 tabs, seed 11", + "tabs": ["editor", "terminal"], + "container": { "width": 1200, "height": 800 }, + "initial": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 595, 800], + "terminal": [605, 0, 595, 800] + }, + "splitters": { "editor>terminal": [595, 0, 10, 800] }, + "maximized": null + }, + "initialSavedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-6" + } + ] + } + ] + }, + "steps": [ + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 40, 800], + "terminal": [50, 0, 1150, 800] + }, + "splitters": { "editor>terminal": [40, 0, 10, 800] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 1150, 800], + "terminal": [1160, 0, 40, 800] + }, + "splitters": { "editor>terminal": [1150, 0, 10, 800] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "terminal", + "point": { "x": 1139, "y": 400 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 1150, 800], + "terminal": [1160, 0, 40, 800] + }, + "splitters": { "editor>terminal": [1150, 0, 10, 800] }, + "maximized": null + } + }, + { + "operation": { "op": "resize", "width": 624, "height": 979 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 574, 979], + "terminal": [584, 0, 40, 979] + }, + "splitters": { "editor>terminal": [574, 0, 10, 979] }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "terminal", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 574, 979], + "terminal": [584, 0, 40, 979] + }, + "splitters": { "editor>terminal": [574, 0, 10, 979] }, + "maximized": null + }, + "accepted": false, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodOpen": "terminal", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-6" + } + ] + } + ] + } + }, + { + "operation": { "op": "maximize", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 574, 979], + "terminal": [0, 0, 624, 979] + }, + "splitters": { "editor>terminal": [574, 0, 10, 979] }, + "maximized": "terminal" + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "root": "bottom" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 624, 485], + "terminal": [0, 495, 624, 484] + }, + "splitters": { "editor>terminal": [0, 485, 624, 10] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 624, 929], + "terminal": [0, 939, 624, 40] + }, + "splitters": { "editor>terminal": [0, 929, 624, 10] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 624, 979], + "terminal": [0, 939, 624, 40] + }, + "splitters": { "editor>terminal": [0, 929, 624, 10] }, + "maximized": "editor" + }, + "accepted": true + }, + { + "operation": { "op": "resize", "width": 588, "height": 706 }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 588, 706], + "terminal": [0, 666, 588, 40] + }, + "splitters": { "editor>terminal": [0, 656, 588, 10] }, + "maximized": "editor" + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodMaxd": "id-2", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-11", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-9", + "nodOpen": "terminal", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-6" + } + ] + } + ] + } + ] + } + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 588, 40], + "terminal": [0, 50, 588, 656] + }, + "splitters": { "editor>terminal": [0, 40, 588, 10] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 588, 40], + "terminal": [0, 0, 588, 706] + }, + "splitters": { "editor>terminal": [0, 40, 588, 10] }, + "maximized": "terminal" + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "terminal" }, + "target": { "tabsetOf": "terminal", "area": "right" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 588, 40], + "terminal": [0, 0, 588, 706] + }, + "splitters": { "editor>terminal": [0, 40, 588, 10] }, + "maximized": "terminal" + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "root": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 289, 706], + "terminal": [299, 0, 289, 706] + }, + "splitters": { "editor>terminal": [289, 0, 10, 706] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "terminal", + "point": { "x": 272, "y": 353 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 267, 706], + "terminal": [277, 0, 311, 706] + }, + "splitters": { "editor>terminal": [267, 0, 10, 706] }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 91.16465863453816, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 108.83534136546184, + "nodName": "", + "uidNode": "id-9", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-6" + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "editor", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 277, 706], + "terminal": [287, 0, 301, 706] + }, + "splitters": { "editor>terminal": [277, 0, 10, 706] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 538, 706], + "terminal": [548, 0, 40, 706] + }, + "splitters": { "editor>terminal": [538, 0, 10, 706] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "resize", "width": 933, "height": 1010 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 883, 1010], + "terminal": [893, 0, 40, 1010] + }, + "splitters": { "editor>terminal": [883, 0, 10, 1010] }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "terminal", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 883, 1010], + "terminal": [893, 0, 40, 1010] + }, + "splitters": { "editor>terminal": [883, 0, 10, 1010] }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "maximize", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 883, 1010], + "terminal": [0, 0, 933, 1010] + }, + "splitters": { "editor>terminal": [883, 0, 10, 1010] }, + "maximized": "terminal" + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodMaxd": "id-14", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 91.16465863453816, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-14", + "nodOpen": "terminal", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-6" + } + ] + } + ] + } + }, + { + "operation": { "op": "maximize", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 933, 1010], + "terminal": [893, 0, 40, 1010] + }, + "splitters": { "editor>terminal": [883, 0, 10, 1010] }, + "maximized": "editor" + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "terminal" }, + "target": { "tabsetOf": "terminal", "area": "top" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 933, 1010], + "terminal": [893, 0, 40, 1010] + }, + "splitters": { "editor>terminal": [883, 0, 10, 1010] }, + "maximized": "editor" + }, + "accepted": false + }, + { + "operation": { "op": "resize", "width": 1023, "height": 546 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 1023, 546], + "terminal": [983, 0, 40, 546] + }, + "splitters": { "editor>terminal": [973, 0, 10, 546] }, + "maximized": "editor" + } + }, + { + "operation": { "op": "resize", "width": 583, "height": 792 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "editor": [0, 0, 583, 792], + "terminal": [543, 0, 40, 792] + }, + "splitters": { "editor>terminal": [533, 0, 10, 792] }, + "maximized": "editor" + } + }, + { + "operation": { "op": "fold", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 583, 792], + "terminal": [285, 0, 298, 792] + }, + "splitters": { "editor>terminal": [275, 0, 10, 792] }, + "maximized": "editor" + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodMaxd": "id-2", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 91.16465863453816, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-14", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-6" + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "terminal", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 583, 792], + "terminal": [285, 0, 298, 792] + }, + "splitters": { "editor>terminal": [275, 0, 10, 792] }, + "maximized": "editor" + }, + "accepted": false + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "terminal", + "point": { "x": 292, "y": 396 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 583, 792], + "terminal": [285, 0, 298, 792] + }, + "splitters": { "editor>terminal": [275, 0, 10, 792] }, + "maximized": "editor" + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tabsetOf": "editor", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 583, 792], + "terminal": [285, 0, 298, 792] + }, + "splitters": { "editor>terminal": [275, 0, 10, 792] }, + "maximized": "editor" + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "editor", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 583, 792], + "terminal": [285, 0, 298, 792] + }, + "splitters": { "editor>terminal": [275, 0, 10, 792] }, + "maximized": "editor" + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "terminal", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 583, 792], + "terminal": [285, 0, 298, 792] + }, + "splitters": { "editor>terminal": [275, 0, 10, 792] }, + "maximized": "editor" + }, + "accepted": false, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodMaxd": "id-2", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 91.16465863453816, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-14", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-6" + } + ] + } + ] + } + } + ] +} diff --git a/packages/core/test/fixtures/v1/scenario-12.json b/packages/core/test/fixtures/v1/scenario-12.json new file mode 100644 index 0000000..98ee280 --- /dev/null +++ b/packages/core/test/fixtures/v1/scenario-12.json @@ -0,0 +1,1147 @@ +{ + "name": "3 tabs, seed 12", + "tabs": ["editor", "terminal", "preview"], + "container": { "width": 640, "height": 420 }, + "initial": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["preview"], "active": "preview" } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 315, 420], + "terminal": [325, 0, 315, 205], + "preview": [325, 215, 315, 205] + }, + "splitters": { + "editor>terminal": [315, 0, 10, 420], + "terminal>preview": [325, 205, 315, 10] + }, + "maximized": null + }, + "initialSavedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-11" + } + ] + } + ] + } + ] + }, + "steps": [ + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tab": "editor", "position": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["preview"], "active": "preview" } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 315, 420], + "terminal": [325, 0, 315, 205], + "preview": [325, 215, 315, 205] + }, + "splitters": { + "editor>terminal": [315, 0, 10, 420], + "terminal>preview": [325, 205, 315, 10] + }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "maximize", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["preview"], "active": "preview" } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 315, 420], + "terminal": [0, 0, 640, 420], + "preview": [325, 215, 315, 205] + }, + "splitters": { + "editor>terminal": [315, 0, 10, 420], + "terminal>preview": [325, 205, 315, 10] + }, + "maximized": "terminal" + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "tabsetOf": "editor", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["preview", "editor"], "active": "preview" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "preview|editor": [0, 0, 315, 420], + "terminal": [325, 0, 315, 420] + }, + "splitters": { "preview>terminal": [315, 0, 10, 420] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tabsetOf": "terminal", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview", "editor", "terminal"], + "active": "editor" + } + ] + }, + "tabsets": { "preview|editor|terminal": [0, 0, 640, 420] }, + "splitters": {}, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "terminal", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview", "editor", "terminal"], + "active": "editor" + } + ] + }, + "tabsets": { "preview|editor|terminal": [0, 0, 640, 420] }, + "splitters": {}, + "maximized": null + }, + "accepted": false, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-11" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "terminal", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview", "editor", "terminal"], + "active": "editor" + } + ] + }, + "tabsets": { "preview|editor|terminal": [0, 0, 640, 420] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "root": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview", "editor", "terminal"], + "active": "editor" + } + ] + }, + "tabsets": { "preview|editor|terminal": [0, 0, 640, 420] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview", "editor", "terminal"], + "active": "editor" + } + ] + }, + "tabsets": { "preview|editor|terminal": [0, 0, 640, 420] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "root": "bottom" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "tabs": ["preview", "terminal"], + "active": "preview" + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "preview|terminal": [0, 0, 640, 205], + "editor": [0, 215, 640, 205] + }, + "splitters": { "preview>editor": [0, 205, 640, 10] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tabsetOf": "preview", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview", "editor", "terminal"], + "active": "editor" + } + ] + }, + "tabsets": { "preview|editor|terminal": [0, 0, 640, 420] }, + "splitters": {}, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-11" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + } + ] + } + }, + { + "operation": { "op": "resize", "width": 801, "height": 952 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview", "editor", "terminal"], + "active": "editor" + } + ] + }, + "tabsets": { "preview|editor|terminal": [0, 0, 801, 952] }, + "splitters": {}, + "maximized": null + } + }, + { + "operation": { "op": "fold", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview", "editor", "terminal"], + "active": "editor" + } + ] + }, + "tabsets": { "preview|editor|terminal": [0, 0, 801, 952] }, + "splitters": {}, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "root": "top" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["preview", "editor"], "active": "editor" } + ] + }, + "tabsets": { + "terminal": [0, 0, 801, 471], + "preview|editor": [0, 481, 801, 471] + }, + "splitters": { "terminal>preview": [0, 471, 801, 10] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "terminal" }, + "target": { "tabsetOf": "preview", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["preview", "editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "preview|editor": [0, 0, 801, 471], + "terminal": [0, 481, 801, 471] + }, + "splitters": { "preview>terminal": [0, 471, 801, 10] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tab": "preview", "position": "left" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor", "preview"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor|preview": [0, 0, 801, 471], + "terminal": [0, 481, 801, 471] + }, + "splitters": { "editor>terminal": [0, 471, 801, 10] }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-28", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-11" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-24", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + } + ] + } + ] + } + }, + { + "operation": { "op": "resize", "width": 486, "height": 648 }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor", "preview"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor|preview": [0, 0, 486, 319], + "terminal": [0, 329, 486, 319] + }, + "splitters": { "editor>terminal": [0, 319, 486, 10] }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "editor", "area": "left" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["preview"], "active": "preview" } + ] + }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "terminal": [0, 329, 486, 319], + "editor": [0, 0, 238, 319], + "preview": [248, 0, 238, 319] + }, + "splitters": { + "editor>terminal": [0, 319, 486, 10], + "editor>preview": [238, 0, 10, 319] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tabsetOf": "preview", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor", "preview"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor|preview": [0, 0, 486, 319], + "terminal": [0, 329, 486, 319] + }, + "splitters": { "editor>terminal": [0, 319, 486, 10] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "editor", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["editor", "preview"], "active": "editor" } + ] + }, + "tabsets": { + "terminal": [0, 0, 238, 648], + "editor|preview": [248, 0, 238, 648] + }, + "splitters": { "terminal>editor": [238, 0, 10, 648] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "tabsetOf": "preview", "area": "top" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["editor"], "active": "editor" } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 238, 648], + "preview": [248, 0, 238, 319], + "editor": [248, 329, 238, 319] + }, + "splitters": { + "terminal>preview": [238, 0, 10, 648], + "preview>editor": [248, 319, 238, 10] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-36", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-38", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-11" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-39", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "tab": "preview", "position": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["editor"], "active": "editor" } + ] + } + ] + }, + "tabsets": { + "terminal": [0, 0, 238, 648], + "preview": [248, 0, 238, 319], + "editor": [248, 329, 238, 319] + }, + "splitters": { + "terminal>preview": [238, 0, 10, 648], + "preview>editor": [248, 319, 238, 10] + }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "terminal" }, + "target": { "tab": "preview", "position": "right" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "tabs": ["preview", "terminal"], + "active": "terminal" + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "preview|terminal": [0, 0, 486, 319], + "editor": [0, 329, 486, 319] + }, + "splitters": { "preview>editor": [0, 319, 486, 10] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "preview", + "after": "editor", + "point": { "x": 243, "y": 154 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "tabs": ["preview", "terminal"], + "active": "terminal" + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "preview|terminal": [0, 0, 486, 149], + "editor": [0, 159, 486, 489] + }, + "splitters": { "preview>editor": [0, 149, 486, 10] }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "terminal", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["preview", "terminal"], + "active": "terminal" + } + ] + }, + "tabsets": { + "editor": [0, 0, 325, 648], + "preview|terminal": [335, 0, 151, 648] + }, + "splitters": { "editor>preview": [325, 0, 10, 648] }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tabsetOf": "editor", "area": "top" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["preview", "terminal"], + "active": "terminal" + } + ] + }, + "tabsets": { + "editor": [0, 0, 325, 648], + "preview|terminal": [335, 0, 151, 648] + }, + "splitters": { "editor>preview": [325, 0, 10, 648] }, + "maximized": null + }, + "accepted": false, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-42", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 39.06810035842294, + "nodName": "", + "uidNode": "id-38", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-11" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + } + ] + } + }, + { + "operation": { "op": "resize", "width": 266, "height": 628 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["preview", "terminal"], + "active": "terminal" + } + ] + }, + "tabsets": { + "editor": [0, 0, 167, 628], + "preview|terminal": [177, 0, 89, 628] + }, + "splitters": { "editor>preview": [167, 0, 10, 628] }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "preview", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 167, 628], + "preview": [177, 0, 89, 309], + "terminal": [177, 319, 89, 309] + }, + "splitters": { + "editor>preview": [167, 0, 10, 628], + "preview>terminal": [177, 309, 89, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "terminal", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 167, 628], + "preview": [177, 0, 89, 309], + "terminal": [177, 319, 89, 309] + }, + "splitters": { + "editor>preview": [167, 0, 10, 628], + "preview>terminal": [177, 309, 89, 10] + }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "preview", + "point": { "x": 209, "y": 314 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 204, 628], + "preview": [214, 0, 52, 309], + "terminal": [214, 319, 52, 309] + }, + "splitters": { + "editor>preview": [204, 0, 10, 628], + "preview>terminal": [214, 309, 52, 10] + }, + "maximized": null + } + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "preview", + "point": { "x": 42, "y": 314 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 40, 628], + "preview": [50, 0, 216, 309], + "terminal": [50, 319, 216, 309] + }, + "splitters": { + "editor>preview": [40, 0, 10, 628], + "preview>terminal": [50, 309, 216, 10] + }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 0, + "nodName": "", + "uidNode": "id-42", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 139.06810035842292, + "nodName": "", + "uidNode": "id-38", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-45", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-11" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-44", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + } + ] + } + ] + } + } + ] +} diff --git a/packages/core/test/fixtures/v1/scenario-13.json b/packages/core/test/fixtures/v1/scenario-13.json new file mode 100644 index 0000000..0bcc365 --- /dev/null +++ b/packages/core/test/fixtures/v1/scenario-13.json @@ -0,0 +1,1459 @@ +{ + "name": "4 tabs, seed 13", + "tabs": ["editor", "terminal", "preview", "console"], + "container": { "width": 1200, "height": 800 }, + "initial": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 570, 800], + "terminal": [580, 0, 620, 395], + "preview": [580, 405, 305, 395], + "console": [895, 405, 305, 395] + }, + "splitters": { + "editor>terminal": [570, 0, 10, 800], + "terminal>preview": [580, 395, 620, 10], + "preview>console": [885, 405, 10, 395] + }, + "maximized": null + }, + "initialSavedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-16" + } + ] + } + ] + } + ] + } + ] + }, + "steps": [ + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "tabsetOf": "editor", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["console"], "active": "console" } + ] + } + ] + }, + "tabsets": { + "preview": [0, 0, 393, 800], + "editor": [403, 0, 394, 800], + "terminal": [807, 0, 393, 395], + "console": [807, 405, 393, 395] + }, + "splitters": { + "preview>editor": [393, 0, 10, 800], + "editor>terminal": [797, 0, 10, 800], + "terminal>console": [807, 395, 393, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "console" }, + "target": { "tabsetOf": "preview", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "preview": [0, 0, 293, 800], + "console": [303, 0, 292, 800], + "editor": [605, 0, 293, 800], + "terminal": [908, 0, 292, 800] + }, + "splitters": { + "preview>console": [293, 0, 10, 800], + "console>editor": [595, 0, 10, 800], + "editor>terminal": [898, 0, 10, 800] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "terminal", + "point": { "x": 934, "y": 400 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "preview": [0, 0, 293, 800], + "console": [303, 0, 292, 800], + "editor": [605, 0, 324, 800], + "terminal": [939, 0, 261, 800] + }, + "splitters": { + "preview>console": [293, 0, 10, 800], + "console>editor": [595, 0, 10, 800], + "editor>terminal": [929, 0, 10, 800] + }, + "maximized": null + } + }, + { + "operation": { "op": "fold", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "preview": [0, 0, 363, 800], + "console": [373, 0, 363, 800], + "editor": [746, 0, 404, 800], + "terminal": [1160, 0, 40, 800] + }, + "splitters": { + "preview>console": [363, 0, 10, 800], + "console>editor": [736, 0, 10, 800], + "editor>terminal": [1150, 0, 10, 800] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "preview" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview", + "folded": true + }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "preview": [0, 0, 40, 800], + "console": [50, 0, 515, 800], + "editor": [575, 0, 575, 800], + "terminal": [1160, 0, 40, 800] + }, + "splitters": { + "preview>console": [40, 0, 10, 800], + "console>editor": [565, 0, 10, 800], + "editor>terminal": [1150, 0, 10, 800] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-16" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 112.47524752475249, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 87.52475247524752, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + } + ] + } + }, + { + "operation": { + "op": "splitter", + "before": "console", + "after": "editor", + "point": { "x": 391, "y": 400 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview", + "folded": true + }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "preview": [0, 0, 40, 800], + "console": [50, 0, 336, 800], + "editor": [396, 0, 754, 800], + "terminal": [1160, 0, 40, 800] + }, + "splitters": { + "preview>console": [40, 0, 10, 800], + "console>editor": [386, 0, 10, 800], + "editor>terminal": [1150, 0, 10, 800] + }, + "maximized": null + } + }, + { + "operation": { "op": "maximize", "tabsetOf": "console" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview", + "folded": true + }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "preview": [0, 0, 40, 800], + "console": [0, 0, 1200, 800], + "editor": [396, 0, 754, 800], + "terminal": [1160, 0, 40, 800] + }, + "splitters": { + "preview>console": [40, 0, 10, 800], + "console>editor": [386, 0, 10, 800], + "editor>terminal": [1150, 0, 10, 800] + }, + "maximized": "console" + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "tabsetOf": "console", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "preview": [0, 0, 363, 800], + "console": [373, 0, 241, 800], + "editor": [624, 0, 526, 800], + "terminal": [1160, 0, 40, 800] + }, + "splitters": { + "preview>console": [363, 0, 10, 800], + "console>editor": [614, 0, 10, 800], + "editor>terminal": [1150, 0, 10, 800] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tab": "terminal", "position": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["terminal"], + "active": "terminal", + "folded": true + } + ] + }, + "tabsets": { + "preview": [0, 0, 363, 800], + "console": [373, 0, 241, 800], + "editor": [624, 0, 526, 800], + "terminal": [1160, 0, 40, 800] + }, + "splitters": { + "preview>console": [363, 0, 10, 800], + "console>editor": [614, 0, 10, 800], + "editor>terminal": [1150, 0, 10, 800] + }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "root": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "terminal": [0, 0, 285, 800], + "preview": [295, 0, 285, 800], + "console": [590, 0, 192, 800], + "editor": [792, 0, 408, 800] + }, + "splitters": { + "terminal>preview": [285, 0, 10, 800], + "preview>console": [580, 0, 10, 800], + "console>editor": [782, 0, 10, 800] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-25", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 62.26997353200667, + "nodName": "", + "uidNode": "id-13", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-16" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 150.20527399274582, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "tabsetOf": "preview", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "terminal": [0, 0, 285, 800], + "preview": [295, 0, 285, 800], + "console": [590, 0, 192, 800], + "editor": [792, 0, 408, 800] + }, + "splitters": { + "terminal>preview": [285, 0, 10, 800], + "preview>console": [580, 0, 10, 800], + "console>editor": [782, 0, 10, 800] + }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "preview", + "point": { "x": 96, "y": 400 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "terminal": [0, 0, 91, 800], + "preview": [101, 0, 479, 800], + "console": [590, 0, 192, 800], + "editor": [792, 0, 408, 800] + }, + "splitters": { + "terminal>preview": [91, 0, 10, 800], + "preview>console": [580, 0, 10, 800], + "console>editor": [782, 0, 10, 800] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tab": "terminal", "position": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 124, 800], + "preview": [134, 0, 764, 800], + "console": [908, 0, 292, 800] + }, + "splitters": { + "editor>preview": [124, 0, 10, 800], + "preview>console": [898, 0, 10, 800] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "terminal" }, + "target": { "tabsetOf": "terminal", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 124, 800], + "preview": [134, 0, 764, 800], + "console": [908, 0, 292, 800] + }, + "splitters": { + "editor>preview": [124, 0, 10, 800], + "preview>console": [898, 0, 10, 800] + }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "preview", + "point": { "x": -22, "y": 400 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 40, 800], + "preview": [50, 0, 848, 800], + "console": [908, 0, 292, 800] + }, + "splitters": { + "editor>preview": [40, 0, 10, 800], + "preview>console": [898, 0, 10, 800] + }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 0, + "nodName": "", + "uidNode": "id-25", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 200, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 62.26997353200667, + "nodName": "", + "uidNode": "id-13", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-16" + } + ] + } + ] + } + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["editor", "terminal"], + "active": "editor", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 40, 800], + "preview": [50, 0, 848, 800], + "console": [908, 0, 292, 800] + }, + "splitters": { + "editor>preview": [40, 0, 10, 800], + "preview>console": [898, 0, 10, 800] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 40, 800], + "preview": [50, 0, 848, 800], + "console": [908, 0, 292, 800] + }, + "splitters": { + "editor>preview": [40, 0, 10, 800], + "preview>console": [898, 0, 10, 800] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "resize", "width": 992, "height": 615 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 40, 615], + "preview": [50, 0, 690, 615], + "console": [750, 0, 242, 615] + }, + "splitters": { + "editor>preview": [40, 0, 10, 615], + "preview>console": [740, 0, 10, 615] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "console" }, + "target": { "root": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 40, 615], + "preview": [50, 0, 608, 615], + "console": [668, 0, 324, 615] + }, + "splitters": { + "editor>preview": [40, 0, 10, 615], + "preview>console": [658, 0, 10, 615] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "preview" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 40, 615], + "preview": [0, 0, 992, 615], + "console": [668, 0, 324, 615] + }, + "splitters": { + "editor>preview": [40, 0, 10, 615], + "preview>console": [658, 0, 10, 615] + }, + "maximized": "preview" + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodMaxd": "id-12", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 0, + "nodName": "", + "uidNode": "id-25", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 200, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-28", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-16" + } + ] + } + ] + } + }, + { + "operation": { "op": "fold", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["editor", "terminal"], + "active": "editor", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 40, 615], + "preview": [50, 0, 608, 615], + "console": [668, 0, 324, 615] + }, + "splitters": { + "editor>preview": [40, 0, 10, 615], + "preview>console": [658, 0, 10, 615] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "preview", + "after": "console", + "point": { "x": 600, "y": 307.5 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["editor", "terminal"], + "active": "editor", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 40, 615], + "preview": [50, 0, 545, 615], + "console": [605, 0, 387, 615] + }, + "splitters": { + "editor>preview": [40, 0, 10, 615], + "preview>console": [595, 0, 10, 615] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "tabsetOf": "console", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["editor", "terminal"], + "active": "editor", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 40, 615], + "preview": [50, 0, 545, 615], + "console": [605, 0, 387, 615] + }, + "splitters": { + "editor>preview": [40, 0, 10, 615], + "preview>console": [595, 0, 10, 615] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "console", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + "tabsets": { + "editor": [0, 0, 40, 615], + "preview": [50, 0, 397, 615], + "console": [457, 0, 285, 615], + "terminal": [752, 0, 240, 615] + }, + "splitters": { + "editor>preview": [40, 0, 10, 615], + "preview>console": [447, 0, 10, 615], + "console>terminal": [742, 0, 10, 615] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "tabsetOf": "terminal", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["preview", "terminal"], "active": "preview" } + ] + }, + "tabsets": { + "editor": [0, 0, 40, 615], + "console": [50, 0, 509, 615], + "preview|terminal": [569, 0, 423, 615] + }, + "splitters": { + "editor>console": [40, 0, 10, 615], + "console>preview": [559, 0, 10, 615] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 0, + "nodName": "", + "uidNode": "id-25", + "nodOpen": "editor", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 122.1830985915493, + "nodName": "", + "uidNode": "id-28", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-16" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-33", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "root": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "tabs": ["editor"], + "active": "editor", + "folded": true + }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["preview", "terminal"], "active": "preview" } + ] + }, + "tabsets": { + "editor": [0, 0, 40, 615], + "console": [50, 0, 509, 615], + "preview|terminal": [569, 0, 423, 615] + }, + "splitters": { + "editor>console": [40, 0, 10, 615], + "console>preview": [559, 0, 10, 615] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "tabsetOf": "editor", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["preview", "terminal"], + "active": "preview" + } + ] + }, + { "tabs": ["console"], "active": "console" } + ] + }, + "tabsets": { + "console": [50, 0, 942, 615], + "editor": [0, 0, 40, 303], + "preview|terminal": [0, 313, 40, 302] + }, + "splitters": { + "editor>console": [40, 0, 10, 615], + "editor>preview": [0, 303, 40, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["preview", "terminal"], + "active": "preview" + } + ] + }, + { "tabs": ["console"], "active": "console" } + ] + }, + "tabsets": { + "console": [50, 0, 942, 615], + "editor": [0, 0, 40, 303], + "preview|terminal": [0, 0, 992, 615] + }, + "splitters": { + "editor>console": [40, 0, 10, 615], + "editor>preview": [0, 303, 40, 10] + }, + "maximized": "preview|terminal" + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tab": "editor", "position": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "tabs": ["preview", "terminal"], + "active": "preview" + } + ] + }, + { "tabs": ["console"], "active": "console" } + ] + }, + "tabsets": { + "console": [50, 0, 942, 615], + "editor": [0, 0, 40, 303], + "preview|terminal": [0, 0, 992, 615] + }, + "splitters": { + "editor>console": [40, 0, 10, 615], + "editor>preview": [0, 303, 40, 10] + }, + "maximized": "preview|terminal" + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "root": "bottom" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview", "terminal"], + "active": "preview" + }, + { "tabs": ["console"], "active": "console" } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "editor": [0, 313, 992, 302], + "preview|terminal": [0, 0, 446, 303], + "console": [456, 0, 536, 303] + }, + "splitters": { + "preview>editor": [0, 303, 992, 10], + "preview>console": [446, 0, 10, 303] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-43", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-44", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-33", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 122.1830985915493, + "nodName": "", + "uidNode": "id-28", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-16" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-38", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + } + ] + } + ] + } + } + ] +} diff --git a/packages/core/test/fixtures/v1/scenario-14.json b/packages/core/test/fixtures/v1/scenario-14.json new file mode 100644 index 0000000..3c8c8f3 --- /dev/null +++ b/packages/core/test/fixtures/v1/scenario-14.json @@ -0,0 +1,2064 @@ +{ + "name": "5 tabs, seed 14", + "tabs": ["editor", "terminal", "preview", "console", "files"], + "container": { "width": 1200, "height": 800 }, + "initial": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 570, 800], + "terminal": [580, 0, 620, 370], + "preview": [580, 380, 305, 420], + "console": [895, 380, 305, 205], + "files": [895, 595, 305, 205] + }, + "splitters": { + "editor>terminal": [570, 0, 10, 800], + "terminal>preview": [580, 370, 620, 10], + "preview>console": [885, 380, 10, 420], + "console>files": [895, 585, 305, 10] + }, + "maximized": null + }, + "initialSavedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-21" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "steps": [ + { + "operation": { "op": "maximize", "tabsetOf": "files" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "tabs": ["files"], + "active": "files" + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 570, 800], + "terminal": [580, 0, 620, 370], + "preview": [580, 380, 305, 420], + "console": [895, 380, 305, 205], + "files": [0, 0, 1200, 800] + }, + "splitters": { + "editor>terminal": [570, 0, 10, 800], + "terminal>preview": [580, 370, 620, 10], + "preview>console": [885, 380, 10, 420], + "console>files": [895, 585, 305, 10] + }, + "maximized": "files" + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "terminal", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "tabs": ["files"], + "active": "files" + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 570, 800], + "terminal": [580, 0, 620, 370], + "preview": [580, 380, 305, 420], + "console": [895, 380, 305, 205], + "files": [0, 0, 1200, 800] + }, + "splitters": { + "editor>terminal": [570, 0, 10, 800], + "terminal>preview": [580, 370, 620, 10], + "preview>console": [885, 380, 10, 420], + "console>files": [895, 585, 305, 10] + }, + "maximized": "files" + }, + "accepted": false + }, + { + "operation": { "op": "resize", "width": 915, "height": 775 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "tabs": ["files"], + "active": "files" + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 428, 775], + "terminal": [438, 0, 477, 358], + "preview": [438, 368, 234, 407], + "console": [682, 368, 233, 199], + "files": [0, 0, 915, 775] + }, + "splitters": { + "editor>terminal": [428, 0, 10, 775], + "terminal>preview": [438, 358, 477, 10], + "preview>console": [672, 368, 10, 407], + "console>files": [682, 567, 233, 10] + }, + "maximized": "files" + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "console" }, + "target": { "tabsetOf": "console", "area": "top" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "tabs": ["files"], + "active": "files" + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 428, 775], + "terminal": [438, 0, 477, 358], + "preview": [438, 368, 234, 407], + "console": [682, 368, 233, 199], + "files": [0, 0, 915, 775] + }, + "splitters": { + "editor>terminal": [428, 0, 10, 775], + "terminal>preview": [438, 358, 477, 10], + "preview>console": [672, 368, 10, 407], + "console>files": [682, 567, 233, 10] + }, + "maximized": "files" + }, + "accepted": false + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "preview", + "point": { "x": 676.5, "y": 331 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "tabs": ["files"], + "active": "files" + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 428, 775], + "terminal": [438, 0, 477, 358], + "preview": [438, 368, 234, 407], + "console": [682, 368, 233, 199], + "files": [0, 0, 915, 775] + }, + "splitters": { + "editor>terminal": [428, 0, 10, 775], + "terminal>preview": [438, 358, 477, 10], + "preview>console": [672, 368, 10, 407], + "console>files": [682, 567, 233, 10] + }, + "maximized": "files" + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodMaxd": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-21" + } + ] + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { "op": "fold", "tabsetOf": "console" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { + "tabs": ["files"], + "active": "files" + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 428, 775], + "terminal": [438, 0, 477, 358], + "preview": [438, 368, 234, 407], + "console": [682, 368, 233, 40], + "files": [682, 418, 233, 357] + }, + "splitters": { + "editor>terminal": [428, 0, 10, 775], + "terminal>preview": [438, 358, 477, 10], + "preview>console": [672, 368, 10, 407], + "console>files": [682, 408, 233, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "console" }, + "target": { "tabsetOf": "files", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["console", "files"], + "active": "console" + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 428, 775], + "terminal": [438, 0, 477, 383], + "preview": [438, 393, 234, 382], + "console|files": [682, 393, 233, 382] + }, + "splitters": { + "editor>terminal": [428, 0, 10, 775], + "terminal>preview": [438, 383, 477, 10], + "preview>console": [672, 393, 10, 382] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "resize", "width": 667, "height": 344 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["console", "files"], + "active": "console" + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 304, 344], + "terminal": [314, 0, 353, 167], + "preview": [314, 177, 172, 167], + "console|files": [496, 177, 171, 167] + }, + "splitters": { + "editor>terminal": [304, 0, 10, 344], + "terminal>preview": [314, 167, 353, 10], + "preview>console": [486, 177, 10, 167] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "console" }, + "target": { "root": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + } + ] + }, + "tabsets": { + "console": [0, 0, 199, 344], + "editor": [209, 0, 199, 344], + "terminal": [418, 0, 249, 167], + "preview": [418, 177, 120, 167], + "files": [548, 177, 119, 167] + }, + "splitters": { + "console>editor": [199, 0, 10, 344], + "editor>terminal": [408, 0, 10, 344], + "terminal>preview": [418, 167, 249, 10], + "preview>files": [538, 177, 10, 167] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "console" }, + "target": { "tabsetOf": "terminal", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "tabs": ["terminal"], + "active": "terminal" + } + ] + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 304, 344], + "console": [314, 0, 172, 167], + "terminal": [496, 0, 171, 167], + "preview": [314, 177, 172, 167], + "files": [496, 177, 171, 167] + }, + "splitters": { + "editor>console": [304, 0, 10, 344], + "console>preview": [314, 167, 353, 10], + "console>terminal": [486, 0, 10, 167], + "preview>files": [486, 177, 10, 167] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-28", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-29", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-21" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "console", "area": "top" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["console"], "active": "console" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 304, 344], + "terminal": [314, 0, 353, 108], + "console": [314, 118, 353, 108], + "preview": [314, 236, 172, 108], + "files": [496, 236, 171, 108] + }, + "splitters": { + "editor>terminal": [304, 0, 10, 344], + "terminal>console": [314, 108, 353, 10], + "console>preview": [314, 226, 353, 10], + "preview>files": [486, 236, 10, 108] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "terminal", + "point": { "x": 453, "y": 172 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { "tabs": ["console"], "active": "console" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 448, 344], + "terminal": [458, 0, 209, 108], + "console": [458, 118, 209, 108], + "preview": [458, 236, 100, 108], + "files": [568, 236, 99, 108] + }, + "splitters": { + "editor>terminal": [448, 0, 10, 344], + "terminal>console": [458, 108, 209, 10], + "console>preview": [458, 226, 209, 10], + "preview>files": [558, 236, 10, 108] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "terminal", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" }, + { "tabs": ["console"], "active": "console" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 667, 108], + "console": [0, 118, 667, 108], + "preview": [0, 236, 329, 108], + "files": [339, 236, 328, 108] + }, + "splitters": { + "editor>console": [0, 108, 667, 10], + "console>preview": [0, 226, 667, 10], + "preview>files": [329, 236, 10, 108] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "console" }, + "target": { "tabsetOf": "editor", "area": "left" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["editor", "terminal"], + "active": "editor" + } + ] + }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + }, + "tabsets": { + "console": [0, 0, 329, 167], + "editor|terminal": [339, 0, 328, 167], + "preview": [0, 177, 329, 167], + "files": [339, 177, 328, 167] + }, + "splitters": { + "console>preview": [0, 167, 667, 10], + "console>editor": [329, 0, 10, 167], + "preview>files": [329, 177, 10, 167] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "preview" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["editor", "terminal"], + "active": "editor" + } + ] + }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + }, + "tabsets": { + "console": [0, 0, 329, 167], + "editor|terminal": [339, 0, 328, 167], + "preview": [0, 0, 667, 344], + "files": [339, 177, 328, 167] + }, + "splitters": { + "console>preview": [0, 167, 667, 10], + "console>editor": [329, 0, 10, 167], + "preview>files": [329, 177, 10, 167] + }, + "maximized": "preview" + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodMaxd": "id-12", + "nodKids": [ + { + "typNode": "row", + "nodPart": 45.16129032258064, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-28", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-36", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-21" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "console" }, + "target": { "tab": "console", "position": "right" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["editor", "terminal"], + "active": "editor" + } + ] + }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + }, + "tabsets": { + "console": [0, 0, 329, 167], + "editor|terminal": [339, 0, 328, 167], + "preview": [0, 0, 667, 344], + "files": [339, 177, 328, 167] + }, + "splitters": { + "console>preview": [0, 167, 667, 10], + "console>editor": [329, 0, 10, 167], + "preview>files": [329, 177, 10, 167] + }, + "maximized": "preview" + }, + "accepted": false + }, + { + "operation": { "op": "resize", "width": 1149, "height": 668 }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["editor", "terminal"], + "active": "editor" + } + ] + }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + }, + "tabsets": { + "console": [0, 0, 570, 329], + "editor|terminal": [580, 0, 569, 329], + "preview": [0, 0, 1149, 668], + "files": [580, 339, 569, 329] + }, + "splitters": { + "console>preview": [0, 329, 1149, 10], + "console>editor": [570, 0, 10, 329], + "preview>files": [570, 339, 10, 329] + }, + "maximized": "preview" + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "console", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "tabs": ["editor"], + "active": "editor" + } + ] + }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["files"], "active": "files" } + ] + } + ] + }, + "tabsets": { + "terminal": [580, 0, 569, 354], + "preview": [0, 364, 570, 304], + "files": [580, 364, 569, 304], + "console": [0, 0, 570, 172], + "editor": [0, 182, 570, 172] + }, + "splitters": { + "console>preview": [0, 354, 1149, 10], + "console>terminal": [570, 0, 10, 354], + "preview>files": [570, 364, 10, 304], + "console>editor": [0, 172, 570, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "tab": "editor", "position": "left" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "tabs": ["preview", "editor"], + "active": "preview" + } + ] + }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "files": [0, 364, 1149, 304], + "terminal": [580, 0, 569, 354], + "console": [0, 0, 570, 172], + "preview|editor": [0, 182, 570, 172] + }, + "splitters": { + "console>files": [0, 354, 1149, 10], + "console>terminal": [570, 0, 10, 354], + "console>preview": [0, 172, 570, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "tabsetOf": "terminal", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["preview", "editor", "terminal"], + "active": "editor" + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "files": [0, 339, 1149, 329], + "console": [0, 0, 570, 329], + "preview|editor|terminal": [580, 0, 569, 329] + }, + "splitters": { + "console>files": [0, 329, 1149, 10], + "console>preview": [570, 0, 10, 329] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 45.16129032258064, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-32", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-39", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-36", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-21" + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "tabsetOf": "terminal", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor", "terminal"], + "active": "editor" + }, + { + "tabs": ["preview"], + "active": "preview" + } + ] + } + ] + }, + { "tabs": ["files"], "active": "files" } + ] + }, + "tabsets": { + "files": [0, 364, 1149, 304], + "console": [0, 0, 570, 354], + "editor|terminal": [580, 0, 569, 172], + "preview": [580, 182, 569, 172] + }, + "splitters": { + "console>files": [0, 354, 1149, 10], + "console>editor": [570, 0, 10, 354], + "editor>preview": [580, 172, 569, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "files" }, + "target": { "tab": "console", "position": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["files", "console"], "active": "files" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor", "terminal"], + "active": "editor" + }, + { "tabs": ["preview"], "active": "preview" } + ] + } + ] + }, + "tabsets": { + "files|console": [0, 0, 570, 668], + "editor|terminal": [580, 0, 569, 329], + "preview": [580, 339, 569, 329] + }, + "splitters": { + "files>editor": [570, 0, 10, 668], + "editor>preview": [580, 329, 569, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "console" }, + "target": { "tabsetOf": "editor", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor", "console", "terminal"], + "active": "console" + }, + { "tabs": ["preview"], "active": "preview" } + ] + } + ] + }, + "tabsets": { + "files": [0, 0, 570, 668], + "editor|console|terminal": [580, 0, 569, 329], + "preview": [580, 339, 569, 329] + }, + "splitters": { + "files>editor": [570, 0, 10, 668], + "editor>preview": [580, 329, 569, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "files", + "after": "editor", + "point": { "x": 476, "y": 334 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor", "console", "terminal"], + "active": "console" + }, + { "tabs": ["preview"], "active": "preview" } + ] + } + ] + }, + "tabsets": { + "files": [0, 0, 471, 668], + "editor|console|terminal": [481, 0, 668, 329], + "preview": [481, 339, 668, 329] + }, + "splitters": { + "files>editor": [471, 0, 10, 668], + "editor>preview": [481, 329, 668, 10] + }, + "maximized": null + } + }, + { + "operation": { + "op": "splitter", + "before": "files", + "after": "editor", + "point": { "x": 331, "y": 334 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor", "console", "terminal"], + "active": "console" + }, + { "tabs": ["preview"], "active": "preview" } + ] + } + ] + }, + "tabsets": { + "files": [0, 0, 326, 668], + "editor|console|terminal": [336, 0, 813, 329], + "preview": [336, 339, 813, 329] + }, + "splitters": { + "files>editor": [326, 0, 10, 668], + "editor>preview": [336, 329, 813, 10] + }, + "maximized": null + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 54.01322001888575, + "nodName": "", + "uidNode": "id-39", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-21" + } + ] + }, + { + "typNode": "row", + "nodPart": 145.98677998111427, + "nodName": "", + "uidNode": "id-36", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-46", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-45", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "console" }, + "target": { "tab": "terminal", "position": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor", "console", "terminal"], + "active": "console" + }, + { "tabs": ["preview"], "active": "preview" } + ] + } + ] + }, + "tabsets": { + "files": [0, 0, 326, 668], + "editor|console|terminal": [336, 0, 813, 329], + "preview": [336, 339, 813, 329] + }, + "splitters": { + "files>editor": [326, 0, 10, 668], + "editor>preview": [336, 329, 813, 10] + }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "preview", + "point": { "x": 742.5, "y": 464 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor", "console", "terminal"], + "active": "console" + }, + { "tabs": ["preview"], "active": "preview" } + ] + } + ] + }, + "tabsets": { + "files": [0, 0, 326, 668], + "editor|console|terminal": [336, 0, 813, 459], + "preview": [336, 469, 813, 199] + }, + "splitters": { + "files>editor": [326, 0, 10, 668], + "editor>preview": [336, 459, 813, 10] + }, + "maximized": null + } + }, + { + "operation": { "op": "resize", "width": 971, "height": 929 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor", "console", "terminal"], + "active": "console" + }, + { "tabs": ["preview"], "active": "preview" } + ] + } + ] + }, + "tabsets": { + "files": [0, 0, 278, 929], + "editor|console|terminal": [288, 0, 683, 648], + "preview": [288, 658, 683, 271] + }, + "splitters": { + "files>editor": [278, 0, 10, 929], + "editor>preview": [288, 648, 683, 10] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "tabsetOf": "preview", "area": "top" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor", "console", "terminal"], + "active": "console" + }, + { "tabs": ["preview"], "active": "preview" } + ] + } + ] + }, + "tabsets": { + "files": [0, 0, 278, 929], + "editor|console|terminal": [288, 0, 683, 648], + "preview": [288, 658, 683, 271] + }, + "splitters": { + "files>editor": [278, 0, 10, 929], + "editor>preview": [288, 648, 683, 10] + }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "terminal", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal"], + "active": "terminal" + }, + { + "tabs": ["editor", "console"], + "active": "console" + } + ] + }, + { "tabs": ["preview"], "active": "preview" } + ] + } + ] + }, + "tabsets": { + "files": [0, 0, 264, 929], + "preview": [274, 658, 697, 271], + "terminal": [274, 0, 344, 648], + "editor|console": [628, 0, 343, 648] + }, + "splitters": { + "files>terminal": [264, 0, 10, 929], + "terminal>preview": [274, 648, 697, 10], + "terminal>editor": [618, 0, 10, 648] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 54.01322001888575, + "nodName": "", + "uidNode": "id-39", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-21" + } + ] + }, + { + "typNode": "row", + "nodPart": 145.98677998111427, + "nodName": "", + "uidNode": "id-36", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "row", + "nodPart": 144.98269896193773, + "nodName": "", + "uidNode": "id-46", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-51", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-52", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 55.017301038062286, + "nodName": "", + "uidNode": "id-45", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + } + ] + } + } + ] +} diff --git a/packages/core/test/fixtures/v1/scenario-15.json b/packages/core/test/fixtures/v1/scenario-15.json new file mode 100644 index 0000000..edeed43 --- /dev/null +++ b/packages/core/test/fixtures/v1/scenario-15.json @@ -0,0 +1,2767 @@ +{ + "name": "6 tabs, seed 15", + "tabs": ["editor", "terminal", "preview", "console", "files", "search"], + "container": { "width": 1200, "height": 800 }, + "initial": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 545, 800], + "terminal": [555, 0, 645, 370], + "preview": [555, 380, 293, 420], + "console": [858, 380, 342, 205], + "files": [858, 595, 166, 205], + "search": [1034, 595, 166, 205] + }, + "splitters": { + "editor>terminal": [545, 0, 10, 800], + "terminal>preview": [555, 370, 645, 10], + "preview>console": [848, 380, 10, 420], + "console>files": [858, 585, 342, 10], + "files>search": [1024, 595, 10, 205] + }, + "maximized": null + }, + "initialSavedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-23", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-26" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "steps": [ + { + "operation": { + "op": "splitter", + "before": "console", + "after": "files", + "point": { "x": 1029, "y": 568 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 545, 800], + "terminal": [555, 0, 645, 370], + "preview": [555, 380, 293, 420], + "console": [858, 380, 342, 183], + "files": [858, 573, 166, 227], + "search": [1034, 573, 166, 227] + }, + "splitters": { + "editor>terminal": [545, 0, 10, 800], + "terminal>preview": [555, 370, 645, 10], + "preview>console": [848, 380, 10, 420], + "console>files": [858, 563, 342, 10], + "files>search": [1024, 573, 10, 227] + }, + "maximized": null + } + }, + { + "operation": { "op": "maximize", "tabsetOf": "search" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 545, 800], + "terminal": [555, 0, 645, 370], + "preview": [555, 380, 293, 420], + "console": [858, 380, 342, 183], + "files": [858, 573, 166, 227], + "search": [0, 0, 1200, 800] + }, + "splitters": { + "editor>terminal": [545, 0, 10, 800], + "terminal>preview": [555, 370, 645, 10], + "preview>console": [848, 380, 10, 420], + "console>files": [858, 563, 342, 10], + "files>search": [1024, 573, 10, 227] + }, + "maximized": "search" + }, + "accepted": true + }, + { + "operation": { "op": "resize", "width": 1560, "height": 350 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 725, 350], + "terminal": [735, 0, 825, 145], + "preview": [735, 155, 383, 195], + "console": [1128, 155, 432, 86], + "files": [1128, 251, 211, 99], + "search": [0, 0, 1560, 350] + }, + "splitters": { + "editor>terminal": [725, 0, 10, 350], + "terminal>preview": [735, 145, 825, 10], + "preview>console": [1118, 155, 10, 195], + "console>files": [1128, 241, 432, 10], + "files>search": [1339, 251, 10, 99] + }, + "maximized": "search" + } + }, + { + "operation": { + "op": "splitter", + "before": "preview", + "after": "console", + "point": { "x": 1288, "y": 252.5 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 725, 350], + "terminal": [735, 0, 825, 145], + "preview": [735, 155, 383, 195], + "console": [1128, 155, 432, 86], + "files": [1128, 251, 211, 99], + "search": [0, 0, 1560, 350] + }, + "splitters": { + "editor>terminal": [725, 0, 10, 350], + "terminal>preview": [735, 145, 825, 10], + "preview>console": [1118, 155, 10, 195], + "console>files": [1128, 241, 432, 10], + "files>search": [1339, 251, 10, 99] + }, + "maximized": "search" + } + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "preview", + "point": { "x": 1147.5, "y": -20 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 725, 350], + "terminal": [735, 0, 825, 145], + "preview": [735, 155, 383, 195], + "console": [1128, 155, 432, 86], + "files": [1128, 251, 211, 99], + "search": [0, 0, 1560, 350] + }, + "splitters": { + "editor>terminal": [725, 0, 10, 350], + "terminal>preview": [735, 145, 825, 10], + "preview>console": [1118, 155, 10, 195], + "console>files": [1128, 241, 432, 10], + "files>search": [1339, 251, 10, 99] + }, + "maximized": "search" + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodMaxd": "id-23", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 86.66666666666667, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 113.33333333333333, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-23", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-26" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "preview", + "point": { "x": 1147.5, "y": -15 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 725, 350], + "terminal": [735, 0, 825, 145], + "preview": [735, 155, 383, 195], + "console": [1128, 155, 432, 86], + "files": [1128, 251, 211, 99], + "search": [0, 0, 1560, 350] + }, + "splitters": { + "editor>terminal": [725, 0, 10, 350], + "terminal>preview": [735, 145, 825, 10], + "preview>console": [1118, 155, 10, 195], + "console>files": [1128, 241, 432, 10], + "files>search": [1339, 251, 10, 99] + }, + "maximized": "search" + } + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "terminal", + "point": { "x": 863, "y": 175 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 725, 350], + "terminal": [735, 0, 825, 145], + "preview": [735, 155, 383, 195], + "console": [1128, 155, 432, 86], + "files": [1128, 251, 211, 99], + "search": [0, 0, 1560, 350] + }, + "splitters": { + "editor>terminal": [725, 0, 10, 350], + "terminal>preview": [735, 145, 825, 10], + "preview>console": [1118, 155, 10, 195], + "console>files": [1128, 241, 432, 10], + "files>search": [1339, 251, 10, 99] + }, + "maximized": "search" + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "editor", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + }, + "tabsets": { + "preview": [507, 0, 496, 350], + "editor": [0, 0, 497, 170], + "terminal": [0, 180, 497, 170], + "console": [1013, 0, 547, 153], + "files": [1013, 163, 269, 187], + "search": [1292, 163, 268, 187] + }, + "splitters": { + "editor>preview": [497, 0, 10, 350], + "preview>console": [1003, 0, 10, 350], + "editor>terminal": [0, 170, 497, 10], + "console>files": [1013, 153, 547, 10], + "files>search": [1282, 163, 10, 187] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "console" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + }, + "tabsets": { + "preview": [507, 0, 496, 350], + "editor": [0, 0, 497, 170], + "terminal": [0, 180, 497, 170], + "console": [0, 0, 1560, 350], + "files": [1013, 163, 269, 187], + "search": [1292, 163, 268, 187] + }, + "splitters": { + "editor>preview": [497, 0, 10, 350], + "preview>console": [1003, 0, 10, 350], + "editor>terminal": [0, 170, 497, 10], + "console>files": [1013, 153, 547, 10], + "files>search": [1282, 163, 10, 187] + }, + "maximized": "console" + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "search" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["terminal"], "active": "terminal" } + ] + }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["console"], "active": "console" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + }, + "tabsets": { + "preview": [507, 0, 496, 350], + "editor": [0, 0, 497, 170], + "terminal": [0, 180, 497, 170], + "console": [1013, 0, 547, 153], + "files": [1013, 163, 269, 187], + "search": [0, 0, 1560, 350] + }, + "splitters": { + "editor>preview": [497, 0, 10, 350], + "preview>console": [1003, 0, 10, 350], + "editor>terminal": [0, 170, 497, 10], + "console>files": [1013, 153, 547, 10], + "files>search": [1282, 163, 10, 187] + }, + "maximized": "search" + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodMaxd": "id-23", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-31", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-30", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 86.66666666666667, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 113.33333333333333, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-23", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-26" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "console" }, + "target": { "root": "bottom" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor"], + "active": "editor" + }, + { + "tabs": ["terminal"], + "active": "terminal" + } + ] + }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["files"], "active": "files" }, + { "tabs": ["search"], "active": "search" } + ] + }, + { "tabs": ["console"], "active": "console" } + ] + }, + "tabsets": { + "console": [0, 205, 1560, 145], + "preview": [393, 0, 382, 195], + "files": [785, 0, 383, 195], + "search": [1178, 0, 382, 195], + "editor": [0, 0, 383, 93], + "terminal": [0, 103, 383, 92] + }, + "splitters": { + "editor>console": [0, 195, 1560, 10], + "editor>preview": [383, 0, 10, 195], + "preview>files": [775, 0, 10, 195], + "files>search": [1168, 0, 10, 195], + "editor>terminal": [0, 93, 383, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "console" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor"], + "active": "editor" + }, + { + "tabs": ["terminal"], + "active": "terminal" + } + ] + }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["files"], "active": "files" }, + { "tabs": ["search"], "active": "search" } + ] + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + } + ] + }, + "tabsets": { + "console": [0, 310, 1560, 40], + "preview": [393, 0, 382, 300], + "files": [785, 0, 383, 300], + "search": [1178, 0, 382, 300], + "editor": [0, 0, 383, 145], + "terminal": [0, 155, 383, 145] + }, + "splitters": { + "editor>console": [0, 300, 1560, 10], + "editor>preview": [383, 0, 10, 300], + "preview>files": [775, 0, 10, 300], + "files>search": [1168, 0, 10, 300], + "editor>terminal": [0, 145, 383, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "preview" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["editor"], + "active": "editor" + }, + { + "tabs": ["terminal"], + "active": "terminal" + } + ] + }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["files"], "active": "files" }, + { "tabs": ["search"], "active": "search" } + ] + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + } + ] + }, + "tabsets": { + "console": [0, 310, 1560, 40], + "preview": [0, 0, 1560, 350], + "files": [785, 0, 383, 300], + "search": [1178, 0, 382, 300], + "editor": [0, 0, 383, 145], + "terminal": [0, 155, 383, 145] + }, + "splitters": { + "editor>console": [0, 300, 1560, 10], + "editor>preview": [383, 0, 10, 300], + "preview>files": [775, 0, 10, 300], + "files>search": [1168, 0, 10, 300], + "editor>terminal": [0, 145, 383, 10] + }, + "maximized": "preview" + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "root": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal"], + "active": "terminal" + }, + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "editor": [860, 0, 700, 350], + "console": [0, 310, 850, 40], + "terminal": [0, 0, 205, 300], + "preview": [215, 0, 205, 300], + "files": [430, 0, 205, 300], + "search": [645, 0, 205, 300] + }, + "splitters": { + "terminal>editor": [850, 0, 10, 350], + "terminal>console": [0, 300, 850, 10], + "terminal>preview": [205, 0, 10, 300], + "preview>files": [420, 0, 10, 300], + "files>search": [635, 0, 10, 300] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "tabsetOf": "terminal", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal"], + "active": "terminal" + }, + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "editor": [860, 0, 700, 350], + "console": [0, 310, 850, 40], + "terminal": [0, 0, 205, 300], + "preview": [215, 0, 205, 300], + "files": [430, 0, 205, 300], + "search": [645, 0, 205, 300] + }, + "splitters": { + "terminal>editor": [850, 0, 10, 350], + "terminal>console": [0, 300, 850, 10], + "terminal>preview": [205, 0, 10, 300], + "preview>files": [420, 0, 10, 300], + "files>search": [635, 0, 10, 300] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-37", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-38", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-30", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-43", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-23", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-26" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-34", + "nodOpen": "console", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-31", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + } + ] + } + }, + { + "operation": { "op": "maximize", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal"], + "active": "terminal" + }, + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "editor": [860, 0, 700, 350], + "console": [0, 310, 850, 40], + "terminal": [0, 0, 1560, 350], + "preview": [215, 0, 205, 300], + "files": [430, 0, 205, 300], + "search": [645, 0, 205, 300] + }, + "splitters": { + "terminal>editor": [850, 0, 10, 350], + "terminal>console": [0, 300, 850, 10], + "terminal>preview": [205, 0, 10, 300], + "preview>files": [420, 0, 10, 300], + "files>search": [635, 0, 10, 300] + }, + "maximized": "terminal" + }, + "accepted": true + }, + { + "operation": { "op": "resize", "width": 372, "height": 835 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal"], + "active": "terminal" + }, + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "editor": [266, 0, 106, 835], + "console": [0, 795, 256, 40], + "terminal": [0, 0, 372, 835], + "preview": [67, 0, 56, 785], + "files": [133, 0, 57, 785], + "search": [200, 0, 56, 785] + }, + "splitters": { + "terminal>editor": [256, 0, 10, 835], + "terminal>console": [0, 785, 256, 10], + "terminal>preview": [57, 0, 10, 785], + "preview>files": [123, 0, 10, 785], + "files>search": [190, 0, 10, 785] + }, + "maximized": "terminal" + } + }, + { + "operation": { + "op": "splitter", + "before": "terminal", + "after": "console", + "point": { "x": 128, "y": 919 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal"], + "active": "terminal" + }, + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "editor": [266, 0, 106, 835], + "console": [0, 795, 256, 40], + "terminal": [0, 0, 372, 835], + "preview": [67, 0, 56, 785], + "files": [133, 0, 57, 785], + "search": [200, 0, 56, 785] + }, + "splitters": { + "terminal>editor": [256, 0, 10, 835], + "terminal>console": [0, 785, 256, 10], + "terminal>preview": [57, 0, 10, 785], + "preview>files": [123, 0, 10, 785], + "files>search": [190, 0, 10, 785] + }, + "maximized": "terminal" + } + }, + { + "operation": { "op": "resize", "width": 1145, "height": 269 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal"], + "active": "terminal" + }, + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "editor": [653, 0, 492, 269], + "console": [0, 229, 643, 40], + "terminal": [0, 0, 1145, 269], + "preview": [163, 0, 154, 219], + "files": [327, 0, 153, 219], + "search": [490, 0, 153, 219] + }, + "splitters": { + "terminal>editor": [643, 0, 10, 269], + "terminal>console": [0, 219, 643, 10], + "terminal>preview": [153, 0, 10, 219], + "preview>files": [317, 0, 10, 219], + "files>search": [480, 0, 10, 219] + }, + "maximized": "terminal" + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "terminal", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal"], + "active": "terminal" + }, + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "editor": [653, 0, 492, 269], + "console": [0, 229, 643, 40], + "terminal": [0, 0, 1145, 269], + "preview": [163, 0, 154, 219], + "files": [327, 0, 153, 219], + "search": [490, 0, 153, 219] + }, + "splitters": { + "terminal>editor": [643, 0, 10, 269], + "terminal>console": [0, 219, 643, 10], + "terminal>preview": [153, 0, 10, 219], + "preview>files": [317, 0, 10, 219], + "files>search": [480, 0, 10, 219] + }, + "maximized": "terminal" + }, + "accepted": false, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodMaxd": "id-30", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-37", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-38", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-30", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-43", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-23", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-26" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-34", + "nodOpen": "console", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-31", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tab": "files", "position": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["files", "terminal"], + "active": "terminal" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "editor": [628, 0, 517, 269], + "console": [0, 229, 618, 40], + "preview": [0, 0, 199, 219], + "files|terminal": [209, 0, 200, 219], + "search": [419, 0, 199, 219] + }, + "splitters": { + "preview>editor": [618, 0, 10, 269], + "preview>console": [0, 219, 618, 10], + "preview>files": [199, 0, 10, 219], + "files>search": [409, 0, 10, 219] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "editor", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["files", "terminal"], + "active": "terminal" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "editor": [628, 0, 517, 269], + "console": [0, 229, 618, 40], + "preview": [0, 0, 199, 219], + "files|terminal": [209, 0, 200, 219], + "search": [419, 0, 199, 219] + }, + "splitters": { + "preview>editor": [618, 0, 10, 269], + "preview>console": [0, 219, 618, 10], + "preview>files": [199, 0, 10, 219], + "files>search": [409, 0, 10, 219] + }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { "op": "maximize", "tabsetOf": "files" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["files", "terminal"], + "active": "terminal" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + } + ] + }, + { "tabs": ["editor"], "active": "editor" } + ] + }, + "tabsets": { + "editor": [628, 0, 517, 269], + "console": [0, 229, 618, 40], + "preview": [0, 0, 199, 219], + "files|terminal": [0, 0, 1145, 269], + "search": [419, 0, 199, 219] + }, + "splitters": { + "preview>editor": [618, 0, 10, 269], + "preview>console": [0, 219, 618, 10], + "preview>files": [199, 0, 10, 219], + "files>search": [409, 0, 10, 219] + }, + "maximized": "files|terminal" + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "search" }, + "target": { "tabsetOf": "editor", "area": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "tabs": ["files", "terminal"], + "active": "terminal" + } + ] + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + } + ] + }, + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["search"], "active": "search" } + ] + }, + "tabsets": { + "editor": [418, 0, 359, 269], + "search": [787, 0, 358, 269], + "console": [0, 229, 408, 40], + "preview": [0, 0, 199, 219], + "files|terminal": [209, 0, 199, 219] + }, + "splitters": { + "preview>editor": [408, 0, 10, 269], + "editor>search": [777, 0, 10, 269], + "preview>console": [0, 219, 408, 10], + "preview>files": [199, 0, 10, 219] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "terminal", "area": "top" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["terminal"], + "active": "terminal" + }, + { + "tabs": ["files"], + "active": "files" + } + ] + } + ] + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + } + ] + }, + { "tabs": ["editor"], "active": "editor" }, + { "tabs": ["search"], "active": "search" } + ] + }, + "tabsets": { + "editor": [418, 0, 359, 269], + "search": [787, 0, 358, 269], + "console": [0, 229, 408, 40], + "preview": [0, 0, 199, 219], + "terminal": [209, 0, 199, 105], + "files": [209, 115, 199, 104] + }, + "splitters": { + "preview>editor": [408, 0, 10, 269], + "editor>search": [777, 0, 10, 269], + "preview>console": [0, 219, 408, 10], + "preview>terminal": [199, 0, 10, 219], + "terminal>files": [209, 105, 199, 10] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-37", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-38", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-43", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-48", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-49", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-34", + "nodOpen": "console", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-31", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-23", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-26" + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "preview", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["editor"], + "active": "editor" + }, + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["terminal"], + "active": "terminal" + }, + { + "tabs": ["files"], + "active": "files" + } + ] + } + ] + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + } + ] + }, + { "tabs": ["search"], "active": "search" } + ] + }, + "tabsets": { + "search": [628, 0, 517, 269], + "console": [0, 229, 618, 40], + "editor": [0, 0, 199, 219], + "preview": [209, 0, 200, 219], + "terminal": [419, 0, 199, 105], + "files": [419, 115, 199, 104] + }, + "splitters": { + "editor>search": [618, 0, 10, 269], + "editor>console": [0, 219, 618, 10], + "editor>preview": [199, 0, 10, 219], + "preview>terminal": [409, 0, 10, 219], + "terminal>files": [419, 105, 199, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "console" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["editor"], + "active": "editor" + }, + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["terminal"], + "active": "terminal" + }, + { + "tabs": ["files"], + "active": "files" + } + ] + } + ] + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + } + ] + }, + { "tabs": ["search"], "active": "search" } + ] + }, + "tabsets": { + "search": [628, 0, 517, 269], + "console": [0, 0, 1145, 269], + "editor": [0, 0, 199, 219], + "preview": [209, 0, 200, 219], + "terminal": [419, 0, 199, 105], + "files": [419, 115, 199, 104] + }, + "splitters": { + "editor>search": [618, 0, 10, 269], + "editor>console": [0, 219, 618, 10], + "editor>preview": [199, 0, 10, 219], + "preview>terminal": [409, 0, 10, 219], + "terminal>files": [419, 105, 199, 10] + }, + "maximized": "console" + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "preview", + "point": { "x": 130, "y": 109.5 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["editor"], + "active": "editor" + }, + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["terminal"], + "active": "terminal" + }, + { + "tabs": ["files"], + "active": "files" + } + ] + } + ] + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + } + ] + }, + { "tabs": ["search"], "active": "search" } + ] + }, + "tabsets": { + "search": [628, 0, 517, 269], + "console": [0, 0, 1145, 269], + "editor": [0, 0, 199, 219], + "preview": [209, 0, 200, 219], + "terminal": [419, 0, 199, 105], + "files": [419, 115, 199, 104] + }, + "splitters": { + "editor>search": [618, 0, 10, 269], + "editor>console": [0, 219, 618, 10], + "editor>preview": [199, 0, 10, 219], + "preview>terminal": [409, 0, 10, 219], + "terminal>files": [419, 105, 199, 10] + }, + "maximized": "console" + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "root": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["editor"], + "active": "editor" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["terminal"], + "active": "terminal" + }, + { + "tabs": ["files"], + "active": "files" + } + ] + } + ] + }, + { + "tabs": ["console"], + "active": "console", + "folded": true + } + ] + }, + { "tabs": ["search"], "active": "search" } + ] + }, + "tabsets": { + "preview": [0, 0, 358, 269], + "search": [787, 0, 358, 269], + "console": [368, 229, 409, 40], + "editor": [368, 0, 200, 219], + "terminal": [578, 0, 199, 105], + "files": [578, 115, 199, 104] + }, + "splitters": { + "preview>editor": [358, 0, 10, 269], + "editor>search": [777, 0, 10, 269], + "editor>console": [368, 219, 409, 10], + "editor>terminal": [568, 0, 10, 219], + "terminal>files": [578, 105, 199, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "terminal" }, + "target": { "tabsetOf": "console", "area": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "direction": "horizontal", + "children": [ + { + "tabs": ["editor"], + "active": "editor" + }, + { "tabs": ["files"], "active": "files" } + ] + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["terminal"], + "active": "terminal" + }, + { + "tabs": ["console"], + "active": "console" + } + ] + } + ] + }, + { "tabs": ["search"], "active": "search" } + ] + }, + "tabsets": { + "preview": [0, 0, 358, 269], + "search": [787, 0, 358, 269], + "editor": [368, 0, 200, 219], + "files": [578, 0, 199, 219], + "terminal": [368, 229, 200, 40], + "console": [578, 229, 199, 40] + }, + "splitters": { + "preview>editor": [358, 0, 10, 269], + "editor>search": [777, 0, 10, 269], + "editor>terminal": [368, 219, 409, 10], + "editor>files": [568, 0, 10, 219], + "terminal>console": [568, 229, 10, 40] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-55", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-37", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-38", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-52", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-49", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-34", + "nodOpen": "console", + "nodFold": true, + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-58", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-59", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-23", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-26" + } + ] + } + ] + } + } + ] +} diff --git a/packages/core/test/fixtures/v1/scenario-16.json b/packages/core/test/fixtures/v1/scenario-16.json new file mode 100644 index 0000000..b55a67d --- /dev/null +++ b/packages/core/test/fixtures/v1/scenario-16.json @@ -0,0 +1,2577 @@ +{ + "name": "7 tabs, seed 16", + "tabs": [ + "editor", + "terminal", + "preview", + "console", + "files", + "search", + "git" + ], + "container": { "width": 640, "height": 420 }, + "initial": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["search"], + "active": "search" + }, + { + "tabs": ["git"], + "active": "git" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 265, 420], + "terminal": [275, 0, 365, 155], + "preview": [275, 165, 153, 255], + "console": [438, 165, 202, 98], + "files": [438, 273, 96, 147], + "search": [544, 273, 96, 69], + "git": [544, 352, 96, 68] + }, + "splitters": { + "editor>terminal": [265, 0, 10, 420], + "terminal>preview": [275, 155, 365, 10], + "preview>console": [428, 165, 10, 255], + "console>files": [438, 263, 202, 10], + "files>search": [534, 273, 10, 147], + "search>git": [544, 342, 96, 10] + }, + "maximized": null + }, + "initialSavedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-2", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-23", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-27", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-28", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-31" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "steps": [ + { + "operation": { + "op": "move", + "source": { "tab": "git" }, + "target": { "tabsetOf": "files", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": [ + "files" + ], + "active": "files" + }, + { + "tabs": [ + "git" + ], + "active": "git" + } + ] + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 265, 420], + "terminal": [275, 0, 365, 155], + "preview": [275, 165, 153, 255], + "console": [438, 165, 202, 98], + "search": [544, 273, 96, 147], + "files": [438, 273, 96, 69], + "git": [438, 352, 96, 68] + }, + "splitters": { + "editor>terminal": [265, 0, 10, 420], + "terminal>preview": [275, 155, 365, 10], + "preview>console": [428, 165, 10, 255], + "console>files": [438, 263, 202, 10], + "files>search": [534, 273, 10, 147], + "files>git": [438, 342, 96, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "git" }, + "target": { "tab": "git", "position": "left" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { "tabs": ["editor"], "active": "editor" }, + { + "direction": "vertical", + "children": [ + { "tabs": ["terminal"], "active": "terminal" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["preview"], + "active": "preview" + }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": [ + "files" + ], + "active": "files" + }, + { + "tabs": [ + "git" + ], + "active": "git" + } + ] + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor": [0, 0, 265, 420], + "terminal": [275, 0, 365, 155], + "preview": [275, 165, 153, 255], + "console": [438, 165, 202, 98], + "search": [544, 273, 96, 147], + "files": [438, 273, 96, 69], + "git": [438, 352, 96, 68] + }, + "splitters": { + "editor>terminal": [265, 0, 10, 420], + "terminal>preview": [275, 155, 365, 10], + "preview>console": [428, 165, 10, 255], + "console>files": [438, 263, 202, 10], + "files>search": [534, 273, 10, 147], + "files>git": [438, 342, 96, 10] + }, + "maximized": null + }, + "accepted": false + }, + { + "operation": { + "op": "move", + "source": { "tab": "editor" }, + "target": { "tabsetOf": "terminal", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["git"], + "active": "git" + } + ] + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 640, 155], + "preview": [0, 165, 290, 255], + "console": [300, 165, 340, 98], + "search": [475, 273, 165, 147], + "files": [300, 273, 165, 69], + "git": [300, 352, 165, 68] + }, + "splitters": { + "editor>preview": [0, 155, 640, 10], + "preview>console": [290, 165, 10, 255], + "console>files": [300, 263, 340, 10], + "files>search": [465, 273, 10, 147], + "files>git": [300, 342, 165, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "files" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["git"], + "active": "git" + } + ] + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 640, 155], + "preview": [0, 165, 290, 255], + "console": [300, 165, 340, 98], + "search": [475, 273, 165, 147], + "files": [0, 0, 640, 420], + "git": [300, 352, 165, 68] + }, + "splitters": { + "editor>preview": [0, 155, 640, 10], + "preview>console": [290, 165, 10, 255], + "console>files": [300, 263, 340, 10], + "files>search": [465, 273, 10, 147], + "files>git": [300, 342, 165, 10] + }, + "maximized": "files" + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "files", + "after": "git", + "point": { "x": 382.5, "y": 268 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["git"], + "active": "git" + } + ] + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 640, 155], + "preview": [0, 165, 290, 255], + "console": [300, 165, 340, 98], + "search": [475, 273, 165, 147], + "files": [0, 0, 640, 420], + "git": [300, 352, 165, 68] + }, + "splitters": { + "editor>preview": [0, 155, 640, 10], + "preview>console": [290, 165, 10, 255], + "console>files": [300, 263, 340, 10], + "files>search": [465, 273, 10, 147], + "files>git": [300, 342, 165, 10] + }, + "maximized": "files" + }, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodMaxd": "id-36", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-8", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-12", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-13", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-22", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-36", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-35", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-31" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-27", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "files" }, + "target": { "tabsetOf": "terminal", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { + "tabs": ["editor", "files", "terminal"], + "active": "files" + }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor|files|terminal": [0, 0, 640, 180], + "preview": [0, 190, 290, 230], + "console": [300, 190, 340, 110], + "git": [300, 310, 165, 110], + "search": [475, 310, 165, 110] + }, + "splitters": { + "editor>preview": [0, 180, 640, 10], + "preview>console": [290, 190, 10, 230], + "console>git": [300, 300, 340, 10], + "git>search": [465, 310, 10, 110] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "files" }, + "target": { "tabsetOf": "git", "area": "right" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["git"], + "active": "git" + }, + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 640, 180], + "preview": [0, 190, 265, 230], + "console": [275, 190, 365, 110], + "git": [275, 310, 115, 110], + "files": [400, 310, 115, 110], + "search": [525, 310, 115, 110] + }, + "splitters": { + "editor>preview": [0, 180, 640, 10], + "preview>console": [265, 190, 10, 230], + "console>git": [275, 300, 365, 10], + "git>files": [390, 310, 10, 110], + "files>search": [515, 310, 10, 110] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "git" }, + "target": { "tabsetOf": "console", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console" + }, + { "tabs": ["git"], "active": "git" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 640, 155], + "preview": [0, 165, 290, 255], + "console": [300, 165, 340, 78], + "git": [300, 253, 340, 79], + "files": [300, 342, 165, 78], + "search": [475, 342, 165, 78] + }, + "splitters": { + "editor>preview": [0, 155, 640, 10], + "preview>console": [290, 165, 10, 255], + "console>git": [300, 243, 340, 10], + "git>files": [300, 332, 340, 10], + "files>search": [465, 342, 10, 78] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "fold", "tabsetOf": "console" }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "vertical", + "children": [ + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { "tabs": ["git"], "active": "git" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["search"], + "active": "search" + } + ] + } + ] + } + ] + } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 640, 155], + "preview": [0, 165, 290, 255], + "console": [300, 165, 340, 40], + "git": [300, 215, 340, 98], + "files": [300, 323, 165, 97], + "search": [475, 323, 165, 97] + }, + "splitters": { + "editor>preview": [0, 155, 640, 10], + "preview>console": [290, 165, 10, 255], + "console>git": [300, 205, 340, 10], + "git>files": [300, 313, 340, 10], + "files>search": [465, 323, 10, 97] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "tabsetOf": "terminal", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" }, + { "tabs": ["preview"], "active": "preview" }, + { + "tabs": ["console"], + "active": "console", + "folded": true + }, + { "tabs": ["git"], "active": "git" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { "tabs": ["search"], "active": "search" } + ] + } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 640, 85], + "preview": [0, 95, 640, 85], + "console": [0, 190, 640, 40], + "git": [0, 240, 640, 85], + "files": [0, 335, 315, 85], + "search": [325, 335, 315, 85] + }, + "splitters": { + "editor>preview": [0, 85, 640, 10], + "preview>console": [0, 180, 640, 10], + "console>git": [0, 230, 640, 10], + "git>files": [0, 325, 640, 10], + "files>search": [315, 335, 10, 85] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-49", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-17", + "nodOpen": "console", + "nodFold": true, + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-35", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-31" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-43", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-27", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "console" }, + "target": { "tabsetOf": "files", "area": "right" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["git"], "active": "git" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["search"], "active": "search" } + ] + } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 640, 98], + "preview": [0, 108, 640, 97], + "git": [0, 215, 640, 98], + "files": [0, 323, 207, 97], + "console": [217, 323, 206, 97], + "search": [433, 323, 207, 97] + }, + "splitters": { + "editor>preview": [0, 98, 640, 10], + "preview>git": [0, 205, 640, 10], + "git>files": [0, 313, 640, 10], + "files>console": [207, 323, 10, 97], + "console>search": [423, 323, 10, 97] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "editor", + "after": "preview", + "point": { "x": 320, "y": -19 } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["editor", "terminal"], "active": "editor" }, + { "tabs": ["preview"], "active": "preview" }, + { "tabs": ["git"], "active": "git" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["search"], "active": "search" } + ] + } + ] + }, + "tabsets": { + "editor|terminal": [0, 0, 640, 40], + "preview": [0, 50, 640, 155], + "git": [0, 215, 640, 98], + "files": [0, 323, 207, 97], + "console": [217, 323, 206, 97], + "search": [433, 323, 207, 97] + }, + "splitters": { + "editor>preview": [0, 40, 640, 10], + "preview>git": [0, 205, 640, 10], + "git>files": [0, 313, 640, 10], + "files>console": [207, 323, 10, 97], + "console>search": [423, 323, 10, 97] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "git" }, + "target": { "root": "top" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["git"], "active": "git" }, + { "tabs": ["editor", "terminal"], "active": "editor" }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["search"], "active": "search" } + ] + } + ] + }, + "tabsets": { + "git": [0, 0, 640, 98], + "editor|terminal": [0, 108, 640, 40], + "preview": [0, 158, 640, 155], + "files": [0, 323, 207, 97], + "console": [217, 323, 206, 97], + "search": [433, 323, 207, 97] + }, + "splitters": { + "git>editor": [0, 98, 640, 10], + "editor>preview": [0, 148, 640, 10], + "preview>files": [0, 313, 640, 10], + "files>console": [207, 323, 10, 97], + "console>search": [423, 323, 10, 97] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "resize", "width": 1111, "height": 969 }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["git"], "active": "git" }, + { "tabs": ["editor", "terminal"], "active": "editor" }, + { "tabs": ["preview"], "active": "preview" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["search"], "active": "search" } + ] + } + ] + }, + "tabsets": { + "git": [0, 0, 1111, 235], + "editor|terminal": [0, 245, 1111, 40], + "preview": [0, 295, 1111, 429], + "files": [0, 734, 364, 235], + "console": [374, 734, 363, 235], + "search": [747, 734, 364, 235] + }, + "splitters": { + "git>editor": [0, 235, 1111, 10], + "editor>preview": [0, 285, 1111, 10], + "preview>files": [0, 724, 1111, 10], + "files>console": [364, 734, 10, 235], + "console>search": [737, 734, 10, 235] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "preview" }, + "target": { "tab": "search", "position": "left" } + }, + "observation": { + "tree": { + "direction": "vertical", + "children": [ + { "tabs": ["git"], "active": "git" }, + { "tabs": ["editor", "terminal"], "active": "editor" }, + { + "direction": "horizontal", + "children": [ + { "tabs": ["files"], "active": "files" }, + { "tabs": ["console"], "active": "console" }, + { + "tabs": ["preview", "search"], + "active": "preview" + } + ] + } + ] + }, + "tabsets": { + "git": [0, 0, 1111, 455], + "editor|terminal": [0, 465, 1111, 40], + "files": [0, 515, 364, 454], + "console": [374, 515, 363, 454], + "preview|search": [747, 515, 364, 454] + }, + "splitters": { + "git>editor": [0, 455, 1111, 10], + "editor>files": [0, 505, 1111, 10], + "files>console": [364, 515, 10, 454], + "console>preview": [737, 515, 10, 454] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-57", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-31" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 0, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-43", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-53", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-27", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tab": "search" }, + "target": { "root": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["git"], "active": "git" }, + { + "tabs": ["editor", "terminal"], + "active": "editor" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["console"], + "active": "console" + }, + { + "tabs": ["preview"], + "active": "preview" + } + ] + } + ] + }, + { "tabs": ["search"], "active": "search" } + ] + }, + "tabsets": { + "search": [611, 0, 500, 969], + "git": [0, 0, 601, 455], + "editor|terminal": [0, 465, 601, 40], + "files": [0, 515, 194, 454], + "console": [204, 515, 193, 454], + "preview": [407, 515, 194, 454] + }, + "splitters": { + "git>search": [601, 0, 10, 969], + "git>editor": [0, 455, 601, 10], + "editor>files": [0, 505, 601, 10], + "files>console": [194, 515, 10, 454], + "console>preview": [397, 515, 10, 454] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "splitter", + "before": "git", + "after": "editor", + "point": { "x": 300.5, "y": 400 } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["git"], "active": "git" }, + { + "tabs": ["editor", "terminal"], + "active": "editor" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["console"], + "active": "console" + }, + { + "tabs": ["preview"], + "active": "preview" + } + ] + } + ] + }, + { "tabs": ["search"], "active": "search" } + ] + }, + "tabsets": { + "search": [611, 0, 500, 969], + "git": [0, 0, 601, 395], + "editor|terminal": [0, 405, 601, 99], + "files": [0, 514, 194, 455], + "console": [204, 514, 193, 455], + "preview": [407, 514, 194, 455] + }, + "splitters": { + "git>search": [601, 0, 10, 969], + "git>editor": [0, 395, 601, 10], + "editor>files": [0, 504, 601, 10], + "files>console": [194, 514, 10, 455], + "console>preview": [397, 514, 10, 455] + }, + "maximized": null + } + }, + { + "operation": { "op": "resize", "width": 610, "height": 1014 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["git"], "active": "git" }, + { + "tabs": ["editor", "terminal"], + "active": "editor" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["console"], + "active": "console" + }, + { + "tabs": ["preview"], + "active": "preview" + } + ] + } + ] + }, + { "tabs": ["search"], "active": "search" } + ] + }, + "tabsets": { + "search": [360, 0, 250, 1014], + "git": [0, 0, 350, 414], + "editor|terminal": [0, 424, 350, 103], + "files": [0, 537, 110, 477], + "console": [120, 537, 110, 477], + "preview": [240, 537, 110, 477] + }, + "splitters": { + "git>search": [350, 0, 10, 1014], + "git>editor": [0, 414, 350, 10], + "editor>files": [0, 527, 350, 10], + "files>console": [110, 537, 10, 477], + "console>preview": [230, 537, 10, 477] + }, + "maximized": null + } + }, + { + "operation": { "op": "resize", "width": 861, "height": 400 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["git"], "active": "git" }, + { + "tabs": ["editor", "terminal"], + "active": "editor" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["console"], + "active": "console" + }, + { + "tabs": ["preview"], + "active": "preview" + } + ] + } + ] + }, + { "tabs": ["search"], "active": "search" } + ] + }, + "tabsets": { + "search": [486, 0, 375, 400], + "git": [0, 0, 476, 151], + "editor|terminal": [0, 161, 476, 59], + "files": [0, 230, 152, 170], + "console": [162, 230, 152, 170], + "preview": [324, 230, 152, 170] + }, + "splitters": { + "git>search": [476, 0, 10, 400], + "git>editor": [0, 151, 476, 10], + "editor>files": [0, 220, 476, 10], + "files>console": [152, 230, 10, 170], + "console>preview": [314, 230, 10, 170] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "console" }, + "target": { "root": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["git"], "active": "git" }, + { + "tabs": ["editor", "terminal"], + "active": "editor" + }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": ["preview"], + "active": "preview" + } + ] + } + ] + }, + { "tabs": ["search"], "active": "search" }, + { "tabs": ["console"], "active": "console" } + ] + }, + "tabsets": { + "search": [324, 0, 263, 400], + "console": [597, 0, 264, 400], + "git": [0, 0, 314, 151], + "editor|terminal": [0, 161, 314, 59], + "files": [0, 230, 152, 170], + "preview": [162, 230, 152, 170] + }, + "splitters": { + "git>search": [314, 0, 10, 400], + "search>console": [587, 0, 10, 400], + "git>editor": [0, 151, 314, 10], + "editor>files": [0, 220, 314, 10], + "files>preview": [152, 230, 10, 170] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 85.54216867469879, + "nodName": "", + "uidNode": "id-57", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-31" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 14.457831325301203, + "nodName": "", + "uidNode": "id-7", + "nodOpen": "editor", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-43", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-27", + "nodOpen": "preview", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-63", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-53", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + } + ] + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "editor" }, + "target": { "tabsetOf": "preview", "area": "contain" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["git"], "active": "git" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": [ + "editor", + "terminal", + "preview" + ], + "active": "terminal" + } + ] + } + ] + }, + { "tabs": ["search"], "active": "search" }, + { "tabs": ["console"], "active": "console" } + ] + }, + "tabsets": { + "search": [324, 0, 263, 400], + "console": [597, 0, 264, 400], + "git": [0, 0, 314, 183], + "files": [0, 193, 152, 207], + "editor|terminal|preview": [162, 193, 152, 207] + }, + "splitters": { + "git>search": [314, 0, 10, 400], + "search>console": [587, 0, 10, 400], + "git>files": [0, 183, 314, 10], + "files>editor": [152, 193, 10, 207] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tab": "search" }, + "target": { "root": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["git"], "active": "git" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": [ + "editor", + "terminal", + "preview" + ], + "active": "terminal" + } + ] + } + ] + }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["search"], "active": "search" } + ] + }, + "tabsets": { + "console": [324, 0, 263, 400], + "search": [597, 0, 264, 400], + "git": [0, 0, 314, 183], + "files": [0, 193, 152, 207], + "editor|terminal|preview": [162, 193, 152, 207] + }, + "splitters": { + "git>console": [314, 0, 10, 400], + "console>search": [587, 0, 10, 400], + "git>files": [0, 183, 314, 10], + "files>editor": [152, 193, 10, 207] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["git"], "active": "git" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": [ + "editor", + "terminal", + "preview" + ], + "active": "terminal" + } + ] + } + ] + }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["search"], "active": "search" } + ] + }, + "tabsets": { + "console": [324, 0, 263, 400], + "search": [597, 0, 264, 400], + "git": [0, 0, 314, 183], + "files": [0, 193, 152, 207], + "editor|terminal|preview": [0, 0, 861, 400] + }, + "splitters": { + "git>console": [314, 0, 10, 400], + "console>search": [587, 0, 10, 400], + "git>files": [0, 183, 314, 10], + "files>editor": [152, 193, 10, 207] + }, + "maximized": "editor|terminal|preview" + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["git"], "active": "git" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": [ + "editor", + "terminal", + "preview" + ], + "active": "terminal" + } + ] + } + ] + }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["search"], "active": "search" } + ] + }, + "tabsets": { + "console": [324, 0, 263, 400], + "search": [597, 0, 264, 400], + "git": [0, 0, 314, 183], + "files": [0, 193, 152, 207], + "editor|terminal|preview": [162, 193, 152, 207] + }, + "splitters": { + "git>console": [314, 0, 10, 400], + "console>search": [587, 0, 10, 400], + "git>files": [0, 183, 314, 10], + "files>editor": [152, 193, 10, 207] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "preview" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["git"], "active": "git" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": [ + "editor", + "terminal", + "preview" + ], + "active": "terminal" + } + ] + } + ] + }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["search"], "active": "search" } + ] + }, + "tabsets": { + "console": [324, 0, 263, 400], + "search": [597, 0, 264, 400], + "git": [0, 0, 314, 183], + "files": [0, 193, 152, 207], + "editor|terminal|preview": [0, 0, 861, 400] + }, + "splitters": { + "git>console": [314, 0, 10, 400], + "console>search": [587, 0, 10, 400], + "git>files": [0, 183, 314, 10], + "files>editor": [152, 193, 10, 207] + }, + "maximized": "editor|terminal|preview" + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodMaxd": "id-27", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 85.54216867469879, + "nodName": "", + "uidNode": "id-57", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-31" + } + ] + }, + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-18", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-43", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-27", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-53", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-70", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + } + ] + } + ] + } + }, + { + "operation": { "op": "maximize", "tabsetOf": "terminal" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["git"], "active": "git" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": [ + "editor", + "terminal", + "preview" + ], + "active": "terminal" + } + ] + } + ] + }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["search"], "active": "search" } + ] + }, + "tabsets": { + "console": [324, 0, 263, 400], + "search": [597, 0, 264, 400], + "git": [0, 0, 314, 183], + "files": [0, 193, 152, 207], + "editor|terminal|preview": [162, 193, 152, 207] + }, + "splitters": { + "git>console": [314, 0, 10, 400], + "console>search": [587, 0, 10, 400], + "git>files": [0, 183, 314, 10], + "files>editor": [152, 193, 10, 207] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "resize", "width": 1579, "height": 294 }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["git"], "active": "git" }, + { + "direction": "horizontal", + "children": [ + { + "tabs": ["files"], + "active": "files" + }, + { + "tabs": [ + "editor", + "terminal", + "preview" + ], + "active": "terminal" + } + ] + } + ] + }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["search"], "active": "search" } + ] + }, + "tabsets": { + "console": [563, 0, 503, 294], + "search": [1076, 0, 503, 294], + "git": [0, 0, 553, 134], + "files": [0, 144, 272, 150], + "editor|terminal|preview": [282, 144, 271, 150] + }, + "splitters": { + "git>console": [553, 0, 10, 294], + "console>search": [1066, 0, 10, 294], + "git>files": [0, 134, 553, 10], + "files>editor": [272, 144, 10, 150] + }, + "maximized": null + } + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "tabsetOf": "files", "area": "bottom" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["git"], "active": "git" }, + { "tabs": ["files"], "active": "files" }, + { + "tabs": ["editor", "terminal", "preview"], + "active": "terminal" + } + ] + }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["search"], "active": "search" } + ] + }, + "tabsets": { + "console": [530, 0, 519, 294], + "search": [1059, 0, 520, 294], + "git": [0, 0, 520, 86], + "files": [0, 96, 520, 94], + "editor|terminal|preview": [0, 200, 520, 94] + }, + "splitters": { + "git>console": [520, 0, 10, 294], + "console>search": [1049, 0, 10, 294], + "git>files": [0, 86, 520, 10], + "files>editor": [0, 190, 520, 10] + }, + "maximized": null + }, + "accepted": true + }, + { + "operation": { "op": "maximize", "tabsetOf": "editor" }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["git"], "active": "git" }, + { "tabs": ["files"], "active": "files" }, + { + "tabs": ["editor", "terminal", "preview"], + "active": "terminal" + } + ] + }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["search"], "active": "search" } + ] + }, + "tabsets": { + "console": [530, 0, 519, 294], + "search": [1059, 0, 520, 294], + "git": [0, 0, 520, 86], + "files": [0, 96, 520, 94], + "editor|terminal|preview": [0, 0, 1579, 294] + }, + "splitters": { + "git>console": [520, 0, 10, 294], + "console>search": [1049, 0, 10, 294], + "git>files": [0, 86, 520, 10], + "files>editor": [0, 190, 520, 10] + }, + "maximized": "editor|terminal|preview" + }, + "accepted": true + }, + { + "operation": { + "op": "move", + "source": { "tabsetOf": "preview" }, + "target": { "root": "right" } + }, + "observation": { + "tree": { + "direction": "horizontal", + "children": [ + { + "direction": "vertical", + "children": [ + { "tabs": ["git"], "active": "git" }, + { "tabs": ["files"], "active": "files" } + ] + }, + { "tabs": ["console"], "active": "console" }, + { "tabs": ["search"], "active": "search" }, + { + "tabs": ["editor", "terminal", "preview"], + "active": "terminal" + } + ] + }, + "tabsets": { + "console": [397, 0, 388, 294], + "search": [795, 0, 387, 294], + "editor|terminal|preview": [1192, 0, 387, 294], + "git": [0, 0, 387, 134], + "files": [0, 144, 387, 150] + }, + "splitters": { + "git>console": [387, 0, 10, 294], + "console>search": [785, 0, 10, 294], + "search>editor": [1182, 0, 10, 294], + "git>files": [0, 134, 387, 10] + }, + "maximized": null + }, + "accepted": true, + "savedV1": { + "typNode": "row", + "nodPart": 100, + "nodName": "dynamix-layout-root", + "uidNode": "dynamix-layout-root", + "nodKids": [ + { + "typNode": "row", + "nodPart": 100, + "nodName": "", + "uidNode": "id-3", + "nodKids": [ + { + "typNode": "tabset", + "nodPart": 85.54216867469879, + "nodName": "", + "uidNode": "id-57", + "nodOpen": "git", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "git", + "uidNode": "id-31" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-43", + "nodOpen": "files", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "files", + "uidNode": "id-24" + } + ] + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-53", + "nodOpen": "console", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "console", + "uidNode": "id-19" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-70", + "nodOpen": "search", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "search", + "uidNode": "id-29" + } + ] + }, + { + "typNode": "tabset", + "nodPart": 100, + "nodName": "", + "uidNode": "id-27", + "nodOpen": "terminal", + "nodKids": [ + { + "typNode": "tab", + "nodPart": 100, + "nodName": "editor", + "uidNode": "id-4" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "terminal", + "uidNode": "id-9" + }, + { + "typNode": "tab", + "nodPart": 100, + "nodName": "preview", + "uidNode": "id-14" + } + ] + } + ] + } + } + ] +} diff --git a/packages/core/test/geometry.test.ts b/packages/core/test/geometry.test.ts deleted file mode 100644 index 3315668..0000000 --- a/packages/core/test/geometry.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { DynamixLayoutCore, Node } from '../src' -import type { LayoutTree } from '../src' - -const BOND = 10 - -/** Every row's kids (plus bonds) must tile the row exactly along its axis and - * span it fully across the other axis. Returns human readable problems. */ -const geometryProblems = (root: Node): string[] => { - const problems: string[] = [] - const walk = (n: Node, path: string) => { - const kids = [...n.kids] - if (n.type === 'row' && kids.length) { - const dir = Node.cache.mapDirs.get(n.unId) - const [pos, size, cpos, csize] = dir - ? (['x', 'w', 'y', 'h'] as const) - : (['y', 'h', 'x', 'w'] as const) - let cursor = n.dims[pos] - kids.forEach((k, i) => { - if (k.dims[pos] !== cursor) - problems.push( - `${path}/${i} ${pos}=${k.dims[pos]} want ${cursor}` - ) - if ( - k.dims[cpos] !== n.dims[cpos] || - k.dims[csize] !== n.dims[csize] - ) - problems.push(`${path}/${i} cross-axis mismatch`) - if (k.dims[size] < 0) - problems.push(`${path}/${i} negative ${size}`) - cursor = k.dims[pos] + k.dims[size] + BOND - }) - const end = cursor - BOND - const parentEnd = n.dims[pos] + n.dims[size] - if (end !== parentEnd) - problems.push( - `${path} kids end at ${end}, row ends at ${parentEnd}` - ) - } - kids.forEach((k, i) => walk(k, `${path}/${i}`)) - } - walk(root, 'root') - return problems -} - -/** Tabs must survive every move and the tree/caches must stay consistent. */ -const structureProblems = (root: Node, expectedTabs: string[]): string[] => { - const problems: string[] = [] - const tabs: string[] = [] - let live = 0 - const walk = (n: Node) => { - live++ - if (n.next) live++ - if (n.type === 'tab') tabs.push(n.name) - if (n.type === 'tabset' && n.kids.size() === 0) - problems.push('empty tabset') - for (const k of n.kids) { - if (k.host !== n) problems.push(`${k.type} has wrong host`) - if (n.type === 'tabset' && k.type !== 'tab') - problems.push(`tabset contains ${k.type}`) - walk(k) - } - } - walk(root) - if (tabs.sort().join() !== [...expectedTabs].sort().join()) - problems.push(`tabs changed: ${tabs.sort().join()}`) - if (Node.cache.mapDirs.size > live) - problems.push( - `mapDirs has ${Node.cache.mapDirs.size} entries for ${live} live nodes` - ) - return problems -} - -const depthOf = (n: Node): number => - n.kids.size() ? 1 + Math.max(...[...n.kids].map(depthOf)) : 0 - -describe('Layout geometry', () => { - beforeEach(() => { - new DynamixLayoutCore().clearAllCache() - }) - - it.each([7, 11, 99, 1, 2, 3, 42, 777, 2024, 31337])( - 'keeps tabs and exact tiling after random moves (seed %i)', - (initialSeed) => { - const tabNames = ['a', 'b', 'c', 'd', 'e', 'f', 'g'] - const layout = new DynamixLayoutCore({ tabs: tabNames, bond: BOND }) - layout.updateDimension({ w: 1440, h: 900, x: 0, y: 0 }, true) - - let seed = initialSeed - const rnd = (n: number) => { - seed = (seed * 16807) % 2147483647 - return seed % n - } - const areas = ['top', 'bottom', 'left', 'right', 'contain'] as const - const failures: string[] = [] - let maxDepth = 0 - - for (let i = 0; i < 400 && failures.length < 5; i++) { - const nodes = [...Node.cache.mapElem.values()].filter( - (n): n is Node => n instanceof Node - ) - const srcs = nodes.filter( - (n) => n.type === 'tab' || n.type === 'tabset' - ) - const src = srcs[rnd(srcs.length)] - // Drop targets the UI can produce: tabs, tabsets and the root. - const targets = nodes.filter( - (n) => n.type !== 'row' || n === DynamixLayoutCore._root - ) - const des = targets[rnd(targets.length)] - if (!src || !des) continue - - const area = areas[rnd(areas.length)] - let ok: boolean - try { - ok = layout.updateTree(src.unId, des.unId, area) - } catch (e) { - failures.push( - `move ${i} (${src.type} -> ${des.type} ${area}) threw: ${(e as Error).message}` - ) - break - } - if (!ok) continue - - layout.updateDimension({ w: 1440, h: 900, x: 0, y: 0 }, true) - maxDepth = Math.max(maxDepth, depthOf(DynamixLayoutCore._root)) - const p = [ - ...structureProblems(DynamixLayoutCore._root, tabNames), - ...geometryProblems(DynamixLayoutCore._root), - ] - if (p.length) - failures.push( - `move ${i} (${src.type} -> ${des.type} ${area}), depth ${depthOf(DynamixLayoutCore._root)}: ${p.slice(0, 3).join('; ')}` - ) - } - - expect(maxDepth).toBeGreaterThan(3) - expect(failures).toEqual([]) - } - ) - - it('moves every edge smoothly while the container resizes', () => { - // Shape of the React demo: a three-column row (the last column split in - // two) above a terminal, with uneven parts left behind by slider drags. - const tabset = (name: string, part: number): LayoutTree => ({ - typNode: 'tabset', - nodName: '', - uidNode: `ts-${name}`, - nodPart: part, - nodOpen: name, - nodKids: [ - { - typNode: 'tab', - nodName: name, - uidNode: `tab-${name}`, - nodPart: 100, - }, - ], - }) - const row = ( - uid: string, - part: number, - kids: LayoutTree[] - ): LayoutTree => ({ - typNode: 'row', - nodName: '', - uidNode: uid, - nodPart: part, - nodKids: kids, - }) - const layout = new DynamixLayoutCore({ - tree: row('dynamix-layout-root', 100, [ - row('main', 95.14, [ - row('columns', 253.64, [ - tabset('ts', 95.68), - tabset('preview', 102.47), - row('side', 101.85, [ - tabset('css', 100), - tabset('html', 100), - ]), - ]), - tabset('terminal', 97.76), - ]), - ]), - bond: BOND, - }) - - let backward = 0 - let maxJump = 0 - let last: number[] | null = null - - for (let w = 600; w <= 2560; w++) { - layout.updateDimension({ w, h: 900, x: 0, y: 0 }, true) - const edges: number[] = [] - for (const [, o] of Node.cache.nodOpts.get()) - edges.push(o.nodDims.x, o.nodDims.x + o.nodDims.w) - if (last) - edges.forEach((e, i) => { - const d = e - last![i] - if (d < 0) backward++ - maxJump = Math.max(maxJump, Math.abs(d)) - }) - last = edges - } - - expect(backward).toBe(0) - expect(maxJump).toBeLessThanOrEqual(1) - }) - - it('does not move unrelated panels while dragging a splitter', () => { - const layout = new DynamixLayoutCore({ - tree: { - typNode: 'row', - nodName: 'dynamix-layout-root', - uidNode: 'dynamix-layout-root', - nodPart: 100, - nodKids: ['a', 'b', 'c'].map((name) => ({ - typNode: 'tabset' as const, - nodName: '', - uidNode: `ts-${name}`, - nodPart: 100, - nodOpen: name, - nodKids: [ - { - typNode: 'tab' as const, - nodName: name, - uidNode: `tab-${name}`, - nodPart: 100, - }, - ], - })), - }, - bond: BOND, - }) - layout.updateDimension({ w: 1440, h: 900, x: 0, y: 0 }, true) - - const first = Node.cache.mapElem.get('ts-a') as Node - const third = Node.cache.mapElem.get('ts-c') as Node - const bondId = first.next!.unId - const before = { ...third.dims } - - // Real pointers report fractional coordinates on HiDPI screens. - for (let i = 0; i < 400; i++) { - const x = first.next!.dims.x + Math.sin(i / 7) * 150 + 0.37 - layout.updateSliderDimension(bondId, { x, y: 0 }) - expect(third.dims).toEqual(before) - } - }) - - it('keeps all tabs when the only tabset is dropped on a root edge', () => { - const tabNames = ['a', 'b', 'c'] - const layout = new DynamixLayoutCore({ tabs: tabNames, bond: BOND }) - layout.updateDimension({ w: 1200, h: 800, x: 0, y: 0 }, true) - - // Gather every tab into one tabset. - const tabsets = () => - [...Node.cache.mapElem.values()].filter( - (n): n is Node => n instanceof Node && n.type === 'tabset' - ) - while (tabsets().length > 1) { - const [target, source] = tabsets() - const tab = source.kids.peek()! - expect(layout.updateTree(tab.unId, target.unId, 'contain')).toBe( - true - ) - } - - const only = tabsets()[0] - for (const area of ['top', 'bottom', 'left', 'right'] as const) { - expect( - layout.updateTree(only.unId, DynamixLayoutCore._root.unId, area) - ).toBe(false) - } - expect(structureProblems(DynamixLayoutCore._root, tabNames)).toEqual([]) - }) - - it('does not reuse the tree of a previously created layout', () => { - new DynamixLayoutCore({ - tree: { - typNode: 'row', - nodName: 'dynamix-layout-root', - uidNode: 'dynamix-layout-root', - nodPart: 100, - nodKids: [ - { - typNode: 'tabset', - nodName: '', - uidNode: 'ts-old', - nodPart: 100, - nodOpen: 'old', - nodKids: [ - { - typNode: 'tab', - nodName: 'old', - uidNode: 'tab-old', - nodPart: 100, - }, - ], - }, - ], - }, - }) - - new DynamixLayoutCore({ tabs: ['x', 'y'] }) - - expect(structureProblems(DynamixLayoutCore._root, ['x', 'y'])).toEqual( - [] - ) - }) - - it('gives a single tab the whole layout', () => { - const layout = new DynamixLayoutCore({ tabs: ['only'], bond: BOND }) - layout.updateDimension({ w: 1200, h: 800, x: 0, y: 0 }, true) - - const root = DynamixLayoutCore._root - expect(root.kids.size()).toBe(1) - expect(root.kids.peek()!.dims).toEqual({ w: 1200, h: 800, x: 0, y: 0 }) - }) -}) diff --git a/packages/core/test/manual.test.ts b/packages/core/test/manual.test.ts deleted file mode 100644 index 3ccd91b..0000000 --- a/packages/core/test/manual.test.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { DynamixLayoutCore, LayoutTree, Node, Queue } from '../src' - -/** - * Yields all nodes in the layout tree using breadth-first traversal - */ -function* nodeLayoutIterator(root: Node): Generator { - const queue = new Queue() - if (root) queue.enqueue(root) - - while (!queue.isEmpty()) { - const current = queue.dequeue()! - yield current - for (const child of current.kids) { - queue.enqueue(child) - } - } -} - -/** - * Validates the integrity of the layout tree structure - * - * Checks: - * - Parent-child relationships are bidirectional - * - Layout directions are opposite between parent and child - * - Sibling doubly-linked lists are intact - * - All nodes have proper host references - * - * @param node - Root node to validate (defaults to Layout._root) - * @returns true if tree structure is valid, false otherwise - */ -const validateTree = (node: Node = DynamixLayoutCore._root): boolean => { - let isValid = true - const allNodes = Array.from(nodeLayoutIterator(node)) - - console.group(`Validating Tree Integrity for ${allNodes.length} nodes...`) - - for (const currentNode of allNodes) { - let nodeIsValid = true - const id = `Node (uid: ${currentNode.unId}, type: ${currentNode.type}, name: '${currentNode.name}')` - console.groupCollapsed(`Checking ${id}`) - - // Validate parent-child host relationship - if (currentNode !== DynamixLayoutCore._root) { - if (!currentNode.host) { - console.error(`❌ ORPHANED: ${id} has a null host.`) - nodeIsValid = false - } else if (currentNode.host.kids.indexOf(currentNode) === -1) { - console.error( - `❌ DISOWNED: ${id}'s host (uid: ${currentNode.host.unId}) does not contain it in its kids queue.` - ) - nodeIsValid = false - } - } - - // Validate children's host relationship - for (const kid of currentNode.kids) { - if (kid.host !== currentNode) { - console.error( - `❌ WRONG PARENT: Child (uid: ${kid.unId}) has host (uid: ${kid.host?.unId}), but should be ${currentNode.unId}.` - ) - nodeIsValid = false - } - } - - // Validate layout direction - if (currentNode !== DynamixLayoutCore._root && currentNode.host) { - const parentDir = Node.cache.mapDirs.get(currentNode.host.unId) - const currentDir = Node.cache.mapDirs.get(currentNode.unId) - - if (parentDir === undefined) { - console.error( - `❌ MISSING PARENT DIR: Host (uid: ${currentNode.host.unId}) of ${id} has no direction set in the cache.` - ) - nodeIsValid = false - } else if (currentDir === undefined) { - console.error( - `❌ MISSING OWN DIR: ${id} has no direction set in the cache.` - ) - nodeIsValid = false - } else if (currentDir === parentDir) { - console.error( - `❌ DIRECTION CONFLICT: ${id} has the same direction (${currentDir}) as its host. It should be the opposite.` - ) - nodeIsValid = false - } - } - - // Validate sibling bonds for container nodes - if (currentNode.type === 'row' || currentNode.type === 'tabset') { - const kidsArray = currentNode.kids.toArray() - if (kidsArray.length > 0) { - if (kidsArray[0].prev !== null) { - console.error( - `❌ BROKEN START: First child (uid: ${kidsArray[0].unId})'s 'prev' link is not null.` - ) - nodeIsValid = false - } - if (kidsArray[kidsArray.length - 1].next !== null) { - console.error( - `❌ BROKEN END: Last child (uid: ${kidsArray[kidsArray.length - 1].unId})'s 'next' link is not null.` - ) - nodeIsValid = false - } - - for (let i = 0; i < kidsArray.length - 1; i++) { - const currentKid = kidsArray[i] - const nextKid = kidsArray[i + 1] - - if (!currentKid.next) { - console.error( - `❌ BROKEN CHAIN: Child (uid: ${currentKid.unId}) is missing its 'next' bond.` - ) - nodeIsValid = false - continue - } - if (currentKid.next.next !== nextKid) { - console.error( - `❌ FORWARD LINK: Child (uid: ${currentKid.unId})'s 'next' bond does not point to the correct next sibling (uid: ${nextKid.unId}).` - ) - nodeIsValid = false - } - if (!nextKid.prev) { - console.error( - `❌ BROKEN CHAIN: Child (uid: ${nextKid.unId}) is missing its 'prev' bond.` - ) - nodeIsValid = false - continue - } - if (nextKid.prev.prev !== currentKid) { - console.error( - `❌ BACKWARD LINK: Child (uid: ${nextKid.unId})'s 'prev' bond does not point back to the correct previous sibling (uid: ${currentKid.unId}).` - ) - nodeIsValid = false - } - if (currentKid.next.host !== currentNode) { - console.error( - `❌ BOND HOST: Bond between ${currentKid.unId} and ${nextKid.unId} has incorrect host (uid: ${currentKid.next.host?.unId}). Should be ${currentNode.unId}.` - ) - nodeIsValid = false - } - } - } - } - - if (nodeIsValid) { - console.log(`✅ OK: ${id} passed all checks.`) - } - - console.groupEnd() - if (!nodeIsValid) { - isValid = false - } - } - - if (isValid) { - console.log( - '✅ Validation Complete: Tree integrity and directions are OK.' - ) - } else { - console.error( - '❌ Validation Complete: Found one or more issues in the tree structure.' - ) - } - console.groupEnd() - - return isValid -} - -describe('Comprehensive Move Test Suite', () => { - const initialLayoutTree: LayoutTree = { - typNode: 'row', - nodPart: 100, - nodName: 'dynamix-layout-root', - uidNode: 'dynamix-layout-root', - nodKids: [ - { - typNode: 'tabset', - nodPart: 100, - nodName: '', - uidNode: '4fb93a5d-2436-4e1d-b530-d88958b21acd', - nodOpen: 'sidebar', - nodKids: [ - { - typNode: 'tab', - nodPart: 100, - nodName: 'sidebar', - uidNode: '51f72497-4be6-4e67-95b6-240b3320105a', - }, - ], - }, - { - typNode: 'row', - nodPart: 100, - nodName: '', - uidNode: '534112f9-0566-484d-b3a7-a36a92cda495', - nodKids: [ - { - typNode: 'tabset', - nodPart: 100, - nodName: '', - uidNode: '01e53ba1-1456-45b0-a2e7-ccb4a399738d', - nodOpen: 'codeblock', - nodKids: [ - { - typNode: 'tab', - nodPart: 100, - nodName: 'codeblock', - uidNode: '10685b53-612e-4ad9-9167-74b8237ebce9', - }, - ], - }, - { - typNode: 'row', - nodPart: 100, - nodName: '', - uidNode: '637e68dc-feec-4eb0-a70a-6e4ec9fbf5af', - nodKids: [ - { - typNode: 'tabset', - nodPart: 100, - nodName: '', - uidNode: 'ee984aa2-b46c-469e-a24d-43492edacf71', - nodOpen: 'terminal', - nodKids: [ - { - typNode: 'tab', - nodPart: 100, - nodName: 'terminal', - uidNode: - '604bc026-018d-40f0-85a6-aa02e65db899', - }, - ], - }, - { - typNode: 'row', - nodPart: 100, - nodName: '', - uidNode: 'fd5e41be-07b1-4caa-a72e-9cc5226e895e', - nodKids: [ - { - typNode: 'tabset', - nodPart: 100, - nodName: '', - uidNode: - 'c0d0d6e5-c4bc-4146-8911-017257757d2a', - nodOpen: 'editor', - nodKids: [ - { - typNode: 'tab', - nodPart: 100, - nodName: 'editor', - uidNode: - 'd7586e7b-9735-4745-b6ac-bf739686db79', - }, - ], - }, - { - typNode: 'row', - nodPart: 100, - nodName: '', - uidNode: - '10fbe6c8-18d4-439a-ad90-21fd3241141c', - nodKids: [ - { - typNode: 'tabset', - nodPart: 100, - nodName: '', - uidNode: - 'c379ba53-044e-4f54-812e-f5e2219ece6d', - nodOpen: 'preview', - nodKids: [ - { - typNode: 'tab', - nodPart: 100, - nodName: 'preview', - uidNode: - '40f1397b-240a-4ef4-bb5e-0b9f3044fdd6', - }, - ], - }, - { - typNode: 'tabset', - nodPart: 100, - nodName: '', - uidNode: - '88d41c42-cc22-46ed-89e7-055070f6d9f6', - nodOpen: 'settings', - nodKids: [ - { - typNode: 'tab', - nodPart: 100, - nodName: 'settings', - uidNode: - '61954f56-03ae-431b-819b-287c0a5ce315', - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - } - - let layout: DynamixLayoutCore - - beforeEach(() => { - const layoutTreeCopy = JSON.parse(JSON.stringify(initialLayoutTree)) - layout = new DynamixLayoutCore({ tree: layoutTreeCopy }) - }) - - describe('Splitting Panels', () => { - it('should split vertical (drop top)', () => { - const success = layout.updateTree( - '604bc026-018d-40f0-85a6-aa02e65db899', - 'd7586e7b-9735-4745-b6ac-bf739686db79', - 'top' - ) - - expect(success).toBe(true) - expect(validateTree()).toBe(true) - }) - - it('should split horizontal (drop right)', () => { - const success = layout.updateTree( - 'd7586e7b-9735-4745-b6ac-bf739686db79', - '604bc026-018d-40f0-85a6-aa02e65db899', - 'right' - ) - - expect(success).toBe(true) - expect(validateTree()).toBe(true) - }) - }) - - describe('Containing (Moving Into Tabsets)', () => { - it('should contain tab (move settings into editor)', () => { - const success = layout.updateTree( - '61954f56-03ae-431b-819b-287c0a5ce315', - 'd7586e7b-9735-4745-b6ac-bf739686db79', - 'contain' - ) - - expect(success).toBe(true) - expect(validateTree()).toBe(true) - }) - - it('should contain tabset (move terminal into sidebar)', () => { - const success = layout.updateTree( - 'ee984aa2-b46c-469e-a24d-43492edacf71', - '51f72497-4be6-4e67-95b6-240b3320105a', - 'contain' - ) - - expect(success).toBe(true) - expect(validateTree()).toBe(true) - }) - }) - - describe('Moving Outward (Deep to Shallow)', () => { - it('should move deepest tab to top level (settings to left of sidebar)', () => { - const success = layout.updateTree( - '61954f56-03ae-431b-819b-287c0a5ce315', - '51f72497-4be6-4e67-95b6-240b3320105a', - 'left' - ) - - expect(success).toBe(true) - expect(validateTree()).toBe(true) - }) - }) - - describe('Moving Inward (Shallow to Deep)', () => { - it('should move top level tab to deepest (sidebar to bottom of settings)', () => { - const success = layout.updateTree( - '51f72497-4be6-4e67-95b6-240b3320105a', - '61954f56-03ae-431b-819b-287c0a5ce315', - 'bottom' - ) - - expect(success).toBe(true) - expect(validateTree()).toBe(true) - }) - }) - - describe('Moving Between Different Branches', () => { - it('should move across branches (codeblock to right of preview)', () => { - const success = layout.updateTree( - '10685b53-612e-4ad9-9167-74b8237ebce9', - '40f1397b-240a-4ef4-bb5e-0b9f3044fdd6', - 'right' - ) - - expect(success).toBe(true) - expect(validateTree()).toBe(true) - }) - }) - - describe('Sibling and Root Edge Cases', () => { - it('should move sibling left (settings to left of preview)', () => { - const success = layout.updateTree( - '88d41c42-cc22-46ed-89e7-055070f6d9f6', - 'c379ba53-044e-4f54-812e-f5e2219ece6d', - 'left' - ) - - expect(success).toBe(true) - expect(validateTree()).toBe(true) - }) - - it('should move to root (bottom)', () => { - const success = layout.updateTree( - 'd7586e7b-9735-4745-b6ac-bf739686db79', - 'dynamix-layout-root', - 'bottom' - ) - - expect(success).toBe(true) - expect(validateTree()).toBe(true) - }) - }) - - describe('Validation Function', () => { - it('should validate tree structure on initial layout', () => { - expect(validateTree()).toBe(true) - }) - - it('should detect invalid tree structure', () => { - const success = layout.updateTree( - 'd7586e7b-9735-4745-b6ac-bf739686db79', - 'dynamix-layout-root', - 'bottom' - ) - - expect(success).toBe(true) - expect(validateTree()).toBe(true) - }) - }) -}) diff --git a/packages/core/test/slider.test.ts b/packages/core/test/slider.test.ts deleted file mode 100644 index 9f85cd8..0000000 --- a/packages/core/test/slider.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { DynamixLayoutCore, Node, Bond } from '../src' - -describe('Slider updates', () => { - beforeEach(() => { - const layout = new DynamixLayoutCore() - layout.clearAllCache() - }) - - it('keeps tabsets, bonds and tabs outside the dragged subtree', () => { - const tabs = ['a', 'b', 'c', 'd', 'e'] - const layout = new DynamixLayoutCore({ tabs }) - layout.updateDimension({ w: 1200, h: 800, x: 0, y: 0 }, true) - - const tabsetIds = [...Node.cache.nodOpts.get().keys()] - const bondIds = [...Node.cache.bndOpts.get().keys()] - const tabIds = [...Node.cache.tabOpts.get().keys()] - - const nestedBondId = bondIds.find((id) => { - const bond = Node.cache.mapElem.get(id) - return bond instanceof Bond && bond.host !== DynamixLayoutCore._root - }) - expect(nestedBondId).toBeDefined() - - const bond = Node.cache.mapElem.get(nestedBondId!) as Bond - layout.updateSliderDimension(nestedBondId!, { - x: bond.dims.x + 20, - y: bond.dims.y + 20, - }) - - expect([...Node.cache.nodOpts.get().keys()].sort()).toEqual( - tabsetIds.sort() - ) - expect([...Node.cache.bndOpts.get().keys()].sort()).toEqual( - bondIds.sort() - ) - expect([...Node.cache.tabOpts.get().keys()].sort()).toEqual( - tabIds.sort() - ) - }) - - it('notifies listeners once per update and stops after unsubscribe', () => { - const layout = new DynamixLayoutCore({ tabs: ['a', 'b', 'c'] }) - layout.updateDimension({ w: 900, h: 600, x: 0, y: 0 }, true) - - let calls = 0 - const off = Node.cache.bndOpts.onChange(() => calls++) - - const bondId = [...Node.cache.bndOpts.get().keys()][0] - const bond = Node.cache.mapElem.get(bondId) as Bond - layout.updateSliderDimension(bondId, { - x: bond.dims.x + 30, - y: bond.dims.y + 30, - }) - expect(calls).toBe(1) - - off() - layout.updateSliderDimension(bondId, { - x: bond.dims.x + 30, - y: bond.dims.y + 30, - }) - expect(calls).toBe(1) - }) -}) diff --git a/packages/core/test/utils/test.utils.ts b/packages/core/test/utils/test.utils.ts deleted file mode 100644 index 9ba932d..0000000 --- a/packages/core/test/utils/test.utils.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Node, Queue } from '../../src' - -/** - * A standalone tree iterator that doesn't rely on a Layout instance. - */ -function* nodeLayoutIterator(root: Node): Generator { - const queue = new Queue() - if (root) queue.enqueue(root) - - while (!queue.isEmpty()) { - const current = queue.dequeue()! - yield current - for (const child of current.kids) { - queue.enqueue(child) - } - } -} - -/** - * Asserts that a node tree is structurally and logically intact. - * Throws a detailed error if any validation check fails. - * @param rootNode The root node of the tree to validate. - */ -export function assertNodeTreeIsValid(rootNode: Node): void { - if (!rootNode) throw new Error('Validation Error: Root node is null.') - - const allNodes = Array.from(nodeLayoutIterator(rootNode)) - - for (const node of allNodes) { - const nodeId = `Node (uid: ${node.unId}, type: ${node.type})` - - // 1. Parent-Child Host Consistency - if (node !== rootNode) { - if (!node.host) - throw new Error(`ORPHANED: ${nodeId} has a null host.`) - if (node.host.kids.indexOf(node) === -1) { - throw new Error( - `DISOWNED: ${nodeId}'s host does not contain it in its kids queue.` - ) - } - } - for (const kid of node.kids) { - if (kid.host !== node) { - throw new Error( - `WRONG PARENT: Child ${kid.unId} does not point back to parent ${node.unId}.` - ) - } - } - - // 2. Layout Direction Consistency - if (node !== rootNode && node.host) { - const parentDir = Node.cache.mapDirs.get(node.host.unId) - const currentDir = Node.cache.mapDirs.get(node.unId) - if (currentDir === undefined) - throw new Error(`MISSING DIR: ${nodeId} has no direction set.`) - if (parentDir === currentDir) { - throw new Error( - `DIRECTION CONFLICT: ${nodeId} has the same direction as its parent.` - ) - } - } - - // 3. Sibling (Linked List) Consistency - const kids = node.kids.toArray() - if (kids.length > 0) { - if (kids[0].prev !== null) - throw new Error( - `BROKEN START: First child ${kids[0].unId} has a non-null 'prev' link.` - ) - if (kids[kids.length - 1].next !== null) - throw new Error( - `BROKEN END: Last child ${kids[kids.length - 1].unId} has a non-null 'next' link.` - ) - - for (let i = 0; i < kids.length - 1; i++) { - if (kids[i].next?.next !== kids[i + 1]) - throw new Error(`BROKEN FORWARD LINK near ${kids[i].unId}.`) - if (kids[i + 1].prev?.prev !== kids[i]) - throw new Error( - `BROKEN BACKWARD LINK near ${kids[i + 1].unId}.` - ) - } - } - } -} diff --git a/packages/core/test/v2/create-layout.test.ts b/packages/core/test/v2/create-layout.test.ts new file mode 100644 index 0000000..e75fdbf --- /dev/null +++ b/packages/core/test/v2/create-layout.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect, vi } from 'vitest' +import { createLayout } from '../../src/create-layout' +import { DynamixLayoutError } from '../../src/errors' +import type { LayoutOptions } from '../../src/layout-types' + +const BOX = { x: 0, y: 0, width: 1210, height: 800 } +const tabs = (...ids: string[]) => ids.map((id) => ({ id })) +const create = (options: Partial = {}) => { + const layout = createLayout({ + tabs: tabs('a', 'b', 'c'), + onWarning: () => {}, + ...options, + }) + layout.setContainerRect(BOX) + return layout +} + +describe('instances', () => { + it('are independent of each other', () => { + const first = create() + const second = create({ tabs: tabs('x', 'y') }) + first.moveTab('a', { type: 'root', position: 'bottom' }) + expect(second.toJSON().root.children.map((c) => c.id)).toEqual([ + 'ts-x', + 'ts-y', + ]) + expect([...second.getSnapshot().tabs.keys()]).toEqual(['x', 'y']) + expect(first.getSnapshot().tabs.has('x')).toBe(false) + }) + + it('produce identical output for identical input (SSR-safe ids)', () => { + expect(JSON.stringify(create().toJSON())).toBe( + JSON.stringify(create().toJSON()) + ) + expect(create().toJSON().root.children[0].id).toBe('ts-a') + }) + + it('throws on duplicate tab ids in development', () => { + expect(() => createLayout({ tabs: tabs('a', 'a') })).toThrow( + DynamixLayoutError + ) + }) + + it('restores a saved layout, v2 or v1, and fills in missing tabs', () => { + const saved = create() + saved.moveTab('c', { type: 'root', position: 'top' }) + const restored = create({ + initialLayout: saved.toJSON(), + tabs: tabs('a', 'b', 'c', 'd'), + }) + expect(restored.getSnapshot().tabs.get('d')!.tabsetId).toBe( + restored.getSnapshot().tabs.get('c')!.tabsetId + ) + expect(restored.getSnapshot().tabs.get('d')!.isActive).toBe(false) + const v1 = { + typNode: 'row', + nodName: 'r', + uidNode: 'r', + nodPart: 100, + nodKids: [ + { + typNode: 'tabset', + nodName: '', + uidNode: 't', + nodPart: 100, + nodOpen: 'a', + nodKids: [ + { + typNode: 'tab', + nodName: 'a', + uidNode: 'u', + nodPart: 100, + }, + ], + }, + ], + } as const + const fromV1 = create({ initialLayout: v1, tabs: tabs('a') }) + expect(fromV1.toJSON().root.children[0]).toMatchObject({ + id: 't', + activeTabId: 'a', + }) + }) +}) + +describe('snapshots', () => { + it('share unchanged parts by reference', () => { + const layout = create() + const before = layout.getSnapshot() + layout.setContainerRect({ ...BOX, width: 1300 }) + const resized = layout.getSnapshot() + expect(resized).not.toBe(before) + expect(resized.tabsets).toBe(before.tabsets) + expect(resized.root).toBe(before.root) + expect(resized.rects).not.toBe(before.rects) + + layout.selectTab('b') + const selected = layout.getSnapshot() + expect(selected.tabsets.get('ts-a')).toBe(resized.tabsets.get('ts-a')) + layout.setContainerRect({ ...BOX, width: 1300 }) + expect(layout.getSnapshot()).toBe(selected) + }) + + it('are frozen in development', () => { + const snapshot = create().getSnapshot() + expect(Object.isFrozen(snapshot)).toBe(true) + expect(Object.isFrozen(snapshot.tabsets.get('ts-a'))).toBe(true) + }) + + it('notify subscribers until they unsubscribe', () => { + const layout = create() + const listener = vi.fn() + const unsubscribe = layout.subscribe(listener) + layout.fold('ts-a') + expect(listener).toHaveBeenCalledWith(layout.getSnapshot()) + unsubscribe() + layout.unfold('ts-a') + expect(listener).toHaveBeenCalledTimes(1) + }) +}) + +describe('onLayoutChange', () => { + it('reports committed changes with a reason and v2 JSON', () => { + const onLayoutChange = vi.fn() + const layout = create({ onLayoutChange }) + expect(onLayoutChange).not.toHaveBeenCalled() + layout.selectTab('b') + layout.moveTab('a', { + type: 'tabset', + tabsetId: 'ts-b', + position: 'center', + }) + layout.moveSplitterBy([...layout.getSnapshot().splitters.keys()][0], 30) + layout.toggleMaximize('ts-b') + layout.fold('ts-c') + layout.addTab({ id: 'd' }) + expect(onLayoutChange.mock.calls.map(([, reason]) => reason)).toEqual([ + 'select', + 'move', + 'resize', + 'maximize', + 'fold', + 'tabs', + ]) + expect(onLayoutChange.mock.calls[0][0].version).toBe(2) + }) + + it('is not called for resizes, refused actions or load', () => { + const onLayoutChange = vi.fn() + const layout = create({ onLayoutChange }) + layout.setContainerRect({ ...BOX, width: 900 }) + expect( + layout.moveTab('a', { type: 'tab', tabId: 'a', position: 'after' }) + ).toBe(false) + layout.load(create().toJSON()) + expect(onLayoutChange).not.toHaveBeenCalled() + }) + + it('keeps returning the same JSON object until the layout changes', () => { + const layout = create() + const json = layout.toJSON() + layout.setContainerRect({ ...BOX, width: 900 }) + expect(layout.toJSON()).toBe(json) + layout.selectTab('b') + expect(layout.toJSON()).not.toBe(json) + }) +}) + +describe('drags', () => { + it('moves a tab on endDrag with one change, showing the target meanwhile', () => { + const onLayoutChange = vi.fn() + const layout = create({ onLayoutChange }) + layout.startDrag({ type: 'tab', tabId: 'c' }) + layout.updateDrag({ x: 100, y: 400 }) + const drag = layout.getSnapshot().drag! + expect(drag.target).toEqual({ + type: 'tabset', + tabsetId: 'ts-a', + position: 'left', + }) + expect(drag.indicator).toEqual({ x: 0, y: 0, width: 300, height: 800 }) + layout.updateDrag({ x: 101, y: 401 }) + expect(layout.getSnapshot().drag).toBe(drag) + expect(layout.endDrag()).toBe(true) + expect(layout.getSnapshot().drag).toBeNull() + expect(onLayoutChange).toHaveBeenCalledTimes(1) + expect(layout.getSnapshot().tabs.get('c')!.tabsetId).not.toBe('ts-c') + }) + + it('leaves maximized mode when a tab drag starts', () => { + const layout = create() + layout.maximize('ts-b') + layout.startDrag({ type: 'tab', tabId: 'a' }) + expect(layout.getSnapshot().maximizedTabsetId).toBeNull() + layout.cancelDrag() + }) + + it('drags a splitter transiently, commits once, and can cancel', () => { + const onLayoutChange = vi.fn() + const layout = create({ onLayoutChange }) + const [id] = layout.getSnapshot().splitters.keys() + const start = layout.getSnapshot().rects.tabsets.get('ts-a')! + layout.startDrag({ type: 'splitter', splitterId: id }) + layout.updateDrag({ x: 400, y: 0 }) + layout.updateDrag({ x: 500, y: 0 }) + expect(layout.getSnapshot().rects.tabsets.get('ts-a')!.width).toBe(495) + expect(onLayoutChange).not.toHaveBeenCalled() + layout.cancelDrag() + expect(layout.getSnapshot().rects.tabsets.get('ts-a')).toEqual(start) + + layout.startDrag({ type: 'splitter', splitterId: id }) + layout.updateDrag({ x: 300, y: 0 }) + expect(layout.endDrag()).toBe(true) + expect(onLayoutChange).toHaveBeenCalledTimes(1) + expect(onLayoutChange.mock.calls[0][1]).toBe('resize') + }) + + it('refuses to drag locked splitters', () => { + const layout = create() + layout.fold('ts-a') + const [id] = layout.getSnapshot().splitters.keys() + expect(layout.getSnapshot().splitters.get(id)!.isLocked).toBe(true) + expect(layout.startDrag({ type: 'splitter', splitterId: id })).toBe( + false + ) + }) + + it('reports splitter bounds for ARIA', () => { + const layout = create() + const [id] = layout.getSnapshot().splitters.keys() + expect(layout.getSplitterBounds(id)).toEqual({ + value: 600, + min: 40, + max: 1160, + }) + }) +}) + +describe('tabs, reset and destroy', () => { + it('setTabs adds, activates and removes tabs', () => { + const layout = create() + layout.selectTab('b') + layout.setTabs(tabs('a', 'b', 'd')) + const snapshot = layout.getSnapshot() + expect(snapshot.tabs.has('c')).toBe(false) + expect(snapshot.tabs.get('d')).toMatchObject({ + tabsetId: 'ts-b', + isActive: true, + }) + }) + + it('reset returns to the initial layout', () => { + const layout = create() + const initial = layout.toJSON() + layout.moveTab('a', { type: 'root', position: 'bottom' }) + layout.reset() + expect(layout.toJSON()).toEqual(initial) + }) + + it('ignores everything after destroy', () => { + const warnings: string[] = [] + const layout = create({ onWarning: (w) => warnings.push(w.code) }) + const listener = vi.fn() + layout.subscribe(listener) + layout.destroy() + expect(layout.moveTab('a', { type: 'root', position: 'bottom' })).toBe( + false + ) + expect(listener).not.toHaveBeenCalled() + expect(warnings).toContain('DESTROYED') + }) + + it('warns about unknown ids instead of throwing', () => { + const warnings: string[] = [] + const layout = create({ onWarning: (w) => warnings.push(w.code) }) + expect(layout.selectTab('nope')).toBe(false) + expect(layout.toggleFold('nope')).toBe(false) + expect(warnings).toEqual(['UNKNOWN_ID', 'UNKNOWN_ID']) + }) +}) diff --git a/packages/core/test/v2/drop.test.ts b/packages/core/test/v2/drop.test.ts new file mode 100644 index 0000000..42e87dc --- /dev/null +++ b/packages/core/test/v2/drop.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { DEFAULT_GEOMETRY } from '../../src/geometry/config' +import { computeLayoutRects } from '../../src/geometry/compute-rects' +import { getDropIndicatorRect } from '../../src/drop/drop-indicator' +import { + getDropTarget, + getRootDropZoneRect, + getTabsetDropPosition, + type DropMeasurements, +} from '../../src/drop/drop-target' +import { listDropTargets } from '../../src/drop/drop-targets' +import { createIdGenerator } from '../../src/ids' +import { buildDefaultTree } from '../../src/tree/build' +import { fold, maximize } from '../../src/tree/view-state' +import type { LayoutModel, Rect } from '../../src/model/types' + +const model = (tabs: string[]): LayoutModel => ({ + root: buildDefaultTree(tabs, createIdGenerator().createId), + maximizedTabsetId: null, + lastActiveTabsetId: null, + foldOrder: [], +}) +const box: Rect = { x: 0, y: 0, width: 1210, height: 800 } +type Preview = { + area: string + left: number + top: number + width: number + height: number +} +const fromPreview = (p: { + left: number + top: number + width: number + height: number +}): Rect => ({ x: p.left, y: p.top, width: p.width, height: p.height }) + +describe('matches v1 drop previews', () => { + // Recorded from the v1 preview helpers before they were removed. + const recorded = JSON.parse( + readFileSync( + resolve(__dirname, '../fixtures/v1/drop-previews.json'), + 'utf8' + ) + ) as { + tabset: { point: { x: number; y: number }; preview: Preview }[] + root: { side: 'left' | 'right' | 'top' | 'bottom'; preview: Preview }[] + navbar: { x: number; index: number; area: 'left' | 'right' }[] + } + + it('tabset thirds and indicator rects', () => { + const m = model(['a', 'b']) + const rects = computeLayoutRects(m.root, box, DEFAULT_GEOMETRY, null) + const rect = rects.tabsets.get('ts-b')! + for (const { point, preview } of recorded.tabset) { + const position = getTabsetDropPosition(rect, point) + expect(position).toBe( + preview.area === 'contain' ? 'center' : preview.area + ) + const target = { + type: 'tabset', + tabsetId: 'ts-b', + position, + } as const + expect(getDropIndicatorRect(m, rects, target)).toEqual( + fromPreview(preview) + ) + } + }) + + it('root halves', () => { + const m = model(['a', 'b']) + const rects = computeLayoutRects(m.root, box, DEFAULT_GEOMETRY, null) + for (const { side, preview } of recorded.root) { + expect( + getDropIndicatorRect(m, rects, { type: 'root', position: side }) + ).toEqual(fromPreview(preview)) + } + }) + + it('tab bar insertion side', () => { + const tabsetOf = (id: string, tabIds: string[]) => ({ + type: 'tabset' as const, + id, + weight: 100, + activeTabId: tabIds[0], + isFolded: false, + children: tabIds.map((tabId) => ({ + type: 'tab' as const, + id: tabId, + })), + }) + const base = model(['a', 'b']) + const m: LayoutModel = { + ...base, + root: { + ...base.root, + children: [ + tabsetOf('ts-a', ['a', 'x', 'y']), + tabsetOf('ts-b', ['b']), + ], + }, + } + const rects = computeLayoutRects(m.root, box, DEFAULT_GEOMETRY, null) + const tabs = ['a', 'x', 'y'].map((id, i) => ({ + id, + rect: { x: 8 + i * 70, y: 4, width: 64, height: 32 }, + })) + const measurements: DropMeasurements = { + tabBars: new Map([ + [ + 'ts-a', + { rect: { x: 0, y: 0, width: 600, height: 40 }, tabs }, + ], + ]), + } + for (const { x, index, area } of recorded.navbar) { + const target = getDropTarget( + m, + rects, + { x, y: 20 }, + { type: 'tab', tabId: 'b' }, + measurements + ) + expect(target).toEqual({ + type: 'tab', + tabId: tabs[index].id, + position: area === 'left' ? 'before' : 'after', + }) + } + }) +}) + +describe('getDropTarget', () => { + const m = model(['a', 'b', 'c']) + const rects = computeLayoutRects(m.root, box, DEFAULT_GEOMETRY, null) + + it('prefers root zones over tabsets', () => { + const zone = getRootDropZoneRect(rects.container, 'left') + expect(zone).toEqual({ x: 0, y: 300, width: 8, height: 200 }) + expect( + getDropTarget( + m, + rects, + { x: 2, y: 400 }, + { type: 'tab', tabId: 'c' } + ) + ).toEqual({ type: 'root', position: 'left' }) + expect( + getDropTarget( + m, + rects, + { x: 2, y: 100 }, + { type: 'tab', tabId: 'c' } + ) + ).toEqual({ type: 'tabset', tabsetId: 'ts-a', position: 'left' }) + }) + + it('returns null for invalid targets and splitters', () => { + const single = model(['a']) + const singleRects = computeLayoutRects( + single.root, + box, + DEFAULT_GEOMETRY, + null + ) + expect( + getDropTarget( + single, + singleRects, + { x: 600, y: 400 }, + { type: 'tab', tabId: 'a' } + ) + ).toBeNull() + const splitter = rects.splitters.get('ts-a~row-1')! + expect( + getDropTarget( + m, + rects, + { x: splitter.x + 5, y: 100 }, + { type: 'tab', tabId: 'c' } + ) + ).toBeNull() + }) + + it('folded tabsets and rotated bars always mean center', () => { + const folded = fold(m, 'ts-a')! + const foldedRects = computeLayoutRects( + folded.root, + box, + DEFAULT_GEOMETRY, + null + ) + expect( + getDropTarget( + folded, + foldedRects, + { x: 5, y: 100 }, + { type: 'tab', tabId: 'c' } + ) + ).toEqual({ type: 'tabset', tabsetId: 'ts-a', position: 'center' }) + expect( + getDropIndicatorRect(folded, foldedRects, { + type: 'tabset', + tabsetId: 'ts-a', + position: 'center', + }) + ).toEqual(foldedRects.tabsets.get('ts-a')) + }) + + it('only the maximized tabset is a target while maximized', () => { + const maxed = maximize(m, 'ts-c')! + const maxRects = computeLayoutRects( + maxed.root, + box, + DEFAULT_GEOMETRY, + 'ts-c' + ) + expect( + getDropTarget( + maxed, + maxRects, + { x: 600, y: 400 }, + { type: 'tab', tabId: 'a' } + ) + ).toEqual({ type: 'tabset', tabsetId: 'ts-c', position: 'center' }) + }) + + it('lists valid targets in reading order', () => { + const targets = listDropTargets(m, { type: 'tab', tabId: 'a' }) + expect(targets[0]).toEqual({ + type: 'tabset', + tabsetId: 'ts-b', + position: 'center', + }) + expect( + targets.some((t) => t.type === 'tabset' && t.tabsetId === 'ts-a') + ).toBe(false) + expect(targets.slice(-4).map((t) => t.position)).toEqual([ + 'left', + 'right', + 'top', + 'bottom', + ]) + }) +}) diff --git a/packages/core/test/v2/geometry.test.ts b/packages/core/test/v2/geometry.test.ts new file mode 100644 index 0000000..2159fc4 --- /dev/null +++ b/packages/core/test/v2/geometry.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from 'vitest' +import { DEFAULT_GEOMETRY } from '../../src/geometry/config' +import { computeLayoutRects } from '../../src/geometry/compute-rects' +import { + getSplitterBounds, + resizeSplitterWeights, +} from '../../src/geometry/splitter' +import { + getTabBarPlacement, + getTabContentRect, +} from '../../src/geometry/tab-bar' +import { createIdGenerator } from '../../src/ids' +import { buildDefaultTree } from '../../src/tree/build' +import { fold } from '../../src/tree/view-state' +import type { LayoutModel, Rect } from '../../src/model/types' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import type { Observation } from '../characterization/scenario' +import { observeV2 } from '../characterization/v2-observe' + +const TABS = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'] +const model = (n: number): LayoutModel => ({ + root: buildDefaultTree(TABS.slice(0, n), createIdGenerator().createId), + maximizedTabsetId: null, + lastActiveTabsetId: null, + foldOrder: [], +}) +const box = (width: number, height: number): Rect => ({ + x: 0, + y: 0, + width, + height, +}) +const observe = (m: LayoutModel, width: number, height: number) => + observeV2( + m, + computeLayoutRects( + m.root, + box(width, height), + DEFAULT_GEOMETRY, + m.maximizedTabsetId + ) + ) + +describe('computeLayoutRects matches v1', () => { + // Default layouts of 1–9 tabs at five sizes, recorded from the v1 engine. + // Splitter drags, folds and maximize are covered by the scenario replay. + const layouts = JSON.parse( + readFileSync( + resolve(__dirname, '../fixtures/v1/default-layouts.json'), + 'utf8' + ) + ) as { + tabs: string[] + container: { width: number; height: number } + observation: Observation + }[] + + it.each( + layouts.map( + (l) => + [ + l.tabs.length, + l.container.width, + l.container.height, + l, + ] as const + ) + )('%i tabs at %ix%i', (_, width, height, layout) => { + expect(observe(model(layout.tabs.length), width, height)).toEqual( + layout.observation + ) + }) +}) + +describe('geometry details', () => { + it('reuses unchanged rect objects', () => { + const m = model(3) + const first = computeLayoutRects( + m.root, + box(1000, 600), + DEFAULT_GEOMETRY, + null + ) + const same = computeLayoutRects( + m.root, + box(1000, 600), + DEFAULT_GEOMETRY, + null, + first + ) + expect(same.tabsets.get('ts-a')).toBe(first.tabsets.get('ts-a')) + const taller = computeLayoutRects( + m.root, + box(1000, 700), + DEFAULT_GEOMETRY, + null, + first + ) + expect(taller.tabsets.get('ts-a')).not.toBe(first.tabsets.get('ts-a')) + }) + + it('offsets everything by the container origin (padding)', () => { + const m = model(2) + const rects = computeLayoutRects( + m.root, + { x: 8, y: 12, width: 400, height: 300 }, + DEFAULT_GEOMETRY, + null + ) + expect(rects.tabsets.get('ts-a')).toEqual({ + x: 8, + y: 12, + width: 195, + height: 300, + }) + }) + + it('reports splitter bounds and refuses locked splitters', () => { + const m = model(2) + const rects = computeLayoutRects( + m.root, + box(410, 300), + DEFAULT_GEOMETRY, + null + ) + expect( + getSplitterBounds(m.root, rects, DEFAULT_GEOMETRY, 'ts-a~ts-b') + ).toEqual({ value: 200, min: 40, max: 360 }) + const folded = fold(m, 'ts-a')! + expect( + resizeSplitterWeights( + folded.root, + rects, + DEFAULT_GEOMETRY, + 'ts-a~ts-b', + { x: 100, y: 0 } + ) + ).toBeNull() + expect( + resizeSplitterWeights(m.root, rects, DEFAULT_GEOMETRY, 'nope', { + x: 100, + y: 0, + }) + ).toBeNull() + }) + + it('places tab bars, rotating folded strips in side-by-side rows', () => { + const rect = { x: 10, y: 20, width: 40, height: 500 } + expect( + getTabBarPlacement( + rect, + { isFolded: true, parentDirection: 'horizontal' }, + 40 + ) + ).toEqual({ + rect: { x: 50, y: 20, width: 500, height: 40 }, + isRotated: true, + }) + expect( + getTabBarPlacement( + rect, + { isFolded: true, parentDirection: 'vertical' }, + 40 + ).isRotated + ).toBe(false) + expect( + getTabContentRect({ x: 0, y: 0, width: 100, height: 30 }, 40) + ).toEqual({ x: 0, y: 40, width: 100, height: 0 }) + }) +}) diff --git a/packages/core/test/v2/public-api.test.ts b/packages/core/test/v2/public-api.test.ts new file mode 100644 index 0000000..412b895 --- /dev/null +++ b/packages/core/test/v2/public-api.test.ts @@ -0,0 +1,173 @@ +import { afterEach, describe, it, expect, vi } from 'vitest' +import * as api from '../../src' +import { createLayout, applyRect, version } from '../../src' +import { isRow, isTab, isTabset } from '../../src/model/guards' +import { createIdGenerator } from '../../src/ids' + +const BOX = { x: 0, y: 0, width: 1210, height: 800 } +const create = (extra: Partial[0]> = {}) => { + const layout = createLayout({ + tabs: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + onWarning: () => {}, + ...extra, + }) + layout.setContainerRect(BOX) + return layout +} + +describe('public exports', () => { + it('exposes the v2 API and nothing from v1', () => { + for (const name of [ + 'createLayout', + 'migrateLayoutFromV1', + 'isLayoutV1', + 'DynamixLayoutError', + 'LAYOUT_VERSION', + 'getTabBarPlacement', + 'getTabContentRect', + 'getRootDropZoneRect', + 'applyRect', + 'createFrameScheduler', + 'version', + ]) + expect(api).toHaveProperty(name) + for (const name of [ + 'DynamixLayoutCore', + 'Node', + 'Bond', + 'Queue', + 'createReactiveState', + 'getTabsetDropPreview', + ]) + expect(api).not.toHaveProperty(name) + expect(typeof version).toBe('string') + }) + + it('has no import-time side effects on window', async () => { + expect( + (globalThis as { __DYNAMIX_LAYOUT__?: unknown }).__DYNAMIX_LAYOUT__ + ).toBeUndefined() + }) + + it('applyRect positions an element', () => { + const style: Record = {} + applyRect({ style } as unknown as HTMLElement, { + x: 1, + y: 2, + width: 3, + height: 4, + }) + expect(style).toEqual({ + left: '1px', + top: '2px', + width: '3px', + height: '4px', + }) + }) + + it('node guards', () => { + const tab = { type: 'tab', id: 't' } as const + expect([isTab(tab), isTabset(tab), isRow(tab)]).toEqual([ + true, + false, + false, + ]) + }) +}) + +describe('createLayout options and less common actions', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('uses a custom createId', () => { + let n = 0 + const layout = create({ createId: (kind) => `${kind}#${++n}` }) + expect([...layout.getSnapshot().tabsets.keys()]).toEqual([ + 'tabset#1', + 'tabset#3', + 'tabset#4', + ]) + const ids = createIdGenerator((kind, hint) => `${kind}:${hint ?? 'x'}`) + expect(ids.createId('tabset', 'q')).toBe('tabset:q') + }) + + it('setOptions resizes without rebuilding', () => { + const layout = create() + const root = layout.getSnapshot().root + layout.setOptions({ + splitterSize: 20, + minPanelSize: { width: 100, height: 60 }, + }) + expect(layout.getSnapshot().root).toBe(root) + expect( + [...layout.getSnapshot().rects.splitters.values()][0].width + ).toBe(20) + }) + + it('moves tabsets, adds tabs at a target, and removes tabs', () => { + const layout = create() + expect( + layout.moveTabset('ts-c', { + type: 'tab', + tabId: 'a', + position: 'after', + }) + ).toBe(true) + expect(layout.getSnapshot().tabsets.get('ts-a')!.tabIds).toEqual([ + 'a', + 'c', + ]) + expect( + layout.addTab({ id: 'd' }, { type: 'root', position: 'bottom' }) + ).toBe(true) + expect(layout.addTab({ id: 'd' })).toBe(false) + expect(layout.getSnapshot().root.direction).toBe('vertical') + expect(layout.removeTab('d')).toBe(true) + expect(layout.removeTab('nope')).toBe(false) + }) + + it('drop helpers ignore splitter sources', () => { + const layout = create() + const [id] = layout.getSnapshot().splitters.keys() + expect( + layout.getDropTarget( + { x: 10, y: 10 }, + { type: 'splitter', splitterId: id } + ) + ).toBeNull() + expect( + layout.listDropTargets({ type: 'splitter', splitterId: id }) + ).toEqual([]) + const target = layout.getDropTarget( + { x: 100, y: 400 }, + { type: 'tab', tabId: 'c' } + )! + expect(layout.getDropIndicatorRect(target)).toEqual({ + x: 0, + y: 0, + width: 300, + height: 800, + }) + }) + + it('keeps the first duplicate tab in production and warns', () => { + vi.stubEnv('NODE_ENV', 'production') + const warnings: string[] = [] + const layout = createLayout({ + tabs: [{ id: 'a' }, { id: 'a' }], + onWarning: (w) => warnings.push(w.code), + }) + expect([...layout.getSnapshot().tabs.keys()]).toEqual(['a']) + expect(warnings).toEqual(['DUPLICATE_TAB_ID']) + }) + + it('the default warning handler logs only in development', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + createLayout({ tabs: [{ id: 'a' }] }).selectTab('nope') + expect(warn).toHaveBeenCalledWith('[dynamix-layout] Unknown tab "nope"') + warn.mockClear() + vi.stubEnv('NODE_ENV', 'production') + createLayout({ tabs: [{ id: 'a' }] }).selectTab('nope') + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) +}) diff --git a/packages/core/test/v2/serialize.test.ts b/packages/core/test/v2/serialize.test.ts new file mode 100644 index 0000000..9399b1d --- /dev/null +++ b/packages/core/test/v2/serialize.test.ts @@ -0,0 +1,377 @@ +import { describe, it, expect } from 'vitest' +import { DynamixLayoutError, type LayoutWarning } from '../../src/errors' +import { DEFAULT_GEOMETRY } from '../../src/geometry/config' +import { computeLayoutRects } from '../../src/geometry/compute-rects' +import { createIdGenerator } from '../../src/ids' +import { layoutFromJSON } from '../../src/serialize/from-json' +import { isLayoutV1, migrateLayoutFromV1 } from '../../src/serialize/migrate-v1' +import { parseLayout } from '../../src/serialize/parse' +import type { LayoutJSON, LayoutTreeV1 } from '../../src/serialize/schema' +import { toLayoutJSON } from '../../src/serialize/to-json' +import { buildDefaultTree } from '../../src/tree/build' +import { fold } from '../../src/tree/view-state' +import type { LayoutModel } from '../../src/model/types' +import { + hasFoldedRow, + hasZeroWeightRow, + loadScenarios, + overflows, + savedStates, +} from '../characterization/fixtures' +import { observeV2 } from '../characterization/v2-observe' + +const ignore = () => {} +const collect = () => { + const warnings: LayoutWarning[] = [] + return { warnings, onWarning: (w: LayoutWarning) => warnings.push(w) } +} +const v1Tab = (name: string): LayoutTreeV1 => ({ + typNode: 'tab', + nodName: name, + uidNode: `u-${name}`, + nodPart: 100, +}) +const v1Tabset = ( + id: string, + tabs: string[], + extra: Partial = {} +): LayoutTreeV1 => ({ + typNode: 'tabset', + nodName: '', + uidNode: id, + nodPart: 100, + nodOpen: tabs[0], + nodKids: tabs.map(v1Tab), + ...extra, +}) +const v1Row = ( + id: string, + kids: LayoutTreeV1[], + extra: Partial = {} +): LayoutTreeV1 => ({ + typNode: 'row', + nodName: '', + uidNode: id, + nodPart: 100, + nodKids: kids, + ...extra, +}) + +describe('toLayoutJSON / layoutFromJSON', () => { + it('round-trips a model, including fold and maximize', () => { + const model: LayoutModel = { + root: buildDefaultTree( + ['a', 'b', 'c'], + createIdGenerator().createId + ), + maximizedTabsetId: null, + lastActiveTabsetId: null, + foldOrder: [], + } + const folded = { ...fold(model, 'ts-a')!, maximizedTabsetId: 'ts-b' } + const json = toLayoutJSON(folded) + expect(json.version).toBe(2) + expect(json.maximizedTabsetId).toBe('ts-b') + expect(json.root.children[0]).toEqual({ + type: 'tabset', + id: 'ts-a', + weight: 100, + activeTabId: 'a', + isFolded: true, + children: [{ type: 'tab', id: 'a' }], + }) + const back = layoutFromJSON(json, ignore) + expect(back.root).toEqual(folded.root) + expect(back.maximizedTabsetId).toBe('ts-b') + }) + + it('throws with a path for malformed input', () => { + const bad = { + version: 2, + root: { + type: 'row', + id: 'r', + weight: 1, + direction: 'horizontal', + children: [ + { + type: 'tabset', + id: 't', + weight: 1, + children: [{ type: 'tab' }], + }, + ], + }, + } + expect(() => parseLayout(bad, ignore)).toThrowError( + /at root\.children\[0\]\.children\[0\]/ + ) + try { + parseLayout({ version: 3, root: {} }, ignore) + } catch (error) { + expect(error).toBeInstanceOf(DynamixLayoutError) + expect((error as DynamixLayoutError).code).toBe( + 'UNSUPPORTED_VERSION' + ) + } + expect(() => parseLayout('nope', ignore)).toThrow(DynamixLayoutError) + expect(() => + parseLayout( + { + version: 2, + root: { + type: 'row', + id: 'r', + weight: 1, + direction: 'diagonal', + children: [], + }, + }, + ignore + ) + ).toThrowError(/root\.direction/) + }) + + it('repairs weights, active tabs and duplicate tabs with warnings', () => { + const { warnings, onWarning } = collect() + const json = { + version: 2, + root: { + type: 'row', + id: 'r', + weight: 100, + direction: 'horizontal', + children: [ + { + type: 'tabset', + id: 't1', + weight: -5, + activeTabId: 'zz', + children: [{ type: 'tab', id: 'a' }], + }, + { + type: 'tabset', + id: 't2', + weight: 100, + children: [ + { type: 'tab', id: 'a' }, + { type: 'tab', id: 'b' }, + ], + }, + ], + }, + } as unknown as LayoutJSON + const { root } = layoutFromJSON(json, onWarning) + expect(warnings.map((w) => w.code).sort()).toEqual([ + 'DUPLICATE_TAB_ID', + 'INVALID_ACTIVE_TAB', + 'INVALID_WEIGHT', + ]) + expect( + toLayoutJSON({ root, maximizedTabsetId: null }).root.children + ).toEqual([ + { + type: 'tabset', + id: 't1', + weight: 100, + activeTabId: 'a', + children: [{ type: 'tab', id: 'a' }], + }, + { + type: 'tabset', + id: 't2', + weight: 100, + activeTabId: 'b', + children: [{ type: 'tab', id: 'b' }], + }, + ]) + }) +}) + +describe('migrateLayoutFromV1', () => { + it('detects v1 layouts', () => { + expect(isLayoutV1(v1Row('root', []))).toBe(true) + expect(isLayoutV1({ version: 2, root: {} })).toBe(false) + expect(isLayoutV1(null)).toBe(false) + }) + + it('maps names, weights, directions and view state', () => { + const tree = v1Row( + 'dynamix-layout-root', + [ + v1Tabset('t1', ['editor', 'notes'], { + nodPart: 63.5, + nodOpen: 'notes', + nodFold: true, + }), + v1Row( + 'r1', + [v1Tabset('t2', ['terminal']), v1Tabset('t3', ['preview'])], + { nodPart: 136.5 } + ), + ], + { nodMaxd: 't3' } + ) + expect(migrateLayoutFromV1(tree)).toEqual({ + version: 2, + maximizedTabsetId: 't3', + root: { + type: 'row', + id: 'dynamix-layout-root', + weight: 100, + direction: 'horizontal', + children: [ + { + type: 'tabset', + id: 't1', + weight: 63.5, + activeTabId: 'notes', + isFolded: true, + children: [ + { type: 'tab', id: 'editor' }, + { type: 'tab', id: 'notes' }, + ], + }, + { + type: 'row', + id: 'r1', + weight: 136.5, + direction: 'vertical', + children: [ + { + type: 'tabset', + id: 't2', + weight: 100, + activeTabId: 'terminal', + children: [{ type: 'tab', id: 'terminal' }], + }, + { + type: 'tabset', + id: 't3', + weight: 100, + activeTabId: 'preview', + children: [{ type: 'tab', id: 'preview' }], + }, + ], + }, + ], + }, + }) + }) + + it('flattens a root with a single nested row (v1 B7) into a vertical root', () => { + const tree = v1Row('root', [ + v1Row('r1', [v1Tabset('t1', ['a']), v1Tabset('t2', ['b'])], { + nodPart: 77, + }), + ]) + const json = migrateLayoutFromV1(tree) + expect(json.root.direction).toBe('vertical') + expect(json.root.weight).toBe(77) + expect(json.root.children.map((c) => c.id)).toEqual(['t1', 't2']) + }) + + it('repairs damaged trees', () => { + const { warnings, onWarning } = collect() + const tree = v1Row( + 'root', + [ + v1Tabset('t1', ['a', 'b'], { + nodOpen: 'missing', + nodPart: NaN, + }), + v1Tabset('t2', ['a']), + v1Tabset('empty', []), + v1Row('emptyRow', []), + v1Tab('stray'), + { typNode: 'bond', nodName: '', uidNode: 'b', nodPart: 0 }, + ], + { nodMaxd: 'gone' } + ) + const json = migrateLayoutFromV1(tree, { onWarning }) + expect(json.maximizedTabsetId).toBeUndefined() + expect(json.root.children).toEqual([ + { + type: 'tabset', + id: 't1', + weight: 100, + activeTabId: 'a', + children: [ + { type: 'tab', id: 'a' }, + { type: 'tab', id: 'b' }, + ], + }, + { + type: 'tabset', + id: 'ts-stray', + weight: 100, + activeTabId: 'stray', + children: [{ type: 'tab', id: 'stray' }], + }, + ]) + expect(warnings.map((w) => w.code)).toEqual([ + 'INVALID_ACTIVE_TAB', + 'INVALID_WEIGHT', + 'DUPLICATE_TAB_ID', + ]) + }) + + it('wraps a tabset root and rejects non-layouts', () => { + expect( + migrateLayoutFromV1(v1Tabset('t', ['a'])).root.children[0].id + ).toBe('t') + expect(() => + migrateLayoutFromV1({ + typNode: 'tab', + nodName: 'a', + uidNode: 'x', + nodPart: 1, + }) + ).toThrow(DynamixLayoutError) + expect(() => migrateLayoutFromV1({} as LayoutTreeV1)).toThrow( + DynamixLayoutError + ) + }) + + it('parseLayout migrates v1 with a warning', () => { + const { warnings, onWarning } = collect() + parseLayout(v1Row('root', [v1Tabset('t', ['a'])]), onWarning) + expect(warnings[0].code).toBe('MIGRATED_FROM_V1') + }) +}) + +describe('every saved v1 layout in the fixtures', () => { + const scenarios = loadScenarios() + it.each(scenarios.map((s) => [s.name, s] as const))( + '%s renders identically after migration', + (_, scenario) => { + let compared = 0 + for (const state of savedStates(scenario)) { + const model = parseLayout(state.saved, ignore) + const rects = computeLayoutRects( + model.root, + { x: 0, y: 0, ...state.container }, + DEFAULT_GEOMETRY, + model.maximizedTabsetId + ) + const observed = observeV2( + { ...model, lastActiveTabsetId: null, foldOrder: [] }, + rects + ) + if ( + overflows(state.observation, state.container) || + hasFoldedRow(state.saved) || + hasZeroWeightRow(state.saved) + ) { + expect(observed.tree, state.label).toEqual( + state.observation.tree + ) + } else { + expect(observed, state.label).toEqual(state.observation) + compared++ + } + } + expect(compared).toBeGreaterThan(0) + } + ) +}) diff --git a/packages/core/test/v2/tree.test.ts b/packages/core/test/v2/tree.test.ts new file mode 100644 index 0000000..574c8c7 --- /dev/null +++ b/packages/core/test/v2/tree.test.ts @@ -0,0 +1,449 @@ +import { describe, it, expect } from 'vitest' +import { createIdGenerator } from '../../src/ids' +import { buildDefaultTree } from '../../src/tree/build' +import { + collectTabIds, + collectTabsets, + findTabsetOfTab, +} from '../../src/tree/find' +import { moveNode, canMove } from '../../src/tree/move' +import { normalizeTree } from '../../src/tree/normalize' +import { removeTab } from '../../src/tree/remove' +import { reconcileTabs } from '../../src/tree/reconcile-tabs' +import { + fold, + maximize, + normalizeViewState, + restore, + unfold, +} from '../../src/tree/view-state' +import type { + LayoutModel, + RowChild, + RowNode, + TabsetNode, +} from '../../src/model/types' + +const ids = () => createIdGenerator().createId + +/** Compact shape: `[direction, ...children]` rows and `'a,b'` tabsets (`*` marks active, `~` folded). */ +type Shape = string | [string, ...Shape[]] +const shape = (node: RowChild): Shape => + node.type === 'row' + ? [node.direction[0], ...node.children.map(shape)] + : (node.isFolded ? '~' : '') + + node.children + .map((t) => (t.id === node.activeTabId ? `*${t.id}` : t.id)) + .join(',') + +const model = (root: RowNode): LayoutModel => ({ + root, + maximizedTabsetId: null, + lastActiveTabsetId: null, + foldOrder: [], +}) + +const tabset = ( + tabIds: string[], + weight = 100, + extra: Partial = {} +): TabsetNode => ({ + type: 'tabset', + id: `ts-${tabIds[0]}`, + weight, + activeTabId: tabIds[0], + isFolded: false, + children: tabIds.map((id) => ({ type: 'tab', id })), + ...extra, +}) +const row = ( + direction: 'horizontal' | 'vertical', + children: RowChild[], + weight = 100, + id = `row-${Math.random()}` +): RowNode => ({ + type: 'row', + id, + weight, + direction, + children, +}) + +describe('buildDefaultTree', () => { + it('matches the v1 default layout', () => { + expect(buildDefaultTree([], ids()).children).toEqual([]) + expect(shape(buildDefaultTree(['a'], ids()))).toEqual(['h', '*a']) + expect(shape(buildDefaultTree(['a', 'b'], ids()))).toEqual([ + 'h', + '*a', + '*b', + ]) + expect(shape(buildDefaultTree(['a', 'b', 'c', 'd'], ids()))).toEqual([ + 'h', + '*a', + ['v', '*b', ['h', '*c', '*d']], + ]) + }) + + it('creates deterministic ids derived from tab ids', () => { + const first = buildDefaultTree(['a', 'b', 'c'], ids()) + const second = buildDefaultTree(['a', 'b', 'c'], ids()) + expect(first).toEqual(second) + expect(collectTabsets(first).map((t) => t.id)).toEqual([ + 'ts-a', + 'ts-b', + 'ts-c', + ]) + }) +}) + +describe('normalizeTree', () => { + it('dissolves single-child rows, keeping the child weight', () => { + const root = row('horizontal', [ + tabset(['a'], 70), + row('vertical', [tabset(['b'], 30)], 200), + ]) + const result = normalizeTree(root) + expect(shape(result)).toEqual(['h', '*a', '*b']) + expect(result.children.map((c) => c.weight)).toEqual([70, 30]) + }) + + it('splices same-direction rows and drops empty nodes', () => { + const root = row('horizontal', [ + tabset(['a']), + row('horizontal', [tabset(['b']), tabset(['c'])]), + tabset([]), + row('vertical', []), + ]) + expect(shape(normalizeTree(root))).toEqual(['h', '*a', '*b', '*c']) + }) + + it('lets the root take over a single child row, including its weight', () => { + const inner = row('vertical', [tabset(['a']), tabset(['b'])], 42) + const result = normalizeTree(row('horizontal', [inner], 100, 'root')) + expect(result.id).toBe('root') + expect(result.direction).toBe('vertical') + expect(result.weight).toBe(42) + }) + + it('fixes invalid active tabs and weights', () => { + const result = normalizeTree( + row('horizontal', [ + tabset(['a', 'b'], -1, { activeTabId: 'zz' }), + tabset(['c'], NaN), + ]) + ) + const [first, second] = result.children as TabsetNode[] + expect(first.activeTabId).toBe('a') + expect(first.weight).toBe(100) + expect(second.weight).toBe(100) + }) + + it('returns the same object when nothing changes', () => { + const root = buildDefaultTree(['a', 'b', 'c'], ids()) + expect(normalizeTree(root)).toBe(root) + }) +}) + +describe('removeTab', () => { + it('activates the first remaining tab and removes emptied tabsets', () => { + let root = normalizeTree( + row('horizontal', [ + tabset(['a', 'b'], 100, { activeTabId: 'b' }), + tabset(['c']), + ]) + ) + root = removeTab(root, 'b') + expect(shape(root)).toEqual(['h', '*a', '*c']) + root = removeTab(root, 'c') + expect(shape(root)).toEqual(['h', '*a']) + }) + + it('lifts the remaining sibling into the grandparent', () => { + const root = buildDefaultTree(['a', 'b', 'c', 'd'], ids()) + expect(shape(removeTab(root, 'c'))).toEqual([ + 'h', + '*a', + ['v', '*b', '*d'], + ]) + expect(shape(removeTab(root, 'b'))).toEqual(['h', '*a', '*c', '*d']) + }) + + it('removing the last tab leaves an empty root', () => { + expect(removeTab(buildDefaultTree(['a'], ids()), 'a').children).toEqual( + [] + ) + }) +}) + +describe('moveNode', () => { + const start = () => model(buildDefaultTree(['a', 'b', 'c'], ids())) + const move = ( + m: LayoutModel, + source: Parameters[1], + target: Parameters[2] + ) => moveNode(m, source, target, ids()) + + it('moves a tab into another tabset, before or after a tab', () => { + const m = move( + start(), + { type: 'tab', tabId: 'a' }, + { type: 'tab', tabId: 'c', position: 'before' } + )! + expect(shape(m.root)).toEqual(['v', '*b', '*a,c']) + expect(m.lastActiveTabsetId).toBe(findTabsetOfTab(m.root, 'a')!.id) + }) + + it('appends on center drops (v1 inserted before the last tab)', () => { + const m = move( + start(), + { type: 'tab', tabId: 'a' }, + { type: 'tabset', tabsetId: 'ts-c', position: 'center' } + )! + expect(shape(m.root)).toEqual(['v', '*b', 'c,*a']) + }) + + it('splits along and across rows', () => { + const along = move( + start(), + { type: 'tab', tabId: 'a' }, + { type: 'tabset', tabsetId: 'ts-b', position: 'bottom' } + )! + expect(shape(along.root)).toEqual(['v', '*b', '*a', '*c']) + const across = move( + start(), + { type: 'tab', tabId: 'c' }, + { type: 'tabset', tabsetId: 'ts-a', position: 'top' } + )! + expect(shape(across.root)).toEqual(['h', ['v', '*c', '*a'], '*b']) + }) + + it('docks at the root, wrapping content when crossing the root direction', () => { + const left = move( + start(), + { type: 'tab', tabId: 'c' }, + { type: 'root', position: 'left' } + )! + expect(shape(left.root)).toEqual(['h', '*c', '*a', '*b']) + const top = move( + start(), + { type: 'tab', tabId: 'c' }, + { type: 'root', position: 'top' } + )! + expect(shape(top.root)).toEqual(['v', '*c', ['h', '*a', '*b']]) + }) + + it('moves whole tabsets and merges them into tabs', () => { + const m = move( + start(), + { type: 'tabset', tabsetId: 'ts-a' }, + { type: 'tab', tabId: 'b', position: 'after' } + )! + expect(shape(m.root)).toEqual(['v', 'b,*a', '*c']) + }) + + it('refuses moves v1 refused', () => { + const m = start() + expect( + canMove( + m.root, + { type: 'tab', tabId: 'a' }, + { type: 'tabset', tabsetId: 'ts-a', position: 'left' } + ) + ).toBe(false) + expect( + canMove( + m.root, + { type: 'tab', tabId: 'a' }, + { type: 'tab', tabId: 'a', position: 'after' } + ) + ).toBe(false) + expect( + canMove( + m.root, + { type: 'tabset', tabsetId: 'ts-a' }, + { type: 'tabset', tabsetId: 'ts-a', position: 'center' } + ) + ).toBe(false) + const single = model(buildDefaultTree(['a'], ids())) + expect( + canMove( + single.root, + { type: 'tab', tabId: 'a' }, + { type: 'root', position: 'top' } + ) + ).toBe(false) + expect( + move( + m, + { type: 'tab', tabId: 'zz' }, + { type: 'root', position: 'top' } + ) + ).toBeNull() + }) + + it('allows splitting a tab out of its own tabset', () => { + const m = model( + normalizeTree(row('horizontal', [tabset(['a', 'b'])], 100, 'root')) + ) + const result = move( + m, + { type: 'tab', tabId: 'b' }, + { type: 'tabset', tabsetId: 'ts-a', position: 'right' } + )! + expect(shape(result.root)).toEqual(['h', '*a', '*b']) + }) + + it('ends maximize and unfolds the receiving tabset', () => { + let m = fold(start(), 'ts-c')! + m = maximize(m, 'ts-a')! + m = move( + m, + { type: 'tab', tabId: 'a' }, + { type: 'tabset', tabsetId: 'ts-c', position: 'center' } + )! + expect(m.maximizedTabsetId).toBeNull() + expect(findTabsetOfTab(m.root, 'a')!.isFolded).toBe(false) + }) + + it('keeps every tab under random moves', () => { + let m = model(buildDefaultTree(['a', 'b', 'c', 'd', 'e', 'f'], ids())) + let seed = 7 + const random = (n: number) => (seed = (seed * 16807) % 2147483647) % n + const createId = ids() + const positions = ['top', 'bottom', 'left', 'right', 'center'] as const + for (let i = 0; i < 500; i++) { + const tabs = collectTabIds(m.root) + const tabsets = collectTabsets(m.root) + const source = + random(3) === 0 + ? { + type: 'tabset' as const, + tabsetId: tabsets[random(tabsets.length)].id, + } + : { type: 'tab' as const, tabId: tabs[random(tabs.length)] } + const target = + random(4) === 0 + ? { + type: 'root' as const, + position: positions[random(4)] as 'top', + } + : { + type: 'tabset' as const, + tabsetId: tabsets[random(tabsets.length)].id, + position: positions[random(5)], + } + m = moveNode(m, source, target, createId) ?? m + expect([...collectTabIds(m.root)].sort()).toEqual([ + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + ]) + expect(normalizeTree(m.root)).toBe(m.root) + } + }) +}) + +describe('view state', () => { + const start = () => model(buildDefaultTree(['a', 'b', 'c'], ids())) + + it('folds and unfolds, refusing a tabset alone in its row', () => { + const m = fold(start(), 'ts-a')! + expect(shape(m.root)).toEqual(['h', '~*a', ['v', '*b', '*c']]) + expect(unfold(m, 'ts-a')!.root).toEqual(start().root) + expect(fold(model(buildDefaultTree(['a'], ids())), 'ts-a')).toBeNull() + expect(fold(m, 'ts-a')).toBeNull() + }) + + it('folding the last open child unfolds the most recently folded sibling', () => { + let m = fold(start(), 'ts-b')! + m = fold(m, 'ts-c')! + expect(shape(m.root)).toEqual(['h', '*a', ['v', '*b', '~*c']]) + }) + + it('maximize needs two tabsets and restore clears it', () => { + expect( + maximize(model(buildDefaultTree(['a'], ids())), 'ts-a') + ).toBeNull() + const m = maximize(start(), 'ts-b')! + expect(m.maximizedTabsetId).toBe('ts-b') + expect(maximize(m, 'ts-b')).toBeNull() + expect(restore(m)!.maximizedTabsetId).toBeNull() + expect(restore(start())).toBeNull() + }) + + it('normalizes stale view state', () => { + const m = normalizeViewState({ + ...model( + normalizeTree( + row( + 'horizontal', + [tabset(['a'], 100, { isFolded: true })], + 100, + 'root' + ) + ) + ), + maximizedTabsetId: 'gone', + foldOrder: ['gone', 'ts-a'], + }) + expect(shape(m.root)).toEqual(['h', '*a']) + expect(m.maximizedTabsetId).toBeNull() + expect(m.foldOrder).toEqual(['ts-a']) + }) +}) + +describe('reconcileTabs', () => { + it('removes unlisted tabs and appends new ones to the last active tabset', () => { + const createId = ids() + let m = { + ...model(buildDefaultTree(['a', 'b', 'c'], createId)), + lastActiveTabsetId: 'ts-b', + } + m = reconcileTabs(m, [{ id: 'a' }, { id: 'b' }, { id: 'd' }], createId) + expect(shape(m.root)).toEqual(['h', '*a', 'b,*d']) + }) + + it('honours a target, and does not activate when asked not to', () => { + const createId = ids() + let m = model(buildDefaultTree(['a', 'b'], createId)) + m = reconcileTabs( + m, + [ + { id: 'a' }, + { id: 'b' }, + { id: 'c', target: { type: 'root', position: 'bottom' } }, + ], + createId + ) + expect(shape(m.root)).toEqual(['v', ['h', '*a', '*b'], '*c']) + m = reconcileTabs( + m, + [ + { id: 'a' }, + { id: 'b' }, + { id: 'c' }, + { + id: 'd', + target: { type: 'tab', tabId: 'a', position: 'after' }, + }, + ], + createId, + false + ) + expect(shape(m.root)).toEqual(['v', ['h', '*a,d', '*b'], '*c']) + }) + + it('builds a tabset when the layout is empty', () => { + const createId = ids() + const m = reconcileTabs( + model(buildDefaultTree([], createId)), + [{ id: 'x' }], + createId + ) + expect(shape(m.root)).toEqual(['h', '*x']) + }) +}) diff --git a/packages/core/test/view-state.test.ts b/packages/core/test/view-state.test.ts deleted file mode 100644 index d0fa847..0000000 --- a/packages/core/test/view-state.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { DynamixLayoutCore, Node, Bond } from '../src' -import type { LayoutTree, NodeOptions } from '../src' - -const BOND = 10 -const STRIP = 36 -const SIZE = { w: 1200, h: 800, x: 0, y: 0 } - -const tabset = (name: string, part = 100): LayoutTree => ({ - typNode: 'tabset', - nodName: '', - uidNode: `ts-${name}`, - nodPart: part, - nodOpen: name, - nodKids: [ - { typNode: 'tab', nodName: name, uidNode: `tab-${name}`, nodPart: 100 }, - ], -}) - -/** Root (side by side): a | b | column(c over d). */ -const tree = (): LayoutTree => ({ - typNode: 'row', - nodName: 'dynamix-layout-root', - uidNode: 'dynamix-layout-root', - nodPart: 100, - nodKids: [ - tabset('a', 120), - tabset('b', 80), - { - typNode: 'row', - nodName: '', - uidNode: 'col', - nodPart: 100, - nodKids: [tabset('c'), tabset('d')], - }, - ], -}) - -const create = (layoutTree: LayoutTree = tree()) => { - const layout = new DynamixLayoutCore({ - tree: layoutTree, - bond: BOND, - minW: 40, - minH: 40, - collapsedSize: STRIP, - }) - layout.updateDimension(SIZE, true) - return layout -} - -const opts = (id: string) => Node.cache.nodOpts.get().get(id) as NodeOptions -const dims = (id: string) => ({ ...opts(id).nodDims }) -const node = (id: string) => Node.cache.mapElem.get(id) as Node - -/** Kids (plus bonds) must tile every row exactly. */ -const tilingProblems = (): string[] => { - const problems: string[] = [] - const walk = (n: Node) => { - const kids = [...n.kids] - if (n.type === 'row' && kids.length) { - const horizontal = Node.cache.mapDirs.get(n.unId) - const [pos, size] = horizontal - ? (['x', 'w'] as const) - : (['y', 'h'] as const) - let cursor = n.dims[pos] - for (const k of kids) { - if (k.dims[pos] !== cursor) - problems.push(`${k.unId} at ${k.dims[pos]}, want ${cursor}`) - cursor = k.dims[pos] + k.dims[size] + BOND - } - if (cursor - BOND !== n.dims[pos] + n.dims[size]) - problems.push(`${n.unId} not filled`) - } - kids.forEach(walk) - } - walk(DynamixLayoutCore._root) - return problems -} - -describe('Maximize', () => { - beforeEach(() => new DynamixLayoutCore().clearAllCache()) - - it('shows one tabset over the whole layout and hides the rest', () => { - const layout = create() - expect(layout.maximize('ts-c')).toBe(true) - - expect(layout.maximizedId).toBe('ts-c') - expect(opts('ts-c').nodMaxd).toBe(true) - expect(dims('ts-c')).toEqual(SIZE) - expect(Node.cache.tabOpts.get().get('tab-c')!.nodDims).toEqual(SIZE) - - for (const id of ['ts-a', 'ts-b', 'ts-d']) - expect(opts(id).nodHidden).toBe(true) - for (const bond of Node.cache.bndOpts.get().values()) - expect(bond.nodHidden).toBe(true) - }) - - it('restores the previous split exactly', () => { - const layout = create() - const before = ['ts-a', 'ts-b', 'ts-c', 'ts-d'].map(dims) - - layout.maximize('ts-b') - layout.restore() - - expect(['ts-a', 'ts-b', 'ts-c', 'ts-d'].map(dims)).toEqual(before) - expect(layout.maximizedId).toBeNull() - for (const o of Node.cache.nodOpts.get().values()) - expect(o.nodHidden).toBe(false) - }) - - it('toggles and refuses a layout with a single tabset', () => { - const layout = create() - expect(layout.toggleMaximize('ts-a')).toBe(true) - expect(layout.toggleMaximize('ts-a')).toBe(true) - expect(layout.maximizedId).toBeNull() - - new DynamixLayoutCore().clearAllCache() - const single = new DynamixLayoutCore({ tabs: ['only'] }) - single.updateDimension(SIZE, true) - const [only] = Node.cache.nodOpts.get().keys() - expect(opts(only).nodMaximizable).toBe(false) - expect(single.maximize(only)).toBe(false) - }) -}) - -describe('Fold', () => { - beforeEach(() => new DynamixLayoutCore().clearAllCache()) - - it('folds to a vertical strip in a horizontal row', () => { - const layout = create() - expect(layout.collapse('ts-a')).toBe(true) - - expect(opts('ts-a').nodFold).toBe(true) - expect(dims('ts-a').w).toBe(STRIP) - expect(dims('ts-a').h).toBe(SIZE.h) - expect(tilingProblems()).toEqual([]) - }) - - it('folds to a horizontal strip in a vertical row', () => { - const layout = create() - expect(layout.collapse('ts-c')).toBe(true) - - expect(dims('ts-c').h).toBe(STRIP) - expect(dims('ts-d').h).toBe(SIZE.h - STRIP - BOND) - expect(tilingProblems()).toEqual([]) - }) - - it('unfolds back to the exact previous size', () => { - const layout = create() - const before = ['ts-a', 'ts-b', 'ts-c', 'ts-d'].map(dims) - - layout.collapse('ts-b') - expect(dims('ts-b').w).toBe(STRIP) - layout.expand('ts-b') - - expect(['ts-a', 'ts-b', 'ts-c', 'ts-d'].map(dims)).toEqual(before) - }) - - it('folding the last open child unfolds the most recently folded sibling', () => { - const layout = create() - layout.collapse('ts-c') - expect(layout.collapse('ts-d')).toBe(true) - - expect(node('ts-d').collapsed).toBe(true) - expect(node('ts-c').collapsed).toBe(false) - expect(tilingProblems()).toEqual([]) - }) - - it('cannot fold a tabset that is alone in its row', () => { - const single = new DynamixLayoutCore({ tabs: ['only'] }) - single.updateDimension(SIZE, true) - const [only] = Node.cache.nodOpts.get().keys() - - expect(opts(only).nodFoldable).toBe(false) - expect(single.collapse(only)).toBe(false) - }) - - it('locks the bonds next to a folded tabset', () => { - const layout = create() - layout.collapse('ts-b') - - const bond = node('ts-b').prev as Bond - expect(Node.cache.bndOpts.get().get(bond.unId)!.nodLocked).toBe(true) - - const before = dims('ts-a') - layout.updateSliderDimension(bond.unId, { x: bond.dims.x + 100, y: 0 }) - expect(dims('ts-a')).toEqual(before) - }) - - it('keeps the strip size while the container resizes', () => { - const layout = create() - layout.collapse('ts-a') - - for (let w = 600; w <= 1600; w += 37) { - layout.updateDimension({ ...SIZE, w }, true) - expect(dims('ts-a').w).toBe(STRIP) - expect(tilingProblems()).toEqual([]) - } - }) - - it('folding a maximized tabset leaves maximized mode', () => { - const layout = create() - layout.maximize('ts-a') - layout.collapse('ts-a') - - expect(layout.maximizedId).toBeNull() - expect(dims('ts-a').w).toBe(STRIP) - }) -}) - -describe('View state with saved layouts and drag and drop', () => { - beforeEach(() => new DynamixLayoutCore().clearAllCache()) - - it('round-trips fold and maximize through toJSON', () => { - const layout = create() - layout.collapse('ts-a') - layout.maximize('ts-c') - const saved = DynamixLayoutCore._root.toJSON() - - expect(saved.nodMaxd).toBe('ts-c') - expect(saved.nodKids![0].nodFold).toBe(true) - - new DynamixLayoutCore().clearAllCache() - const restored = create(saved) - expect(restored.maximizedId).toBe('ts-c') - expect(node('ts-a').collapsed).toBe(true) - }) - - it('ends maximize on any move', () => { - const layout = create() - layout.maximize('ts-a') - expect(layout.updateTree('tab-b', 'tab-c', 'contain')).toBe(true) - expect(layout.maximizedId).toBeNull() - }) - - it('unfolds a tabset that receives a dropped tab', () => { - const layout = create() - layout.collapse('ts-a') - expect(layout.updateTree('tab-b', 'tab-a', 'contain')).toBe(true) - expect(node('ts-a').collapsed).toBe(false) - }) - - it('unfolds a tabset that is dragged elsewhere', () => { - const layout = create() - layout.collapse('ts-c') - expect(layout.updateTree('ts-c', 'ts-a', 'left')).toBe(true) - expect(node('ts-c').collapsed).toBe(false) - }) - - it('stays consistent under random moves, folds and maximizes', () => { - const tabs = ['a', 'b', 'c', 'd', 'e', 'f'] - const layout = new DynamixLayoutCore({ - tabs, - bond: BOND, - collapsedSize: STRIP, - }) - layout.updateDimension(SIZE, true) - - let seed = 42 - const rnd = (n: number) => { - seed = (seed * 16807) % 2147483647 - return seed % n - } - const areas = ['top', 'bottom', 'left', 'right', 'contain'] as const - const problems: string[] = [] - - for (let i = 0; i < 400 && problems.length < 5; i++) { - const nodes = [...Node.cache.mapElem.values()].filter( - (n): n is Node => n instanceof Node - ) - const tabsets = nodes.filter((n) => n.type === 'tabset') - const action = rnd(4) - if (action === 0) { - layout.toggleCollapse(tabsets[rnd(tabsets.length)].unId) - } else if (action === 1) { - layout.toggleMaximize(tabsets[rnd(tabsets.length)].unId) - } else { - const srcs = nodes.filter( - (n) => n.type === 'tab' || n.type === 'tabset' - ) - const targets = nodes.filter( - (n) => n.type !== 'row' || n === DynamixLayoutCore._root - ) - layout.updateTree( - srcs[rnd(srcs.length)].unId, - targets[rnd(targets.length)].unId, - areas[rnd(areas.length)] - ) - } - layout.updateDimension(SIZE, true) - - const found: string[] = [] - const walk = (n: Node) => { - if (n.type === 'tab') found.push(n.name) - if ( - n.type !== 'tab' && - n.kids.size() && - [...n.kids].every((k) => k.collapsed) - ) - problems.push( - `move ${i}: every child of ${n.unId} is folded` - ) - if (n.collapsed && n.host && n.host.kids.size() < 2) - problems.push(`move ${i}: ${n.unId} folded alone`) - for (const kid of n.kids) walk(kid) - } - walk(DynamixLayoutCore._root) - if (found.sort().join() !== tabs.join()) - problems.push(`move ${i}: tabs changed`) - problems.push(...tilingProblems().map((p) => `move ${i}: ${p}`)) - } - - expect(problems).toEqual([]) - }) -}) diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 043975b..a228d36 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -7,7 +7,7 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "declaration": true, - "outDir": "./dist/types", + "outDir": "./dist", "moduleResolution": "Node", "rootDir": "./src", "composite": true diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index 81ceec5..2820d47 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -3,45 +3,35 @@ import { resolve } from 'path' import dts from 'vite-plugin-dts' import fs from 'fs' import { visualizer } from 'rollup-plugin-visualizer' -import stripComments from 'vite-plugin-strip-comments' +import { licenseBanner } from '../../scripts/build/license-banner' -const license = fs.readFileSync(resolve(__dirname, '../../LICENSE'), 'utf-8') +const { version } = JSON.parse(fs.readFileSync(resolve(__dirname, 'package.json'), 'utf-8')) export default defineConfig({ define: { - __LICENSE__: JSON.stringify(license), + __VERSION__: JSON.stringify(version), }, build: { sourcemap: 'hidden', lib: { entry: resolve(__dirname, 'src/index.ts'), - name: 'dynamix.layout.core', + name: 'DynamixLayoutCore', // `.cjs` so Node loads the CommonJS build as CommonJS in this `"type": "module"` package. - fileName: (format) => (format === 'cjs' ? 'core.cjs' : `core.${format}.js`), - formats: ['cjs', 'es', 'iife', 'umd'], + fileName: (format) => ({ es: 'index.js', cjs: 'index.cjs', umd: 'index.umd.js' })[format as 'es'], + formats: ['es', 'cjs', 'umd'], }, }, plugins: [ dts({ - outDir: 'dist/types', + outDir: 'dist', // Types-only modules never reach the bundle graph; list sources explicitly. include: ['src'], - // One self-contained declaration file (Node16 resolution can't follow - // extensionless imports), copied to index.d.cts for `require`. + exclude: ['node_modules/**', 'test/**'], + // One self-contained declaration file, copied to index.d.cts for `require`. rollupTypes: true, - afterBuild: () => - fs.copyFileSync( - resolve(__dirname, 'dist/types/index.d.ts'), - resolve(__dirname, 'dist/types/index.d.cts') - ), - exclude: ['node_modules/**', 'src/test/**'], + afterBuild: () => fs.copyFileSync(resolve(__dirname, 'dist/index.d.ts'), resolve(__dirname, 'dist/index.d.cts')), }), - visualizer({ - filename: 'core.html', - gzipSize: true, - brotliSize: true, - template: 'treemap', - }), - stripComments({ type: 'none' }), + licenseBanner('@dynamix-layout/core'), + visualizer({ filename: 'core.html', gzipSize: true, brotliSize: true, template: 'treemap' }), ], }) diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index de95535..06269fb 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -1,6 +1,13 @@ import { defineConfig } from 'vitest/config' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const { version } = JSON.parse( + readFileSync(resolve(__dirname, 'package.json'), 'utf-8') +) export default defineConfig({ + define: { __VERSION__: JSON.stringify(version) }, test: { name: 'core', environment: 'node', diff --git a/packages/react/README.md b/packages/react/README.md index b35ab65..4311254 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -1,215 +1,54 @@ -
+# @dynamix-layout/react -[![Patreon](https://img.shields.io/badge/Patreon-Support-F96854?style=for-the-badge&logo=patreon)](https://www.patreon.com/akashaman) -[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-Donate-FFDD00?style=for-the-badge&logo=buy-me-a-coffee)](https://www.buymeacoffee.com/akashaman) -[![Hire Me](https://img.shields.io/badge/Hire%20Me-Email-blue?style=for-the-badge&logo=gmail)](mailto:sir.akashaman@gmail.com) +Dockable, resizable tab layouts for React 18 and 19. Drag tabs to rearrange or split panels, resize them with splitters, maximize or fold a panel, and save the layout as JSON. -
+![Dragging tabs between panels and resizing them](https://raw.githubusercontent.com/xcode-studio/dynamix-layout/main/assets/demo1.gif) -## 🚀 @dynamix-layout/react - -### The official React wrapper for `@dynamix-layout/core` - -## Overview - -**@dynamix-layout/react** brings the power of the `@dynamix-layout/core` engine to React applications. It provides a flexible `` component and an advanced `useDynamixLayout` hook to create fully dynamic, resizable, and draggable tab-based layouts with ease. Build complex UIs like VS Code or JSFiddle in minutes. - ---- - -### Made with ❤️ by [Akash Aman](https://linktr.ee/akash_aman) - ---- - -![Demo](https://raw.githubusercontent.com/xcode-studio/dynamix-layout/main/assets/demo1.gif) - ---- - -## 📦 Installation +## Install ```bash -npm install @dynamix-layout/react @dynamix-layout/core +npm install @dynamix-layout/react ``` ---- - -## 🏁 Getting Started - -The easiest way to get started is by using the `` component. Provide it with an array of `[id, component]` tuples. +## Quick start -```jsx -import React from 'react' +```tsx import { DynamixLayout } from '@dynamix-layout/react' -import '@dynamix-layout/react/style.css' // Don't forget to import the default styles +import '@dynamix-layout/react/styles.css' -function App() { - // 1. Define your tabs as an array of [id, component] tuples - const myTabs = [ - ['editor',
This is my editor!
], - ['terminal',
This is the terminal.
], - ['preview',
Live preview here.
], - ] +const tabs = [ + { id: 'editor', title: 'Editor', content: }, + { id: 'terminal', title: 'Terminal', content: }, + { id: 'preview', title: 'Preview', content: , closable: true }, +] +export function App() { + const [saved] = useState(() => JSON.parse(localStorage.getItem('layout') ?? 'null') ?? undefined) return ( -
- {/* 2. Render the DynamixLayout component with your tabs */} - -
- ) -} - -export default App -``` - ---- - -## 🧩 `` Component API - -The `` component is the primary way to use this package. It's highly configurable through its props. - -### Core Functionality - -| Prop | Type | Description | Default | -| ---------------------- | ----------------------- | ------------------------------------------------------------------------------------- | ---------------------------- | -| **`tabs`** (required) | `[string, ReactNode][]` | An array of `[id, component]` tuples for your tabs. | ` ` | -| `layoutTree` | `LayoutTree` | A serialized layout object from a previous session to restore a saved layout. | `null` | -| `tabNames` | `Map` | An optional map to provide friendly display names for your tabs in the tab bar. | `new Map()` | -| `enableTabbar` | `boolean` | If `true`, renders the draggable tab bar on top of each tab panel. | `true` | -| `tabHeadHeight` | `number` | The height of the tab bar in pixels. | `40` | -| `pad` | `{ t, b, l, r }` | Padding for the root layout container in pixels. | `{ t: 0, b: 0, l: 0, r: 0 }` | -| `minTabWidth` | `number` | Minimum width of a tab panel in pixels. | `40` | -| `minTabHeight` | `number` | Minimum height of a tab panel in pixels. | `40` | -| `bondWidth` | `number` | The width/height of the draggable slider between panels in pixels. | `10` | -| `rootId` | `string` | The HTML `id` for the root `
` element of the layout. | `"dynamix-layout-root"` | -| `disableResizeTimeout` | `boolean` | Disables debounce on window resize for faster updates. Can impact performance. | `true` | -| `disableSliderTimeout` | `boolean` | Disables debounce when dragging a slider. | `true` | -| `windowResizeTimeout` | `number` | Debounce timeout in milliseconds for window resize events. | `2` | -| `sliderUpdateTimeout` | `number` | Debounce timeout in milliseconds for slider drag events. | `2` | -| `...props` | `HTMLAttributes` | Standard HTML attributes like `style` and `className` are passed to the root `
`. | ` ` | - -### 🎨 Customization with Wrapper Components - -You can completely change the look and feel of the layout by providing your own React components for rendering different parts of the UI. - -| Prop | Description | -| --------------- | ----------------------------------------------------------------------------- | -| `WrapTabPanel` | A component to wrap the entire tab panel (tab bar + tab content area). | -| `WrapTabHead` | A component to wrap the tab bar that contains the tab labels. | -| `WrapTabLabel` | A component for an individual, clickable tab label in the tab bar. | -| `WrapTabBody` | A component to wrap the content of a single tab. | -| `SliderElement` | A component for the draggable slider used to resize panels. | -| `HoverElement` | A component that displays the visual drop zone indicator when dragging a tab. | - -**Example: Custom Slider** - -```jsx -import { DynamixLayout } from '@dynamix-layout/react' -import React from 'react' - -const MyCustomSlider = React.forwardRef((props, ref) => ( -
-)) - -function App() { - // ... (myTabs definition) - return -} -``` - -### Styling Props - -For finer-grained control, you can pass `style` objects or `className` strings to the default wrapper components without replacing them entirely. - -| Prop | Type | Target Element | -| --------------------------- | --------------------- | -------------------- | -| `tabPanelElementStyles` | `React.CSSProperties` | `WrapTabPanel` | -| `tabPanelElementClass` | `string` | `WrapTabPanel` | -| `tabHeadElementStyles` | `React.CSSProperties` | `WrapTabHead` | -| `tabHeadElementClass` | `string` | `WrapTabHead` | -| `tabLabelElementStyles` | `React.CSSProperties` | `WrapTabLabel` | -| `tabLabelElementClass` | `string` | `WrapTabLabel` | -| `tabBodyElementStyles` | `React.CSSProperties` | `WrapTabBody` | -| `tabBodyElementClass` | `string` | `WrapTabBody` | -| `sliderElementStyles` | `React.CSSProperties` | `SliderElement` | -| `sliderElementClass` | `string` | `SliderElement` | -| `hoverElementStyles` | `React.CSSProperties` | `HoverElement` | -| `hoverElementClass` | `string` | `HoverElement` | -| `RootSplitterHoverElStyles` | `React.CSSProperties` | Root edge drop zones | -| `RootSplitterHoverElClass` | `string` | Root edge drop zones | - ---- - -## ⚙️ Advanced Usage: The `useDynamixLayout` Hook - -For ultimate control, you can use the `useDynamixLayout` hook to build your own layout renderer from scratch. It contains all the state, refs, and event handlers needed to power the layout. - -### Options - -The hook accepts an object with the same props as the `` component, with two key differences: - -1. It requires a `tabOutput` prop, generated from your `tabs` array. -2. It requires a `dimensions` function that returns the layout container's size and position. - -### Return Value - -The hook returns an object with everything you need to build your UI, including: - -- **State**: `tabsets` and `sliders` (Maps containing the layout data to render). -- **Refs**: `tabsetsRef`, `slidersRef`, `hoverElementRef`, etc., to connect to your DOM elements. -- **Event Handlers**: `onDragStart`, `onDragEnd`, `onDragOver`, `onPointerDown`, `updateActiveTab`, etc. -- **Instance**: `layoutInstance` (The raw instance of `@dynamix-layout/core` for advanced actions like saving state). - -### Example: Saving a Layout - -- `updateJSON`: A callback function that receives the complete layout state object (LayoutTree) every time a change occurs (like dragging a tab or resizing a panel). You can use this to save the state to localStorage, a database, or anywhere else. - -```jsx -import { tabs } from './comp' -import { DynamixLayout } from '@dynamix-layout/react' -import type { LayoutTree } from '@dynamix-layout/core' -import '@dynamix-layout/react/style.css' - -function App() { - - const UpdateJSON = (layoutTree: LayoutTree) => { - console.log(layoutTree); - }; - - return ( - <> +
localStorage.setItem('layout', JSON.stringify(layout))} /> - +
) } - -export default App - ``` ---- +The layout fills its parent, so give the parent a size. + +## Three levels of API -
+- **``**: one component. Customize it with `components`, `classNames`, `styles` and CSS variables. +- **Headless hooks**: `useDynamixLayout`, `useTabset`, `useTab` and `useSplitter` return prop getters for a fully custom UI. +- **State and actions anywhere**: `useLayoutState(selector)` and `useLayoutActions()`. -[![Patreon](https://img.shields.io/badge/Patreon-Support-F96854?style=for-the-badge&logo=patreon)](https://www.patreon.com/akashaman) -[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-Donate-FFDD00?style=for-the-badge&logo=buy-me-a-coffee)](https://www.buymeacoffee.com/akashaman) -[![Hire Me](https://img.shields.io/badge/Hire%20Me-Email-blue?style=for-the-badge&logo=gmail)](mailto:sir.akashaman@gmail.com) +## Documentation -### Made with ❤️ by [Akash Aman](https://linktr.ee/akash_aman) +- [Getting started](https://github.com/xcode-studio/dynamix-layout/blob/main/docs/getting-started.md) +- [API reference](https://github.com/xcode-studio/dynamix-layout/blob/main/docs/api/README.md) +- [Guides](https://github.com/xcode-studio/dynamix-layout/blob/main/README.md#documentation) +- [Migrating from v1](https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md) -
+MIT © [Akash Aman](https://linktr.ee/akash_aman) diff --git a/packages/react/eslint.config.mjs b/packages/react/eslint.config.mjs index 19170f8..447a2c5 100644 --- a/packages/react/eslint.config.mjs +++ b/packages/react/eslint.config.mjs @@ -1,4 +1,15 @@ -import { config } from "@repo/eslint-config/react-internal"; +import { config } from '@repo/eslint-config/react-internal' /** @type {import("eslint").Linter.Config} */ -export default config; +export default [ + ...config, + { + rules: { + // Slot components destructure state props so they don't reach the DOM. + '@typescript-eslint/no-unused-vars': [ + 'warn', + { ignoreRestSiblings: true }, + ], + }, + }, +] diff --git a/packages/react/package.json b/packages/react/package.json index 64e5b31..ba0d8b8 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -34,26 +34,29 @@ "url": "https://github.com/xcode-studio/dynamix-layout/issues" }, "homepage": "https://github.com/xcode-studio/dynamix-layout#readme", + "type": "module", "main": "./dist/index.cjs", - "module": "./dist/index.es.js", - "types": "./dist/types/index.d.ts", - "typings": "./dist/types/index.d.ts", - "style": "./dist/index.css", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "style": "./dist/styles.css", "exports": { ".": { "import": { - "types": "./dist/types/index.d.ts", - "default": "./dist/index.es.js" + "types": "./dist/index.d.ts", + "default": "./dist/index.js" }, "require": { - "types": "./dist/types/index.d.cts", + "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } }, - "./style.css": "./dist/index.css", - "./styles.css": "./dist/index.css", + "./styles.css": "./dist/styles.css", + "./style.css": "./dist/styles.css", "./package.json": "./package.json" }, + "sideEffects": [ + "**/*.css" + ], "files": [ "dist", "LICENSE", @@ -61,13 +64,9 @@ "README.md", "!**/*.map" ], - "sideEffects": [ - "./dist/index.css" - ], "publishConfig": { "access": "public" }, - "type": "module", "engines": { "node": ">=18" }, @@ -78,10 +77,11 @@ "build": "pnpm run clean && vite build && cp ../../LICENSE dist/LICENSE", "dev:local": "vite", "preview": "vite preview", - "lint": "eslint . --ext ts,tsx" + "lint": "eslint . --ext ts,tsx --max-warnings 0", + "test": "vitest" }, "dependencies": { - "@dynamix-layout/core": "workspace:*" + "@dynamix-layout/core": "workspace:^" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", @@ -96,7 +96,6 @@ "react-dom": "^19.3.0", "typescript": "^5.9.3", "vite": "^7.3.6", - "vite-plugin-dts": "^4.5.4", - "vite-plugin-lib-inject-css": "^2.2.2" + "vite-plugin-dts": "^4.5.4" } } diff --git a/packages/react/src/components/Default.tsx b/packages/react/src/components/Default.tsx deleted file mode 100644 index ef74389..0000000 --- a/packages/react/src/components/Default.tsx +++ /dev/null @@ -1,413 +0,0 @@ -import React, { - forwardRef, - HTMLAttributes, - ReactNode, - useEffect, - useRef, -} from 'react' -import type { TabsetToolbarProps } from '../types' - -type DefaultWrapTabLabelProps = HTMLAttributes & { - active?: boolean - children?: ReactNode -} - -/** @deprecated Renamed in 2.0 to `Tab` (pass it as `components.Tab`). See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -export const DefaultWrapTabLabel = forwardRef< - HTMLDivElement, - DefaultWrapTabLabelProps ->(({ children, active, ...props }, ref) => ( -
- {children} -
-)) -DefaultWrapTabLabel.displayName = 'DefaultWrapTabLabel' - -/** @deprecated Renamed in 2.0 to `TabContent` (pass it as `components.TabContent`). See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -export const DefaultWrapTabBody = forwardRef< - HTMLDivElement, - { children?: ReactNode } & HTMLAttributes ->(({ children, ...props }, ref) => ( -
- {children} -
-)) -DefaultWrapTabBody.displayName = 'DefaultWrapTabBody' - -/** @deprecated Renamed in 2.0 to `TabBar` (pass it as `components.TabBar`). See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -export const DefaultWrapTabHead = forwardRef< - HTMLDivElement, - { children?: ReactNode } & HTMLAttributes ->(({ children, ...props }, ref) => ( -
- {children} -
-)) -DefaultWrapTabHead.displayName = 'DefaultWrapTabHead' - -/** @deprecated Renamed in 2.0 to `Panel` (pass it as `components.Panel`). See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -export const DefaultWrapTabPanel = forwardRef< - HTMLDivElement, - { children?: ReactNode } & HTMLAttributes ->(({ children, ...props }, ref) => ( -
- {children} -
-)) -DefaultWrapTabPanel.displayName = 'DefaultWrapTabPanel' - -/** @deprecated Renamed in 2.0 to `DropIndicator` (pass it as `components.DropIndicator`). See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -export const DefaultHoverElement = forwardRef< - HTMLDivElement, - { children?: ReactNode } & HTMLAttributes ->(({ children, ...props }, ref) => ( -
- {children} -
-)) -DefaultHoverElement.displayName = 'DefaultHoverElement' - -type DefaultSliderElementProps = HTMLAttributes & { - children?: ReactNode - direction?: boolean -} - -/** @deprecated Renamed in 2.0 to `Splitter` (pass it as `components.Splitter`). See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -export const DefaultSliderElement = forwardRef< - HTMLDivElement, - DefaultSliderElementProps ->(({ children, direction, ...props }, ref) => ( -
-
- {!direction ? ( - - - - - - - - - ) : ( - - - - - - - - - )} -
- {children} -
-)) -DefaultSliderElement.displayName = 'DefaultSliderElement' - -/** @deprecated Renamed in 2.0 to `RootDropZone` (pass it as `components.RootDropZone`). See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -export const RootSplitterHoverEl = forwardRef< - HTMLDivElement, - { - children?: ReactNode - area: 'left' | 'right' | 'top' | 'bottom' - size?: { w: string; h: string } - gap?: string - } & HTMLAttributes ->( - ( - { - children, - area, - size = { h: '20%', w: '6px' }, - gap = '0px', - ...props - }, - ref - ) => { - let dynamicStyle: React.CSSProperties = {} - - if (area === 'left') { - dynamicStyle = { - borderTopRightRadius: '16px', - borderBottomRightRadius: '16px', - height: size.h, - left: gap, - width: size.w, - } - dynamicStyle.top = `calc(50% - ${dynamicStyle.height} / 2)` - } else if (area === 'top') { - dynamicStyle = { - borderBottomLeftRadius: '16px', - borderBottomRightRadius: '16px', - width: size.h, - top: gap, - height: size.w, - } - dynamicStyle.left = `calc(50% - ${dynamicStyle.width} / 2)` - } else if (area === 'right') { - dynamicStyle = { - borderTopLeftRadius: '16px', - borderBottomLeftRadius: '16px', - height: size.h, - right: gap, - width: size.w, - } - dynamicStyle.top = `calc(50% - ${dynamicStyle.height} / 2)` - } else if (area === 'bottom') { - dynamicStyle = { - borderTopLeftRadius: '16px', - borderTopRightRadius: '16px', - width: size.h, - bottom: gap, - height: size.w, - } - dynamicStyle.left = `calc(50% - ${dynamicStyle.width} / 2)` - } - - return ( -
- {children} -
- ) - } -) -RootSplitterHoverEl.displayName = 'RootSplitterHoverEl' - -const ToolbarIcon = ({ - path, - rotate = 0, -}: { - path: string - rotate?: number -}) => ( - -) - -const ICON_MAXIMIZE = - 'M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M3 16v3a2 2 0 0 0 2 2h3M16 21h3a2 2 0 0 0 2-2v-3' -const ICON_RESTORE = - 'M8 3v3a2 2 0 0 1-2 2H3M21 8h-3a2 2 0 0 1-2-2V3M3 16h3a2 2 0 0 1 2 2v3M16 21v-3a2 2 0 0 1 2-2h3' -const ICON_CHEVRON_LEFT = 'm15 18-6-6 6-6' - -export const DefaultTabsetToolbar = ({ - maximized, - folded, - rotated, - rowIsHorizontal, - showMaximize, - showFold, - onToggleMaximize, - onToggleFold, -}: TabsetToolbarProps) => { - // Fold points back along the row (left or up); unfold points the other way. - // Inside a rotated strip the icons are turned back so they read upright. - const chevronTurn = (rowIsHorizontal ? 0 : 90) + (folded ? 180 : 0) - const upright = rotated ? -90 : 0 - const toolbarRef = useRef(null) - - // Stay pinned to the visible end of the tab bar when its tabs overflow - // and it scrolls. - useEffect(() => { - const toolbar = toolbarRef.current - const bar = toolbar?.parentElement - if (!toolbar || !bar) return - const pin = () => { - toolbar.style.transform = `translateX(${bar.scrollLeft}px)` - } - pin() - bar.addEventListener('scroll', pin, { passive: true }) - return () => bar.removeEventListener('scroll', pin) - }, []) - - return ( -
e.stopPropagation()} - > - {showMaximize && ( - - )} - {showFold && ( - - )} -
- ) -} diff --git a/packages/react/src/components/DropIndicator.tsx b/packages/react/src/components/DropIndicator.tsx new file mode 100644 index 0000000..11ea191 --- /dev/null +++ b/packages/react/src/components/DropIndicator.tsx @@ -0,0 +1,15 @@ +import { forwardRef } from 'react' +import type { DropIndicatorProps } from '../types' + +/** + * Default `DropIndicator`: highlights where a dragged tab would land. + * @example + * const MyIndicator = forwardRef(function MyIndicator({ target, ...props }, ref) { + * return
{target.type === 'root' ? 'Dock here' : null}
+ * }) + */ +export const DropIndicator = forwardRef( + function DropIndicator({ target, ...props }, ref) { + return
+ } +) diff --git a/packages/react/src/components/DynamixLayout.tsx b/packages/react/src/components/DynamixLayout.tsx new file mode 100644 index 0000000..60aa04c --- /dev/null +++ b/packages/react/src/components/DynamixLayout.tsx @@ -0,0 +1,237 @@ +import { + forwardRef, + useImperativeHandle, + useRef, + useState, + type ForwardedRef, + type ForwardRefExoticComponent, + type RefAttributes, + type ReactElement, + type Ref, +} from 'react' +import { LayoutContext, fromPublicController } from '../context/layout-context' +import { useDynamixLayout } from '../hooks/use-dynamix-layout' +import type { + DynamixLayoutHandle, + DynamixLayoutProps, + LayoutComponents, +} from '../types' +import { + ContentView, + sameParts, + slot, + SplitterView, + TabsetView, + type Parts, +} from './LayoutParts' +import { DropIndicator } from './DropIndicator' +import { LiveRegion } from './LiveRegion' +import { Panel } from './Panel' +import { RootDropZone } from './RootDropZone' +import { Splitter } from './Splitter' +import { Tab } from './Tab' +import { TabBar } from './TabBar' +import { TabContent } from './TabContent' +import { TabsetToolbar } from './TabsetToolbar' + +const DEFAULT_COMPONENTS: LayoutComponents = { + Panel, + TabBar, + Tab, + TabContent, + Splitter, + DropIndicator, + RootDropZone, + TabsetToolbar, +} + +function DynamixLayoutImpl( + props: DynamixLayoutProps, + ref: ForwardedRef +): ReactElement { + const { + tabs, + defaultLayout, + layout, + onLayoutChange, + onTabClose, + minPanelSize, + splitterSize, + tabBarHeight, + showTabBar = true, + padding, + allowMaximize = true, + allowFold = true, + maximizeOnDoubleClick, + keyboardShortcuts, + tabActivation, + resizeThrottleMs, + components, + classNames = {}, + styles = {}, + className, + style, + ...rest + } = props + const result = useDynamixLayout({ + tabs, + defaultLayout, + layout, + onLayoutChange, + onTabClose, + minPanelSize, + splitterSize, + tabBarHeight, + showTabBar, + padding, + allowMaximize, + allowFold, + maximizeOnDoubleClick, + keyboardShortcuts, + tabActivation, + resizeThrottleMs, + id: rest.id, + }) + const { + controller, + getRootProps, + tabsets, + splitters, + tabIds, + dropIndicator, + rootDropZones, + actions, + } = result + + // `components`, `classNames` and `styles` are usually inline objects; only their contents matter. + const [parts, setParts] = useState(() => ({ + components: { ...DEFAULT_COMPONENTS, ...components }, + classNames, + styles, + showTabBar, + allowMaximize, + allowFold: allowFold && showTabBar, + })) + const nextParts: Parts = { + components: { ...DEFAULT_COMPONENTS, ...components }, + classNames, + styles, + showTabBar, + allowMaximize, + allowFold: allowFold && showTabBar, + } + if (!sameParts(parts, nextParts)) setParts(nextParts) + + const rootElement = useRef(null) + useImperativeHandle( + ref, + () => ({ + ...actions, + get element() { + return rootElement.current + }, + focusTab(tabId: string) { + document + .getElementById( + fromPublicController(controller).core.ids.tab(tabId) + ) + ?.focus() + }, + }), + [actions, controller] + ) + + const context = fromPublicController(controller) + const rootProps = getRootProps({ + ...rest, + className: + [className, classNames.root].filter(Boolean).join(' ') || undefined, + style: { ...style, ...styles.root }, + ref: rootElement as Ref, + }) + const { DropIndicator: Indicator, RootDropZone: Zone } = parts.components + + return ( + +
+ {tabsets.map((tabset) => ( + + ))} + {tabIds.map((tabId) => ( + + ))} + {splitters.map((splitter) => ( + + ))} + {rootDropZones.map((zone) => ( + + ))} + {dropIndicator && ( + + )} + +
+
+ ) +} + +/** + * A docking layout: tabs in tabsets, split by draggable splitters, with tab + * drag-and-drop, maximize and fold. Most apps only need this component. + * + * Tab contents are rendered once and never remount when tabs move. Save the + * layout from `onLayoutChange` and pass it back as `defaultLayout` (or control + * it with `layout`). + * + * @example + * }, + * { id: 'terminal', title: 'Terminal', content: }, + * ]} + * defaultLayout={saved} + * onLayoutChange={(layout) => save(layout)} + * /> + */ +export const DynamixLayout: ForwardRefExoticComponent< + DynamixLayoutProps & RefAttributes +> = forwardRef(DynamixLayoutImpl) + +DynamixLayout.displayName = 'DynamixLayout' diff --git a/packages/react/src/components/Layout.tsx b/packages/react/src/components/Layout.tsx deleted file mode 100644 index d94a33e..0000000 --- a/packages/react/src/components/Layout.tsx +++ /dev/null @@ -1,439 +0,0 @@ -import React, { - ForwardRefExoticComponent, - RefAttributes, - useCallback, - useMemo, - useRef, -} from 'react' -import { useDynamixLayout } from '../hooks/useLayout' -import { - DynamixLayoutCore, - NodeOptions, - getTabbarPlacement, -} from '@dynamix-layout/core' -import { LayoutProps, TabEntry, TabInput, TabOutput } from '../types' -import { - DefaultHoverElement, - DefaultWrapTabLabel, - DefaultWrapTabPanel, - DefaultWrapTabHead, - DefaultSliderElement, - DefaultWrapTabBody, - DefaultTabsetToolbar, - RootSplitterHoverEl, -} from './Default' -import './layout.css' - -/** @deprecated Internal; removed in 2.0 (tabs are objects with stable ids). See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -export const getTabOutput = (input: TabInput): TabOutput => { - const keys: string[] = [] - const maps: Map = new Map() - const name: Map = new Map() - - for (const [label, node] of input) { - const key = crypto.randomUUID() - keys.push(label) - maps.set(label, { - uqid: key, - name: label, - node, - } as TabEntry) - name.set(label, key) - } - - return { - keys, - maps, - name, - } -} - -export const DynamixLayout: ForwardRefExoticComponent< - LayoutProps & RefAttributes -> = React.forwardRef( - ( - { - tabs, - bondWidth = DynamixLayoutCore._bond, - minTabHeight = DynamixLayoutCore._minH, - minTabWidth = DynamixLayoutCore._minW, - updateJSON, - sliderUpdateTimeout = 2, - windowResizeTimeout = 2, - disableSliderTimeout = true, - disableResizeTimeout = true, - HoverElement = DefaultHoverElement, - enableTabbar = true, - WrapTabLabel = DefaultWrapTabLabel, - WrapTabPanel = DefaultWrapTabPanel, - WrapTabHead = DefaultWrapTabHead, - WrapTabBody = DefaultWrapTabBody, - SliderElement = DefaultSliderElement, - tabHeadHeight = DynamixLayoutCore._minH, - tabNames = new Map(), - layoutTree, - pad = { - t: 0, - b: 0, - l: 0, - r: 0, - }, - rootId = 'dynamix-layout-root', - hoverElementStyles, - sliderElementStyles, - tabPanelElementStyles, - tabBodyElementStyles, - tabHeadElementStyles, - tabLabelElementStyles, - RootSplitterHoverElStyles, - hoverElementClass, - sliderElementClass, - tabPanelElementClass, - tabBodyElementClass, - tabHeadElementClass, - tabLabelElementClass, - RootSplitterHoverElClass, - enableMaximize = true, - enableCollapse = true, - enableDoubleClickMaximize = true, - keyboardShortcuts = true, - TabsetToolbar = DefaultTabsetToolbar, - ...props - }: LayoutProps, - ref? - ) => { - const tabOutput = useMemo(() => getTabOutput(tabs), [tabs]) - const ownRef = useRef(null) - const resolvedRef = typeof ref === 'function' ? ownRef : ref || ownRef - - const dimensions = useCallback(() => { - const el = resolvedRef.current - if (!el) return { w: 0, h: 0, x: 0, y: 0 } - const rect = el.getBoundingClientRect() - return { - w: rect.width - (pad.l + pad.r), - h: rect.height - (pad.t + pad.b), - x: rect.left + pad.l, - y: rect.top + pad.t, - } - }, [resolvedRef, pad.l, pad.r, pad.t, pad.b]) - - const { - tabsetsRef, - slidersRef, - sliders, - tabsets, - hoverElementRef, - onDragEnd, - onDragOver, - onDragStart, - onDragEnter, - onDragLeave, - onDrop, - dragging, - panelsRef, - onPointerDown, - handleNavbarDragOver, - updateActiveTab, - rootSplitHoverEl, - handleRootSplit, - tabsRef, - isUpdating, - toggleMaximize, - toggleCollapse, - onRootPointerDown, - onTabbarDoubleClick, - } = useDynamixLayout({ - tabOutput, - rootId, - updateJSON, - enableTabbar, - layoutTree, - tabHeadHeight, - dimensions, - sliderUpdateTimeout, - windowResizeTimeout, - bondWidth, - minTabHeight, - minTabWidth, - disableSliderTimeout, - disableResizeTimeout, - hoverElementStyles, - keyboardShortcuts, - enableDoubleClickMaximize, - }) - - // Folding needs the tab bar: a folded tabset is only its tab bar. - const canFold = enableCollapse && enableTabbar - - return ( -
- {enableTabbar && - tabsets && - Array.from(tabsets.values()).map((tabset) => { - const placement = getTabbarPlacement( - tabset, - tabHeadHeight - ) - const showMaximize = - enableMaximize && !!tabset.nodMaximizable - const showFold = - canFold && !!tabset.nodFoldable && !tabset.nodMaxd - const hasToolbar = showMaximize || showFold - - return ( - - { - const id = tabset.uidNode - if (el) panelsRef.current.set(id, el) - else panelsRef.current.delete(id) - }} - onDragEnd={onDragEnd} - onDragOver={onDragOver} - onDragEnter={onDragEnter} - onDragLeave={onDragLeave} - onDrop={onDrop} - data-uid={tabset.uidNode} - data-type={tabset.typNode} - className={ - 'hide-scrollbar DefaultWrapTabPanel ' + - (tabPanelElementClass || '') - } - style={{ - position: 'absolute', - ...tabPanelElementStyles, - width: `${tabset.nodDims.w}px`, - height: `${tabset.nodDims.h}px`, - left: `${tabset.nodDims.x}px`, - top: `${tabset.nodDims.y}px`, - backgroundColor: 'transparent', - }} - /> - - { - const id = tabset.uidNode - if (el) tabsetsRef.current.set(id, el) - else tabsetsRef.current.delete(id) - }} - className={ - 'hide-scrollbar ' + - (tabHeadElementClass || '') - } - style={{ - ...tabHeadElementStyles, - position: 'absolute', - zIndex: 99, - width: `${placement.rect.w}px`, - height: `${placement.rect.h}px`, - left: `${placement.rect.x}px`, - top: `${placement.rect.y}px`, - transformOrigin: '0 0', - // Only add keys when needed: an explicit `undefined` - // would wipe the tab bar component's own value. - ...(placement.rotated - ? { transform: 'rotate(90deg)' } - : {}), - ...(hasToolbar - ? { paddingRight: '64px' } - : {}), - cursor: isUpdating ? 'wait' : 'pointer', - }} - > - {tabset.nodKids && - tabset.nodKids.map( - (tab: NodeOptions) => ( - - {tabNames.get( - tab.nodName - ) || tab.nodName} - - ) - )} - {hasToolbar && ( - - toggleMaximize(tabset.uidNode) - } - onToggleFold={() => - toggleCollapse(tabset.uidNode) - } - /> - )} - - - ) - })} - - {tabOutput.keys.map((key) => { - const tabId = tabOutput.maps.get(key)?.uqid - return ( - { - if (tabId) { - if (el) tabsRef.current.set(tabId, el) - else tabsRef.current.delete(tabId) - } - }} - > - {tabOutput.maps.get(key)?.node || ( -
- - No content - -
- )} -
- ) - })} - - {sliders && - Array.from(sliders.values()).map((slider) => ( - { - const id = slider.uidNode - if (el) slidersRef.current.set(id, el) - else slidersRef.current.delete(id) - }} - style={{ - width: `${slider.nodDims.w}px`, - height: `${slider.nodDims.h}px`, - left: `${slider.nodDims.x}px`, - top: `${slider.nodDims.y}px`, - cursor: slider.nodeDir - ? 'ns-resize' - : 'ew-resize', - ...(slider.nodLocked - ? { pointerEvents: 'none' as const } - : {}), - ...sliderElementStyles, - }} - /> - ))} - - - - {(['left', 'right', 'top', 'bottom'] as const).map( - (area, index) => ( - { - if (el) rootSplitHoverEl.current[index] = el - }} - style={{ ...RootSplitterHoverElStyles }} - key={area} - area={area} - size={{ h: '25%', w: '8px' }} - /> - ) - )} -
- ) - } -) - -DynamixLayout.displayName = 'DynamixLayout' diff --git a/packages/react/src/components/LayoutParts.tsx b/packages/react/src/components/LayoutParts.tsx new file mode 100644 index 0000000..d5dcdc3 --- /dev/null +++ b/packages/react/src/components/LayoutParts.tsx @@ -0,0 +1,206 @@ +import { + memo, + type CSSProperties, + type MouseEvent, + type PointerEvent, +} from 'react' +import { useSplitter } from '../hooks/use-splitter' +import { useTab } from '../hooks/use-tab' +import { useTabset } from '../hooks/use-tabset' +import type { LayoutComponents, LayoutSlot } from '../types' + +/** + * The pieces renders for each tabset, tab, tab content and + * splitter. Each is memoized and subscribes only to its own state. + */ + +/** Rendering settings shared by the parts below. */ +export interface Parts { + components: LayoutComponents + classNames: Partial> + styles: Partial> + showTabBar: boolean + allowMaximize: boolean + allowFold: boolean +} + +export const slot = (parts: Parts, name: LayoutSlot) => ({ + className: parts.classNames[name], + style: parts.styles[name], +}) + +export const TabView = memo(function TabView({ + tabId, + parts, +}: { + tabId: string + parts: Parts +}) { + const { item, isActive, isDragging, close, getTabProps } = useTab(tabId) + if (!item) return null + const props = getTabProps({ + className: + [parts.classNames.tab, isActive && parts.classNames.activeTab] + .filter(Boolean) + .join(' ') || undefined, + style: { + ...parts.styles.tab, + ...(isActive ? parts.styles.activeTab : {}), + }, + }) + const closeButtonProps = item.closable + ? { + className: ['dx-tab-close', parts.classNames.tabClose] + .filter(Boolean) + .join(' '), + style: parts.styles.tabClose, + 'aria-label': `Close ${typeof item.title === 'string' ? item.title : item.id}`, + tabIndex: -1, + onPointerDown: (event: PointerEvent) => event.stopPropagation(), + onClick: (event: MouseEvent) => { + event.stopPropagation() + close() + }, + } + : undefined + const Component = parts.components.Tab + return ( + + ) +}) + +export const TabsetView = memo(function TabsetView({ + tabsetId, + parts, +}: { + tabsetId: string + parts: Parts +}) { + const { + tabset, + isRotated, + getPanelProps, + getTabBarProps, + toggleMaximize, + toggleFold, + } = useTabset(tabsetId) + if (!tabset) return null + const { + Panel: PanelComponent, + TabBar: TabBarComponent, + TabsetToolbar: Toolbar, + } = parts.components + const canMaximize = parts.allowMaximize && tabset.canMaximize + const canFold = parts.allowFold && tabset.canFold && !tabset.isMaximized + return ( + <> + + {parts.showTabBar && ( + + {tabset.tabIds.map((tabId) => ( + + ))} + {(canMaximize || canFold) && ( + + )} + + )} + + ) +}) + +export const ContentView = memo(function ContentView({ + tabId, + parts, +}: { + tabId: string + parts: Parts +}) { + const { item, isActive, getTabContentProps } = useTab(tabId) + if (!item) return null + const Component = parts.components.TabContent + return ( + + {item.content} + + ) +}) + +export const SplitterView = memo(function SplitterView({ + splitterId, + parts, +}: { + splitterId: string + parts: Parts +}) { + const { splitter, isDragging, getSplitterProps } = useSplitter(splitterId) + if (!splitter) return null + const Component = parts.components.Splitter + return ( + + ) +}) + +/** Whether two sets of rendering settings are equal by content (they are usually inline objects). */ +export function sameParts(a: Parts, b: Parts): boolean { + const shallow = (x: object, y: object) => { + const keys = Object.keys(x) + return ( + keys.length === Object.keys(y).length && + keys.every( + (k) => + (x as Record)[k] === + (y as Record)[k] + ) + ) + } + return ( + a.showTabBar === b.showTabBar && + a.allowMaximize === b.allowMaximize && + a.allowFold === b.allowFold && + shallow(a.components, b.components) && + shallow(a.classNames, b.classNames) && + Object.keys({ ...a.styles, ...b.styles }).every((k) => + shallow( + (a.styles as Record)[k] ?? {}, + (b.styles as Record)[k] ?? {} + ) + ) + ) +} diff --git a/packages/react/src/components/LiveRegion.tsx b/packages/react/src/components/LiveRegion.tsx new file mode 100644 index 0000000..48896b7 --- /dev/null +++ b/packages/react/src/components/LiveRegion.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState, type ReactElement } from 'react' +import { useLayoutContext } from '../context/layout-context' + +/** Polite live region for screen-reader announcements (keyboard move mode). */ +export function LiveRegion(): ReactElement { + const { core } = useLayoutContext('LiveRegion') + const [message, setMessage] = useState('') + useEffect(() => core.onAnnounce(setMessage), [core]) + return ( +
+ {message} +
+ ) +} diff --git a/packages/react/src/components/Panel.tsx b/packages/react/src/components/Panel.tsx new file mode 100644 index 0000000..a36cce2 --- /dev/null +++ b/packages/react/src/components/Panel.tsx @@ -0,0 +1,18 @@ +import { forwardRef } from 'react' +import type { PanelProps } from '../types' + +/** + * Default `Panel`: the box of a tabset, behind its tab bar and content. + * @example + * // Wrap the default (slot components must forward `ref`): + * const MyPanel = forwardRef(function MyPanel(props, ref) { + * return + * }) + * + */ +export const Panel = forwardRef(function Panel( + { tabset, ...props }, + ref +) { + return
+}) diff --git a/packages/react/src/components/RootDropZone.tsx b/packages/react/src/components/RootDropZone.tsx new file mode 100644 index 0000000..e5a46aa --- /dev/null +++ b/packages/react/src/components/RootDropZone.tsx @@ -0,0 +1,21 @@ +import { forwardRef } from 'react' +import type { RootDropZoneProps } from '../types' + +/** + * Default `RootDropZone`: a handle at one edge of the layout, shown while dragging a tab. + * @example + * const MyZone = forwardRef(function MyZone({ side, isActive, ...props }, ref) { + * return
+ * }) + */ +export const RootDropZone = forwardRef( + function RootDropZone({ side, isActive, ...props }, ref) { + return ( +
+ ) + } +) diff --git a/packages/react/src/components/Splitter.tsx b/packages/react/src/components/Splitter.tsx new file mode 100644 index 0000000..4c7d34a --- /dev/null +++ b/packages/react/src/components/Splitter.tsx @@ -0,0 +1,16 @@ +import { forwardRef } from 'react' +import type { SplitterProps } from '../types' + +/** + * Default `Splitter`: a bar with a grip (drawn in CSS). + * @example + * const MySplitter = forwardRef(function MySplitter({ splitter, isDragging, ...props }, ref) { + * return
+ * }) + * + */ +export const Splitter = forwardRef( + function Splitter({ splitter, isDragging, ...props }, ref) { + return
+ } +) diff --git a/packages/react/src/components/Tab.tsx b/packages/react/src/components/Tab.tsx new file mode 100644 index 0000000..17859a4 --- /dev/null +++ b/packages/react/src/components/Tab.tsx @@ -0,0 +1,40 @@ +import { forwardRef, type HTMLAttributes, type Ref } from 'react' +import type { TabProps } from '../types' + +/** + * Default `Tab`: the title, plus a close button when the tab is closable. + * @example + * // Wrap the default tab, e.g. to add a tooltip: + * const MyTab = forwardRef(function MyTab(props, ref) { + * return + * }) + */ +export const Tab = forwardRef(function Tab( + { tab, isActive, isDragging, onClose, closeButtonProps, ...props }, + ref +) { + return ( +
} + {...(props as HTMLAttributes)} + > + {tab.title ?? tab.id} + {closeButtonProps && ( + + )} +
+ ) +}) diff --git a/packages/react/src/components/TabBar.tsx b/packages/react/src/components/TabBar.tsx new file mode 100644 index 0000000..109c23d --- /dev/null +++ b/packages/react/src/components/TabBar.tsx @@ -0,0 +1,21 @@ +import { forwardRef } from 'react' +import type { TabBarProps } from '../types' + +/** + * Default `TabBar`: a scrollable row of tabs with the toolbar at its end. + * @example + * const MyTabBar = forwardRef(function MyTabBar({ tabset, isRotated, children, ...props }, ref) { + * return
{children}
+ * }) + * + */ +export const TabBar = forwardRef(function TabBar( + { tabset, isRotated, children, ...props }, + ref +) { + return ( +
+ {children} +
+ ) +}) diff --git a/packages/react/src/components/TabContent.tsx b/packages/react/src/components/TabContent.tsx new file mode 100644 index 0000000..de0ba16 --- /dev/null +++ b/packages/react/src/components/TabContent.tsx @@ -0,0 +1,20 @@ +import { forwardRef } from 'react' +import type { TabContentProps } from '../types' + +/** + * Default `TabContent`: holds a tab's content; positioned by the library. + * @example + * const MyContent = forwardRef(function MyContent({ tab, isActive, children, ...props }, ref) { + * return
{children}
+ * }) + * + */ +export const TabContent = forwardRef( + function TabContent({ tab, isActive, children, ...props }, ref) { + return ( +
+ {children} +
+ ) + } +) diff --git a/packages/react/src/components/TabsetToolbar.tsx b/packages/react/src/components/TabsetToolbar.tsx new file mode 100644 index 0000000..4de641f --- /dev/null +++ b/packages/react/src/components/TabsetToolbar.tsx @@ -0,0 +1,115 @@ +import { useEffect, useRef, type ReactElement } from 'react' +import type { TabsetToolbarProps } from '../types' + +const ICON_MAXIMIZE = + 'M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M3 16v3a2 2 0 0 0 2 2h3M16 21h3a2 2 0 0 0 2-2v-3' +const ICON_RESTORE = + 'M8 3v3a2 2 0 0 1-2 2H3M21 8h-3a2 2 0 0 1-2-2V3M3 16h3a2 2 0 0 1 2 2v3M16 21v-3a2 2 0 0 1 2-2h3' +const ICON_CHEVRON = 'm15 18-6-6 6-6' + +function Icon({ path, rotate }: { path: string; rotate: number }) { + return ( + + ) +} + +/** + * Default `TabsetToolbar`: maximize/restore and fold/unfold buttons at the end + * of a tab bar, shown on hover and always on folded strips. + * @param props - The tabset, what it can do, and the toggle handlers. + * @example + * function MyToolbar({ tabset, canMaximize, onToggleMaximize }: TabsetToolbarProps) { + * return canMaximize ? : null + * } + * + */ +export function TabsetToolbar({ + tabset, + isRotated, + canMaximize, + canFold, + onToggleMaximize, + onToggleFold, + className, + style, + buttonClassName, + buttonStyle, +}: TabsetToolbarProps): ReactElement { + const toolbarRef = useRef(null) + const { isMaximized, isFolded } = tabset + // Fold points back along the row (left or up), unfold the other way; inside + // a rotated strip the icons turn back so they read upright. + const chevronTurn = + (tabset.parentDirection === 'horizontal' ? 0 : 90) + + (isFolded ? 180 : 0) + const upright = isRotated ? -90 : 0 + + // Stay pinned to the visible end of a tab bar that scrolls. + useEffect(() => { + const toolbar = toolbarRef.current + const bar = toolbar?.parentElement + if (!toolbar || !bar) return + const pin = () => { + toolbar.style.transform = `translateX(${bar.scrollLeft}px)` + } + pin() + bar.addEventListener('scroll', pin, { passive: true }) + return () => bar.removeEventListener('scroll', pin) + }, []) + + const buttonClass = ['dx-toolbar-button', buttonClassName] + .filter(Boolean) + .join(' ') + return ( +
event.stopPropagation()} + > + {canMaximize && ( + + )} + {canFold && ( + + )} +
+ ) +} diff --git a/packages/react/src/components/layout.css b/packages/react/src/components/layout.css deleted file mode 100644 index 13703ad..0000000 --- a/packages/react/src/components/layout.css +++ /dev/null @@ -1,83 +0,0 @@ -/* This file contains all the necessary global CSS for the SolidJS components. - These rules cannot be applied as inline styles. -*/ -/* --- Tab Label State --- */ -/* Controls the background color of the DefaultWrapTabLabel component based on its state. */ -.DefaultWrapTabLabel[data-state='active'] { - background-color: #ffffff; - font-family: 'Poppins', sans-serif; -} - -.DefaultWrapTabLabel[data-state='inactive'] { - background-color: #dfdfdf; - font-family: 'Poppins', sans-serif; -} - -/* --- Scrollbar Hiding --- */ -/* Hides the scrollbar for WebKit-based browsers (Chrome, Safari, Opera) - in components using the 'hide-scrollbar' class. */ -.hide-scrollbar::-webkit-scrollbar { - display: none; -} - -/* --- Dragging State --- */ -/* Prevents child elements (like a text editor) inside DefaultWrapTabBody - from capturing mouse events when a parent container has the 'is-dragging' class. - This ensures smooth drag-and-drop behavior. */ -.is-dragging .DefaultWrapTabBody > * { - pointer-events: none; -} -/* --- Tabset toolbar (maximize / fold) --- */ -/* Shown while the tab bar is hovered or focused, like LeetCode. Folded strips - always show it so they can be unfolded. */ -.DefaultTabsetToolbar { - position: absolute; - top: 0; - right: 6px; - height: 100%; - display: flex; - align-items: center; - gap: 2px; - padding-left: 6px; - /* Opaque, so overflowing tab labels slide underneath instead of showing - through the buttons. */ - background: inherit; - opacity: 0; - transition: opacity 0.15s ease; -} - -[data-tabbar]:hover .DefaultTabsetToolbar, -[data-tabbar][data-folded] .DefaultTabsetToolbar, -.DefaultTabsetToolbar:focus-within { - opacity: 1; -} - -@media (hover: none) { - .DefaultTabsetToolbar { - opacity: 1; - } -} - -.DefaultTabsetToolbarButton { - display: grid; - place-items: center; - width: 24px; - height: 24px; - padding: 0; - border: 0; - border-radius: 4px; - background: transparent; - color: inherit; - cursor: pointer; -} - -.DefaultTabsetToolbarButton:hover, -.DefaultTabsetToolbarButton:focus-visible { - background-color: rgba(0, 0, 0, 0.08); -} - -/* Hidden while another tabset is maximized. An attribute (not inline display) - keeps each component's own display value intact. */ -[data-dx-hidden] { - display: none !important; -} diff --git a/packages/react/src/context/DynamixLayoutProvider.tsx b/packages/react/src/context/DynamixLayoutProvider.tsx new file mode 100644 index 0000000..886bbd7 --- /dev/null +++ b/packages/react/src/context/DynamixLayoutProvider.tsx @@ -0,0 +1,34 @@ +import type { ReactElement, ReactNode } from 'react' +import type { LayoutController } from '../types' +import { LayoutContext, fromPublicController } from './layout-context' + +/** Props of {@link DynamixLayoutProvider}. */ +export interface DynamixLayoutProviderProps { + /** The `controller` returned by `useDynamixLayout`. */ + controller: LayoutController + children?: ReactNode +} + +/** + * Makes a layout created with `useDynamixLayout` available to `useTab`, + * `useTabset`, `useSplitter`, `useLayoutState` and `useLayoutActions`. + * `` renders one for you. + * + * @example + * const layout = useDynamixLayout({ tabs }) + * return ( + * + *
…
+ *
+ * ) + */ +export function DynamixLayoutProvider({ + controller, + children, +}: DynamixLayoutProviderProps): ReactElement { + return ( + + {children} + + ) +} diff --git a/packages/react/src/context/layout-context.ts b/packages/react/src/context/layout-context.ts new file mode 100644 index 0000000..72c71d4 --- /dev/null +++ b/packages/react/src/context/layout-context.ts @@ -0,0 +1,33 @@ +import { createContext, useContext } from 'react' +import type { LayoutController as Core } from '../internal/controller' +import type { TabRegistry } from '../internal/tab-registry' +import type { LayoutController } from '../types' + +/** What the hooks share. Stable for the layout's lifetime, so it never re-renders consumers. */ +export interface LayoutContextValue { + readonly core: Core + readonly tabs: TabRegistry +} + +export const LayoutContext = createContext(null) + +/** The public, opaque controller is the context value itself. */ +export const toPublicController = (value: LayoutContextValue) => + value as unknown as LayoutController + +export const fromPublicController = (controller: LayoutController) => + controller as unknown as LayoutContextValue + +/** + * Reads the layout context. + * @throws When used outside `` or ``. + */ +export function useLayoutContext(hookName: string): LayoutContextValue { + const value = useContext(LayoutContext) + if (!value) { + throw new Error( + `\`${hookName}\` must be used inside or .` + ) + } + return value +} diff --git a/packages/react/src/hooks/use-drag-state.ts b/packages/react/src/hooks/use-drag-state.ts new file mode 100644 index 0000000..8185b06 --- /dev/null +++ b/packages/react/src/hooks/use-drag-state.ts @@ -0,0 +1,17 @@ +import type { DragState } from '@dynamix-layout/core' +import { useLayoutState } from './use-layout-state' + +/** + * The drag in progress (its source, target and indicator rect), or `null`. + * Changes when a drag starts or ends and when its target changes, not on every + * pointer move. + * + * @throws When used outside a layout. + * @returns The drag source, target and indicator rect, or `null`. + * @example + * const drag = useDragState() + * const isDraggingTab = drag?.source.type === 'tab' + */ +export function useDragState(): DragState | null { + return useLayoutState((snapshot) => snapshot.drag) +} diff --git a/packages/react/src/hooks/use-dynamix-layout.ts b/packages/react/src/hooks/use-dynamix-layout.ts new file mode 100644 index 0000000..1ab2843 --- /dev/null +++ b/packages/react/src/hooks/use-dynamix-layout.ts @@ -0,0 +1,177 @@ +import type { Side, TabsetState } from '@dynamix-layout/core' +import { + useCallback, + useId, + useMemo, + type FocusEvent, + type KeyboardEvent, + type PointerEvent, +} from 'react' +import { toPublicController } from '../context/layout-context' +import type { LayoutController as Core } from '../internal/controller' +import { handleRootKeyDown } from '../internal/keyboard' +import { getSlotGeometry, positionStyle } from '../internal/slot-geometry' +import { useController } from '../internal/use-controller' +import type { + PropGetter, + SlotProps, + UseDynamixLayoutOptions, + UseDynamixLayoutResult, +} from '../types' +import { mergeProps } from '../utils/merge-props' +import { createActions } from './use-layout-actions' +import { useSnapshotSelector } from './use-layout-state' + +const SIDES: readonly Side[] = ['left', 'right', 'top', 'bottom'] + +/** Remembers which tabset the user is working in, for the keyboard shortcuts. */ +function trackFocusedTabset(core: Core, target: EventTarget) { + const element = + target instanceof Element + ? target.closest('[data-dx-slot][data-dx-id]') + : null + const id = element?.dataset.dxId + if (!element || !id) return + const slot = element.dataset.dxSlot + if (slot === 'panel' || slot === 'tabBar') core.focusedTabsetId = id + else if (slot === 'tab' || slot === 'tabContent') + core.focusedTabsetId = + core.engine.getSnapshot().tabs.get(id)?.tabsetId ?? + core.focusedTabsetId +} + +/** Values of a map as an array whose identity follows the map's. */ +const useValues = (map: ReadonlyMap) => + useMemo(() => [...map.values()], [map]) + +/** + * The headless layout: state, prop getters and actions for building a fully + * custom UI. `` is built on it. + * + * Render every tab's content once, in `tabIds` order, as direct children of + * the root (see `useTab().getTabContentProps`); the library positions it, so + * moving a tab never remounts its content. + * + * @param options - Tabs (content optional) and behaviour options. + * @returns Lists that change only on structural changes, stable prop getters + * and actions, and the `controller` to pass to ``. + * @example + * const { controller, getRootProps, tabsets, splitters, tabIds } = useDynamixLayout({ tabs }) + */ +export function useDynamixLayout( + options: UseDynamixLayoutOptions +): UseDynamixLayoutResult { + const reactId = useId() + const { core, contextValue, rootRef, tabIds } = useController( + options, + options.id ?? reactId + ) + + const tabsets: readonly TabsetState[] = useValues( + useSnapshotSelector(core, (s) => s.tabsets) + ) + const splitters = useValues(useSnapshotSelector(core, (s) => s.splitters)) + const tabs = useValues(useSnapshotSelector(core, (s) => s.tabs)) + const drag = useSnapshotSelector(core, (s) => s.drag) + + const getRootProps = useCallback( + (props?: SlotProps) => + mergeProps(props, { + ref: rootRef, + className: 'dx-root', + // Positioned children need a containing block, with or without the stylesheet. + style: { position: 'relative' as const }, + 'data-dx-measuring': core.isMeasured ? undefined : '', + 'data-dx-dragging': core.engine.getSnapshot().drag + ? '' + : undefined, + onKeyDown: (event: KeyboardEvent) => + handleRootKeyDown(core, event), + onPointerDownCapture: (event: PointerEvent) => + trackFocusedTabset(core, event.target), + onFocusCapture: (event: FocusEvent) => + trackFocusedTabset(core, event.target), + }), + [core, rootRef] + ) as PropGetter + + const indicatorRef = useCallback( + (el: HTMLElement | null) => + core.register('dropIndicator', 'indicator', el), + [core] + ) + const zoneRefs = useMemo( + () => + Object.fromEntries( + SIDES.map((side) => [ + side, + (el: HTMLElement | null) => + core.register('rootDropZone', side, el), + ]) + ), + [core] + ) + + const dropIndicator = useMemo(() => { + if (!drag?.target) return null + const geometry = getSlotGeometry( + core.engine.getSnapshot(), + core.options, + 'dropIndicator', + 'indicator' + ) + return { + target: drag.target, + props: { + ref: indicatorRef, + className: 'dx-drop-indicator', + style: positionStyle(geometry), + 'aria-hidden': true, + 'data-dx-slot': 'dropIndicator', + } satisfies SlotProps, + } + }, [core, drag, indicatorRef]) + + const rootDropZones = useMemo(() => { + if (!drag || drag.source.type === 'splitter') return [] + const snapshot = core.engine.getSnapshot() + return SIDES.map((side) => ({ + side, + isActive: + drag.target?.type === 'root' && drag.target.position === side, + props: { + ref: zoneRefs[side], + className: 'dx-root-drop-zone', + style: positionStyle( + getSlotGeometry( + snapshot, + core.options, + 'rootDropZone', + side + ) + ), + 'aria-hidden': true, + 'data-dx-slot': 'rootDropZone', + 'data-dx-side': side, + } satisfies SlotProps, + })) + }, [core, drag, zoneRefs]) + + const actions = useMemo(() => createActions(core), [core]) + const controller = useMemo( + () => toPublicController(contextValue), + [contextValue] + ) + + return { + controller, + getRootProps, + tabsets, + splitters, + tabs, + tabIds, + dropIndicator, + rootDropZones, + actions, + } +} diff --git a/packages/react/src/hooks/use-layout-actions.ts b/packages/react/src/hooks/use-layout-actions.ts new file mode 100644 index 0000000..7d28524 --- /dev/null +++ b/packages/react/src/hooks/use-layout-actions.ts @@ -0,0 +1,37 @@ +import { useMemo } from 'react' +import { useLayoutContext } from '../context/layout-context' +import type { LayoutController as Core } from '../internal/controller' +import type { LayoutActions } from '../types' + +/** Builds the stable actions object of a layout. */ +export function createActions(core: Core): LayoutActions { + const { engine } = core + return { + moveTab: engine.moveTab, + moveTabset: engine.moveTabset, + selectTab: engine.selectTab, + maximize: engine.maximize, + restore: engine.restore, + toggleMaximize: engine.toggleMaximize, + fold: engine.fold, + unfold: engine.unfold, + toggleFold: engine.toggleFold, + reset: engine.reset, + toJSON: engine.toJSON, + getSnapshot: engine.getSnapshot, + } +} + +/** + * Layout actions from anywhere inside a layout. The object and every function + * in it keep their identity for the layout's lifetime. + * + * @throws When used outside a layout. + * @example + * const { moveTab, selectTab } = useLayoutActions() + * moveTab('terminal', { type: 'root', position: 'bottom' }) + */ +export function useLayoutActions(): LayoutActions { + const { core } = useLayoutContext('useLayoutActions') + return useMemo(() => createActions(core), [core]) +} diff --git a/packages/react/src/hooks/use-layout-state.ts b/packages/react/src/hooks/use-layout-state.ts new file mode 100644 index 0000000..8b94611 --- /dev/null +++ b/packages/react/src/hooks/use-layout-state.ts @@ -0,0 +1,48 @@ +import type { LayoutSnapshot } from '@dynamix-layout/core' +import { useRef, useSyncExternalStore } from 'react' +import { useLayoutContext } from '../context/layout-context' +import type { LayoutController as Core } from '../internal/controller' + +/** + * Subscribes to a slice of a layout's snapshot. Re-renders only when the + * selected value changes according to `isEqual`. The selection is cached per + * snapshot, so React's repeated `getSnapshot` calls return the same value. + */ +export function useSnapshotSelector( + core: Core, + selector: (snapshot: LayoutSnapshot) => T, + isEqual: (a: T, b: T) => boolean = Object.is +): T { + const cache = useRef<{ snapshot: LayoutSnapshot; value: T } | null>(null) + const select = () => { + const snapshot = core.engine.getSnapshot() + const cached = cache.current + if (cached && cached.snapshot === snapshot) return cached.value + const next = selector(snapshot) + const value = + cached && isEqual(cached.value, next) ? cached.value : next + cache.current = { snapshot, value } + return value + } + return useSyncExternalStore(core.engine.subscribe, select, select) +} + +/** + * Reads part of the layout state from anywhere inside a layout, re-rendering + * only when that part changes. Snapshots share unchanged parts by reference, + * so selecting an object (`s.tabsets.get(id)`) is cheap and precise. + * + * @param selector - Picks the value from the snapshot. + * @param isEqual - Decides whether the value changed. @default Object.is + * @returns The selected value. + * @throws When used outside a layout. + * @example + * const activeTabId = useLayoutState((s) => s.tabsets.get(tabsetId)?.activeTabId) + */ +export function useLayoutState( + selector: (snapshot: LayoutSnapshot) => T, + isEqual?: (a: T, b: T) => boolean +): T { + const { core } = useLayoutContext('useLayoutState') + return useSnapshotSelector(core, selector, isEqual) +} diff --git a/packages/react/src/hooks/use-splitter.ts b/packages/react/src/hooks/use-splitter.ts new file mode 100644 index 0000000..7fa937b --- /dev/null +++ b/packages/react/src/hooks/use-splitter.ts @@ -0,0 +1,120 @@ +import type { Direction, SplitterState } from '@dynamix-layout/core' +import { useCallback, type KeyboardEvent, type PointerEvent } from 'react' +import { useLayoutContext } from '../context/layout-context' +import { splitterAria } from '../internal/splitter-aria' +import { nodeLabel } from '../internal/tab-label' +import { handleSplitterKeyDown } from '../internal/keyboard' +import { beginPointerDrag } from '../internal/pointer-drag' +import { getSlotGeometry, positionStyle } from '../internal/slot-geometry' +import type { PropGetter, SlotProps } from '../types' +import { mergeProps } from '../utils/merge-props' +import { useSnapshotSelector } from './use-layout-state' + +/** What `useSplitter` returns. Functions are stable. */ +export interface UseSplitterResult { + /** `undefined` once the splitter no longer exists. */ + splitter: SplitterState | undefined + isDragging: boolean + /** Direction of the row it divides; a `'horizontal'` row has a vertical bar. */ + direction: Direction | undefined + /** + * Size of the panel before the splitter and its limits, in px, as of the + * last render. They aren't reactive (they change on every drag frame); the + * `aria-value*` attributes are kept current by the library. + */ + value: number + min: number + max: number + /** Props for the splitter: `role="separator"`, ARIA values, pointer drag and arrow keys. */ + getSplitterProps: PropGetter +} + +/** + * State and prop getters for one splitter. + * + * @param splitterId - The splitter's id (from `useDynamixLayout().splitters`). + * @throws When used outside a layout. + * @returns State and the prop getter. + * @example + * function MySplitter({ splitterId }: { splitterId: string }) { + * const { getSplitterProps } = useSplitter(splitterId) + * return
+ * } + */ +export function useSplitter(splitterId: string): UseSplitterResult { + const { core, tabs } = useLayoutContext('useSplitter') + const splitter = useSnapshotSelector(core, (s) => + s.splitters.get(splitterId) + ) + const isDragging = useSnapshotSelector( + core, + (s) => + s.drag?.source.type === 'splitter' && + s.drag.source.splitterId === splitterId + ) + const ref = useCallback( + (el: HTMLElement | null) => core.register('splitter', splitterId, el), + [core, splitterId] + ) + + const getSplitterProps = useCallback( + (props?: SlotProps) => { + const snapshot = core.engine.getSnapshot() + const state = snapshot.splitters.get(splitterId) + const geometry = getSlotGeometry( + snapshot, + core.options, + 'splitter', + splitterId + ) + const { percent } = splitterAria(core, splitterId) + const label = (id: string | undefined) => + id ? nodeLabel(snapshot, id, tabs) : 'panel' + return mergeProps(props, { + ref, + role: 'separator', + 'aria-orientation': + state?.direction === 'horizontal' + ? 'vertical' + : 'horizontal', + 'aria-valuenow': percent?.now, + 'aria-valuemin': percent?.min, + 'aria-valuemax': percent?.max, + 'aria-controls': + state && snapshot.tabsets.has(state.beforeId) + ? core.ids.panel(state.beforeId) + : undefined, + 'aria-label': `Resize ${label(state?.beforeId)} and ${label(state?.afterId)}`, + 'aria-disabled': state?.isLocked || undefined, + tabIndex: geometry?.isHidden ? -1 : 0, + className: 'dx-splitter', + style: positionStyle(geometry), + 'data-dx-slot': 'splitter', + 'data-dx-id': splitterId, + 'data-dx-direction': state?.direction, + 'data-dx-locked': state?.isLocked ? '' : undefined, + 'data-dx-hidden': geometry?.isHidden ? '' : undefined, + hidden: geometry?.isHidden || undefined, + onPointerDown: (event: PointerEvent) => + beginPointerDrag(core, event, { + type: 'splitter', + splitterId, + }), + onKeyDown: (event: KeyboardEvent) => + handleSplitterKeyDown(core, event, splitterId), + }) + }, + [core, tabs, splitterId, ref] + ) as PropGetter + + const bounds = core.engine.getSplitterBounds(splitterId) + return { + splitter, + isDragging, + direction: splitter?.direction, + value: bounds?.value ?? 0, + min: bounds?.min ?? 0, + max: bounds?.max ?? 0, + getSplitterProps, + } +} diff --git a/packages/react/src/hooks/use-tab.ts b/packages/react/src/hooks/use-tab.ts new file mode 100644 index 0000000..c15a1a6 --- /dev/null +++ b/packages/react/src/hooks/use-tab.ts @@ -0,0 +1,129 @@ +import type { TabState } from '@dynamix-layout/core' +import { useCallback, type KeyboardEvent, type PointerEvent } from 'react' +import { useLayoutContext } from '../context/layout-context' +import { handleTabKeyDown } from '../internal/keyboard' +import { beginPointerDrag } from '../internal/pointer-drag' +import { useTabItem } from '../internal/tab-registry' +import { getSlotGeometry, positionStyle } from '../internal/slot-geometry' +import type { HeadlessTabItem, PropGetter, SlotProps } from '../types' +import { mergeProps } from '../utils/merge-props' +import { useSnapshotSelector } from './use-layout-state' + +/** What `useTab` returns. Functions are stable. */ +export interface UseTabResult { + /** The tab's layout state; `undefined` while it isn't placed. */ + tab: TabState | undefined + /** The item you passed in `tabs`. */ + item: HeadlessTabItem | undefined + isActive: boolean + isDragging: boolean + select(): void + /** Asks to close the tab (`onTabClose`); does nothing unless it's closable. */ + close(): void + /** Props for the tab: `role="tab"`, ARIA state, roving tabindex, click, drag and keys. */ + getTabProps: PropGetter + /** Props for the tab's content: `role="tabpanel"`, positioned by the library. */ + getTabContentProps: PropGetter +} + +/** + * State and prop getters for one tab. + * + * @param tabId - The tab's id. + * @throws When used outside a layout. + * @returns State, stable actions and prop getters. + * @example + * function MyTab({ tabId }: { tabId: string }) { + * const { item, getTabProps } = useTab(tabId) + * return + * } + */ +export function useTab(tabId: string): UseTabResult { + const { core, tabs } = useLayoutContext('useTab') + const item = useTabItem(tabs, tabId) + const tab = useSnapshotSelector(core, (s) => s.tabs.get(tabId)) + const isDragging = useSnapshotSelector( + core, + (s) => s.drag?.source.type === 'tab' && s.drag.source.tabId === tabId + ) + const isClosable = !!item?.closable + + const contentRef = useCallback( + (el: HTMLElement | null) => core.register('tabContent', tabId, el), + [core, tabId] + ) + const select = useCallback( + () => void core.engine.selectTab(tabId), + [core, tabId] + ) + const close = useCallback(() => { + if (isClosable) core.options.onTabClose?.(tabId) + }, [core, tabId, isClosable]) + + const getTabProps = useCallback( + (props?: SlotProps) => { + const snapshot = core.engine.getSnapshot() + const state = snapshot.tabs.get(tabId) + const tabset = state && snapshot.tabsets.get(state.tabsetId) + const isRotated = + !!tabset?.isFolded && tabset.parentDirection === 'horizontal' + const active = !!state?.isActive + return mergeProps(props, { + role: 'tab', + id: core.ids.tab(tabId), + 'aria-selected': active, + 'aria-controls': core.ids.tabContent(tabId), + tabIndex: active ? 0 : -1, + className: 'dx-tab', + 'data-dx-slot': 'tab', + 'data-dx-id': tabId, + 'data-state': active ? 'active' : 'inactive', + onClick: () => core.engine.selectTab(tabId), + onPointerDown: (event: PointerEvent) => + beginPointerDrag(core, event, { type: 'tab', tabId }), + onKeyDown: (event: KeyboardEvent) => + handleTabKeyDown(core, event, tabId, { + isRotated, + isClosable, + }), + }) + }, + [core, tabId, isClosable] + ) as PropGetter + + const getTabContentProps = useCallback( + (props?: SlotProps) => { + const geometry = getSlotGeometry( + core.engine.getSnapshot(), + core.options, + 'tabContent', + tabId + ) + return mergeProps(props, { + ref: contentRef, + role: 'tabpanel', + id: core.ids.tabContent(tabId), + 'aria-labelledby': core.ids.tab(tabId), + tabIndex: 0, + className: 'dx-tab-content', + style: positionStyle(geometry), + 'data-dx-slot': 'tabContent', + 'data-dx-id': tabId, + 'data-dx-hidden': geometry?.isHidden ? '' : undefined, + hidden: geometry?.isHidden || undefined, + }) + }, + [core, tabId, contentRef] + ) as PropGetter + + return { + tab, + item, + isActive: !!tab?.isActive, + isDragging, + select, + close, + getTabProps, + getTabContentProps, + } +} diff --git a/packages/react/src/hooks/use-tabset.ts b/packages/react/src/hooks/use-tabset.ts new file mode 100644 index 0000000..008b3d8 --- /dev/null +++ b/packages/react/src/hooks/use-tabset.ts @@ -0,0 +1,149 @@ +import type { TabsetState } from '@dynamix-layout/core' +import { useCallback, type MouseEvent, type PointerEvent } from 'react' +import { useLayoutContext } from '../context/layout-context' +import { beginPointerDrag } from '../internal/pointer-drag' +import { getSlotGeometry, positionStyle } from '../internal/slot-geometry' +import type { PropGetter, SlotProps } from '../types' +import { mergeProps } from '../utils/merge-props' +import { useSnapshotSelector } from './use-layout-state' + +/** What `useTabset` returns. Functions are stable. */ +export interface UseTabsetResult { + /** `undefined` once the tabset no longer exists. */ + tabset: TabsetState | undefined + /** The tab bar is drawn as a vertical strip (folded in a side-by-side row). */ + isRotated: boolean + /** Props for the tabset's box (`role` none; positioned by the library). */ + getPanelProps: PropGetter + /** Props for the tab bar: `role="tablist"`, drag-to-move the whole tabset, double-click to maximize. */ + getTabBarProps: PropGetter + toggleMaximize(): void + toggleFold(): void +} + +const isInteractive = (target: EventTarget) => + target instanceof Element && + !!target.closest('[data-dx-slot="tab"], button, a, input, select, textarea') + +/** + * State and prop getters for one tabset. + * + * @param tabsetId - The tabset's id. + * @throws When used outside a layout. + * @returns State, stable actions and prop getters. + * @example + * function MyTabset({ tabsetId }: { tabsetId: string }) { + * const { tabset, getPanelProps, getTabBarProps } = useTabset(tabsetId) + * if (!tabset) return null + * return ( + * <> + *
+ *
{tabset.tabIds.map((id) => )}
+ * + * ) + * } + */ +export function useTabset(tabsetId: string): UseTabsetResult { + const { core } = useLayoutContext('useTabset') + const tabset = useSnapshotSelector(core, (s) => s.tabsets.get(tabsetId)) + const isRotated = + !!tabset?.isFolded && tabset.parentDirection === 'horizontal' + + const panelRef = useCallback( + (el: HTMLElement | null) => core.register('panel', tabsetId, el), + [core, tabsetId] + ) + const barRef = useCallback( + (el: HTMLElement | null) => core.register('tabBar', tabsetId, el), + [core, tabsetId] + ) + + const getPanelProps = useCallback( + (props?: SlotProps) => { + const geometry = getSlotGeometry( + core.engine.getSnapshot(), + core.options, + 'panel', + tabsetId + ) + return mergeProps(props, { + ref: panelRef, + id: core.ids.panel(tabsetId), + className: 'dx-panel', + style: positionStyle(geometry), + 'data-dx-slot': 'panel', + 'data-dx-id': tabsetId, + 'data-dx-hidden': geometry?.isHidden ? '' : undefined, + hidden: geometry?.isHidden || undefined, + }) + }, + [core, tabsetId, panelRef] + ) as PropGetter + + const getTabBarProps = useCallback( + (props?: SlotProps) => { + const snapshot = core.engine.getSnapshot() + const state = snapshot.tabsets.get(tabsetId) + const geometry = getSlotGeometry( + snapshot, + core.options, + 'tabBar', + tabsetId + ) + const rotated = !!geometry?.isRotated + return mergeProps(props, { + ref: barRef, + role: 'tablist', + 'aria-orientation': rotated ? 'vertical' : 'horizontal', + className: 'dx-tab-bar', + style: positionStyle(geometry), + 'data-dx-slot': 'tabBar', + 'data-dx-id': tabsetId, + 'data-dx-folded': state?.isFolded ? '' : undefined, + 'data-dx-maximized': state?.isMaximized ? '' : undefined, + 'data-dx-rotated': rotated ? '' : undefined, + 'data-dx-hidden': geometry?.isHidden ? '' : undefined, + hidden: geometry?.isHidden || undefined, + onPointerDown: (event: PointerEvent) => { + if (!isInteractive(event.target)) + beginPointerDrag(core, event, { + type: 'tabset', + tabsetId, + }) + }, + onDoubleClick: (event: MouseEvent) => { + const { allowMaximize, maximizeOnDoubleClick } = + core.options + if ( + allowMaximize && + maximizeOnDoubleClick && + !( + event.target instanceof Element && + event.target.closest('button') + ) + ) + core.engine.toggleMaximize(tabsetId) + }, + }) + }, + [core, tabsetId, barRef] + ) as PropGetter + + const toggleMaximize = useCallback( + () => void core.engine.toggleMaximize(tabsetId), + [core, tabsetId] + ) + const toggleFold = useCallback( + () => void core.engine.toggleFold(tabsetId), + [core, tabsetId] + ) + + return { + tabset, + isRotated, + getPanelProps, + getTabBarProps, + toggleMaximize, + toggleFold, + } +} diff --git a/packages/react/src/hooks/useLayout.ts b/packages/react/src/hooks/useLayout.ts deleted file mode 100644 index 8724942..0000000 --- a/packages/react/src/hooks/useLayout.ts +++ /dev/null @@ -1,734 +0,0 @@ -import { - LayoutTree, - DynamixLayoutCore, - Node, - NodeOptions, - DropPreview, - RootSide, - createFrameScheduler, - getNavbarDropPreview, - getRootSplitPreview, - getTabBodyRect, - getTabsetDropPreview, - getTabbarPlacement, - isSameDropPreview, - placeTabbar, - setElementRect, -} from '@dynamix-layout/core' -import React, { useState, useEffect } from 'react' -import { useDynamixLayoutOptions } from '../types' - -/** @deprecated Internal in 1.x. In 2.0 `useDynamixLayout` is a different, headless hook with prop getters. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ -export const useDynamixLayout = ({ - tabOutput, - rootId, - layoutTree, - updateJSON, - dimensions, - tabHeadHeight, - enableTabbar, - bondWidth, - minTabHeight, - minTabWidth, - sliderUpdateTimeout, - windowResizeTimeout, - disableSliderTimeout, - disableResizeTimeout, - keyboardShortcuts = true, - enableDoubleClickMaximize = true, -}: useDynamixLayoutOptions) => { - const tabsetsRef = React.useRef(new Map()) - const slidersRef = React.useRef(new Map()) - const panelsRef = React.useRef(new Map()) - const tabsRef = React.useRef(new Map()) - const hoverElementRef = React.useRef(null) - const rootSplitHoverEl = React.useRef([]) - const [layoutJSON, setLayoutJSON] = useState( - layoutTree - ) - const [tabsets, setTabsets] = useState>() - const [sliders, setSliders] = useState>() - const [dragging, setDragging] = useState(false) - const animationFrameRef = React.useRef(null) - const [isUpdating, setIsUpdating] = useState(false) - const dragElemRef = React.useRef<{ - src?: HTMLDivElement - des?: HTMLDivElement - drag?: boolean - area?: 'top' | 'bottom' | 'left' | 'right' | 'contain' - } | null>({ - src: undefined, - des: undefined, - area: undefined, - drag: false, - }) - - // --- REFACTORED: Using Pointer Events for Slider --- - const dragSliderRef = React.useRef<{ - sliderId?: string - isSliding?: boolean - }>({ - sliderId: undefined, - isSliding: false, - }) - - // A tabset can never be shorter than its tab bar, otherwise the bar spills - // over the slider below it and the tab body gets a negative height. - const minLayoutHeight = enableTabbar - ? Math.max(minTabHeight, tabHeadHeight) - : minTabHeight - - const layoutInstance = React.useMemo(() => { - DynamixLayoutCore._bond = bondWidth - DynamixLayoutCore._minH = minLayoutHeight - DynamixLayoutCore._minW = minTabWidth - - const LT = new DynamixLayoutCore({ - tabs: tabOutput.keys, - tree: layoutJSON, - minW: minTabWidth, - minH: minLayoutHeight, - bond: bondWidth, - uqid: rootId, - tabsIds: tabOutput.name, - // A folded tabset shrinks to its tab bar. - collapsedSize: enableTabbar ? tabHeadHeight : minLayoutHeight, - }) - return LT - }, [ - layoutJSON, - tabOutput, - bondWidth, - minLayoutHeight, - minTabWidth, - rootId, - enableTabbar, - tabHeadHeight, - ]) - - const updateTabsets = (nodes: Map) => { - const newTabsets = new Map(nodes) - setTabsets(newTabsets) - } - - const updateSliders = (nodes: Map) => { - const newSliders = new Map(nodes) - setSliders(newSliders) - } - - // Read through refs so the subscriptions below never need to be recreated. - const enableTabbarRef = React.useRef(enableTabbar) - const tabHeadHeightRef = React.useRef(tabHeadHeight) - enableTabbarRef.current = enableTabbar - tabHeadHeightRef.current = tabHeadHeight - - useEffect(() => { - const offNodes = Node.cache.nodOpts.onChange( - (nodes: Map) => { - nodes.forEach((node: NodeOptions, id: string) => { - const panelEl = panelsRef.current.get(id) - if (panelEl) setElementRect(panelEl, node.nodDims) - - const tabsetEl = tabsetsRef.current.get(id) - if (tabsetEl && enableTabbarRef.current) { - tabsetEl.toggleAttribute( - 'data-dx-hidden', - !!node.nodHidden - ) - placeTabbar( - tabsetEl, - getTabbarPlacement(node, tabHeadHeightRef.current) - ) - } - }) - } - ) - - const offBonds = Node.cache.bndOpts.onChange( - (nodes: Map) => { - nodes.forEach((node: NodeOptions, id: string) => { - const sliderEl = slidersRef.current.get(id) - if (!sliderEl) return - setElementRect(sliderEl, node.nodDims) - sliderEl.toggleAttribute('data-dx-hidden', !!node.nodHidden) - sliderEl.style.pointerEvents = node.nodLocked ? 'none' : '' - }) - } - ) - - const offTabs = Node.cache.tabOpts.onChange( - (nodes: Map) => { - const tabbarHeight = enableTabbarRef.current - ? tabHeadHeightRef.current - : 0 - - nodes.forEach((node: NodeOptions, id: string) => { - const tabEl = tabsRef.current.get(id) - if (!tabEl) return - - const body = getTabBodyRect(node.nodDims, tabbarHeight) - setElementRect(tabEl, body) - // Empty bodies stay hidden so their borders/shadows don't - // bleed onto the slider below. - const visible = - node.nodOpen && - !node.nodFold && - !node.nodHidden && - body.h > 0 - tabEl.style.display = visible ? 'block' : 'none' - }) - } - ) - - return () => { - offNodes() - offBonds() - offTabs() - } - }, []) - - // --- START: SLIDER POINTER EVENT HANDLERS --- - const sliderScheduler = React.useMemo( - () => - createFrameScheduler( - ({ - id, - point, - }: { - id: string - point: { x: number; y: number } - }) => - layoutInstance.updateSlider( - id, - point, - disableSliderTimeout, - sliderUpdateTimeout - ) - ), - [layoutInstance, disableSliderTimeout, sliderUpdateTimeout] - ) - - useEffect(() => () => sliderScheduler.cancel(), [sliderScheduler]) - - const onPointerMove = (e: PointerEvent) => { - const id = dragSliderRef.current.sliderId - if (!dragSliderRef.current.isSliding || !id) return - - sliderScheduler.schedule({ id, point: { x: e.clientX, y: e.clientY } }) - } - - const onPointerUp = (e: PointerEvent) => { - if (!dragSliderRef.current.isSliding) return - - sliderScheduler.flush() - - const sliderElement = e.currentTarget as HTMLDivElement - dragSliderRef.current.isSliding = false - dragSliderRef.current.sliderId = undefined - setDragging(false) - - if (updateJSON) updateJSON(DynamixLayoutCore._root.toJSON()) - sliderElement.releasePointerCapture(e.pointerId) - sliderElement.removeEventListener('pointermove', onPointerMove) - sliderElement.removeEventListener('pointerup', onPointerUp) - sliderElement.removeEventListener('pointercancel', onPointerUp) - } - - const onPointerDown = (e: React.PointerEvent) => { - e.preventDefault() - - const sliderElement = e.currentTarget - dragSliderRef.current.isSliding = true - dragSliderRef.current.sliderId = sliderElement.id - // Applies `.is-dragging`, so iframes and editors inside tab bodies - // cannot swallow pointer events while the slider is being dragged. - setDragging(true) - - // Capture the pointer to ensure events are received - sliderElement.setPointerCapture(e.pointerId) - - // Add listeners - sliderElement.addEventListener('pointermove', onPointerMove) - sliderElement.addEventListener('pointerup', onPointerUp) - sliderElement.addEventListener('pointercancel', onPointerUp) - } - // --- END: SLIDER POINTER EVENT HANDLERS --- - - const lastHoverState = React.useRef>({}) - - const findDropTargetTabset = ( - clientX: number, - clientY: number, - target: HTMLDivElement - ) => { - if (!hoverElementRef.current || !dragElemRef.current || !target) { - return null - } - - const preview = getTabsetDropPreview( - target.getBoundingClientRect(), - clientX, - clientY - ) - updateHoverElement(preview) - dragElemRef.current.des = target - dragElemRef.current.area = preview.area - } - - const handleNavbarDragOver = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'move' - } - - // A folded strip shows its tab bar rotated; label boxes are vertical - // there, so the whole strip is the target and drops go into the tabset. - if (e.currentTarget.hasAttribute('data-rotated')) { - if (!hoverElementRef.current || !dragElemRef.current) return - const r = e.currentTarget.getBoundingClientRect() - updateHoverElement({ - area: 'contain', - left: r.left, - top: r.top, - width: r.width, - height: r.height, - }) - dragElemRef.current.des = e.currentTarget - dragElemRef.current.area = 'contain' - return - } - - // Only tab labels: the tab bar also holds the maximize/fold toolbar. - const tabElems = Array.from( - e.currentTarget.querySelectorAll( - ':scope > [data-type="tab"]' - ) - ) - if (!hoverElementRef.current || !dragElemRef.current) return - - const preview = getNavbarDropPreview( - e.currentTarget.getBoundingClientRect(), - tabElems.map((tab) => tab.getBoundingClientRect()), - e.clientX, - e.clientY - ) - if (!preview) return - - updateHoverElement(preview) - dragElemRef.current.des = tabElems[preview.index] - dragElemRef.current.area = preview.area - } - - const handleRootSplit = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'move' - } - - const { area, uid } = e.currentTarget.dataset - if (!area || !uid || !hoverElementRef.current || !dragElemRef.current) { - return - } - - const side = area as RootSide - dragElemRef.current.des = e.currentTarget - dragElemRef.current.area = side - updateHoverElement(getRootSplitPreview(dimensions(), side)) - } - - const updateHoverElement = (preview: DropPreview) => { - const hoverEl = hoverElementRef.current - if (!hoverEl || isSameDropPreview(lastHoverState.current, preview)) - return - - Object.assign(hoverEl.style, { - left: `${preview.left}px`, - top: `${preview.top}px`, - width: `${preview.width}px`, - height: `${preview.height}px`, - display: 'block', - zIndex: '100', - }) - lastHoverState.current = preview - } - - // --- Maximize / fold --- - const activeTabsetRef = React.useRef(null) - - const refreshViewState = () => { - updateTabsets(Node.cache.nodOpts.get()) - updateSliders(Node.cache.bndOpts.get()) - if (updateJSON) updateJSON(DynamixLayoutCore._root.toJSON()) - } - - const toggleMaximize = (tabsetId: string) => { - if (layoutInstance.toggleMaximize(tabsetId)) refreshViewState() - } - - const toggleCollapse = (tabsetId: string) => { - if (layoutInstance.toggleCollapse(tabsetId)) refreshViewState() - } - - /** Remembers which tabset the user last touched, for the shortcuts. */ - const onRootPointerDown = (e: React.PointerEvent) => { - const el = (e.target as HTMLElement).closest('[data-uid]') - const node = el?.dataset.uid - ? Node.cache.mapElem.get(el.dataset.uid) - : undefined - if (!(node instanceof Node)) return - if (node.type === 'tabset') activeTabsetRef.current = node.unId - else if (node.type === 'tab' && node.host) { - activeTabsetRef.current = node.host.unId - } - } - - const onTabbarDoubleClick = (e: React.MouseEvent) => { - const id = e.currentTarget.dataset.uid - if (enableDoubleClickMaximize && id) toggleMaximize(id) - } - - useEffect(() => { - if (!keyboardShortcuts) return - - const onKeyDown = (e: KeyboardEvent) => { - if (!e.altKey || e.ctrlKey || e.metaKey) return - const id = activeTabsetRef.current ?? layoutInstance.maximizedId - if (!id) return - - if (e.code === 'Equal' || e.code === 'NumpadAdd') { - e.preventDefault() - toggleMaximize(id) - } else if (e.code === 'Minus' || e.code === 'NumpadSubtract') { - e.preventDefault() - toggleCollapse(id) - } - } - - window.addEventListener('keydown', onKeyDown) - return () => window.removeEventListener('keydown', onKeyDown) - }) - - const onDragStart = (e: React.DragEvent) => { - e.stopPropagation() - // Every drop target must be visible while dragging. - if (layoutInstance.restore()) refreshViewState() - setDragging(true) - document.body.style.cursor = 'move' - if (e.currentTarget) { - if (e.dataTransfer) { - e.dataTransfer.effectAllowed = 'move' - - const dragImage = document.createElement('div') - dragImage.style.width = '1px' - dragImage.style.height = '1px' - dragImage.style.backgroundColor = 'transparent' - dragImage.style.position = 'absolute' - dragImage.style.top = '-1000px' - document.body.appendChild(dragImage) - - e.dataTransfer.setDragImage(dragImage, 0, 0) - - setTimeout(() => { - if (dragImage.parentNode) { - dragImage.parentNode.removeChild(dragImage) - } - }, 0) - } - - rootSplitHoverEl.current.forEach((el) => { - if (el) { - el.style.display = 'block' - el.style.zIndex = '99' - } - }) - - panelsRef.current.forEach((el) => { - if (el) { - el.style.display = 'block' - el.style.zIndex = '95' - } - }) - - dragElemRef.current!.src = e.currentTarget as HTMLDivElement - dragElemRef.current!.drag = true - } - } - - const onDragOver = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - document.body.style.cursor = 'move' - - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'move' - } - - if (!dragElemRef.current?.src) { - return - } - - const target = e.currentTarget as HTMLDivElement - const clientX = e.clientX - const clientY = e.clientY - - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current) - } - - animationFrameRef.current = requestAnimationFrame(() => { - findDropTargetTabset(clientX, clientY, target) - }) - } - - const onDragEnd = () => { - document.body.style.cursor = 'default' - rootSplitHoverEl.current.forEach((el) => { - if (el) { - el.style.display = 'none' - el.style.zIndex = '-1' - } - }) - - panelsRef.current.forEach((el) => { - if (el) { - el.style.display = 'none' - el.style.zIndex = '-1' - } - }) - - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current) - animationFrameRef.current = null - } - - if (hoverElementRef.current) { - hoverElementRef.current.style.display = 'none' - hoverElementRef.current.style.zIndex = '-1' - } - - lastHoverState.current = {} - dragElemRef.current!.drag = false - - if (!dragElemRef.current?.src || !dragElemRef.current?.des) { - dragElemRef.current!.area = undefined - dragElemRef.current!.src = undefined - dragElemRef.current!.des = undefined - - setDragging(false) - return - } - - if (dragElemRef.current.src === dragElemRef.current.des) { - dragElemRef.current!.area = undefined - dragElemRef.current!.src = undefined - dragElemRef.current!.des = undefined - - setDragging(false) - return - } - - if (dragElemRef.current.src && dragElemRef.current.des) { - if (!dragElemRef.current.area) { - console.warn('No area specified for drag and drop') - - setDragging(false) - return - } - - const result = layoutInstance.updateTree( - dragElemRef.current.src.dataset.uid as string, - dragElemRef.current.des.dataset.uid as string, - dragElemRef.current.area || 'contain' - ) - - if (!result) { - console.warn('Failed to update layout tree') - - setDragging(false) - return - } - - requestAnimationFrame(() => { - updateTabsets(Node.cache.nodOpts.get()) - updateSliders(Node.cache.bndOpts.get()) - if (updateJSON) updateJSON(DynamixLayoutCore._root.toJSON()) - }) - } - - dragElemRef.current!.area = undefined - dragElemRef.current!.src = undefined - dragElemRef.current!.des = undefined - - setDragging(false) - } - - const onDragEnter = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'move' - } - } - - const onDragLeave = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - } - - const onDrop = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - } - - const updateActiveTab = (e: React.MouseEvent) => { - const target = e.currentTarget as HTMLDivElement - const uid = target.dataset.uid - - if (!uid) return - - const node = Node.cache.mapElem.get(uid) - - if (!(node instanceof Node)) { - console.warn(`Element with uid ${uid} is not a Node instance`) - return - } - - if (!node || !node.host || !node.host.unId) { - console.warn(`Node with uid ${uid} not found in mapNode`) - return - } - - if (node.host.open == node.name) { - console.warn(`Node ${node.name} is already open`) - return - } - - const nodeOpts = Node.cache.nodOpts.get().get(node.host.unId) - - if (!nodeOpts) return - - if (nodeOpts.nodOpen && node?.open) { - return - } - - node.host.open = node.name - nodeOpts.nodOpen = node.name - - const childNodes = target?.parentElement - ?.childNodes as NodeListOf - - childNodes?.forEach((child) => { - if (child instanceof HTMLDivElement && child.dataset.uid !== uid) { - child.dataset.state = 'inactive' - } else { - child.dataset.state = 'active' - } - }) - - nodeOpts.nodKids?.forEach((kid: NodeOptions) => { - if (kid.nodName == node.name) { - kid.nodOpen = true - const tabbarHeight = enableTabbar ? tabHeadHeight : 0 - const body = getTabBodyRect(kid.nodDims, tabbarHeight) - const visible = !kid.nodFold && !kid.nodHidden && body.h > 0 - tabsRef.current - .get(kid.uidNode) - ?.style.setProperty('display', visible ? 'block' : 'none') - } else { - kid.nodOpen = false - tabsRef.current - .get(kid.uidNode) - ?.style.setProperty('display', 'none') - } - }) - } - - useEffect(() => { - const updateDimension = (flag: boolean) => { - layoutInstance.updateDimension( - dimensions(), - flag, - windowResizeTimeout - ) - } - - updateDimension(true) - - const handler = async ( - _: UIEvent // eslint-disable-line @typescript-eslint/no-unused-vars - ) => updateDimension(disableResizeTimeout ?? false) - - window.addEventListener('resize', handler) - - updateTabsets(Node.cache.nodOpts.get()) - updateSliders(Node.cache.bndOpts.get()) - - Node.cache.nodOpts.triggerChange() - Node.cache.bndOpts.triggerChange() - Node.cache.tabOpts.triggerChange() - - if (updateJSON) updateJSON(DynamixLayoutCore._root.toJSON()) - return () => { - window.removeEventListener('resize', handler) - - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current) - } - } - }, [dimensions, disableResizeTimeout, windowResizeTimeout]) // eslint-disable-line - - useEffect(() => { - Node.cache.nodOpts.triggerChange() - Node.cache.bndOpts.triggerChange() - Node.cache.tabOpts.triggerChange() - return () => { - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current) - } - } - }, [tabsets, sliders]) - - return { - tabsets, - sliders, - tabsetsRef, - panelsRef, - tabsRef, - slidersRef, - layoutJSON, - layoutInstance, - hoverElementRef, - rootSplitHoverEl, - dragging, - isUpdating, - /** @deprecated Internal state setter; will be removed in v2. */ - setIsUpdating, - /** @deprecated Internal state setter; will be removed in v2. */ - setDragging, - /** @deprecated Internal state setter; will be removed in v2. */ - setTabsets, - /** @deprecated Internal state setter; will be removed in v2. */ - setSliders, - /** @deprecated Internal state setter; will be removed in v2. */ - setLayoutJSON, - onDragStart, - onDragOver, - onDragEnd, - onDragEnter, - onDragLeave, - onDrop, - onPointerDown, - updateActiveTab, - handleRootSplit, - handleNavbarDragOver, - toggleMaximize, - toggleCollapse, - onRootPointerDown, - onTabbarDoubleClick, - } -} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 614fa39..c004cad 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,4 +1,36 @@ -export * from './components/Layout' -export * from './hooks/useLayout' -export * from './components/Default' -export * from './types' +import './styles.css' +export { DynamixLayout } from './components/DynamixLayout' +export { + DynamixLayoutProvider, + type DynamixLayoutProviderProps, +} from './context/DynamixLayoutProvider' +export { useDynamixLayout } from './hooks/use-dynamix-layout' +export { useTabset, type UseTabsetResult } from './hooks/use-tabset' +export { useTab, type UseTabResult } from './hooks/use-tab' +export { useSplitter, type UseSplitterResult } from './hooks/use-splitter' +export { useLayoutState } from './hooks/use-layout-state' +export { useLayoutActions } from './hooks/use-layout-actions' +export { useDragState } from './hooks/use-drag-state' +export { Panel } from './components/Panel' +export { TabBar } from './components/TabBar' +export { Tab } from './components/Tab' +export { TabContent } from './components/TabContent' +export { Splitter } from './components/Splitter' +export { DropIndicator } from './components/DropIndicator' +export { RootDropZone } from './components/RootDropZone' +export { TabsetToolbar } from './components/TabsetToolbar' +export type * from './types' +export { + isLayoutV1, + migrateLayoutFromV1, + DynamixLayoutError, + type DropTarget, + type LayoutJSON, + type LayoutTreeV1, + type LayoutSnapshot, + type LayoutChangeReason, + type TabsetState, + type TabState, + type SplitterState, + type Side, +} from '@dynamix-layout/core' diff --git a/packages/react/src/internal/container-observer.ts b/packages/react/src/internal/container-observer.ts new file mode 100644 index 0000000..90b697c --- /dev/null +++ b/packages/react/src/internal/container-observer.ts @@ -0,0 +1,59 @@ +import { createFrameScheduler, type Rect } from '@dynamix-layout/core' +import type { LayoutController, Padding } from './controller' + +/** The area inside the root (its padding box) minus the layout's padding. */ +export function readContainer(element: HTMLElement, padding: Padding): Rect { + return { + x: padding.left, + y: padding.top, + width: Math.max(0, element.clientWidth - padding.left - padding.right), + height: Math.max( + 0, + element.clientHeight - padding.top - padding.bottom + ), + } +} + +/** + * Measures the root now, then follows its size with a `ResizeObserver`, so + * resizes from sidebars or flex parents are caught, not only window resizes. + * Updates run at most once per animation frame, or once per + * `resizeThrottleMs` when that is set. + * + * @returns A function that disconnects the observer and drops pending work. + */ +export function observeContainer( + controller: LayoutController, + element: HTMLElement +): () => void { + const update = () => + controller.engine.setContainerRect( + readContainer(element, controller.options.padding) + ) + update() + if (typeof ResizeObserver === 'undefined') return () => {} + + const frame = createFrameScheduler(update) + let timer: ReturnType | null = null + let lastRun = 0 + + const schedule = () => { + const throttle = controller.options.resizeThrottleMs + if (throttle <= 0) return frame.schedule(null) + if (timer !== null) return + const wait = Math.max(0, lastRun + throttle - Date.now()) + timer = setTimeout(() => { + timer = null + lastRun = Date.now() + update() + }, wait) + } + + const observer = new ResizeObserver(schedule) + observer.observe(element) + return () => { + observer.disconnect() + frame.cancel() + if (timer !== null) clearTimeout(timer) + } +} diff --git a/packages/react/src/internal/controller.ts b/packages/react/src/internal/controller.ts new file mode 100644 index 0000000..7fc2d3b --- /dev/null +++ b/packages/react/src/internal/controller.ts @@ -0,0 +1,186 @@ +import { + createLayout, + type DropMeasurements, + type Layout, + type LayoutChangeReason, + type LayoutJSON, + type LayoutTreeV1, + type Point, + type Rect, + type TabBarMeasurement, + type TabInit, +} from '@dynamix-layout/core' +import { createDomIds, type DomIds } from './dom-ids' +import type { PositionedSlot, SlotGeometryOptions } from './slot-geometry' + +/** Padding on each side of the layout, in px. */ +export interface Padding { + readonly top: number + readonly right: number + readonly bottom: number + readonly left: number +} + +/** Behaviour options the internals read; always the latest props. */ +export interface ControllerOptions extends SlotGeometryOptions { + readonly padding: Padding + readonly resizeThrottleMs: number + readonly allowMaximize: boolean + readonly allowFold: boolean + readonly maximizeOnDoubleClick: boolean + readonly keyboardShortcuts: boolean + readonly tabActivation: 'automatic' | 'manual' + /** Called when a tab's close button or Delete key asks to close it. */ + readonly onTabClose?: (tabId: string) => void +} + +/** + * Everything one layout instance shares between its hooks and components: + * the engine, the positioned elements, DOM ids and the latest options. Created + * once per instance; never recreated by prop changes. + */ +export interface LayoutController { + readonly engine: Layout + readonly ids: DomIds + /** Latest options; updated in a layout effect, never during render. */ + options: ControllerOptions + /** The root element, once mounted. */ + root: HTMLElement | null + /** The container has been measured (the first render can't know its size). */ + isMeasured: boolean + /** Tabset the user last pointed at or focused (target of keyboard shortcuts). */ + focusedTabsetId: string | null + /** Plain-text tab titles for announcements and ARIA labels (ids when titles aren't text). */ + readonly labels: Map + /** Registers (or, with `null`, removes) an element the fast path positions. */ + register( + slot: PositionedSlot, + id: string, + element: HTMLElement | null + ): void + /** Every registered element of a slot. */ + elements(slot: PositionedSlot): ReadonlyMap + /** Converts viewport coordinates to layout-root coordinates. */ + toRootPoint(clientX: number, clientY: number): Point + /** Tab bar and tab rects for drop targeting, in layout-root coordinates. */ + measure(): DropMeasurements + /** Says something to screen readers through the layout's live region. */ + announce(message: string): void + /** Listeners for `announce`, used by the live region. */ + onAnnounce(listener: (message: string) => void): () => void +} + +/** Options for {@link createController}. */ +export interface CreateControllerOptions { + readonly idBase: string + readonly tabs: readonly TabInit[] + readonly initialLayout: LayoutJSON | LayoutTreeV1 | null | undefined + readonly options: ControllerOptions + readonly minPanelSize: { width: number; height: number } + readonly splitterSize: number + readonly onLayoutChange: ( + layout: LayoutJSON, + reason: LayoutChangeReason + ) => void +} + +/** Effective core sizes: a tabset is never shorter than its tab bar, and folds to it. */ +export function coreSizes( + minPanelSize: { width: number; height: number }, + splitterSize: number, + options: SlotGeometryOptions +) { + const height = options.showTabBar + ? Math.max(minPanelSize.height, options.tabBarHeight) + : minPanelSize.height + return { + minPanelSize: { width: minPanelSize.width, height }, + splitterSize, + foldedSize: options.showTabBar ? options.tabBarHeight : height, + } +} + +const relativeRect = (rect: DOMRect, origin: Point): Rect => ({ + x: rect.left - origin.x, + y: rect.top - origin.y, + width: rect.width, + height: rect.height, +}) + +/** + * Creates the controller. Pure apart from creating the engine, so it is safe + * to call during render (StrictMode may call it twice and drop one). + */ +export function createController( + init: CreateControllerOptions +): LayoutController { + const registry = new Map>() + const announceListeners = new Set<(message: string) => void>() + const engine = createLayout({ + tabs: init.tabs, + initialLayout: init.initialLayout ?? null, + ...coreSizes(init.minPanelSize, init.splitterSize, init.options), + onLayoutChange: init.onLayoutChange, + }) + + const slotMap = (slot: PositionedSlot) => { + let map = registry.get(slot) + if (!map) registry.set(slot, (map = new Map())) + return map + } + + const controller: LayoutController = { + engine, + ids: createDomIds(init.idBase), + options: init.options, + root: null, + isMeasured: false, + focusedTabsetId: null, + labels: new Map(), + register(slot, id, element) { + if (element) slotMap(slot).set(id, element) + else slotMap(slot).delete(id) + }, + elements: (slot) => slotMap(slot), + toRootPoint(clientX, clientY) { + const root = controller.root + if (!root) return { x: clientX, y: clientY } + const box = root.getBoundingClientRect() + return { + x: clientX - box.left - root.clientLeft, + y: clientY - box.top - root.clientTop, + } + }, + measure() { + const root = controller.root + const tabBars = new Map() + if (!root) return { tabBars } + const box = root.getBoundingClientRect() + const origin = { + x: box.left + root.clientLeft, + y: box.top + root.clientTop, + } + slotMap('tabBar').forEach((bar, tabsetId) => { + const tabs = Array.from( + bar.querySelectorAll('[data-dx-slot="tab"]') + ).map((tab) => ({ + id: tab.dataset.dxId ?? '', + rect: relativeRect(tab.getBoundingClientRect(), origin), + })) + tabBars.set(tabsetId, { + rect: relativeRect(bar.getBoundingClientRect(), origin), + isRotated: bar.hasAttribute('data-dx-rotated'), + tabs, + }) + }) + return { tabBars } + }, + announce: (message) => + announceListeners.forEach((listener) => listener(message)), + onAnnounce(listener) { + announceListeners.add(listener) + return () => announceListeners.delete(listener) + }, + } + return controller +} diff --git a/packages/react/src/internal/dom-ids.ts b/packages/react/src/internal/dom-ids.ts new file mode 100644 index 0000000..7b52cfe --- /dev/null +++ b/packages/react/src/internal/dom-ids.ts @@ -0,0 +1,23 @@ +/** + * Encodes any tab or tabset id into characters that are safe in an HTML id + * and in CSS selectors. Injective: different ids never collide. + */ +export function encodeId(id: string): string { + return id.replace( + /[^A-Za-z0-9-]/g, + (char) => `_${char.codePointAt(0)!.toString(36)}_` + ) +} + +/** DOM ids for ARIA relationships, scoped to one layout via React's `useId`. */ +export function createDomIds(base: string) { + const prefix = `dx${encodeId(base)}` + return { + tab: (tabId: string) => `${prefix}-tab-${encodeId(tabId)}`, + tabContent: (tabId: string) => `${prefix}-content-${encodeId(tabId)}`, + panel: (tabsetId: string) => `${prefix}-panel-${encodeId(tabsetId)}`, + live: `${prefix}-live`, + } +} + +export type DomIds = ReturnType diff --git a/packages/react/src/internal/keyboard-move-mode.ts b/packages/react/src/internal/keyboard-move-mode.ts new file mode 100644 index 0000000..6533b73 --- /dev/null +++ b/packages/react/src/internal/keyboard-move-mode.ts @@ -0,0 +1,96 @@ +import type { DropTarget } from '@dynamix-layout/core' +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' +import type { LayoutController } from './controller' + +/** Keyboard move mode: a tab drag driven by arrow keys (Mod+Shift+M, Enter drops, Escape cancels). */ +interface MoveMode { + readonly tabId: string + readonly targets: readonly DropTarget[] + index: number +} +const moveModes = new WeakMap() + +function describeTarget( + controller: LayoutController, + target: DropTarget +): string { + const snapshot = controller.engine.getSnapshot() + const label = (tabId: string | undefined) => + tabId ? (controller.labels.get(tabId) ?? tabId) : '' + if (target.type === 'root') return `${target.position} edge of the layout` + if (target.type === 'tab') + return `${target.position} ${label(target.tabId)}` + const tabset = snapshot.tabsets.get(target.tabsetId) + const name = label(tabset?.tabIds[0]) + return target.position === 'center' + ? `into ${name}` + : `${target.position} of ${name}` +} + +function showMoveTarget(controller: LayoutController, mode: MoveMode) { + const target = mode.targets[mode.index] + controller.engine.setDragTarget(target, controller.measure()) + const name = controller.labels.get(mode.tabId) ?? mode.tabId + controller.announce( + `Move ${name}: ${describeTarget(controller, target)}. Enter to drop, Escape to cancel.` + ) +} + +export function startMoveMode( + controller: LayoutController, + tabId: string +): boolean { + const source = { type: 'tab', tabId } as const + const targets = controller.engine.listDropTargets(source) + if (targets.length === 0 || !controller.engine.startDrag(source)) + return false + const mode = { tabId, targets, index: 0 } + moveModes.set(controller, mode) + showMoveTarget(controller, mode) + return true +} + +export function handleMoveMode( + controller: LayoutController, + event: ReactKeyboardEvent +): boolean { + const mode = moveModes.get(controller) + if (!mode) return false + const name = controller.labels.get(mode.tabId) ?? mode.tabId + switch (event.key) { + case 'ArrowRight': + case 'ArrowDown': + mode.index = (mode.index + 1) % mode.targets.length + showMoveTarget(controller, mode) + break + case 'ArrowLeft': + case 'ArrowUp': + mode.index = + (mode.index - 1 + mode.targets.length) % mode.targets.length + showMoveTarget(controller, mode) + break + case 'Enter': + case ' ': + moveModes.delete(controller) + controller.engine.endDrag() + controller.announce(`Moved ${name}.`) + break + case 'Escape': + case 'Tab': + moveModes.delete(controller) + controller.engine.cancelDrag() + controller.announce(`Move of ${name} cancelled.`) + if (event.key === 'Tab') return true + break + default: + return true + } + event.preventDefault() + return true +} + +/** Ends keyboard move mode, if active (on blur or unmount). */ +export function cancelMoveMode(controller: LayoutController): void { + if (!moveModes.delete(controller)) return + controller.engine.cancelDrag() +} diff --git a/packages/react/src/internal/keyboard.ts b/packages/react/src/internal/keyboard.ts new file mode 100644 index 0000000..cef9b7c --- /dev/null +++ b/packages/react/src/internal/keyboard.ts @@ -0,0 +1,140 @@ +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' +import type { LayoutController } from './controller' +import { handleMoveMode, startMoveMode } from './keyboard-move-mode' + +export { cancelMoveMode } from './keyboard-move-mode' + +/** Splitter keyboard steps, in px. */ +const STEP = 10 +const LARGE_STEP = 50 + +const isModifier = (event: ReactKeyboardEvent) => event.metaKey || event.ctrlKey + +/** + * Keys on a focused tab (WAI-ARIA tabs pattern): arrows and Home/End move + * focus along the tab bar (selecting with automatic activation), Enter/Space + * select, Delete closes a closable tab, Mod+Shift+M starts move mode. + */ +export function handleTabKeyDown( + controller: LayoutController, + event: ReactKeyboardEvent, + tabId: string, + options: { isRotated: boolean; isClosable: boolean } +): void { + if (handleMoveMode(controller, event)) return + const { engine, options: settings } = controller + + if ( + event.key.toLowerCase() === 'm' && + event.shiftKey && + isModifier(event) + ) { + if (startMoveMode(controller, tabId)) event.preventDefault() + return + } + + const snapshot = engine.getSnapshot() + const tab = snapshot.tabs.get(tabId) + const tabIds = tab && snapshot.tabsets.get(tab.tabsetId)?.tabIds + if (!tabIds) return + const index = tabIds.indexOf(tabId) + const [previousKey, nextKey] = options.isRotated + ? ['ArrowUp', 'ArrowDown'] + : ['ArrowLeft', 'ArrowRight'] + + let next: string | undefined + if (event.key === previousKey) + next = tabIds[(index - 1 + tabIds.length) % tabIds.length] + else if (event.key === nextKey) next = tabIds[(index + 1) % tabIds.length] + else if (event.key === 'Home') next = tabIds[0] + else if (event.key === 'End') next = tabIds[tabIds.length - 1] + else if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + engine.selectTab(tabId) + return + } else if ( + (event.key === 'Delete' || event.key === 'Backspace') && + options.isClosable + ) { + event.preventDefault() + settings.onTabClose?.(tabId) + return + } + if (next === undefined) return + + event.preventDefault() + document.getElementById(controller.ids.tab(next))?.focus() + if (settings.tabActivation === 'automatic') engine.selectTab(next) +} + +/** + * Keys on a focused splitter (WAI-ARIA window splitter pattern): arrows along + * its row move it (Shift for larger steps), Home/End move it to its limits, + * Enter folds or unfolds the panel before it when that panel can fold. + */ +export function handleSplitterKeyDown( + controller: LayoutController, + event: ReactKeyboardEvent, + splitterId: string +): void { + const { engine } = controller + const splitter = engine.getSnapshot().splitters.get(splitterId) + if (!splitter) return + const horizontal = splitter.direction === 'horizontal' + const step = event.shiftKey ? LARGE_STEP : STEP + const keys = horizontal + ? ['ArrowLeft', 'ArrowRight'] + : ['ArrowUp', 'ArrowDown'] + const bounds = engine.getSplitterBounds(splitterId) + + let delta: number | null = null + if (event.key === keys[0]) delta = -step + else if (event.key === keys[1]) delta = step + else if (event.key === 'Home' && bounds) delta = bounds.min - bounds.value + else if (event.key === 'End' && bounds) delta = bounds.max - bounds.value + else if (event.key === 'Enter') { + const before = engine.getSnapshot().tabsets.get(splitter.beforeId) + const after = engine.getSnapshot().tabsets.get(splitter.afterId) + const target = + before?.isFolded || (before && !after?.isFolded) ? before : after + if (target?.canFold && controller.options.allowFold) { + event.preventDefault() + engine.toggleFold(target.id) + } + return + } + if (delta === null) return + event.preventDefault() + engine.moveSplitterBy(splitterId, delta) +} + +/** + * Layout-wide shortcuts, listened to on the root (so several layouts on a + * page don't all react): Alt/Option + "+" maximizes or restores, Alt/Option + + * "-" folds or unfolds the tabset that has focus or was last pointed at. + */ +export function handleRootKeyDown( + controller: LayoutController, + event: ReactKeyboardEvent +): void { + const { engine, options } = controller + if (!options.keyboardShortcuts || !event.altKey || isModifier(event)) return + const snapshot = engine.getSnapshot() + const id = controller.focusedTabsetId ?? snapshot.maximizedTabsetId + if (!id || !snapshot.tabsets.has(id)) return + + if ( + (event.code === 'Equal' || event.code === 'NumpadAdd') && + options.allowMaximize + ) { + event.preventDefault() + engine.toggleMaximize(id) + } else if ( + (event.code === 'Minus' || event.code === 'NumpadSubtract') && + options.allowFold && + options.showTabBar + ) { + event.preventDefault() + engine.toggleFold(id) + } +} diff --git a/packages/react/src/internal/pointer-drag.ts b/packages/react/src/internal/pointer-drag.ts new file mode 100644 index 0000000..7be68a9 --- /dev/null +++ b/packages/react/src/internal/pointer-drag.ts @@ -0,0 +1,152 @@ +import { + createFrameScheduler, + type DragSource, + type DropMeasurements, +} from '@dynamix-layout/core' +import type { PointerEvent as ReactPointerEvent } from 'react' +import type { LayoutController } from './controller' + +/** Distance a mouse or pen must move before a press becomes a tab drag. */ +const DRAG_THRESHOLD = 4 +/** How long a touch must hold still before it becomes a tab drag (so panning still scrolls tab bars). */ +const LONG_PRESS_MS = 350 +/** Movement that turns a pending touch into a pan instead. */ +const TOUCH_SLOP = 8 + +const active = new WeakMap void>() + +/** Cancels the drag in progress, if any (used on unmount). */ +export function stopPointerDrag(controller: LayoutController): void { + active.get(controller)?.() +} + +/** + * Starts tracking a press on a tab, tab bar or splitter. Splitters drag + * immediately; tabs and tab bars only after moving `DRAG_THRESHOLD` px (mouse, + * pen) or a long press (touch), so a plain click still selects a tab. + * + * During the drag: moves are coalesced to one per frame, Escape or + * `pointercancel` cancels, and the click that follows a drag is swallowed. + */ +export function beginPointerDrag( + controller: LayoutController, + event: ReactPointerEvent, + source: DragSource +): void { + if (event.button !== 0 || event.isPrimary === false) return + stopPointerDrag(controller) + + const { engine } = controller + const element = event.currentTarget + const pointerId = event.pointerId + const isTouch = event.pointerType === 'touch' + const start = { x: event.clientX, y: event.clientY } + let isDragging = false + let measurements: DropMeasurements | undefined + let longPress: ReturnType | null = null + + const frame = createFrameScheduler<{ x: number; y: number }>((client) => + engine.updateDrag( + controller.toRootPoint(client.x, client.y), + measurements + ) + ) + + const capture = () => { + try { + element.setPointerCapture?.(pointerId) + } catch { + // The pointer may already be released (e.g. a very short tap). + } + } + + const startDrag = () => { + if (isDragging) return + measurements = + source.type === 'splitter' ? undefined : controller.measure() + if (!engine.startDrag(source)) return cleanup() + isDragging = true + capture() + } + + const swallowClick = (click: MouseEvent) => { + click.stopPropagation() + click.preventDefault() + } + + const onMove = (move: PointerEvent) => { + if (move.pointerId !== pointerId) return + if (!isDragging) { + const distance = Math.hypot( + move.clientX - start.x, + move.clientY - start.y + ) + if (isTouch) { + if (distance > TOUCH_SLOP) cleanup() + return + } + if (distance < DRAG_THRESHOLD) return + startDrag() + if (!isDragging) return + } + move.preventDefault() + frame.schedule({ x: move.clientX, y: move.clientY }) + } + + const onUp = (up: PointerEvent) => { + if (up.pointerId !== pointerId) return + if (isDragging) { + frame.flush() + engine.endDrag() + // The click that follows the release would select or toggle something. + element.addEventListener('click', swallowClick, { + capture: true, + once: true, + }) + setTimeout( + () => + element.removeEventListener('click', swallowClick, { + capture: true, + }), + 0 + ) + } + cleanup() + } + + const onCancel = () => { + if (isDragging) engine.cancelDrag() + cleanup() + } + + const onKeyDown = (key: KeyboardEvent) => { + if (key.key === 'Escape' && isDragging) { + key.preventDefault() + onCancel() + } + } + + function cleanup() { + frame.cancel() + if (longPress !== null) clearTimeout(longPress) + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onCancel) + window.removeEventListener('keydown', onKeyDown, true) + if (active.get(controller) === onCancel) active.delete(controller) + isDragging = false + } + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onCancel) + window.addEventListener('keydown', onKeyDown, true) + active.set(controller, onCancel) + + if (source.type === 'splitter') { + event.preventDefault() + startDrag() + } else if (isTouch) { + longPress = setTimeout(startDrag, LONG_PRESS_MS) + } +} diff --git a/packages/react/src/internal/rect-writer.ts b/packages/react/src/internal/rect-writer.ts new file mode 100644 index 0000000..9cdc9e5 --- /dev/null +++ b/packages/react/src/internal/rect-writer.ts @@ -0,0 +1,78 @@ +import type { LayoutSnapshot } from '@dynamix-layout/core' +import type { LayoutController } from './controller' +import { splitterAria } from './splitter-aria' +import { + applySlotGeometry, + getSlotGeometry, + type PositionedSlot, + type SlotGeometry, +} from './slot-geometry' + +/** + * The DOM fast path. Pointer moves (splitter drags, drop targeting) and + * container resizes change only rects, which React never subscribes to, so + * React doesn't re-render; this writer moves the registered elements instead. + * + * Components render the same geometry from the current snapshot + * (`getSlotGeometry`), so a later render and the writer always agree: React + * diffs props against its previous props, never against the DOM. + */ + +const SLOTS: readonly PositionedSlot[] = [ + 'panel', + 'tabBar', + 'tabContent', + 'splitter', + 'dropIndicator', + 'rootDropZone', +] + +const sameGeometry = (a: SlotGeometry | undefined, b: SlotGeometry) => + !!a && + a.isHidden === b.isHidden && + a.isRotated === b.isRotated && + a.rect.x === b.rect.x && + a.rect.y === b.rect.y && + a.rect.width === b.rect.width && + a.rect.height === b.rect.height + +/** Splitter values change on every drag frame, so they're kept current here, not by renders. */ +function writeSplitterAria( + controller: LayoutController, + element: HTMLElement, + id: string +) { + const { percent } = splitterAria(controller, id) + if (!percent) return + element.setAttribute('aria-valuenow', String(percent.now)) + element.setAttribute('aria-valuemin', String(percent.min)) + element.setAttribute('aria-valuemax', String(percent.max)) +} + +/** Starts writing positions on every snapshot. @returns A function that stops it. */ +export function startRectWriter(controller: LayoutController): () => void { + const written = new WeakMap() + + const write = (snapshot: LayoutSnapshot) => { + controller.root?.toggleAttribute('data-dx-dragging', !!snapshot.drag) + for (const slot of SLOTS) { + controller.elements(slot).forEach((element, id) => { + const geometry = getSlotGeometry( + snapshot, + controller.options, + slot, + id + ) + if (!geometry || sameGeometry(written.get(element), geometry)) + return + applySlotGeometry(element, geometry) + written.set(element, geometry) + if (slot === 'splitter') + writeSplitterAria(controller, element, id) + }) + } + } + + write(controller.engine.getSnapshot()) + return controller.engine.subscribe(write) +} diff --git a/packages/react/src/internal/slot-geometry.ts b/packages/react/src/internal/slot-geometry.ts new file mode 100644 index 0000000..5c99dcd --- /dev/null +++ b/packages/react/src/internal/slot-geometry.ts @@ -0,0 +1,137 @@ +import { + getRootDropZoneRect, + getTabBarPlacement, + getTabContentRect, + type LayoutSnapshot, + type Rect, + type Side, +} from '@dynamix-layout/core' +import type { CSSProperties } from 'react' + +/** Elements the library positions. */ +export type PositionedSlot = + | 'panel' + | 'tabBar' + | 'tabContent' + | 'splitter' + | 'dropIndicator' + | 'rootDropZone' + +/** Geometry the slot geometry depends on besides the snapshot. */ +export interface SlotGeometryOptions { + readonly tabBarHeight: number + readonly showTabBar: boolean +} + +/** Where an element goes and whether it shows. */ +export interface SlotGeometry { + readonly rect: Rect + readonly isRotated: boolean + readonly isHidden: boolean +} + +const EMPTY: Rect = { x: 0, y: 0, width: 0, height: 0 } + +/** + * Geometry of one positioned element, computed from the snapshot alone. Both + * the render and the DOM fast path use it, so they always agree. + */ +export function getSlotGeometry( + snapshot: LayoutSnapshot, + options: SlotGeometryOptions, + slot: PositionedSlot, + id: string +): SlotGeometry | null { + const { rects } = snapshot + switch (slot) { + case 'panel': { + const tabset = snapshot.tabsets.get(id) + const rect = rects.tabsets.get(id) + if (!tabset || !rect) return null + return { rect, isRotated: false, isHidden: tabset.isHidden } + } + case 'tabBar': { + const tabset = snapshot.tabsets.get(id) + const rect = rects.tabsets.get(id) + if (!tabset || !rect) return null + const placement = getTabBarPlacement( + rect, + tabset, + options.tabBarHeight + ) + return { + rect: placement.rect, + isRotated: placement.isRotated, + isHidden: tabset.isHidden, + } + } + case 'tabContent': { + const tab = snapshot.tabs.get(id) + const tabsetRect = tab && rects.tabsets.get(tab.tabsetId) + if (!tab || !tabsetRect) + return { rect: EMPTY, isRotated: false, isHidden: true } + const rect = getTabContentRect( + tabsetRect, + options.showTabBar ? options.tabBarHeight : 0 + ) + return { + rect, + isRotated: false, + isHidden: !tab.isVisible || rect.height <= 0, + } + } + case 'splitter': { + const splitter = snapshot.splitters.get(id) + const rect = rects.splitters.get(id) + if (!splitter || !rect) return null + return { rect, isRotated: false, isHidden: splitter.isHidden } + } + case 'dropIndicator': { + const rect = snapshot.drag?.indicator + return rect + ? { rect, isRotated: false, isHidden: false } + : { rect: EMPTY, isRotated: false, isHidden: true } + } + case 'rootDropZone': { + const rect = getRootDropZoneRect(rects.container, id as Side) + const isTabDrag = + !!snapshot.drag && snapshot.drag.source.type !== 'splitter' + return { rect, isRotated: false, isHidden: !isTabDrag } + } + } +} + +/** + * Inline style for a geometry: positioning only (everything else is CSS), and + * complete on its own so headless layouts work without the stylesheet. + */ +export function positionStyle(geometry: SlotGeometry | null): CSSProperties { + if (!geometry) return {} + const { x, y, width, height } = geometry.rect + return { + position: 'absolute', + left: x, + top: y, + width, + height, + ...(geometry.isRotated ? { transform: 'rotate(90deg)' } : {}), + } +} + +/** Applies a geometry to an element (the fast path between renders). */ +export function applySlotGeometry( + element: HTMLElement, + geometry: SlotGeometry | null +): void { + if (!geometry) return + const { style } = element + const { x, y, width, height } = geometry.rect + style.left = `${x}px` + style.top = `${y}px` + style.width = `${width}px` + style.height = `${height}px` + style.transform = geometry.isRotated ? 'rotate(90deg)' : '' + element.toggleAttribute('data-dx-hidden', geometry.isHidden) + // Native `hidden` works without the stylesheet (headless layouts). + element.hidden = geometry.isHidden +} diff --git a/packages/react/src/internal/splitter-aria.ts b/packages/react/src/internal/splitter-aria.ts new file mode 100644 index 0000000..a3d7b4d --- /dev/null +++ b/packages/react/src/internal/splitter-aria.ts @@ -0,0 +1,25 @@ +import type { LayoutController as Core } from './controller' + +/** ARIA values as percentages of the two neighbours' combined size. */ +export function splitterAria(core: Core, splitterId: string) { + const bounds = core.engine.getSplitterBounds(splitterId) + const snapshot = core.engine.getSnapshot() + const splitter = snapshot.splitters.get(splitterId) + const after = + splitter && + (snapshot.rects.tabsets.get(splitter.afterId) ?? + snapshot.rects.rows.get(splitter.afterId)) + if (!bounds || !splitter || !after) return { bounds, percent: null } + const afterSize = + splitter.direction === 'horizontal' ? after.width : after.height + const total = bounds.value + afterSize || 1 + const percent = (value: number) => Math.round((value / total) * 100) + return { + bounds, + percent: { + now: percent(bounds.value), + min: percent(bounds.min), + max: percent(bounds.max), + }, + } +} diff --git a/packages/react/src/internal/tab-label.ts b/packages/react/src/internal/tab-label.ts new file mode 100644 index 0000000..dfcf2ba --- /dev/null +++ b/packages/react/src/internal/tab-label.ts @@ -0,0 +1,41 @@ +import type { LayoutSnapshot, RowNode, TabsetNode } from '@dynamix-layout/core' +import type { HeadlessTabItem } from '../types' + +/** Plain-text name of a tab for ARIA labels: its title when that's text, else its id. */ +export function tabLabel(tab: HeadlessTabItem | undefined, id: string): string { + const title = tab?.title + return typeof title === 'string' || typeof title === 'number' + ? String(title) + : id +} + +/** Name of a row or tabset for a splitter label: the active tab of it (or of its first tabset). */ +export function nodeLabel( + snapshot: LayoutSnapshot, + id: string, + tabs: { get(tabId: string): HeadlessTabItem | undefined } +): string { + const find = (node: RowNode | TabsetNode): TabsetNode | undefined => { + if (node.type === 'tabset') return node + for (const child of node.children) { + const found = find(child) + if (found) return found + } + return undefined + } + const search = (row: RowNode): RowNode | TabsetNode | undefined => { + for (const child of row.children) { + if (child.id === id) return child + if (child.type === 'row') { + const found = search(child) + if (found) return found + } + } + return undefined + } + const node = search(snapshot.root) + const tabset = node && find(node) + return tabset + ? tabLabel(tabs.get(tabset.activeTabId), tabset.activeTabId) + : 'panel' +} diff --git a/packages/react/src/internal/tab-registry.ts b/packages/react/src/internal/tab-registry.ts new file mode 100644 index 0000000..02c9bfa --- /dev/null +++ b/packages/react/src/internal/tab-registry.ts @@ -0,0 +1,67 @@ +import { useCallback, useSyncExternalStore } from 'react' +import type { HeadlessTabItem } from '../types' + +/** + * The `tabs` prop as a store with one subscription per tab. Titles and + * content live here, outside the engine, so changing them never touches the + * layout, and a component reading one tab re-renders only when that tab's item + * changes (an inline `tabs` array with unchanged items re-renders nothing). + */ +export interface TabRegistry { + get(tabId: string): HeadlessTabItem | undefined + subscribe(tabId: string, listener: () => void): () => void + /** Applies a new `tabs` prop; notifies only tabs whose item changed. */ + update(items: readonly HeadlessTabItem[]): void +} + +const sameItem = (a: HeadlessTabItem, b: HeadlessTabItem) => + a.title === b.title && + a.content === b.content && + a.closable === b.closable && + a.target === b.target + +export function createTabRegistry( + initial: readonly HeadlessTabItem[] +): TabRegistry { + let items = new Map(initial.map((item) => [item.id, item])) + const listeners = new Map void>>() + + return { + get: (tabId) => items.get(tabId), + subscribe(tabId, listener) { + let set = listeners.get(tabId) + if (!set) listeners.set(tabId, (set = new Set())) + set.add(listener) + return () => set!.delete(listener) + }, + update(next) { + const previous = items + const changed: string[] = [] + items = new Map( + next.map((item) => { + const old = previous.get(item.id) + if (old && sameItem(old, item)) return [item.id, old] + changed.push(item.id) + return [item.id, item] + }) + ) + for (const id of previous.keys()) + if (!items.has(id)) changed.push(id) + for (const id of changed) + listeners.get(id)?.forEach((listener) => listener()) + }, + } +} + +/** Subscribes to one tab's item. */ +export function useTabItem( + registry: TabRegistry, + tabId: string +): HeadlessTabItem | undefined { + const subscribe = useCallback( + (listener: () => void) => registry.subscribe(tabId, listener), + [registry, tabId] + ) + const get = () => registry.get(tabId) + return useSyncExternalStore(subscribe, get, get) +} diff --git a/packages/react/src/internal/use-controller.ts b/packages/react/src/internal/use-controller.ts new file mode 100644 index 0000000..45a40e5 --- /dev/null +++ b/packages/react/src/internal/use-controller.ts @@ -0,0 +1,181 @@ +import type { LayoutChangeReason, LayoutJSON } from '@dynamix-layout/core' +import { useCallback, useMemo, useReducer, useRef, useState } from 'react' +import type { LayoutContextValue } from '../context/layout-context' +import type { + HeadlessTabItem, + LayoutPadding, + UseDynamixLayoutOptions, +} from '../types' +import { useIsomorphicLayoutEffect } from '../utils/use-isomorphic-layout-effect' +import { useStableCallback } from '../utils/use-stable-callback' +import { observeContainer, readContainer } from './container-observer' +import { + coreSizes, + createController, + type ControllerOptions, + type Padding, +} from './controller' +import { cancelMoveMode } from './keyboard' +import { stopPointerDrag } from './pointer-drag' +import { startRectWriter } from './rect-writer' +import { tabLabel } from './tab-label' +import { createTabRegistry } from './tab-registry' + +const DEFAULT_MIN_PANEL_SIZE = { width: 40, height: 40 } + +function toPadding(padding: LayoutPadding | undefined): Padding { + if (typeof padding === 'number') + return { top: padding, right: padding, bottom: padding, left: padding } + return { + top: padding?.top ?? 0, + right: padding?.right ?? 0, + bottom: padding?.bottom ?? 0, + left: padding?.left ?? 0, + } +} + +/** Joins tab ids into a key that changes only when the set or order of ids does. */ +const idsKey = (tabs: readonly HeadlessTabItem[]) => + tabs.map((tab) => tab.id).join('\u0000') + +/** + * Creates the controller once and keeps it in sync with props: options and + * tab ids are pushed into the existing engine (never recreated), the container + * is observed, the fast path runs, and a controlled `layout` is reconciled. + */ +export function useController( + options: UseDynamixLayoutOptions, + idBase: string +) { + const { + tabs, + layout, + defaultLayout, + minPanelSize = DEFAULT_MIN_PANEL_SIZE, + splitterSize = 10, + tabBarHeight = 40, + showTabBar = true, + } = options + const padding = toPadding(options.padding) + const controllerOptions: ControllerOptions = { + tabBarHeight, + showTabBar, + padding, + resizeThrottleMs: options.resizeThrottleMs ?? 0, + allowMaximize: options.allowMaximize ?? true, + allowFold: (options.allowFold ?? true) && showTabBar, + maximizeOnDoubleClick: options.maximizeOnDoubleClick ?? true, + keyboardShortcuts: options.keyboardShortcuts ?? true, + tabActivation: options.tabActivation ?? 'automatic', + onTabClose: options.onTabClose, + } + + const isControlled = layout !== undefined + const [, reconcile] = useReducer((count: number) => count + 1, 0) + const controlledRef = useRef(isControlled) + const userOnLayoutChange = useStableCallback(options.onLayoutChange) + const onLayoutChange = useCallback( + (json: LayoutJSON, reason: LayoutChangeReason) => { + userOnLayoutChange(json, { reason }) + // Re-render so the effect below can revert a change the parent didn't accept. + if (controlledRef.current) reconcile() + }, + [userOnLayoutChange] + ) + + const [core] = useState(() => + createController({ + idBase, + tabs: tabs.map(({ id, target }) => ({ id, target })), + initialLayout: layout ?? defaultLayout, + options: controllerOptions, + minPanelSize, + splitterSize, + onLayoutChange, + }) + ) + const sync = useRef({ from: layout, json: core.engine.toJSON() }) + + useIsomorphicLayoutEffect(() => { + controlledRef.current = isControlled + core.options = controllerOptions + }) + + useIsomorphicLayoutEffect(() => { + core.engine.setOptions( + coreSizes(minPanelSize, splitterSize, { tabBarHeight, showTabBar }) + ) + }, [ + core, + minPanelSize.width, + minPanelSize.height, + splitterSize, + tabBarHeight, + showTabBar, + ]) + + const key = idsKey(tabs) + const latestTabs = useRef(tabs) + useIsomorphicLayoutEffect(() => { + latestTabs.current = tabs + core.labels.clear() + for (const tab of tabs) core.labels.set(tab.id, tabLabel(tab, tab.id)) + }) + useIsomorphicLayoutEffect(() => { + core.engine.setTabs( + latestTabs.current.map(({ id, target }) => ({ id, target })) + ) + }, [core, key]) + + // Controlled mode: adopt the parent's layout unless it is what we last emitted or loaded. + useIsomorphicLayoutEffect(() => { + if (!isControlled) return + const current = core.engine.toJSON() + if (layout === current) sync.current = { from: layout, json: current } + else if ( + layout !== sync.current.from || + current !== sync.current.json + ) { + core.engine.load(layout!) + sync.current = { from: layout, json: core.engine.toJSON() } + } + }) + + const [root, setRoot] = useState(null) + // State only to re-render once measured; getters read `core.isMeasured`. + const [, setMeasured] = useState(false) + const rootRef = useCallback( + (element: HTMLElement | null) => { + core.root = element + setRoot(element) + }, + [core] + ) + useIsomorphicLayoutEffect(() => { + if (!root) return + const stopObserving = observeContainer(core, root) + const stopWriting = startRectWriter(core) + core.isMeasured = true + setMeasured(true) + return () => { + stopObserving() + stopWriting() + stopPointerDrag(core) + cancelMoveMode(core) + } + }, [core, root]) + // Padding changes the container without resizing the element. + useIsomorphicLayoutEffect(() => { + if (root) core.engine.setContainerRect(readContainer(root, padding)) + }, [core, root, padding.top, padding.right, padding.bottom, padding.left]) + + const [registry] = useState(() => createTabRegistry(tabs)) + useIsomorphicLayoutEffect(() => registry.update(tabs), [registry, tabs]) + const contextValue = useMemo( + () => ({ core, tabs: registry }), + [core, registry] + ) + const tabIds = useMemo(() => (key === '' ? [] : key.split('\u0000')), [key]) + + return { core, contextValue, rootRef, tabIds } +} diff --git a/packages/react/src/styles.css b/packages/react/src/styles.css new file mode 100644 index 0000000..85ac9d9 --- /dev/null +++ b/packages/react/src/styles.css @@ -0,0 +1,264 @@ +/* + * Default styles for @dynamix-layout/react. Every visual default is a CSS + * variable, so themes only need to set variables (or use `classNames`). + * Positions (left/top/width/height) are inline and set by the library. + */ + +.dx-root { + --dx-font: inherit; + --dx-tab-bar-bg: #dfdfdf; + --dx-tab-bg: #dfdfdf; + --dx-tab-active-bg: #ffffff; + --dx-tab-color: inherit; + --dx-tab-radius: 4px; + --dx-tab-gap: 6px; + --dx-splitter-bg: #ffffff; + --dx-splitter-hover-bg: #e8f2ff; + --dx-grip-color: #9a9a9a; + --dx-drop-indicator-bg: rgba(0, 175, 249, 0.35); + --dx-drop-indicator-border: 2px dashed rgb(0, 196, 42); + --dx-root-drop-zone-bg: #0081f9; + --dx-focus-ring: 2px solid #0081f9; + + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + box-sizing: border-box; + font-family: var(--dx-font); +} + +/* Server render and first client render: nothing is measured yet. */ +.dx-root[data-dx-measuring] > * { + visibility: hidden; +} + +.dx-root *, +.dx-root *::before, +.dx-root *::after { + box-sizing: border-box; +} + +.dx-panel, +.dx-tab-bar, +.dx-tab-content, +.dx-splitter, +.dx-drop-indicator, +.dx-root-drop-zone { + position: absolute; +} + +.dx-panel { + z-index: 1; +} + +/* --- Tab bar and tabs --- */ +.dx-tab-bar { + z-index: 3; + display: flex; + align-items: center; + gap: var(--dx-tab-gap); + padding-left: 8px; + background: var(--dx-tab-bar-bg); + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + transform-origin: 0 0; + touch-action: pan-x; + cursor: default; +} +.dx-tab-bar::-webkit-scrollbar { + display: none; +} +.dx-tab-bar[data-dx-has-toolbar] { + padding-right: 64px; +} + +.dx-tab { + display: inline-flex; + flex: none; + align-items: center; + gap: 4px; + padding: 4px 6px; + border-radius: var(--dx-tab-radius); + background: var(--dx-tab-bg); + color: var(--dx-tab-color); + font-size: 14px; + white-space: nowrap; + cursor: pointer; + user-select: none; +} +.dx-tab[data-state='active'] { + background: var(--dx-tab-active-bg); +} +.dx-tab:focus-visible, +.dx-splitter:focus-visible, +.dx-toolbar-button:focus-visible { + outline: var(--dx-focus-ring); + outline-offset: -2px; +} + +.dx-tab-close { + display: grid; + place-items: center; + width: 18px; + height: 18px; + padding: 0; + border: 0; + border-radius: 3px; + background: transparent; + color: inherit; + cursor: pointer; +} +.dx-tab-close:hover { + background: rgba(0, 0, 0, 0.08); +} + +/* --- Tab content --- */ +.dx-tab-content { + z-index: 2; + overflow: auto; + overscroll-behavior: contain; + scrollbar-width: none; +} +.dx-tab-content::-webkit-scrollbar { + display: none; +} +/* While dragging, iframes and editors inside tabs must not swallow pointer events. */ +.dx-root[data-dx-dragging] .dx-tab-content > * { + pointer-events: none; +} +.dx-root[data-dx-dragging], +.dx-root[data-dx-dragging] * { + cursor: grabbing; +} + +/* --- Splitters --- */ +.dx-splitter { + z-index: 4; + display: grid; + place-items: center; + background: var(--dx-splitter-bg); + touch-action: none; +} +.dx-splitter[data-dx-direction='horizontal'] { + cursor: ew-resize; +} +.dx-splitter[data-dx-direction='vertical'] { + cursor: ns-resize; +} +.dx-splitter:hover { + background: var(--dx-splitter-hover-bg); +} +.dx-splitter[data-dx-locked] { + pointer-events: none; +} +/* A grip of three dots across the bar. */ +.dx-splitter::after { + content: ''; + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--dx-grip-color); + box-shadow: + 0 -6px 0 var(--dx-grip-color), + 0 6px 0 var(--dx-grip-color); +} +.dx-splitter[data-dx-direction='vertical']::after { + box-shadow: + -6px 0 0 var(--dx-grip-color), + 6px 0 0 var(--dx-grip-color); +} + +/* --- Drag feedback --- */ +.dx-drop-indicator { + z-index: 6; + background: var(--dx-drop-indicator-bg); + border: var(--dx-drop-indicator-border); + border-radius: 10px; + pointer-events: none; + transition: all 0.15s ease; +} + +.dx-root-drop-zone { + z-index: 5; + background: var(--dx-root-drop-zone-bg); + opacity: 0.6; + pointer-events: none; +} +.dx-root-drop-zone[data-dx-active] { + opacity: 1; +} +.dx-root-drop-zone[data-dx-side='left'] { + border-radius: 0 16px 16px 0; +} +.dx-root-drop-zone[data-dx-side='right'] { + border-radius: 16px 0 0 16px; +} +.dx-root-drop-zone[data-dx-side='top'] { + border-radius: 0 0 16px 16px; +} +.dx-root-drop-zone[data-dx-side='bottom'] { + border-radius: 16px 16px 0 0; +} + +/* --- Tabset toolbar (maximize / fold) --- */ +/* Shown while the tab bar is hovered or focused; folded strips always show it. */ +.dx-toolbar { + position: absolute; + top: 0; + right: 6px; + height: 100%; + display: flex; + align-items: center; + gap: 2px; + padding-left: 6px; + /* Opaque, so overflowing tabs slide underneath instead of showing through. */ + background: inherit; + opacity: 0; + transition: opacity 0.15s ease; +} +.dx-tab-bar:hover .dx-toolbar, +.dx-tab-bar[data-dx-folded] .dx-toolbar, +.dx-toolbar:focus-within { + opacity: 1; +} +@media (hover: none) { + .dx-toolbar { + opacity: 1; + } +} + +.dx-toolbar-button { + display: grid; + place-items: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: inherit; + cursor: pointer; +} +.dx-toolbar-button:hover { + background-color: rgba(0, 0, 0, 0.08); +} + +/* Hidden elements (another tabset is maximized, an inactive tab). An attribute, + not inline `display`, keeps each slot component's own display value intact. */ +.dx-root [data-dx-hidden] { + display: none !important; +} + +.dx-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} diff --git a/packages/react/src/test/dom.ts b/packages/react/src/test/dom.ts new file mode 100644 index 0000000..70556c2 --- /dev/null +++ b/packages/react/src/test/dom.ts @@ -0,0 +1,150 @@ +import { act } from '@testing-library/react' +import { vi } from 'vitest' + +/** + * jsdom has no layout. These fakes give the library what it measures: the + * root's client size, element rects from their inline position (the root sits + * at 0,0), tabs laid out left to right in their bar, a ResizeObserver the + * tests can trigger, and an animation-frame queue the tests flush. + */ + +const px = (value: string) => Number.parseFloat(value) || 0 + +export const TAB_WIDTH = 64 +export const TAB_GAP = 6 + +function rectOf(element: Element): DOMRect { + const html = element as HTMLElement + if (html.dataset?.dxSlot === 'tab') { + const bar = html.parentElement! + const barRect = rectOf(bar) + const index = Array.from( + bar.querySelectorAll('[data-dx-slot="tab"]') + ).indexOf(html) + const x = barRect.left + 8 + index * (TAB_WIDTH + TAB_GAP) + return DOMRect.fromRect({ + x, + y: barRect.top + 4, + width: TAB_WIDTH, + height: 32, + }) + } + const { left, top, width, height, transform } = html.style ?? {} + if (transform?.includes('rotate(90deg)')) { + // Rotated around its top-left corner: the visual box is to the left. + return DOMRect.fromRect({ + x: px(left) - px(height), + y: px(top), + width: px(height), + height: px(width), + }) + } + if (html.classList?.contains('dx-root')) + return DOMRect.fromRect({ + x: 0, + y: 0, + width: size.width, + height: size.height, + }) + return DOMRect.fromRect({ + x: px(left), + y: px(top), + width: px(width), + height: px(height), + }) +} + +const size = { width: 1210, height: 800 } + +export class FakeResizeObserver { + static instances: FakeResizeObserver[] = [] + connected = true + constructor(public callback: ResizeObserverCallback) { + FakeResizeObserver.instances.push(this) + } + observe() {} + unobserve() {} + disconnect() { + this.connected = false + } +} + +/** jsdom has no PointerEvent; a MouseEvent with the pointer fields is enough. */ +class FakePointerEvent extends MouseEvent { + readonly pointerId: number + readonly pointerType: string + readonly isPrimary: boolean + constructor(type: string, init: PointerEventInit = {}) { + super(type, { bubbles: true, cancelable: true, ...init }) + this.pointerId = init.pointerId ?? 1 + this.pointerType = init.pointerType ?? 'mouse' + this.isPrimary = init.isPrimary ?? true + } +} + +let frames: FrameRequestCallback[] = [] + +/** Runs queued animation frames (inside `act`). */ +export function flushFrames() { + act(() => { + while (frames.length) { + const queue = frames + frames = [] + queue.forEach((callback) => callback(performance.now())) + } + }) +} + +/** Changes the layout's size and notifies resize observers (inside `act`). */ +export function resizeTo(width: number, height: number) { + size.width = width + size.height = height + act(() => { + FakeResizeObserver.instances + .filter((o) => o.connected) + .forEach((o) => o.callback([], o as unknown as ResizeObserver)) + }) + flushFrames() +} + +/** Installs the fakes. Call in `beforeEach`; `vi.restoreAllMocks` undoes the spies. */ +export function installDom(width = 1210, height = 800) { + size.width = width + size.height = height + frames = [] + FakeResizeObserver.instances = [] + vi.stubGlobal('ResizeObserver', FakeResizeObserver) + vi.stubGlobal('PointerEvent', FakePointerEvent) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + frames.push(callback) + ) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + frames[id - 1] = () => {} + }) + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation( + function (this: HTMLElement) { + return this.classList.contains('dx-root') ? size.width : 0 + } + ) + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation( + function (this: HTMLElement) { + return this.classList.contains('dx-root') ? size.height : 0 + } + ) + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation( + function (this: Element) { + return rectOf(this) + } + ) +} + +/** Inline rect of an element, as numbers. */ +export function styleRect(element: Element | null) { + const style = (element as HTMLElement).style + return { + x: px(style.left), + y: px(style.top), + width: px(style.width), + height: px(style.height), + } +} diff --git a/packages/react/src/test/drag.test.tsx b/packages/react/src/test/drag.test.tsx new file mode 100644 index 0000000..44799ec --- /dev/null +++ b/packages/react/src/test/drag.test.tsx @@ -0,0 +1,276 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { createRef, useState } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + DynamixLayout, + type DynamixLayoutHandle, + type LayoutJSON, + type TabItem, +} from '..' +import { flushFrames, installDom } from './dom' + +const tabs: TabItem[] = ['a', 'b', 'c'].map((id) => ({ + id, + title: id.toUpperCase(), + content:

{`body ${id}`}

, +})) +const pointer = { button: 0, pointerId: 1 } + +beforeEach(() => installDom()) +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +const move = (x: number, y: number) => { + fireEvent.pointerMove(window, { ...pointer, clientX: x, clientY: y }) + flushFrames() +} + +describe('pointer tab drag', () => { + it('a click without movement selects; a drag moves the tab where the indicator shows', () => { + const ref = createRef() + const onLayoutChange = vi.fn() + const { container } = render( + + ) + const c = screen.getByRole('tab', { name: 'C' }) + + fireEvent.pointerDown(c, { ...pointer, clientX: 700, clientY: 420 }) + move(702, 421) + expect(ref.current!.getSnapshot().drag).toBeNull() + fireEvent.pointerUp(window, { ...pointer, clientX: 702, clientY: 421 }) + + fireEvent.pointerDown(c, { ...pointer, clientX: 700, clientY: 420 }) + move(100, 400) + expect(container.querySelector('.dx-root')).toHaveAttribute( + 'data-dx-dragging' + ) + expect(container.querySelectorAll('.dx-root-drop-zone')).toHaveLength(4) + const indicator = + container.querySelector('.dx-drop-indicator')! + expect(indicator.style.width).toBe('300px') + fireEvent.pointerUp(window, { ...pointer, clientX: 100, clientY: 400 }) + fireEvent.click(c) + + expect(container.querySelector('.dx-root')).not.toHaveAttribute( + 'data-dx-dragging' + ) + expect(container.querySelector('.dx-drop-indicator')).toBeNull() + expect(onLayoutChange).toHaveBeenCalledTimes(1) + expect(onLayoutChange.mock.calls[0][1]).toEqual({ reason: 'move' }) + const json = ref.current!.toJSON() + expect(json.root.children.map((child) => child.type)).toEqual([ + 'tabset', + 'tabset', + 'tabset', + ]) + expect(json.root.children[0]).toMatchObject({ children: [{ id: 'c' }] }) + }) + + it('Escape cancels a drag', () => { + const ref = createRef() + render() + const before = ref.current!.toJSON() + fireEvent.pointerDown(screen.getByRole('tab', { name: 'C' }), { + ...pointer, + clientX: 700, + clientY: 420, + }) + move(100, 400) + fireEvent.keyDown(window, { key: 'Escape' }) + expect(ref.current!.getSnapshot().drag).toBeNull() + fireEvent.pointerUp(window, { ...pointer, clientX: 100, clientY: 400 }) + expect(ref.current!.toJSON()).toBe(before) + }) + + it('inserts between tabs when dropped on a tab bar', () => { + const ref = createRef() + render() + // Tab bar of "a" spans x 0–600, y 0–40; tab "A" sits at x 8–72. + fireEvent.pointerDown(screen.getByRole('tab', { name: 'C' }), { + ...pointer, + clientX: 700, + clientY: 420, + }) + move(20, 20) + expect(ref.current!.getSnapshot().drag!.target).toEqual({ + type: 'tab', + tabId: 'a', + position: 'before', + }) + fireEvent.pointerUp(window, { ...pointer, clientX: 20, clientY: 20 }) + expect(ref.current!.getSnapshot().tabsets.get('ts-a')!.tabIds).toEqual([ + 'c', + 'a', + ]) + }) + + it('drags a whole tabset from its tab bar background', () => { + const ref = createRef() + render() + const bar = screen.getAllByRole('tablist')[2] + fireEvent.pointerDown(bar, { ...pointer, clientX: 1100, clientY: 425 }) + move(300, 400) + expect(ref.current!.getSnapshot().drag!.source).toEqual({ + type: 'tabset', + tabsetId: 'ts-c', + }) + fireEvent.pointerUp(window, { ...pointer, clientX: 300, clientY: 400 }) + expect(ref.current!.getSnapshot().tabs.get('c')!.tabsetId).toBe('ts-a') + }) + + it('leaves maximized mode when a drag starts', () => { + const ref = createRef() + render() + act(() => { + ref.current!.maximize('ts-c') + }) + fireEvent.pointerDown(screen.getByRole('tab', { name: 'C' }), { + ...pointer, + clientX: 20, + clientY: 20, + }) + move(300, 300) + expect(ref.current!.getSnapshot().maximizedTabsetId).toBeNull() + fireEvent.pointerUp(window, { ...pointer, clientX: 300, clientY: 300 }) + }) +}) + +describe('controlled and uncontrolled', () => { + it('uncontrolled: starts from defaultLayout and ignores later changes to it', () => { + const ref = createRef() + const first = { + version: 2, + root: { + type: 'row', + id: 'r', + weight: 100, + direction: 'vertical', + children: [ + { + type: 'tabset', + id: 'top', + weight: 100, + activeTabId: 'a', + children: [ + { type: 'tab', id: 'a' }, + { type: 'tab', id: 'b' }, + { type: 'tab', id: 'c' }, + ], + }, + ], + }, + } as LayoutJSON + const { rerender } = render( + + ) + expect(ref.current!.getSnapshot().tabsets.get('top')!.tabIds).toEqual([ + 'a', + 'b', + 'c', + ]) + rerender( + + ) + expect(ref.current!.toJSON().root.id).toBe('r') + }) + + it('controlled: follows the parent, and reverts changes the parent rejects', () => { + const ref = createRef() + let accept = true + const payloads: LayoutJSON[] = [] + function App() { + const [layout, setLayout] = useState( + undefined + ) + const [current, setCurrent] = useState(null) + return ( + { + payloads.push(next) + if (accept) setCurrent(next) + else setLayout((l) => l) + }} + /> + ) + } + render() + act(() => { + ref.current!.selectTab('b') + ref.current!.moveTab('c', { type: 'root', position: 'bottom' }) + }) + expect(payloads.at(-1)!.version).toBe(2) + expect(ref.current!.toJSON()).toBe(payloads.at(-1)) + const accepted = ref.current!.toJSON() + + accept = false + act(() => { + ref.current!.moveTab('a', { type: 'root', position: 'top' }) + }) + expect(ref.current!.toJSON()).toEqual(accepted) + }) + + it('controlled: loads a new layout from the parent (v1 or v2)', () => { + const ref = createRef() + const v1 = { + typNode: 'row', + nodName: 'root', + uidNode: 'root', + nodPart: 100, + nodKids: [ + { + typNode: 'tabset', + nodName: '', + uidNode: 'x', + nodPart: 100, + nodOpen: 'b', + nodKids: [ + { + typNode: 'tab', + nodName: 'a', + uidNode: '1', + nodPart: 100, + }, + { + typNode: 'tab', + nodName: 'b', + uidNode: '2', + nodPart: 100, + }, + { + typNode: 'tab', + nodName: 'c', + uidNode: '3', + nodPart: 100, + }, + ], + }, + ], + } as const + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { rerender } = render( + + ) + rerender() + expect(ref.current!.getSnapshot().tabsets.get('x')!.activeTabId).toBe( + 'b' + ) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('v1 layout')) + }) +}) diff --git a/packages/react/src/test/layout.test.ts b/packages/react/src/test/layout.test.ts deleted file mode 100644 index 233416b..0000000 --- a/packages/react/src/test/layout.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -// src/test/layout.test.ts -import { describe, it, expect } from 'vitest' -import { render, screen } from '@testing-library/react' -import '@testing-library/jest-dom/vitest' // Ensure you have this import -import { DynamixLayout } from '../' -import * as React from 'react' - -describe('React Layout Component', () => { - it('should render the root div', () => { - const mockTabs: [string, React.ReactNode][] = [ - ['tab1', React.createElement('div', null, 'Tab 1 Content')], - ['tab2', React.createElement('div', null, 'Tab 2 Content')], - ['tab3', React.createElement('div', null, 'Tab 3 Content')], - ['tab4', React.createElement('div', null, 'Tab 4 Content')], - ['tab5', React.createElement('div', null, 'Tab 5 Content')], - ['tab6', React.createElement('div', null, 'Tab 6 Content')], - ['tab7', React.createElement('div', null, 'Tab 7 Content')], - ] - - render( - React.createElement(DynamixLayout, { - tabs: mockTabs, - rootId: 'test-root', - }) - ) - - const layoutElement = screen.getByTestId('test-root') - expect(layoutElement).toBeInTheDocument() - }) -}) diff --git a/packages/react/src/test/render.test.tsx b/packages/react/src/test/render.test.tsx new file mode 100644 index 0000000..307734f --- /dev/null +++ b/packages/react/src/test/render.test.tsx @@ -0,0 +1,271 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + DynamixLayout, + type DynamixLayoutHandle, + type TabItem, + type TabProps, +} from '..' +import { createRef } from 'react' +import { installDom, styleRect } from './dom' + +const tabs: TabItem[] = [ + { id: 'editor', title: 'Editor', content:

editor body

}, + { id: 'terminal', title: 'Terminal', content:

terminal body

}, + { + id: 'preview', + title: 'Preview', + content:

preview body

, + closable: true, + }, +] + +beforeEach(() => installDom()) +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe(' rendering', () => { + it('renders tabs, contents and splitters at their positions', () => { + const { container } = render() + expect(screen.getAllByRole('tablist')).toHaveLength(3) + expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual([ + 'Editor', + 'Terminal', + 'Preview', + ]) + expect(screen.getByText('editor body')).toBeInTheDocument() + expect(container.querySelectorAll('[role="separator"]')).toHaveLength(2) + const editorPanel = screen.getByText('editor body').parentElement! + expect(styleRect(editorPanel)).toEqual({ + x: 0, + y: 40, + width: 600, + height: 760, + }) + expect(container.querySelector('.dx-root')).not.toHaveAttribute( + 'data-dx-measuring' + ) + }) + + it('follows container resizes from a ResizeObserver', async () => { + const { resizeTo } = await import('./dom') + render() + resizeTo(810, 600) + expect( + styleRect(screen.getByText('editor body').parentElement) + ).toEqual({ x: 0, y: 40, width: 400, height: 560 }) + }) + + it('applies padding, classNames and styles', () => { + const { container } = render( + + ) + const root = container.querySelector('.dx-root')! + expect(root).toHaveClass('app', 'root-x') + expect(screen.getAllByRole('tab')[0]).toHaveClass( + 'dx-tab', + 'tab-x', + 'active-x' + ) + expect(container.querySelector('.dx-splitter')).toHaveClass('split-x') + const bar = screen.getAllByRole('tablist')[0] + expect(bar.style.background).toBe('red') + expect(styleRect(bar).x).toBe(10) + expect(styleRect(bar).y).toBe(5) + }) + + it('accepts custom slot components', () => { + render( + {`${tab.id}${isActive ? '*' : ''}`} + ) + }, + }} + /> + ) + expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual([ + 'editor*', + 'terminal*', + 'preview*', + ]) + }) + + it('gives a working imperative handle through ref', () => { + const ref = createRef() + const callback = vi.fn() + render() + render() + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ moveTab: expect.any(Function) }) + ) + expect(ref.current!.element).toHaveClass('dx-root') + act(() => { + ref.current!.moveTab('preview', { + type: 'root', + position: 'bottom', + }) + }) + expect(ref.current!.toJSON().root.direction).toBe('vertical') + }) +}) + +describe('accessibility', () => { + it('follows the WAI-ARIA tabs pattern', () => { + render() + const [editor] = screen.getAllByRole('tab') + const panel = document.getElementById( + editor.getAttribute('aria-controls')! + )! + expect(panel).toHaveAttribute('role', 'tabpanel') + expect(panel).toHaveAttribute('aria-labelledby', editor.id) + expect(editor).toHaveAttribute('aria-selected', 'true') + expect(editor).toHaveAttribute('tabindex', '0') + expect(screen.getAllByRole('tablist')[0]).toHaveAttribute( + 'aria-orientation', + 'horizontal' + ) + }) + + it('moves focus and selection with arrow keys, Home and End', () => { + const ref = createRef() + render() + act(() => { + ref.current!.moveTab('terminal', { + type: 'tab', + tabId: 'editor', + position: 'after', + }) + }) + const [editor, terminal] = screen.getAllByRole('tab') + act(() => editor.focus()) + fireEvent.keyDown(editor, { key: 'ArrowRight' }) + expect(document.activeElement).toBe(terminal) + expect(terminal).toHaveAttribute('aria-selected', 'true') + expect(editor).toHaveAttribute('tabindex', '-1') + fireEvent.keyDown(terminal, { key: 'Home' }) + expect(document.activeElement).toBe(screen.getAllByRole('tab')[0]) + }) + + it('selects only on Enter with manual activation', () => { + const ref = createRef() + render() + act(() => { + ref.current!.moveTab('terminal', { + type: 'tab', + tabId: 'editor', + position: 'after', + }) + ref.current!.selectTab('editor') + }) + const [editor, terminal] = screen.getAllByRole('tab') + fireEvent.keyDown(editor, { key: 'ArrowRight' }) + expect(terminal).toHaveAttribute('aria-selected', 'false') + fireEvent.keyDown(terminal, { key: 'Enter' }) + expect(terminal).toHaveAttribute('aria-selected', 'true') + }) + + it('follows the window splitter pattern and resizes from the keyboard', () => { + const { container } = render() + const splitter = + container.querySelector('[role="separator"]')! + expect(splitter).toHaveAttribute('aria-orientation', 'vertical') + expect(splitter).toHaveAttribute('aria-valuenow', '50') + expect(splitter).toHaveAttribute( + 'aria-label', + 'Resize Editor and Terminal' + ) + expect(splitter).toHaveAttribute('tabindex', '0') + const before = styleRect( + screen.getByText('editor body').parentElement + ).width + fireEvent.keyDown(splitter, { key: 'ArrowRight' }) + expect( + styleRect(screen.getByText('editor body').parentElement).width + ).toBe(before + 10) + fireEvent.keyDown(splitter, { key: 'ArrowLeft', shiftKey: true }) + expect( + styleRect(screen.getByText('editor body').parentElement).width + ).toBe(before - 40) + fireEvent.keyDown(splitter, { key: 'Home' }) + expect( + styleRect(screen.getByText('editor body').parentElement).width + ).toBe(40) + expect(splitter.getAttribute('aria-valuenow')).toBe('3') + }) + + it('closes closable tabs from the button and the Delete key', () => { + const onTabClose = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Close Preview' })) + fireEvent.keyDown(screen.getByRole('tab', { name: /Preview/ }), { + key: 'Delete', + }) + fireEvent.keyDown(screen.getByRole('tab', { name: 'Editor' }), { + key: 'Delete', + }) + expect(onTabClose.mock.calls).toEqual([['preview'], ['preview']]) + }) + + it('maximizes and folds from the toolbar and shortcuts', () => { + const ref = createRef() + const { container } = render() + fireEvent.click(screen.getAllByRole('button', { name: 'Maximize' })[0]) + expect(ref.current!.getSnapshot().maximizedTabsetId).toBe('ts-editor') + expect( + container.querySelectorAll('.dx-tab-bar[data-dx-hidden]') + ).toHaveLength(2) + const root = container.querySelector('.dx-root')! + fireEvent.pointerDown(screen.getByRole('tab', { name: 'Editor' })) + fireEvent.keyDown(root, { altKey: true, code: 'Equal' }) + expect(ref.current!.getSnapshot().maximizedTabsetId).toBeNull() + fireEvent.keyDown(root, { altKey: true, code: 'Minus' }) + expect( + ref.current!.getSnapshot().tabsets.get('ts-editor')!.isFolded + ).toBe(true) + expect(screen.getAllByRole('tablist')[0]).toHaveAttribute( + 'aria-orientation', + 'vertical' + ) + }) + + it('moves a tab with the keyboard (move mode)', () => { + const ref = createRef() + render() + const editor = screen.getByRole('tab', { name: 'Editor' }) + fireEvent.keyDown(editor, { key: 'm', ctrlKey: true, shiftKey: true }) + expect(screen.getByRole('status').textContent).toMatch( + /^Move Editor: into Terminal/ + ) + fireEvent.keyDown(editor, { key: 'Enter' }) + expect(ref.current!.getSnapshot().tabs.get('editor')!.tabsetId).toBe( + 'ts-terminal' + ) + expect(screen.getByRole('status').textContent).toBe('Moved Editor.') + }) +}) diff --git a/packages/react/src/test/rerender.test.tsx b/packages/react/src/test/rerender.test.tsx new file mode 100644 index 0000000..100053d --- /dev/null +++ b/packages/react/src/test/rerender.test.tsx @@ -0,0 +1,314 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { memo, Profiler, useEffect, useState, type ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + DynamixLayout, + DynamixLayoutProvider, + useDynamixLayout, + useLayoutActions, + useLayoutState, + useTab, + type TabItem, +} from '..' +import { flushFrames, installDom, styleRect } from './dom' + +beforeEach(() => installDom()) +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +/** Counts mounts and renders of a tab's content. */ +function Probe({ id, log }: { id: string; log: string[] }) { + log.push(`render ${id}`) + useEffect(() => { + log.push(`mount ${id}`) + return () => void log.push(`unmount ${id}`) + }, [id, log]) + return {`body ${id}`} +} + +describe('re-renders', () => { + it('an inline tabs array with the same ids does no engine work and remounts nothing', () => { + const log: string[] = [] + const onLayoutChange = vi.fn() + function App({ tick }: { tick: number }) { + // A new array and new elements on every render, as most apps write it. + const tabs: TabItem[] = ['a', 'b', 'c'].map((id) => ({ + id, + title: `${id}${tick}`, + content: , + })) + return + } + const { rerender } = render() + const before = screen.getAllByRole('tab').map((t) => t.id) + log.length = 0 + rerender() + rerender() + expect(log.filter((entry) => !entry.startsWith('render'))).toEqual([]) + expect(screen.getAllByRole('tab').map((t) => t.id)).toEqual(before) + expect(screen.getAllByRole('tab')[0].textContent).toBe('a2') + expect(onLayoutChange).not.toHaveBeenCalled() + }) + + it('moving a tab keeps its content mounted (and in place in the DOM)', () => { + const log: string[] = [] + const tabs: TabItem[] = ['a', 'b', 'c'].map((id) => ({ + id, + content: , + })) + render() + const contentA = screen.getByText('body a').parentElement! + log.length = 0 + act(() => { + fireEvent.keyDown(screen.getByRole('tab', { name: 'a' }), { + key: 'm', + ctrlKey: true, + shiftKey: true, + }) + fireEvent.keyDown(screen.getByRole('tab', { name: 'a' }), { + key: 'Enter', + }) + }) + expect( + log.filter( + (entry) => + entry.startsWith('mount') || entry.startsWith('unmount') + ) + ).toEqual([]) + expect(screen.getByText('body a').parentElement).toBe(contentA) + }) + + it('selecting a tab re-renders only the components that read it', () => { + const renders: Record = {} + const ActiveTab = memo(function ActiveTab({ + tabsetId, + }: { + tabsetId: string + }) { + const active = useLayoutState( + (s) => s.tabsets.get(tabsetId)?.activeTabId + ) + renders[tabsetId] = (renders[tabsetId] ?? 0) + 1 + return {active} + }) + let select: (id: string) => void = () => {} + function Actions() { + select = useLayoutActions().selectTab + return null + } + function App() { + const tabs = [{ id: 'a' }, { id: 'b' }, { id: 'c' }] + const layout = useDynamixLayout({ tabs, defaultLayout: twoTabsets }) + return ( + +
+ + + +
+
+ ) + } + render() + const start = { ...renders } + act(() => select('c')) + expect(screen.getByTestId('right').textContent).toBe('c') + expect(renders.right).toBe(start.right + 1) + expect(renders.left).toBe(start.left) + }) + + it('a splitter drag commits nothing to React; positions come from the fast path', () => { + const commits: string[] = [] + const tabs: TabItem[] = ['a', 'b'].map((id) => ({ + id, + content: {`body ${id}`}, + })) + const { container } = render( + commits.push(phase)}> + + + ) + const splitter = + container.querySelector('[role="separator"]')! + const pointer = { button: 0, pointerId: 1 } + fireEvent.pointerDown(splitter, { + ...pointer, + clientX: 600, + clientY: 10, + }) + const afterStart = commits.length + for (const x of [550, 500, 450, 400]) { + fireEvent.pointerMove(window, { + ...pointer, + clientX: x, + clientY: 10, + }) + flushFrames() + } + expect(commits.length).toBe(afterStart) + expect(styleRect(screen.getByText('body a').parentElement).width).toBe( + 395 + ) + expect(styleRect(splitter).x).toBe(395) + expect(splitter.getAttribute('aria-valuenow')).toBe('33') + fireEvent.pointerUp(window, { ...pointer, clientX: 400, clientY: 10 }) + }) +}) + +const twoTabsets = { + version: 2 as const, + root: { + type: 'row' as const, + id: 'root', + weight: 100, + direction: 'horizontal' as const, + children: [ + { + type: 'tabset' as const, + id: 'left', + weight: 100, + activeTabId: 'a', + children: [{ type: 'tab' as const, id: 'a' }], + }, + { + type: 'tabset' as const, + id: 'right', + weight: 100, + activeTabId: 'b', + children: [ + { type: 'tab' as const, id: 'b' }, + { type: 'tab' as const, id: 'c' }, + ], + }, + ], + }, +} + +describe('headless hooks', () => { + it('throw a clear error outside a layout', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + function Lonely() { + useTab('a') + return null + } + expect(() => render()).toThrow( + '`useTab` must be used inside or .' + ) + spy.mockRestore() + }) + + it('build a custom layout with prop getters that merge user props', () => { + const onClick = vi.fn() + function Tab({ id }: { id: string }) { + const { getTabProps, isActive } = useTab(id) + return ( + + ) + } + function Content({ id }: { id: string }): ReactNode { + const { getTabContentProps } = useTab(id) + return
{`content ${id}`}
+ } + function App() { + const [tabs] = useState([{ id: 'a' }, { id: 'b' }, { id: 'c' }]) + const layout = useDynamixLayout({ tabs, defaultLayout: twoTabsets }) + return ( + +
+ {layout.tabs.map((tab) => ( + + ))} + {layout.tabIds.map((id) => ( + + ))} +
+
+ ) + } + render() + const c = screen.getByRole('tab', { name: 'c' }) + expect(c).toHaveClass('dx-tab', 'mine') + fireEvent.click(c) + expect(onClick).toHaveBeenCalledTimes(1) + expect(screen.getByRole('tab', { name: 'c!' })).toHaveAttribute( + 'aria-selected', + 'true' + ) + expect(screen.getByText('content c')).not.toHaveAttribute( + 'data-dx-hidden' + ) + expect(screen.getByText('content b')).toHaveAttribute('data-dx-hidden') + }) + + it('lets a user handler stop the library handler with preventDefault', () => { + function Tab({ id }: { id: string }) { + const { getTabProps } = useTab(id) + return ( + + ) + } + function App() { + const layout = useDynamixLayout({ + tabs: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + defaultLayout: twoTabsets, + }) + return ( + +
+ {layout.tabs.map((tab) => ( + + ))} +
+
+ ) + } + render() + fireEvent.click(screen.getByRole('tab', { name: 'c' })) + expect(screen.getByRole('tab', { name: 'c' })).toHaveAttribute( + 'aria-selected', + 'false' + ) + }) +}) + +describe('stability', () => { + it('keeps every function from useDynamixLayout and useTab stable across renders', () => { + const seen: unknown[][] = [] + function Probe() { + const { getTabProps, getTabContentProps, select, close } = + useTab('a') + seen.push([getTabProps, getTabContentProps, select, close]) + return null + } + function App({ tick }: { tick: number }) { + const layout = useDynamixLayout({ + tabs: [{ id: 'a', title: `A${tick}` }, { id: 'b' }], + }) + seen.push([layout.getRootProps, layout.controller, layout.actions]) + return ( + +
+ +
+
+ ) + } + const { rerender } = render() + rerender() + const layoutRenders = seen.filter((entry) => entry.length === 3) + const tabRenders = seen.filter((entry) => entry.length === 4) + expect(layoutRenders.length).toBeGreaterThan(1) + for (const entry of [...layoutRenders.slice(1)]) + entry.forEach((fn, i) => expect(fn).toBe(layoutRenders[0][i])) + for (const entry of [...tabRenders.slice(1)]) + entry.forEach((fn, i) => expect(fn).toBe(tabRenders[0][i])) + }) +}) diff --git a/packages/react/src/test/ssr.test.tsx b/packages/react/src/test/ssr.test.tsx new file mode 100644 index 0000000..aa405cf --- /dev/null +++ b/packages/react/src/test/ssr.test.tsx @@ -0,0 +1,32 @@ +// @vitest-environment node +import { renderToString } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { DynamixLayout, type TabItem } from '..' + +const tabs: TabItem[] = [ + { id: 'editor', title: 'Editor', content:

editor

}, + { id: 'a tab/with "odd" id', title: 'Odd', content:

odd

}, +] + +describe('server rendering', () => { + it('renders without a DOM', () => { + expect(typeof window).toBe('undefined') + const html = renderToString() + expect(html).toContain('data-dx-measuring') + expect(html).toContain('role="tablist"') + expect(html).toContain('editor') + }) + + it('produces the same markup every time (hydrates without mismatches)', () => { + const first = renderToString() + const second = renderToString() + expect(second).toBe(first) + }) + + it('encodes tab ids into valid DOM ids', () => { + const html = renderToString() + const ids = [...html.matchAll(/ id="([^"]+)"/g)].map((m) => m[1]) + expect(ids.length).toBeGreaterThan(0) + for (const id of ids) expect(id).toMatch(/^[A-Za-z0-9_\-«»:]+$/) + }) +}) diff --git a/packages/react/src/test/strict-mode.test.tsx b/packages/react/src/test/strict-mode.test.tsx new file mode 100644 index 0000000..d4bc3bd --- /dev/null +++ b/packages/react/src/test/strict-mode.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render } from '@testing-library/react' +import { StrictMode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DynamixLayout, type TabItem } from '..' +import { FakeResizeObserver, installDom } from './dom' + +const tabs: TabItem[] = [ + { id: 'a', content: 'A' }, + { id: 'b', content: 'B' }, +] + +beforeEach(() => installDom()) +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('StrictMode', () => { + it('leaves no listeners or observers behind after unmount', () => { + const added = vi.spyOn(window, 'addEventListener') + const removed = vi.spyOn(window, 'removeEventListener') + const { unmount, container } = render( + + + + ) + expect(container.querySelectorAll('[role="tab"]')).toHaveLength(2) + expect( + FakeResizeObserver.instances.filter((o) => o.connected) + ).toHaveLength(1) + // Unmount in the middle of a drag: its window listeners must go too. + fireEvent.pointerDown(container.querySelector('[role="separator"]')!, { + button: 0, + isPrimary: true, + pointerId: 1, + clientX: 600, + clientY: 10, + }) + expect(added.mock.calls.map(([type]) => type)).toContain('pointermove') + unmount() + expect(FakeResizeObserver.instances.every((o) => !o.connected)).toBe( + true + ) + const count = (spy: typeof added) => + spy.mock.calls.reduce>( + (acc, [type]) => ({ ...acc, [type]: (acc[type] ?? 0) + 1 }), + {} + ) + expect(count(removed)).toEqual(count(added)) + }) + + it('creates the engine once per layout, even when React double-invokes', () => { + const { container } = render( + + + + ) + const ids = Array.from(container.querySelectorAll('[role="tab"]')).map( + (tab) => tab.id + ) + expect(new Set(ids).size).toBe(2) + }) +}) diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts new file mode 100644 index 0000000..0973d3a --- /dev/null +++ b/packages/react/src/types.ts @@ -0,0 +1,258 @@ +import type { + Direction, + DragState, + DropTarget, + Layout, + LayoutChangeReason, + LayoutJSON, + LayoutTreeV1, + Side, + SplitterState, + TabState, + TabsetState, +} from '@dynamix-layout/core' +import type { + ComponentType, + CSSProperties, + HTMLAttributes, + ReactNode, + Ref, + RefCallback, +} from 'react' + +/** A tab: its id, what its tab shows, and its content. */ +export interface TabItem { + /** Unique, stable id. It's what saved layouts store. */ + id: string + /** Tab label. @default id */ + title?: ReactNode + content: ReactNode + /** Show a close button (and allow Delete); closing calls `onTabClose`. @default false */ + closable?: boolean + /** Where the tab goes the first time it appears. @default the last active tabset */ + target?: DropTarget +} + +/** A tab for the headless hooks, where content is rendered by you. */ +export type HeadlessTabItem = Omit & { content?: ReactNode } + +/** Elements you can style through `classNames` and `styles`. */ +export type LayoutSlot = + | 'root' + | 'panel' + | 'tabBar' + | 'tab' + | 'activeTab' + | 'tabClose' + | 'tabContent' + | 'splitter' + | 'dropIndicator' + | 'rootDropZone' + | 'toolbar' + | 'toolbarButton' + +/** DOM props a slot component receives; spread them on its root element and forward `ref`. */ +export type SlotProps = + HTMLAttributes & { + ref?: Ref + [dataAttribute: `data-${string}`]: string | boolean | undefined + } + +/** Props of the `Panel` slot: the box of a tabset, behind its tab bar and content. */ +export type PanelProps = SlotProps & { tabset: TabsetState } + +/** Props of the `TabBar` slot. Children are the tabs and the toolbar. */ +export type TabBarProps = SlotProps & { + tabset: TabsetState + /** Drawn as a vertical strip (a folded tabset in a side-by-side row). */ + isRotated: boolean + children: ReactNode +} + +/** Props of the `Tab` slot. */ +export type TabProps = SlotProps & { + tab: HeadlessTabItem + isActive: boolean + isDragging: boolean + /** Present when the tab is closable. */ + onClose?: () => void + /** Props for the close button, when the tab is closable. */ + closeButtonProps?: SlotProps +} + +/** Props of the `TabContent` slot. */ +export type TabContentProps = SlotProps & { + tab: HeadlessTabItem + isActive: boolean + children: ReactNode +} + +/** Props of the `Splitter` slot. */ +export type SplitterProps = SlotProps & { + splitter: SplitterState + isDragging: boolean +} + +/** Props of the `DropIndicator` slot. */ +export type DropIndicatorProps = SlotProps & { target: DropTarget } + +/** Props of the `RootDropZone` slot: a target at one edge of the layout, shown while dragging. */ +export type RootDropZoneProps = SlotProps & { side: Side; isActive: boolean } + +/** Props of the `TabsetToolbar` slot (maximize and fold buttons). */ +export interface TabsetToolbarProps { + tabset: TabsetState + isRotated: boolean + canMaximize: boolean + canFold: boolean + onToggleMaximize: () => void + onToggleFold: () => void + className?: string + style?: CSSProperties + buttonClassName?: string + buttonStyle?: CSSProperties +} + +/** Components that replace the default slots. Slot components must forward `ref`. */ +export interface LayoutComponents { + Panel: ComponentType + TabBar: ComponentType + Tab: ComponentType + TabContent: ComponentType + Splitter: ComponentType + DropIndicator: ComponentType + RootDropZone: ComponentType + TabsetToolbar: ComponentType +} + +/** Padding inside the layout, in px. */ +export type LayoutPadding = + number | { top?: number; right?: number; bottom?: number; left?: number } + +/** Options shared by `` and `useDynamixLayout`. */ +export interface LayoutBehaviorOptions { + /** Uncontrolled: the initial layout (v1 or v2). Later changes are ignored. */ + defaultLayout?: LayoutJSON | LayoutTreeV1 + /** Controlled: the layout to show (v1 or v2). Update it from `onLayoutChange`. */ + layout?: LayoutJSON | LayoutTreeV1 + /** Called after every committed change, never on mount or for drag frames. */ + onLayoutChange?: ( + layout: LayoutJSON, + details: { reason: LayoutChangeReason } + ) => void + /** Called when a closable tab is closed; remove it from `tabs` to close it. */ + onTabClose?: (tabId: string) => void + /** @default { width: 40, height: 40 } */ + minPanelSize?: { width: number; height: number } + /** @default 10 */ + splitterSize?: number + /** @default 40 */ + tabBarHeight?: number + /** @default true */ + showTabBar?: boolean + /** @default 0 */ + padding?: LayoutPadding + /** @default true */ + allowMaximize?: boolean + /** Requires the tab bar. @default true */ + allowFold?: boolean + /** @default true */ + maximizeOnDoubleClick?: boolean + /** Alt/Option + "+" maximizes, Alt/Option + "-" folds the focused tabset. @default true */ + keyboardShortcuts?: boolean + /** WAI-ARIA tabs activation: select on focus, or on Enter/Space. @default 'automatic' */ + tabActivation?: 'automatic' | 'manual' + /** Container resize throttle; 0 means once per animation frame. @default 0 */ + resizeThrottleMs?: number +} + +/** Props of ``. */ +export interface DynamixLayoutProps + extends + LayoutBehaviorOptions, + Omit< + HTMLAttributes, + 'children' | 'defaultValue' | 'onChange' + > { + tabs: readonly TabItem[] + /** Replace any default slot component. */ + components?: Partial + /** Extra class names, by slot. */ + classNames?: Partial> + /** Extra styles, by slot (positioning is applied last and always wins). */ + styles?: Partial> +} + +/** Actions available anywhere inside a layout. Every function is stable. */ +export type LayoutActions = Pick< + Layout, + | 'moveTab' + | 'moveTabset' + | 'selectTab' + | 'maximize' + | 'restore' + | 'toggleMaximize' + | 'fold' + | 'unfold' + | 'toggleFold' + | 'reset' + | 'toJSON' + | 'getSnapshot' +> + +/** The imperative handle `ref` gives on ``. */ +export interface DynamixLayoutHandle extends LayoutActions { + /** The root element. */ + readonly element: HTMLDivElement | null + /** Moves keyboard focus to a tab. */ + focusTab(tabId: string): void +} + +/** + * A prop getter: merges your props with the library's (see `mergeProps`). + * The element type follows the `ref` you pass, and the returned `ref` is a + * callback that fits any element, so the result can be spread on a `
`, + * ` + + + )} + + + + layout.toggleMaximize(tabsetId) } - style={{ - cursor: isUpdating() - ? 'wait' - : 'pointer', - ...(props.tabLabelElementStyles as object), - }} - onClick={updateActiveTab} - > - {(props.tabNames && - props.tabNames.get( - tab.nodName - )) || - tab.nodName} - - )} - - {hasToolbar && ( - - toggleMaximize(tabset.uidNode) - } - onToggleFold={() => - toggleCollapse(tabset.uidNode) - } - /> - )} - + onToggleFold={() => + layout.toggleFold(tabsetId) + } + /> + + + ) }} - - {(key) => { - const tabId = tabOutput().maps.get(key)?.uqid + + {([tabId, content]) => { + const tab = () => snapshot().tabs.get(tabId) + const body = () => { + const tabsetRect = + tab() && + snapshot().rects.tabsets.get(tab()!.tabsetId) + return ( + tabsetRect && + getTabContentRect( + tabsetRect, + enableTabbar ? tabHeadHeight : 0 + ) + ) + } + const visible = () => + !!tab()?.isVisible && (body()?.height ?? 0) > 0 return ( = (props) => { 'z-index': 90, 'overscroll-behavior': 'contain', ...(props.tabBodyElementStyles as object), - }} - ref={(el: HTMLDivElement) => { - if (tabId) { - if (el) tabsRef.set(tabId, el) - else tabsRef.delete(tabId) - } + ...position(body()), }} > - {tabOutput().maps.get(key)?.node || ( -
- - No content - -
- )} + {content}
) }}
- - {(slider) => ( - { - const id = slider.uidNode - if (el) slidersRef.set(id, el) - else slidersRef.delete(id) - }} - style={{ - width: `${slider.nodDims.w}px`, - height: `${slider.nodDims.h}px`, - left: `${slider.nodDims.x}px`, - top: `${slider.nodDims.y}px`, - cursor: slider.nodeDir ? 'ns-resize' : 'ew-resize', - ...(slider.nodLocked - ? { 'pointer-events': 'none' } - : {}), - ...(props.sliderElementStyles as object), - }} - /> - )} + + {(splitterId) => { + const splitter = () => snapshot().splitters.get(splitterId) + return ( + + layout.onSliderPointerDown(e, splitterId) + } + class={ + 'hide-scrollbar ' + + (props.sliderElementClass || '') + } + style={{ + ...position( + snapshot().rects.splitters.get(splitterId) + ), + cursor: + splitter()?.direction === 'vertical' + ? 'ns-resize' + : 'ew-resize', + ...(splitter()?.isLocked + ? { 'pointer-events': 'none' } + : {}), + ...(props.sliderElementStyles as object), + }} + /> + ) + }} (hoverElementRef.current = el)} + style={{ + ...(props.hoverElementStyles as object), + ...position(snapshot().drag?.indicator ?? undefined), + display: snapshot().drag?.indicator ? 'block' : 'none', + 'z-index': 100, + }} /> - - {(area, index) => ( + + {(side) => ( { - if (el) rootSplitHoverEl[index()] = el - }} style={{ ...(props.RootSplitterHoverElStyles as object), + ...(snapshot().drag && + snapshot().drag!.source.type !== 'splitter' + ? { display: 'block', 'z-index': 99 } + : {}), }} - area={area} + area={side} size={{ h: '25%', w: '8px' }} /> )} diff --git a/packages/solid/src/components/layout.css b/packages/solid/src/components/layout.css index 00c4de8..26d7b99 100644 --- a/packages/solid/src/components/layout.css +++ b/packages/solid/src/components/layout.css @@ -82,3 +82,19 @@ [data-dx-hidden] { display: none !important; } + +/* --- Close button of closable tabs --- */ +.DefaultTabClose { + margin-left: 6px; + padding: 0 4px; + border: 0; + border-radius: 3px; + background: transparent; + color: inherit; + font-size: 14px; + line-height: 1; + cursor: pointer; +} +.DefaultTabClose:hover { + background: rgba(0, 0, 0, 0.1); +} diff --git a/packages/solid/src/hooks/useLayout.ts b/packages/solid/src/hooks/useLayout.ts index bd95261..20f3653 100644 --- a/packages/solid/src/hooks/useLayout.ts +++ b/packages/solid/src/hooks/useLayout.ts @@ -1,657 +1,248 @@ import { - LayoutTree, - DynamixLayoutCore, - Node, - NodeOptions, - DropPreview, - RootSide, createFrameScheduler, - getNavbarDropPreview, - getRootSplitPreview, - getTabBodyRect, - getTabsetDropPreview, - getTabbarPlacement, - isSameDropPreview, - placeTabbar, - setElementRect, + createLayout, + type DragSource, + type DropMeasurements, + type Layout, + type LayoutSnapshot, + type Point, + type Rect, + type TabBarMeasurement, } from '@dynamix-layout/core' -import { createSignal, onCleanup, onMount } from 'solid-js' -import { useDynamixLayoutOptions } from '../types' - -export const useDynamixLayout = ({ - tabOutput, - rootId, - layoutTree, - updateJSON, - dimensions, - tabHeadHeight, - enableTabbar, - bondWidth, - minTabHeight, - minTabWidth, - sliderUpdateTimeout, - windowResizeTimeout, - disableSliderTimeout, - disableResizeTimeout, - keyboardShortcuts = true, - enableDoubleClickMaximize = true, -}: useDynamixLayoutOptions) => { - const tabsetsRef = new Map() - const slidersRef = new Map() - const panelsRef = new Map() - const tabsRef = new Map() - const hoverElementRef = { current: undefined as HTMLDivElement | undefined } - const rootSplitHoverEl: HTMLDivElement[] = [] - const [layoutJSON, setLayoutJSON] = createSignal( - layoutTree - ) - const [tabsets, setTabsets] = createSignal>( - new Map() - ) - const [sliders, setSliders] = createSignal>( - new Map() - ) - const [dragging, setDragging] = createSignal(false) - let animationFrameRef: number | null = null - const [isUpdating, setIsUpdating] = createSignal(false) - const dragElemRef: { - src?: HTMLDivElement - des?: HTMLDivElement - drag?: boolean - area?: 'top' | 'bottom' | 'left' | 'right' | 'contain' - } = { - src: undefined, - des: undefined, - area: undefined, - drag: false, - } - - const dragSliderRef: { - sliderId?: string - isSliding?: boolean - } = { - sliderId: undefined, - isSliding: false, - } - - // A tabset can never be shorter than its tab bar, otherwise the bar spills - // over the slider below it and the tab body gets a negative height. - const minLayoutHeight = enableTabbar - ? Math.max(minTabHeight, tabHeadHeight) - : minTabHeight +import { createSignal, onCleanup, onMount, type Accessor } from 'solid-js' +import type { useDynamixLayoutOptions } from '../types' + +/** What `useDynamixLayout` returns to the Solid ``. */ +export interface DynamixLayoutState { + engine: Layout + /** The current snapshot; Solid updates only what reads the parts that changed. */ + snapshot: Accessor + /** A tab or tabset is being dragged (HTML5) or a splitter is moving. */ + dragging: Accessor + onDragStart: (event: DragEvent, source: DragSource) => void + onDragOver: (event: DragEvent) => void + onDragEnd: () => void + onDrop: (event: DragEvent) => void + onSliderPointerDown: (event: PointerEvent, splitterId: string) => void + onRootPointerDown: (event: PointerEvent) => void + onTabbarDoubleClick: (tabsetId: string) => void + selectTab: (tabId: string) => void + toggleMaximize: (tabsetId: string) => void + toggleFold: (tabsetId: string) => void +} - const layoutInstance = new DynamixLayoutCore({ - tabs: tabOutput.keys, - tree: layoutJSON(), // eslint-disable-line solid/reactivity - minW: minTabWidth, - minH: minLayoutHeight, - bond: bondWidth, - uqid: rootId, - tabsIds: tabOutput.name, +const relativeRect = (rect: DOMRect, origin: Point): Rect => ({ + x: rect.left - origin.x, + y: rect.top - origin.y, + width: rect.width, + height: rect.height, +}) + +/** + * The Solid adapter's engine binding: one `createLayout` instance per + * component, a snapshot signal, HTML5 drag-and-drop for tabs (drop targets + * come from the core), pointer drags for splitters, and window-resize + * tracking. + */ +export const useDynamixLayout = ( + options: useDynamixLayoutOptions +): DynamixLayoutState => { + const minHeight = options.enableTabbar + ? Math.max(options.minTabHeight, options.tabHeadHeight) + : options.minTabHeight + + const engine = createLayout({ + tabs: options.tabIds.map((id) => ({ id })), + initialLayout: options.layoutTree ?? null, + minPanelSize: { width: options.minTabWidth, height: minHeight }, + splitterSize: options.bondWidth, // A folded tabset shrinks to its tab bar. - collapsedSize: enableTabbar ? tabHeadHeight : minLayoutHeight, + foldedSize: options.enableTabbar ? options.tabHeadHeight : minHeight, + onLayoutChange: (json, reason) => options.updateJSON?.(json, reason), }) - const updateTabsets = (nodes: Map) => { - const newTabsets = new Map(nodes) - setTabsets(newTabsets) - } - - const updateSliders = (nodes: Map) => { - const newSliders = new Map(nodes) - setSliders(newSliders) - } - - const updateAllTabBodyStyles = () => { - const tabbarHeight = enableTabbar ? tabHeadHeight : 0 - - Node.cache.tabOpts.get().forEach((node: NodeOptions, id: string) => { - const tabEl = tabsRef.get(id) - if (!tabEl) return - - const body = getTabBodyRect(node.nodDims, tabbarHeight) - setElementRect(tabEl, body) - // Empty bodies stay hidden so their borders/shadows don't bleed onto - // the slider below. - const visible = - node.nodOpen && !node.nodFold && !node.nodHidden && body.h > 0 - tabEl.style.display = visible ? 'block' : 'none' - }) - } - - const offNodes = Node.cache.nodOpts.onChange( - (nodes: Map) => { - nodes.forEach((node: NodeOptions, id: string) => { - const panelEl = panelsRef.get(id) - if (panelEl) setElementRect(panelEl, node.nodDims) - - const tabsetEl = tabsetsRef.get(id) - if (tabsetEl && enableTabbar) { - tabsetEl.toggleAttribute('data-dx-hidden', !!node.nodHidden) - placeTabbar( - tabsetEl, - getTabbarPlacement(node, tabHeadHeight) + const [snapshot, setSnapshot] = createSignal(engine.getSnapshot(), { + equals: false, + }) + const [dragging, setDragging] = createSignal(false) + const unsubscribe = engine.subscribe(setSnapshot) + + const toRootPoint = (clientX: number, clientY: number): Point => { + const root = options.getRoot() + if (!root) return { x: clientX, y: clientY } + const box = root.getBoundingClientRect() + return { + x: clientX - box.left - root.clientLeft, + y: clientY - box.top - root.clientTop, + } + } + + const measure = (): DropMeasurements => { + const tabBars = new Map() + const root = options.getRoot() + if (!root) return { tabBars } + const box = root.getBoundingClientRect() + const origin = { + x: box.left + root.clientLeft, + y: box.top + root.clientTop, + } + root.querySelectorAll('[data-tabbar]').forEach((bar) => { + const id = bar.dataset.uid + if (!id) return + tabBars.set(id, { + rect: relativeRect(bar.getBoundingClientRect(), origin), + isRotated: bar.hasAttribute('data-rotated'), + tabs: Array.from( + bar.querySelectorAll( + ':scope > [data-type="tab"]' ) - } - }) - } - ) - - // Tab bodies must follow every engine update, including the deferred ones - // scheduled when the resize/slider timeouts are enabled. - const offTabs = Node.cache.tabOpts.onChange(() => updateAllTabBodyStyles()) - - const offBonds = Node.cache.bndOpts.onChange( - (nodes: Map) => { - nodes.forEach((node: NodeOptions, id: string) => { - const sliderEl = slidersRef.get(id) - if (!sliderEl) return - setElementRect(sliderEl, node.nodDims) - sliderEl.toggleAttribute('data-dx-hidden', !!node.nodHidden) - sliderEl.style.pointerEvents = node.nodLocked ? 'none' : '' + ).map((tab) => ({ + id: tab.dataset.uid ?? '', + rect: relativeRect(tab.getBoundingClientRect(), origin), + })), }) - } - ) - - const sliderScheduler = createFrameScheduler( - ({ id, point }: { id: string; point: { x: number; y: number } }) => - layoutInstance.updateSlider( - id, - point, - disableSliderTimeout, - sliderUpdateTimeout - ) - ) - - const onPointerMove = (e: PointerEvent) => { - const id = dragSliderRef.sliderId - if (!dragSliderRef.isSliding || !id) return - - sliderScheduler.schedule({ id, point: { x: e.clientX, y: e.clientY } }) - } - - const onPointerUp = (e: PointerEvent) => { - if (!dragSliderRef.isSliding) return - - sliderScheduler.flush() - - const sliderElement = e.currentTarget as HTMLDivElement - dragSliderRef.isSliding = false - dragSliderRef.sliderId = undefined - setDragging(false) - - if (updateJSON) updateJSON(DynamixLayoutCore._root.toJSON()) - sliderElement.releasePointerCapture(e.pointerId) - sliderElement.removeEventListener('pointermove', onPointerMove) - sliderElement.removeEventListener('pointerup', onPointerUp) - sliderElement.removeEventListener('pointercancel', onPointerUp) - } - - const onPointerDown = (e: PointerEvent) => { - e.preventDefault() - - const sliderElement = e.currentTarget as HTMLDivElement - dragSliderRef.isSliding = true - dragSliderRef.sliderId = sliderElement.id - // Applies `.is-dragging`, so iframes and editors inside tab bodies - // cannot swallow pointer events while the slider is being dragged. - setDragging(true) - - sliderElement.setPointerCapture(e.pointerId) - - sliderElement.addEventListener('pointermove', onPointerMove) - sliderElement.addEventListener('pointerup', onPointerUp) - sliderElement.addEventListener('pointercancel', onPointerUp) - } - - let lastHoverState: Partial = {} - - const findDropTargetTabset = ( - clientX: number, - clientY: number, - target: HTMLDivElement - ) => { - if (!hoverElementRef.current || !dragElemRef || !target) { - return null - } - - const preview = getTabsetDropPreview( - target.getBoundingClientRect(), - clientX, - clientY - ) - updateHoverElement(preview) - dragElemRef.des = target - dragElemRef.area = preview.area - } - - const handleNavbarDragOver = (e: DragEvent) => { - e.preventDefault() - e.stopPropagation() - - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'move' - } - - const navbarElement = e.currentTarget as HTMLDivElement - // A folded strip shows its tab bar rotated; label boxes are vertical - // there, so the whole strip is the target and drops go into the tabset. - if (navbarElement.hasAttribute('data-rotated')) { - if (!hoverElementRef.current || !dragElemRef) return - const r = navbarElement.getBoundingClientRect() - updateHoverElement({ - area: 'contain', - left: r.left, - top: r.top, - width: r.width, - height: r.height, - }) - dragElemRef.des = navbarElement - dragElemRef.area = 'contain' - return - } - - // Only tab labels: the tab bar also holds the maximize/fold toolbar. - const tabElems = Array.from( - navbarElement.querySelectorAll( - ':scope > [data-type="tab"]' - ) - ) - if (!hoverElementRef.current || !dragElemRef) return - - const preview = getNavbarDropPreview( - navbarElement.getBoundingClientRect(), - tabElems.map((tab) => tab.getBoundingClientRect()), - e.clientX, - e.clientY - ) - if (!preview) return - - updateHoverElement(preview) - dragElemRef.des = tabElems[preview.index] - dragElemRef.area = preview.area - } - - const handleRootSplit = (e: DragEvent) => { - e.preventDefault() - e.stopPropagation() - - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'move' - } - - const target = e.currentTarget as HTMLDivElement - const { area, uid } = target.dataset - if (!area || !uid || !hoverElementRef.current || !dragElemRef) { - return - } - - const side = area as RootSide - dragElemRef.des = target - dragElemRef.area = side - updateHoverElement(getRootSplitPreview(dimensions(), side)) - } - - const updateHoverElement = (preview: DropPreview) => { - const hoverEl = hoverElementRef.current - if (!hoverEl || isSameDropPreview(lastHoverState, preview)) return - - Object.assign(hoverEl.style, { - left: `${preview.left}px`, - top: `${preview.top}px`, - width: `${preview.width}px`, - height: `${preview.height}px`, - display: 'block', - zIndex: '100', }) - lastHoverState = preview - } - - // --- Maximize / fold --- - let activeTabset: string | null = null - - const refreshViewState = () => { - updateTabsets(Node.cache.nodOpts.get()) - updateSliders(Node.cache.bndOpts.get()) - updateAllTabBodyStyles() - if (updateJSON) updateJSON(DynamixLayoutCore._root.toJSON()) + return { tabBars } } - const toggleMaximize = (tabsetId: string) => { - if (layoutInstance.toggleMaximize(tabsetId)) refreshViewState() - } - - const toggleCollapse = (tabsetId: string) => { - if (layoutInstance.toggleCollapse(tabsetId)) refreshViewState() - } - - /** Remembers which tabset the user last touched, for the shortcuts. */ - const onRootPointerDown = (e: PointerEvent) => { - const el = (e.target as HTMLElement).closest('[data-uid]') - const node = el?.dataset.uid - ? Node.cache.mapElem.get(el.dataset.uid) - : undefined - if (!(node instanceof Node)) return - if (node.type === 'tabset') activeTabset = node.unId - else if (node.type === 'tab' && node.host) activeTabset = node.host.unId - } - - const onTabbarDoubleClick = (e: MouseEvent) => { - const id = (e.currentTarget as HTMLElement).dataset.uid - if (enableDoubleClickMaximize && id) toggleMaximize(id) - } - - const onKeyDown = (e: KeyboardEvent) => { - if (!keyboardShortcuts || !e.altKey || e.ctrlKey || e.metaKey) return - const id = activeTabset ?? layoutInstance.maximizedId - if (!id) return - - if (e.code === 'Equal' || e.code === 'NumpadAdd') { - e.preventDefault() - toggleMaximize(id) - } else if (e.code === 'Minus' || e.code === 'NumpadSubtract') { - e.preventDefault() - toggleCollapse(id) - } - } + // --- Tabs and tabsets: HTML5 drag-and-drop, targets from the core --- + let measurements: DropMeasurements | undefined + const dragFrame = createFrameScheduler((point) => + engine.updateDrag(point, measurements) + ) - const onDragStart = (e: DragEvent) => { - e.stopPropagation() - // Every drop target must be visible while dragging. - if (layoutInstance.restore()) refreshViewState() + const onDragStart = (event: DragEvent, source: DragSource) => { + event.stopPropagation() + measurements = measure() + if (!engine.startDrag(source)) return setDragging(true) - document.body.style.cursor = 'move' - if (e.currentTarget) { - if (e.dataTransfer) { - e.dataTransfer.effectAllowed = 'move' - - const dragImage = document.createElement('div') - dragImage.style.width = '1px' - dragImage.style.height = '1px' - dragImage.style.backgroundColor = 'transparent' - dragImage.style.position = 'absolute' - dragImage.style.top = '-1000px' - document.body.appendChild(dragImage) - - e.dataTransfer.setDragImage(dragImage, 0, 0) - - setTimeout(() => { - if (dragImage.parentNode) { - dragImage.parentNode.removeChild(dragImage) - } - }, 0) - } - - rootSplitHoverEl.forEach((el) => { - if (el) { - el.style.display = 'block' - el.style.zIndex = '99' - } - }) - - panelsRef.forEach((el) => { - if (el) { - el.style.display = 'block' - el.style.zIndex = '95' - } - }) - - dragElemRef.src = e.currentTarget as HTMLDivElement - dragElemRef.drag = true + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move' + // A blank drag image: the drop indicator shows where the tab goes. + const image = document.createElement('div') + image.style.cssText = + 'width:1px;height:1px;position:absolute;top:-1000px' + document.body.appendChild(image) + event.dataTransfer.setDragImage(image, 0, 0) + setTimeout(() => image.remove(), 0) } } - const onDragOver = (e: DragEvent) => { - e.preventDefault() - e.stopPropagation() - document.body.style.cursor = 'move' - - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'move' - } - - if (!dragElemRef?.src) { - return - } - - const target = e.currentTarget as HTMLDivElement - const clientX = e.clientX - const clientY = e.clientY - - if (animationFrameRef) { - cancelAnimationFrame(animationFrameRef) - } - - animationFrameRef = requestAnimationFrame(() => { - findDropTargetTabset(clientX, clientY, target) - }) + const onDragOver = (event: DragEvent) => { + if (!snapshot().drag) return + event.preventDefault() + if (event.dataTransfer) event.dataTransfer.dropEffect = 'move' + dragFrame.schedule(toRootPoint(event.clientX, event.clientY)) } const onDragEnd = () => { - document.body.style.cursor = 'default' - rootSplitHoverEl.forEach((el) => { - if (el) { - el.style.display = 'none' - el.style.zIndex = '-1' - } - }) - - if (animationFrameRef) { - cancelAnimationFrame(animationFrameRef) - animationFrameRef = null - } - - if (hoverElementRef.current) { - hoverElementRef.current.style.display = 'none' - hoverElementRef.current.style.zIndex = '-1' - } - - lastHoverState = {} - dragElemRef.drag = false - - if (!dragElemRef?.src || !dragElemRef?.des) { - dragElemRef.area = undefined - dragElemRef.src = undefined - dragElemRef.des = undefined - - setDragging(false) - return - } - - if (dragElemRef.src === dragElemRef.des) { - dragElemRef.area = undefined - dragElemRef.src = undefined - dragElemRef.des = undefined - - setDragging(false) - return - } - - if (dragElemRef.src && dragElemRef.des) { - if (!dragElemRef.area) { - console.warn('No area specified for drag and drop') - - setDragging(false) - return - } - - const result = layoutInstance.updateTree( - dragElemRef.src.dataset.uid as string, - dragElemRef.des.dataset.uid as string, - dragElemRef.area || 'contain' - ) - - if (!result) { - console.warn('Failed to update layout tree') - - setDragging(false) - return - } - - requestAnimationFrame(() => { - updateTabsets(Node.cache.nodOpts.get()) - updateSliders(Node.cache.bndOpts.get()) - updateAllTabBodyStyles() - if (updateJSON) updateJSON(DynamixLayoutCore._root.toJSON()) - }) - } - - dragElemRef.area = undefined - dragElemRef.src = undefined - dragElemRef.des = undefined - + dragFrame.flush() + engine.endDrag() + measurements = undefined setDragging(false) } - const onDragEnter = (e: DragEvent) => { - e.preventDefault() - e.stopPropagation() - - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'move' - } - } - - const onDragLeave = (e: DragEvent) => { - e.preventDefault() - e.stopPropagation() - } - - const onDrop = (e: DragEvent) => { - e.preventDefault() - e.stopPropagation() + const onDrop = (event: DragEvent) => event.preventDefault() - if (e.dataTransfer) { - e.dataTransfer.getData('text/plain') + // --- Splitters: pointer events --- + const sliderFrame = createFrameScheduler((point) => + engine.updateDrag(point) + ) + const onSliderPointerDown = (event: PointerEvent, splitterId: string) => { + event.preventDefault() + if (!engine.startDrag({ type: 'splitter', splitterId })) return + const element = event.currentTarget as HTMLElement + element.setPointerCapture?.(event.pointerId) + // `.is-dragging` stops iframes and editors in tab bodies swallowing events. + setDragging(true) + const move = (e: PointerEvent) => + sliderFrame.schedule(toRootPoint(e.clientX, e.clientY)) + const up = () => { + sliderFrame.flush() + engine.endDrag() + setDragging(false) + element.removeEventListener('pointermove', move) + element.removeEventListener('pointerup', up) + element.removeEventListener('pointercancel', up) } + element.addEventListener('pointermove', move) + element.addEventListener('pointerup', up) + element.addEventListener('pointercancel', up) } - const updateActiveTab = (e: MouseEvent) => { - const target = e.currentTarget as HTMLDivElement - const uid = target.dataset.uid - - if (!uid) return - - const node = Node.cache.mapElem.get(uid) - - if (!(node instanceof Node)) { - console.warn(`Element with uid ${uid} is not a Node instance`) - return - } - - if (!node || !node.host || !node.host.unId) { - console.warn(`Node with uid ${uid} not found in mapNode`) - return - } - - if (node.host.open == node.name) { - console.warn(`Node ${node.name} is already open`) - return - } - - const nodeOpts = Node.cache.nodOpts.get().get(node.host.unId) - - if (!nodeOpts) return - - if (nodeOpts.nodOpen && node?.open) { + // --- Maximize / fold --- + let activeTabset: string | null = null + const onRootPointerDown = (event: PointerEvent) => { + const element = (event.target as HTMLElement).closest( + '[data-uid]' + ) + const id = element?.dataset.uid + if (!id) return + const current = snapshot() + if (current.tabsets.has(id)) activeTabset = id + else if (current.tabs.has(id)) + activeTabset = current.tabs.get(id)!.tabsetId + } + const onTabbarDoubleClick = (tabsetId: string) => { + if (options.enableDoubleClickMaximize) engine.toggleMaximize(tabsetId) + } + const onKeyDown = (event: KeyboardEvent) => { + if ( + !options.keyboardShortcuts || + !event.altKey || + event.ctrlKey || + event.metaKey + ) return + const id = activeTabset ?? snapshot().maximizedTabsetId + if (!id) return + if (event.code === 'Equal' || event.code === 'NumpadAdd') { + event.preventDefault() + engine.toggleMaximize(id) + } else if (event.code === 'Minus' || event.code === 'NumpadSubtract') { + event.preventDefault() + engine.toggleFold(id) } - - // Update the state in the cache - node.host.open = node.name - nodeOpts.nodOpen = node.name - nodeOpts.nodKids?.forEach((kid: NodeOptions) => { - kid.nodOpen = kid.nodName === node.name - }) - - const childNodes = target?.parentElement - ?.childNodes as NodeListOf - - childNodes?.forEach((child) => { - if (child instanceof HTMLDivElement && child.dataset.uid !== uid) { - child.dataset.state = 'inactive' - } else { - child.dataset.state = 'active' - } - }) - - updateAllTabBodyStyles() } onMount(() => { - const updateDimension = (flag: boolean) => { - layoutInstance.updateDimension( - dimensions(), - flag, - windowResizeTimeout - ) - } - - updateDimension(true) - - const handler = () => updateDimension(disableResizeTimeout ?? false) - - window.addEventListener('resize', handler) + let timer: ReturnType | undefined + const update = () => engine.setContainerRect(options.container()) + const onResize = () => { + if (options.disableResizeTimeout) return update() + clearTimeout(timer) + timer = setTimeout(update, options.windowResizeTimeout) + } + update() + window.addEventListener('resize', onResize) window.addEventListener('keydown', onKeyDown) - - updateTabsets(Node.cache.nodOpts.get()) - updateSliders(Node.cache.bndOpts.get()) - - updateAllTabBodyStyles() - - if (updateJSON) updateJSON(DynamixLayoutCore._root.toJSON()) + // v1 behaviour, kept for the minimal port: report the layout once mounted. + options.updateJSON?.(engine.toJSON(), 'mount') onCleanup(() => { - window.removeEventListener('resize', handler) + clearTimeout(timer) + window.removeEventListener('resize', onResize) window.removeEventListener('keydown', onKeyDown) - offNodes() - offBonds() - offTabs() - - sliderScheduler.cancel() - - if (animationFrameRef) { - cancelAnimationFrame(animationFrameRef) - } + dragFrame.cancel() + sliderFrame.cancel() + unsubscribe() + engine.destroy() }) }) return { - tabsets, - sliders, - tabsetsRef, - panelsRef, - tabsRef, - slidersRef, - layoutJSON, - layoutInstance, - hoverElementRef, - rootSplitHoverEl, + engine, + snapshot, dragging, - isUpdating, - /** @deprecated Internal state setter; will be removed in v2. */ - setIsUpdating, - /** @deprecated Internal state setter; will be removed in v2. */ - setDragging, - /** @deprecated Internal state setter; will be removed in v2. */ - setTabsets, - /** @deprecated Internal state setter; will be removed in v2. */ - setSliders, - /** @deprecated Internal state setter; will be removed in v2. */ - setLayoutJSON, onDragStart, onDragOver, onDragEnd, - onDragEnter, - onDragLeave, onDrop, - onPointerDown, - updateActiveTab, - handleRootSplit, - handleNavbarDragOver, - toggleMaximize, - toggleCollapse, + onSliderPointerDown, onRootPointerDown, onTabbarDoubleClick, + selectTab: (tabId) => void engine.selectTab(tabId), + toggleMaximize: (tabsetId) => void engine.toggleMaximize(tabsetId), + toggleFold: (tabsetId) => void engine.toggleFold(tabsetId), } } diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 2a00c29..94260b3 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -3,3 +3,9 @@ export * from './components/Layout' export * from './hooks/useLayout' export * from './components/Default' export * from './types' +export { + isLayoutV1, + migrateLayoutFromV1, + type LayoutJSON, + type LayoutTreeV1, +} from '@dynamix-layout/core' diff --git a/packages/solid/src/test/port.test.tsx b/packages/solid/src/test/port.test.tsx new file mode 100644 index 0000000..37bda21 --- /dev/null +++ b/packages/solid/src/test/port.test.tsx @@ -0,0 +1,208 @@ +import { fireEvent, render, screen } from '@solidjs/testing-library' +import '@testing-library/jest-dom/vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Layout, LayoutJSON } from '@dynamix-layout/core' +import type { JSX } from 'solid-js' +import { DynamixLayout } from '..' + +const tabs = (): [string, JSX.Element][] => [ + ['editor',

editor body

], + ['terminal',

terminal body

], +] + +beforeEach(() => { + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1010) + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(600) +}) +afterEach(() => vi.restoreAllMocks()) + +describe('Solid adapter on the v2 core', () => { + it('positions tab bodies relative to the root and reports v2 JSON on mount', () => { + const updates: LayoutJSON[] = [] + render(() => ( + updates.push(json)} + /> + )) + const body = screen.getByText('editor body').parentElement! + expect(body.style.left).toBe('0px') + expect(body.style.width).toBe('500px') + expect(body.style.top).toBe('40px') + expect(updates[0].version).toBe(2) + expect(updates[0].root.children.map((c) => c.id)).toEqual([ + 'ts-editor', + 'ts-terminal', + ]) + }) + + it('selects tabs and folds through the engine', () => { + const updates: LayoutJSON[] = [] + const saved: LayoutJSON = { + version: 2, + root: { + type: 'row', + id: 'root', + weight: 100, + direction: 'horizontal', + children: [ + { + type: 'tabset', + id: 'one', + weight: 100, + activeTabId: 'editor', + children: [ + { type: 'tab', id: 'editor' }, + { type: 'tab', id: 'terminal' }, + ], + }, + ], + }, + } + render(() => ( + updates.push(json)} + /> + )) + expect(screen.getByText('terminal body').parentElement).toHaveAttribute( + 'data-dx-hidden' + ) + fireEvent.click(screen.getByText('terminal')) + expect( + screen.getByText('terminal body').parentElement + ).not.toHaveAttribute('data-dx-hidden') + expect(updates.at(-1)!.root.children[0]).toMatchObject({ + activeTabId: 'terminal', + }) + }) + + it('migrates a v1 layoutTree', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const v1 = { + typNode: 'row', + nodName: 'r', + uidNode: 'r', + nodPart: 100, + nodKids: [ + { + typNode: 'tabset', + nodName: '', + uidNode: 'a', + nodPart: 300, + nodOpen: 'editor', + nodKids: [ + { + typNode: 'tab', + nodName: 'editor', + uidNode: 'x', + nodPart: 100, + }, + ], + }, + { + typNode: 'tabset', + nodName: '', + uidNode: 'b', + nodPart: 100, + nodOpen: 'terminal', + nodKids: [ + { + typNode: 'tab', + nodName: 'terminal', + uidNode: 'y', + nodPart: 100, + }, + ], + }, + ], + } as const + render(() => ) + expect(screen.getByText('editor body').parentElement!.style.width).toBe( + '730px' + ) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('v1 layout')) + }) +}) + +describe('Solid additions for the feature showcase', () => { + it('adds and removes tabs reactively without remounting existing content', async () => { + const { createSignal } = await import('solid-js') + let mounts = 0 + const Probe = (p: { id: string }) => { + mounts++ + return

{`${p.id} body`}

+ } + const [list, setList] = createSignal<[string, JSX.Element][]>([ + ['editor', ], + ['terminal', ], + ]) + render(() => ) + expect(mounts).toBe(2) + setList([...list(), ['notes', ]]) + expect(screen.getByText('notes body')).toBeInTheDocument() + expect( + screen.getByText('notes', { selector: '[data-type="tab"]' }) + ).toBeInTheDocument() + setList(list().filter(([id]) => id !== 'terminal')) + expect(screen.queryByText('terminal body')).toBeNull() + expect(mounts).toBe(3) + }) + + it('closable tabs call onTabClose', () => { + const closed: string[] = [] + render(() => ( + editor body

], + [ + 'notes', +

notes body

, + { title: 'Notes', closable: true }, + ], + ]} + onTabClose={(id) => closed.push(id)} + /> + )) + expect(screen.getByText('Notes')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Close notes' })) + expect(closed).toEqual(['notes']) + expect( + screen.queryByRole('button', { name: 'Close editor' }) + ).toBeNull() + }) + + it('onReady gives the engine for actions from code', () => { + let engine: Layout | undefined + render(() => ( + (engine = layout)} + /> + )) + expect(engine).toBeDefined() + const [first] = engine!.getSnapshot().tabsets.keys() + engine!.maximize(first) + expect(engine!.getSnapshot().maximizedTabsetId).toBe(first) + expect( + document.querySelectorAll('[data-tabbar][data-dx-hidden]') + ).toHaveLength(1) + }) + + it('passes the change reason to updateJSON', () => { + const reasons: string[] = [] + let engine: Layout | undefined + render(() => ( + (engine = layout)} + updateJSON={(_, reason) => reasons.push(reason)} + /> + )) + const [first] = engine!.getSnapshot().tabsets.keys() + engine!.maximize(first) + engine!.selectTab('terminal') + expect(reasons).toEqual(['mount', 'maximize', 'select']) + }) +}) diff --git a/packages/solid/src/types/index.ts b/packages/solid/src/types/index.ts index af6a261..d50c836 100644 --- a/packages/solid/src/types/index.ts +++ b/packages/solid/src/types/index.ts @@ -1,45 +1,43 @@ -import type { LayoutTree, Dimension } from '@dynamix-layout/core' +import type { + Layout, + LayoutChangeReason, + LayoutJSON, + LayoutTreeV1, + Rect, +} from '@dynamix-layout/core' import { JSX } from 'solid-js' -export interface DynamixLayoutProps { - children?: JSX.Element - layoutTree?: LayoutTree - tabs?: string[] - class?: string - style?: JSX.CSSProperties -} - -export interface UseDynamixLayoutOptions { - initialLayoutTree?: LayoutTree - tabs?: string[] -} - -export interface UseDynamixLayoutResult { - layoutTree: LayoutTree | undefined - tabs: string[] - setTabs: (tabs: string[]) => void - setLayoutTree: (layoutTree: LayoutTree) => void -} +/** Why `updateJSON` was called. */ +export type LayoutUpdateReason = LayoutChangeReason | 'mount' export type DivFC = ( props: { children?: JSX.Element } & JSX.HTMLAttributes ) => JSX.Element -export type TabEntry = { - uqid: string - name: string - node: JSX.Element +/** Per-tab options: the third element of a `tabs` entry. */ +export interface TabOptions { + /** Tab label. @default `tabNames.get(id)`, else the id */ + title?: JSX.Element + /** Show a close button; clicking it calls `onTabClose`. @default false */ + closable?: boolean } -export type TabInput = [string, JSX.Element][] -export type TabOutput = { - keys: string[] - maps: Map - name: Map -} +/** One tab: `[id, content]` or `[id, content, options]`. */ +export type TabItem = [id: string, content: JSX.Element, options?: TabOptions] export interface LayoutProps { - tabs: [string, JSX.Element][] + /** + * The open tabs. Reactive: add an entry to open a tab, remove it to close + * one; existing tabs keep their content mounted. + */ + tabs: TabItem[] + /** Called when a closable tab's close button is clicked; remove it from `tabs` to close it. */ + onTabClose?: (tabId: string) => void + /** + * Receives the layout engine once mounted, for actions from code: + * `maximize`, `restore`, `fold`, `unfold`, `selectTab`, `moveTab`, `reset`, `toJSON`, … + */ + onReady?: (layout: Layout) => void enableTabbar?: boolean WrapTabPanel?: DivFC WrapTabLabel?: ( @@ -58,7 +56,8 @@ export interface LayoutProps { } & JSX.HTMLAttributes ) => JSX.Element tabHeadHeight?: number - layoutTree?: LayoutTree + /** A saved layout: v2 JSON, or a v1 tree (migrated automatically). */ + layoutTree?: LayoutJSON | LayoutTreeV1 pad?: { t: number b: number @@ -68,14 +67,15 @@ export interface LayoutProps { class?: string style?: JSX.CSSProperties bondWidth?: number - /** In 2.0 this receives the v2 `LayoutJSON` format instead of `LayoutTree` (v1 layouts passed to `layoutTree` keep loading). See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ - updateJSON?: (layoutTree: LayoutTree) => void + /** + * Called with the v2 layout after every change, and once on mount. + * `reason` says why: the core's `LayoutChangeReason`, or `'mount'`. + */ + updateJSON?: (layout: LayoutJSON, reason: LayoutUpdateReason) => void minTabHeight?: number minTabWidth?: number - /** @deprecated No effect in 2.0: splitters update once per animation frame. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ sliderUpdateTimeout?: number windowResizeTimeout?: number - /** @deprecated No effect in 2.0: splitters update once per animation frame. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ disableSliderTimeout?: boolean disableResizeTimeout?: boolean hoverElementStyles?: JSX.CSSProperties @@ -118,18 +118,17 @@ export interface TabsetToolbarProps { onToggleFold: () => void } +/** Options of the Solid `useDynamixLayout` (resolved by ``). */ export interface useDynamixLayoutOptions { - tabOutput: TabOutput - rootId: string - layoutTree?: LayoutTree - updateJSON?: (layoutTree: LayoutTree) => void + tabIds: string[] + layoutTree?: LayoutJSON | LayoutTreeV1 + updateJSON?: (layout: LayoutJSON, reason: LayoutUpdateReason) => void enableTabbar: boolean - dimensions: () => Dimension - sliderUpdateTimeout: number + /** The area to fill, relative to the root element. */ + container: () => Rect + getRoot: () => HTMLElement | undefined windowResizeTimeout: number - disableSliderTimeout: boolean disableResizeTimeout: boolean - hoverElementStyles?: JSX.CSSProperties bondWidth: number minTabHeight: number minTabWidth: number diff --git a/packages/solid/tsconfig.build.json b/packages/solid/tsconfig.build.json index 5a8c8d2..757fb74 100644 --- a/packages/solid/tsconfig.build.json +++ b/packages/solid/tsconfig.build.json @@ -3,7 +3,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "paths": {}, - "composite": false + "rootDir": "src" }, "exclude": ["node_modules", "src/test"] } diff --git a/packages/solid/tsconfig.json b/packages/solid/tsconfig.json index c1e65b2..5727c4a 100644 --- a/packages/solid/tsconfig.json +++ b/packages/solid/tsconfig.json @@ -10,9 +10,8 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "declaration": true, - "outDir": "./dist/types", + "outDir": "./dist", "moduleResolution": "Node", - "rootDir": "src", "allowSyntheticDefaultImports": true, "noImplicitAny": true, "paths": { @@ -20,10 +19,8 @@ "@dynamix-layout/core/*": ["../core/src/*"] }, "baseUrl": ".", - "types": ["vitest/globals", "@testing-library/jest-dom", "solid-js"], - "composite": true + "types": ["vitest/globals", "@testing-library/jest-dom", "solid-js"] }, "include": ["src/**/*", "src/types/**/*"], - "exclude": ["node_modules"], - "references": [{ "path": "../core" }] + "exclude": ["node_modules"] } diff --git a/packages/solid/vite.config.ts b/packages/solid/vite.config.ts index 1b69acb..5a3d8ae 100644 --- a/packages/solid/vite.config.ts +++ b/packages/solid/vite.config.ts @@ -3,96 +3,58 @@ import { defineConfig } from 'vite' import solidPlugin from 'vite-plugin-solid' -import stripComments from 'vite-plugin-strip-comments' import { visualizer } from 'rollup-plugin-visualizer' +import { licenseBanner } from '../../scripts/build/license-banner' import dts from 'vite-plugin-dts' import { resolve } from 'path' import fs from 'fs' -const license = fs.readFileSync(resolve(__dirname, '../../LICENSE'), 'utf-8') - export default defineConfig({ - define: { - __LICENSE__: JSON.stringify(license), - }, - server: { - port: 5174, - open: false, - }, + server: { port: 5174, open: false }, test: { environment: 'jsdom', globals: true, setupFiles: './src/test/setup.ts', testTransformMode: { web: ['/[jt]sx?$/'] }, - deps: { - inline: [/@solidjs\/start/, /solid-js/], - }, + deps: { inline: [/@solidjs\/start/, /solid-js/] }, }, plugins: [ solidPlugin(), dts({ - outDir: 'dist/types', + outDir: 'dist', tsconfigPath: './tsconfig.build.json', // Types-only modules never reach the bundle graph; list sources explicitly. include: ['src'], - // One self-contained declaration file (Node16 resolution can't follow - // extensionless imports), copied to index.d.cts for `require`. - rollupTypes: true, - afterBuild: () => - fs.copyFileSync( - resolve(__dirname, 'dist/types/index.d.ts'), - resolve(__dirname, 'dist/types/index.d.cts') - ), + exclude: ['node_modules/**', 'src/test/**'], // Keep `@dynamix-layout/core` as a package import in the .d.ts files. aliasesExclude: [/^@dynamix-layout\/core/], pathsToAliases: false, - exclude: ['node_modules/**', 'src/test/**'], - staticImport: true, - }), - visualizer({ - filename: 'react.html', - gzipSize: true, - brotliSize: true, - template: 'treemap', + // One self-contained declaration file, copied to index.d.cts for `require`. + rollupTypes: true, + afterBuild: () => fs.copyFileSync(resolve(__dirname, 'dist/index.d.ts'), resolve(__dirname, 'dist/index.d.cts')), }), - stripComments({ type: 'none' }), + licenseBanner('@dynamix-layout/solid'), + visualizer({ filename: 'solid.html', gzipSize: true, brotliSize: true, template: 'treemap' }), ], build: { - target: 'esnext', + target: 'es2020', sourcemap: 'hidden', lib: { entry: resolve(__dirname, 'src/index.ts'), - name: 'dynamix.layout.solid', // `.cjs` so Node loads the CommonJS build as CommonJS in this `"type": "module"` package. - fileName: (format) => (format === 'cjs' ? 'index.cjs' : `index.${format}.js`), + fileName: (format) => (format === 'es' ? 'index.js' : 'index.cjs'), formats: ['es', 'cjs'], + cssFileName: 'styles', }, - cssCodeSplit: true, rollupOptions: { external: [/^solid-js($|\/)/, /^@dynamix-layout\/core($|\/)/], - output: { - globals: { - 'solid-js': 'solidJs', - 'solid-js/web': 'solidJsWeb', - '@dynamix-layout/core': 'DynamixLayoutCore', - }, - preserveModules: false, - }, }, }, resolve: { alias: [ { find: '@', replacement: resolve(__dirname, './src') }, - { - find: '@dynamix-layout/core', - replacement: resolve(__dirname, '../core/src'), - }, + { find: '@dynamix-layout/core', replacement: resolve(__dirname, '../core/src') }, ], conditions: ['development', 'browser'], }, - css: { - modules: { - localsConvention: 'camelCaseOnly', - }, - }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42a584b..573a7dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.7.0 version: 4.7.0(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) + '@vitest/coverage-v8': + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) '@vitest/ui': specifier: ^4.1.11 version: 4.1.11(vitest@4.1.11) @@ -94,7 +97,7 @@ importers: version: 0.0.6 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.19.1)(@vitest/ui@4.1.11)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) + version: 4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) apps/site: dependencies: @@ -484,6 +487,18 @@ importers: specifier: ^0.5.11 version: 0.5.11(@netlify/blobs@9.1.2)(@parcel/watcher@2.6.0)(@types/node@24.19.1)(db0@0.3.4)(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(srvx@1.0.5)(terser@5.51.2)(yaml@2.8.3) + examples/vanilla: + devDependencies: + '@dynamix-layout/core': + specifier: workspace:* + version: link:../../packages/core + typescript: + specifier: ~5.8.3 + version: 5.8.3 + vite: + specifier: ^7.3.6 + version: 7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3) + packages/core: devDependencies: '@changesets/cli': @@ -541,7 +556,7 @@ importers: packages/react: dependencies: '@dynamix-layout/core': - specifier: workspace:* + specifier: workspace:^ version: link:../core devDependencies: '@repo/eslint-config': @@ -571,14 +586,11 @@ importers: vite-plugin-dts: specifier: ^4.5.4 version: 4.5.4(@types/node@24.19.1)(rollup@4.64.0)(typescript@5.9.3)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) - vite-plugin-lib-inject-css: - specifier: ^2.2.2 - version: 2.2.2(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) packages/solid: dependencies: '@dynamix-layout/core': - specifier: workspace:* + specifier: workspace:^ version: link:../core devDependencies: '@repo/eslint-config': @@ -610,7 +622,7 @@ importers: version: 2.11.14(@testing-library/jest-dom@6.9.1)(solid-js@1.9.15)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.19.1)(@vitest/ui@4.1.11)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) + version: 4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) packages/typescript-config: {} @@ -626,64 +638,6 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} - '@ast-grep/napi-darwin-arm64@0.36.3': - resolution: {integrity: sha512-uM0Hrm5gcHqaBL64ktmPBFMTorTlPKWsUfi0E2Cg09GJfeYWvZmicCqgd7qVtjURmQvFQdb4JSqHIkJvws6Uqw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@ast-grep/napi-darwin-x64@0.36.3': - resolution: {integrity: sha512-wEMeQw8lRL66puG2m8m0kDRQDtubygj59HA/cmut2V5SPx/13BN3wuEk6JPv97gqGUCUGhG2+5Z6UZ/Ll2q01Q==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@ast-grep/napi-linux-arm64-gnu@0.36.3': - resolution: {integrity: sha512-sMsTMaUjW7SM8KPbLviCSBuM4zgJcwvie1yZI92HKSlFzC7ABe7X7UvyUREB+JwqccDVEL5yOJAjqB8eFSCizw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@ast-grep/napi-linux-arm64-musl@0.36.3': - resolution: {integrity: sha512-2XRmNYuovZu0Pa4J3or4PKMkQZnXXfpVcCrPwWB/2ytX7XUo+TWLgYE8rPVnJOyw5zujkveFb0XUrro9mQgLzw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@ast-grep/napi-linux-x64-gnu@0.36.3': - resolution: {integrity: sha512-mTwPRbBi1feGqR2b5TWC5gkEDeRi8wfk4euF5sKNihfMGHj6pdfINHQ3QvLVO4C7z0r/wgWLAvditFA0b997dg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@ast-grep/napi-linux-x64-musl@0.36.3': - resolution: {integrity: sha512-tMGPrT+zuZzJK6n1cD1kOii7HYZE9gUXjwtVNE/uZqXEaWP6lmkfoTMbLjnxEe74VQbmaoDGh1/cjrDBnqC6Uw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@ast-grep/napi-win32-arm64-msvc@0.36.3': - resolution: {integrity: sha512-7pFyr9+dyV+4cBJJ1I57gg6PDXP3GBQeVAsEEitzEruxx4Hb4cyNro54gGtlsS+6ty+N0t004tPQxYO2VrsPIg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@ast-grep/napi-win32-ia32-msvc@0.36.3': - resolution: {integrity: sha512-MPAgccH9VscRaFuEBMzDGPS+3c4cKNVGIVJ7WSNa1nZtLQ0eFEaPJ7pyDnCezgVSxfNFVYBvKyyF/vcm7Qc9+A==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - - '@ast-grep/napi-win32-x64-msvc@0.36.3': - resolution: {integrity: sha512-TIVtuSbXhty9kaSEfr4ULWx5PAuUeGgUkFaR60lmOs7sGTWgpig+suwKfTmevoAblFknCW/aMHOwziwJoUZA6A==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@ast-grep/napi@0.36.3': - resolution: {integrity: sha512-ExypohE8L7FvKBHxu7UpwcV9XVfyS+AqNZKyKIfxYwJyD9l7Gw6pmMYd7J2uopJsPEIUf44/emEFds6nFUx/dw==} - engines: {node: '>= 10'} - '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -873,6 +827,10 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@changesets/apply-release-plan@7.1.1': resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} @@ -3417,6 +3375,15 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} + peerDependencies: + '@vitest/browser': 4.1.11 + vitest: 4.1.11 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.11': resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} @@ -3681,6 +3648,9 @@ packages: resolution: {integrity: sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==} engines: {node: '>=4'} + ast-v8-to-istanbul@1.0.7: + resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -7564,11 +7534,6 @@ packages: vite: optional: true - vite-plugin-lib-inject-css@2.2.2: - resolution: {integrity: sha512-NF30p0GwtfSAmVlxo2NgPXM2rEdtgV7LFi4lkzajKD7P3Ru/ZAFmI533M0Z5qyMZpvNMxVGkewzpjD0HOWtbDQ==} - peerDependencies: - vite: '*' - vite-plugin-solid@2.11.14: resolution: {integrity: sha512-7ZVBt8rpoyqmlwin2kRIUveaHoF6/kulY7gsnD+qFh4nS29V4OPAnw+ojoAspXIjObiL9o1xh9a/nTuYHm02Rw==} peerDependencies: @@ -7918,45 +7883,6 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 - '@ast-grep/napi-darwin-arm64@0.36.3': - optional: true - - '@ast-grep/napi-darwin-x64@0.36.3': - optional: true - - '@ast-grep/napi-linux-arm64-gnu@0.36.3': - optional: true - - '@ast-grep/napi-linux-arm64-musl@0.36.3': - optional: true - - '@ast-grep/napi-linux-x64-gnu@0.36.3': - optional: true - - '@ast-grep/napi-linux-x64-musl@0.36.3': - optional: true - - '@ast-grep/napi-win32-arm64-msvc@0.36.3': - optional: true - - '@ast-grep/napi-win32-ia32-msvc@0.36.3': - optional: true - - '@ast-grep/napi-win32-x64-msvc@0.36.3': - optional: true - - '@ast-grep/napi@0.36.3': - optionalDependencies: - '@ast-grep/napi-darwin-arm64': 0.36.3 - '@ast-grep/napi-darwin-x64': 0.36.3 - '@ast-grep/napi-linux-arm64-gnu': 0.36.3 - '@ast-grep/napi-linux-arm64-musl': 0.36.3 - '@ast-grep/napi-linux-x64-gnu': 0.36.3 - '@ast-grep/napi-linux-x64-musl': 0.36.3 - '@ast-grep/napi-win32-arm64-msvc': 0.36.3 - '@ast-grep/napi-win32-ia32-msvc': 0.36.3 - '@ast-grep/napi-win32-x64-msvc': 0.36.3 - '@babel/code-frame@7.26.2': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -8168,6 +8094,8 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@bcoe/v8-coverage@1.0.2': {} + '@changesets/apply-release-plan@7.1.1': dependencies: '@changesets/config': 3.1.4 @@ -11127,6 +11055,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.11 + ast-v8-to-istanbul: 1.0.7 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.5 + obug: 2.2.1 + std-env: 4.3.0 + tinyrainbow: 3.2.0 + vitest: 4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 @@ -11171,7 +11113,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.2.0 - vitest: 4.1.11(@types/node@24.19.1)(@vitest/ui@4.1.11)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) + vitest: 4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) '@vitest/utils@4.1.11': dependencies: @@ -11464,6 +11406,12 @@ snapshots: dependencies: tslib: 2.8.1 + ast-v8-to-istanbul@1.0.7: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + astring@1.9.0: {} async-function@1.0.0: {} @@ -16038,13 +15986,6 @@ snapshots: - rollup - supports-color - vite-plugin-lib-inject-css@2.2.2(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)): - dependencies: - '@ast-grep/napi': 0.36.3 - magic-string: 0.30.21 - picocolors: 1.1.1 - vite: 7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3) - vite-plugin-solid@2.11.14(@testing-library/jest-dom@6.9.1)(solid-js@1.9.15)(vite@6.4.3(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)): dependencies: '@babel/core': 7.29.7 @@ -16133,7 +16074,7 @@ snapshots: optionalDependencies: vite: 7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3) - vitest@4.1.11(@types/node@24.19.1)(@vitest/ui@4.1.11)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)): + vitest@4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) @@ -16157,6 +16098,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.19.1 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) '@vitest/ui': 4.1.11(vitest@4.1.11) jsdom: 26.1.0 transitivePeerDependencies: diff --git a/scripts/build/license-banner.ts b/scripts/build/license-banner.ts new file mode 100644 index 0000000..3a85812 --- /dev/null +++ b/scripts/build/license-banner.ts @@ -0,0 +1,24 @@ +import type { Plugin } from 'vite' + +const DIRECTIVE = /^(['"])use client\1;?/ + +/** + * Starts every chunk with `/*! | MIT License *\/`. Rollup's + * `output.banner` is added before Vite minifies, and the minifier drops it, so + * this runs in `generateBundle`, after minification. The comment goes on the + * first line (after a `'use client'` directive) so source maps stay aligned. + */ +export function licenseBanner(name: string): Plugin { + const banner = `/*! ${name} | MIT License */ ` + return { + name: 'dynamix-license-banner', + apply: 'build', + generateBundle(_, bundle) { + for (const chunk of Object.values(bundle)) { + if (chunk.type !== 'chunk') continue + const directive = chunk.code.match(DIRECTIVE)?.[0] ?? '' + chunk.code = `${directive}${banner}${chunk.code.slice(directive.length)}` + } + }, + } +} diff --git a/scripts/react18-smoke/package.json b/scripts/react18-smoke/package.json new file mode 100644 index 0000000..b47abb7 --- /dev/null +++ b/scripts/react18-smoke/package.json @@ -0,0 +1,9 @@ +{ + "name": "react18-smoke", + "private": true, + "type": "module", + "description": "Checks the packed React adapter against React 18. Dependencies are installed by CI.", + "scripts": { + "test": "node smoke.mjs" + } +} diff --git a/scripts/react18-smoke/smoke.mjs b/scripts/react18-smoke/smoke.mjs new file mode 100644 index 0000000..37b1358 --- /dev/null +++ b/scripts/react18-smoke/smoke.mjs @@ -0,0 +1,44 @@ +/** + * Installs the packed @dynamix-layout/core and @dynamix-layout/react into a + * project that only has React 18, then renders on the server and in jsdom. + * Run by the `react-18` CI job (see .github/workflows/ci.yml). + */ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { JSDOM } from 'jsdom' +import { createElement as h, act, version } from 'react' +import { renderToString } from 'react-dom/server' + +assert.match(version, /^18\./, `expected React 18, got ${version}`) +const tabs = ['editor', 'terminal', 'preview'].map((id) => ({ id, title: id.toUpperCase(), content: h('p', null, `${id} body`) })) +const { DynamixLayout } = await import('@dynamix-layout/react') + +// Server render: no DOM, deterministic markup. +const html = renderToString(h(DynamixLayout, { tabs })) +assert.ok(html.includes('role="tablist"'), 'server markup has tab lists') +assert.equal(renderToString(h(DynamixLayout, { tabs })), html, 'server markup is deterministic') + +// Client render in jsdom. +const dom = new JSDOM('
', { pretendToBeVisual: true }) +Object.assign(globalThis, { window: dom.window, document: dom.window.document, HTMLElement: dom.window.HTMLElement, Element: dom.window.Element, IS_REACT_ACT_ENVIRONMENT: true }) +const { createRoot } = await import('react-dom/client') +const reasons = [] +const root = createRoot(document.getElementById('root')) +await act(async () => root.render(h(DynamixLayout, { tabs, onLayoutChange: (_, details) => reasons.push(details.reason) }))) +const tabEls = [...document.querySelectorAll('[role=tab]')] +assert.deepEqual(tabEls.map((tab) => tab.textContent), ['EDITOR', 'TERMINAL', 'PREVIEW']) + +const key = (init) => act(async () => tabEls[0].dispatchEvent(new dom.window.KeyboardEvent('keydown', { bubbles: true, ...init }))) +await key({ key: 'm', ctrlKey: true, shiftKey: true }) +await key({ key: 'Enter' }) +assert.deepEqual(reasons, ['move'], 'keyboard move mode moved the tab') + +await act(async () => root.unmount()) +assert.equal(document.getElementById('root').innerHTML, '', 'unmounted cleanly') + +// CommonJS consumers. +const require = createRequire(import.meta.url) +assert.equal(typeof require('@dynamix-layout/core').createLayout, 'function') +assert.equal(typeof require('@dynamix-layout/react').useTab, 'function') + +console.log('React 18 smoke test passed')