diff --git a/.changeset/maximize-and-fold-core.md b/.changeset/maximize-and-fold-core.md new file mode 100644 index 0000000..c3dc221 --- /dev/null +++ b/.changeset/maximize-and-fold-core.md @@ -0,0 +1,12 @@ +--- +'@dynamix-layout/core': minor +--- + +Maximize and fold tabsets (engine): + +- `maximize(id)`, `restore()`, `toggleMaximize(id)` and `maximizedId`: one tabset fills the whole layout; the others are hidden but stay mounted, and restoring brings back the exact previous split. +- `collapse(id)`, `expand(id)`, `toggleCollapse(id)`: a tabset folds to a strip along its row (its tab bar). Siblings share the freed space, unfolding restores the previous size, and folding the last open child of a row unfolds its most recently folded sibling. +- New `collapsedSize` option (defaults to `minH`); pass the tab bar height. +- Computed options expose `nodFold`, `nodMaxd`, `nodHidden`, `nodLocked`, `nodFoldable` and `nodMaximizable`; saved layouts keep `nodFold` (tabsets) and `nodMaxd` (root). +- Bonds next to a folded tabset are locked; any drag-and-drop move ends maximize; a dragged or drop-target tabset unfolds. +- Fix: a layout created from a single tab now fills the whole area (an empty row used to take half of it). diff --git a/.changeset/maximize-and-fold-ui.md b/.changeset/maximize-and-fold-ui.md new file mode 100644 index 0000000..953f001 --- /dev/null +++ b/.changeset/maximize-and-fold-ui.md @@ -0,0 +1,12 @@ +--- +'@dynamix-layout/react': minor +'@dynamix-layout/solid': minor +--- + +Maximize and fold tabsets (UI), on by default: + +- Hover toolbar at the end of every tab bar with Maximize/Restore and Fold/Unfold buttons (accessible labels and tooltips), like LeetCode. +- Folded tabsets in side-by-side rows show their tab bar as a rotated vertical strip with the toolbar at the bottom; in stacked rows they shrink to their tab bar. +- Double-click a tab bar to maximize/restore; Alt/Option + `+` maximizes and Alt/Option + `-` folds the last touched tabset. +- New props: `enableMaximize`, `enableCollapse`, `enableDoubleClickMaximize`, `keyboardShortcuts`, and `TabsetToolbar` to replace the toolbar. +- Starting a drag while maximized restores the layout first; locked bonds next to folded tabsets ignore the pointer. diff --git a/packages/core/src/app/comparator.ts b/packages/core/src/app/comparator.ts index 0782fac..1496584 100644 --- a/packages/core/src/app/comparator.ts +++ b/packages/core/src/app/comparator.ts @@ -25,6 +25,10 @@ export function areNodeOptionsEqual(a: NodeOptions, b: NodeOptions): boolean { 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 diff --git a/packages/core/src/app/dom.ts b/packages/core/src/app/dom.ts index 3db119d..49057b7 100644 --- a/packages/core/src/app/dom.ts +++ b/packages/core/src/app/dom.ts @@ -1,4 +1,4 @@ -import type { Dimension } from '../type' +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) { @@ -24,6 +24,37 @@ export function getTabBodyRect( } } +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 diff --git a/packages/core/src/app/dynamix.ts b/packages/core/src/app/dynamix.ts index 55b8f87..7cfe551 100644 --- a/packages/core/src/app/dynamix.ts +++ b/packages/core/src/app/dynamix.ts @@ -16,6 +16,7 @@ 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 { @@ -83,6 +84,8 @@ class Layout { 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 = { @@ -99,10 +102,12 @@ class Layout { 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, @@ -257,6 +262,39 @@ class Layout { 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 } diff --git a/packages/core/src/app/geometry.ts b/packages/core/src/app/geometry.ts index 29f82c2..b10b07e 100644 --- a/packages/core/src/app/geometry.ts +++ b/packages/core/src/app/geometry.ts @@ -1,5 +1,11 @@ import { Node } from './node' import { layoutState } from './state' +import { + canCollapse, + canMaximize, + getMaximizedTabset, + normalizeViewState, +} from './view-state' import type { Dimension, NodeOptions, @@ -16,6 +22,7 @@ export function calcTabsetCountAndMinDim( clear: boolean = true ) { if (root === layoutState.root && clear) { + normalizeViewState(root) Node.cache.tabCnts.clear() Node.cache.dimMins.clear() Node.cache.mapElem.clear() @@ -73,6 +80,24 @@ export function calcTabsetCountAndMinDim( 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') { @@ -162,12 +187,24 @@ export function calcDimensions( 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, @@ -175,25 +212,28 @@ export function calcDimensions( 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 = { + const KidNode: NodeOptions = { typNode: kid.type, nodName: kid.name, uidNode: kid.unId, nodPart: kid.part, nodOpen: node.open === kid.name ? true : false, nodeDir: !nodeDir, - nodDims: { - ...node.dims, - }, + nodHidden: hidden, + nodFold: node.collapsed && !isMaximized, + nodDims: { ...dims }, } tabsIds.set(kid.unId, KidNode) - return KidNode as NodeOptions + return KidNode }), - nodDims: { - ...node.dims, - }, + nodDims: dims, } nodeOpts.set(node.unId, nodeOption) } @@ -205,6 +245,8 @@ export function calcDimensions( uidNode: node.next.unId, nodeDir: nodeDir, nodPart: 0, + nodHidden: !!maximized, + nodLocked: node.collapsed || !!node.next.next?.collapsed, nodDims: { ...node.next.dims, }, diff --git a/packages/core/src/app/node.ts b/packages/core/src/app/node.ts index d09276d..159bb9f 100644 --- a/packages/core/src/app/node.ts +++ b/packages/core/src/app/node.ts @@ -35,6 +35,10 @@ export class Node { 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: { @@ -108,8 +112,14 @@ export class Node { } 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 @@ -122,7 +132,6 @@ export class Node { } const isBelowMinSize = extraSpace < 0 - const lastKid = this.kids.peekBack() // Round cumulative boundaries instead of individual sizes. Each edge // stays within half a pixel of its exact position, moves at most one @@ -138,10 +147,10 @@ export class Node { } let extraChildSpace = 0 - if (!isBelowMinSize) { + if (!isBelowMinSize && !kid.collapsed) { cumWeight += kid.part const boundary = - kid === lastKid + kid === lastFlexKid ? extraSpace : Math.round((extraSpace * cumWeight) / totalWeight) extraChildSpace = boundary - prevBoundary @@ -200,6 +209,14 @@ export class Node { 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 } diff --git a/packages/core/src/app/slider.ts b/packages/core/src/app/slider.ts index 9129a1b..dfc254c 100644 --- a/packages/core/src/app/slider.ts +++ b/packages/core/src/app/slider.ts @@ -50,6 +50,12 @@ export function updateSliderDimension( 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, diff --git a/packages/core/src/app/state.ts b/packages/core/src/app/state.ts index 7d35492..dc4063f 100644 --- a/packages/core/src/app/state.ts +++ b/packages/core/src/app/state.ts @@ -24,4 +24,10 @@ export const layoutState = { 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 index eb6d581..0748a6c 100644 --- a/packages/core/src/app/tree-builder.ts +++ b/packages/core/src/app/tree-builder.ts @@ -148,6 +148,28 @@ export function createBinaryNodeTreeFromQueue( } 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() @@ -228,6 +250,7 @@ export function createNodeFromJSON(engine: Layout, json: LayoutTree): Node { 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 diff --git a/packages/core/src/app/tree-mutations.ts b/packages/core/src/app/tree-mutations.ts index ee17b49..0d3cca0 100644 --- a/packages/core/src/app/tree-mutations.ts +++ b/packages/core/src/app/tree-mutations.ts @@ -86,6 +86,10 @@ export function updateTree( 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') @@ -160,6 +164,7 @@ export function moveNodeAsTab( 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() @@ -180,6 +185,7 @@ export function moveNodeAsTab( } desHst.open = kidLst ? kidLst.name : '' + desHst.collapsed = false } engine.updateChildDirections(desHst) diff --git a/packages/core/src/app/view-state.ts b/packages/core/src/app/view-state.ts new file mode 100644 index 0000000..7aee166 --- /dev/null +++ b/packages/core/src/app/view-state.ts @@ -0,0 +1,125 @@ +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/type/index.ts b/packages/core/src/type/index.ts index 002ca1b..e53663e 100644 --- a/packages/core/src/type/index.ts +++ b/packages/core/src/type/index.ts @@ -36,11 +36,27 @@ export interface BaseNode { /** @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 { diff --git a/packages/core/test/geometry.test.ts b/packages/core/test/geometry.test.ts index 03514a3..3315668 100644 --- a/packages/core/test/geometry.test.ts +++ b/packages/core/test/geometry.test.ts @@ -307,4 +307,13 @@ describe('Layout geometry', () => { [] ) }) + + 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/view-state.test.ts b/packages/core/test/view-state.test.ts new file mode 100644 index 0000000..d0fa847 --- /dev/null +++ b/packages/core/test/view-state.test.ts @@ -0,0 +1,315 @@ +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/react/src/components/Default.tsx b/packages/react/src/components/Default.tsx index ebb5bea..ef74389 100644 --- a/packages/react/src/components/Default.tsx +++ b/packages/react/src/components/Default.tsx @@ -1,4 +1,11 @@ -import React, { forwardRef, HTMLAttributes, ReactNode } from 'react' +import React, { + forwardRef, + HTMLAttributes, + ReactNode, + useEffect, + useRef, +} from 'react' +import type { TabsetToolbarProps } from '../types' type DefaultWrapTabLabelProps = HTMLAttributes & { active?: boolean @@ -304,3 +311,103 @@ export const RootSplitterHoverEl = forwardRef< } ) 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/Layout.tsx b/packages/react/src/components/Layout.tsx index 4200c7e..d94a33e 100644 --- a/packages/react/src/components/Layout.tsx +++ b/packages/react/src/components/Layout.tsx @@ -6,7 +6,11 @@ import React, { useRef, } from 'react' import { useDynamixLayout } from '../hooks/useLayout' -import { DynamixLayoutCore, NodeOptions } from '@dynamix-layout/core' +import { + DynamixLayoutCore, + NodeOptions, + getTabbarPlacement, +} from '@dynamix-layout/core' import { LayoutProps, TabEntry, TabInput, TabOutput } from '../types' import { DefaultHoverElement, @@ -15,6 +19,7 @@ import { DefaultWrapTabHead, DefaultSliderElement, DefaultWrapTabBody, + DefaultTabsetToolbar, RootSplitterHoverEl, } from './Default' import './layout.css' @@ -88,6 +93,11 @@ export const DynamixLayout: ForwardRefExoticComponent< tabHeadElementClass, tabLabelElementClass, RootSplitterHoverElClass, + enableMaximize = true, + enableCollapse = true, + enableDoubleClickMaximize = true, + keyboardShortcuts = true, + TabsetToolbar = DefaultTabsetToolbar, ...props }: LayoutProps, ref? @@ -129,6 +139,10 @@ export const DynamixLayout: ForwardRefExoticComponent< handleRootSplit, tabsRef, isUpdating, + toggleMaximize, + toggleCollapse, + onRootPointerDown, + onTabbarDoubleClick, } = useDynamixLayout({ tabOutput, rootId, @@ -145,8 +159,13 @@ export const DynamixLayout: ForwardRefExoticComponent< 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 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', - }} - /> + 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 - { - 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: `${tabset.nodDims.w}px`, - height: `${tabHeadHeight}px`, - left: `${tabset.nodDims.x}px`, - top: `${tabset.nodDims.y}px`, - cursor: isUpdating ? 'wait' : 'pointer', - }} - > - {tabset.nodKids && - tabset.nodKids.map((tab: NodeOptions) => ( - + { + 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 && ( + - {tabNames.get(tab.nodName) || - tab.nodName} - - ))} - - - ))} + showMaximize={showMaximize} + showFold={showFold} + onToggleMaximize={() => + toggleMaximize(tabset.uidNode) + } + onToggleFold={() => + toggleCollapse(tabset.uidNode) + } + /> + )} + + + ) + })} {tabOutput.keys.map((key) => { const tabId = tabOutput.maps.get(key)?.uqid @@ -295,6 +374,7 @@ export const DynamixLayout: ForwardRefExoticComponent< key={slider.uidNode} id={slider.uidNode} data-uid={slider.uidNode} + data-dx-hidden={slider.nodHidden ? '' : undefined} direction={slider.nodeDir} onPointerDown={onPointerDown} className={ @@ -313,6 +393,9 @@ export const DynamixLayout: ForwardRefExoticComponent< cursor: slider.nodeDir ? 'ns-resize' : 'ew-resize', + ...(slider.nodLocked + ? { pointerEvents: 'none' as const } + : {}), ...sliderElementStyles, }} /> diff --git a/packages/react/src/components/layout.css b/packages/react/src/components/layout.css index 6c900d7..13703ad 100644 --- a/packages/react/src/components/layout.css +++ b/packages/react/src/components/layout.css @@ -4,26 +4,80 @@ /* --- 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; + background-color: #ffffff; + font-family: 'Poppins', sans-serif; } .DefaultWrapTabLabel[data-state='inactive'] { - background-color: #dfdfdf; - font-family: 'Poppins', sans-serif; + 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; + 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; -} \ No newline at end of file +.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/hooks/useLayout.ts b/packages/react/src/hooks/useLayout.ts index f8bcdab..8724942 100644 --- a/packages/react/src/hooks/useLayout.ts +++ b/packages/react/src/hooks/useLayout.ts @@ -10,7 +10,9 @@ import { getRootSplitPreview, getTabBodyRect, getTabsetDropPreview, + getTabbarPlacement, isSameDropPreview, + placeTabbar, setElementRect, } from '@dynamix-layout/core' import React, { useState, useEffect } from 'react' @@ -32,6 +34,8 @@ export const useDynamixLayout = ({ windowResizeTimeout, disableSliderTimeout, disableResizeTimeout, + keyboardShortcuts = true, + enableDoubleClickMaximize = true, }: useDynamixLayoutOptions) => { const tabsetsRef = React.useRef(new Map()) const slidersRef = React.useRef(new Map()) @@ -87,9 +91,20 @@ export const useDynamixLayout = ({ 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]) + }, [ + layoutJSON, + tabOutput, + bondWidth, + minLayoutHeight, + minTabWidth, + rootId, + enableTabbar, + tabHeadHeight, + ]) const updateTabsets = (nodes: Map) => { const newTabsets = new Map(nodes) @@ -116,10 +131,14 @@ export const useDynamixLayout = ({ const tabsetEl = tabsetsRef.current.get(id) if (tabsetEl && enableTabbarRef.current) { - setElementRect(tabsetEl, { - ...node.nodDims, - h: tabHeadHeightRef.current, - }) + tabsetEl.toggleAttribute( + 'data-dx-hidden', + !!node.nodHidden + ) + placeTabbar( + tabsetEl, + getTabbarPlacement(node, tabHeadHeightRef.current) + ) } }) } @@ -129,7 +148,10 @@ export const useDynamixLayout = ({ (nodes: Map) => { nodes.forEach((node: NodeOptions, id: string) => { const sliderEl = slidersRef.current.get(id) - if (sliderEl) setElementRect(sliderEl, node.nodDims) + if (!sliderEl) return + setElementRect(sliderEl, node.nodDims) + sliderEl.toggleAttribute('data-dx-hidden', !!node.nodHidden) + sliderEl.style.pointerEvents = node.nodLocked ? 'none' : '' }) } ) @@ -148,8 +170,12 @@ export const useDynamixLayout = ({ setElementRect(tabEl, body) // Empty bodies stay hidden so their borders/shadows don't // bleed onto the slider below. - tabEl.style.display = - node.nodOpen && body.h > 0 ? 'block' : 'none' + const visible = + node.nodOpen && + !node.nodFold && + !node.nodHidden && + body.h > 0 + tabEl.style.display = visible ? 'block' : 'none' }) } ) @@ -257,9 +283,29 @@ export const useDynamixLayout = ({ 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.childNodes - ) as HTMLDivElement[] + e.currentTarget.querySelectorAll( + ':scope > [data-type="tab"]' + ) + ) if (!hoverElementRef.current || !dragElemRef.current) return const preview = getNavbarDropPreview( @@ -310,8 +356,66 @@ export const useDynamixLayout = ({ 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) { @@ -531,12 +635,10 @@ export const useDynamixLayout = ({ 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', - body.h > 0 ? 'block' : 'none' - ) + ?.style.setProperty('display', visible ? 'block' : 'none') } else { kid.nodOpen = false tabsRef.current @@ -624,5 +726,9 @@ export const useDynamixLayout = ({ updateActiveTab, handleRootSplit, handleNavbarDragOver, + toggleMaximize, + toggleCollapse, + onRootPointerDown, + onTabbarDoubleClick, } } diff --git a/packages/react/src/types/index.ts b/packages/react/src/types/index.ts index 7985625..90f0e10 100644 --- a/packages/react/src/types/index.ts +++ b/packages/react/src/types/index.ts @@ -133,6 +133,38 @@ export interface LayoutProps { rootId?: string /** @deprecated Replaced in v2 by `tabs: { id, title, content }[]`. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ tabNames?: Map + /** + * Show the maximize button on tab bars (default true). + * @deprecated Replaced in v2 by `allowMaximize`. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md + */ + enableMaximize?: boolean + /** + * Show the fold button on tab bars (default true; needs the tab bar). + * @deprecated Replaced in v2 by `allowFold`. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md + */ + enableCollapse?: boolean + /** + * Double-click a tab bar to maximize or restore it (default true). + * @deprecated Replaced in v2 by `maximizeOnDoubleClick`. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md + */ + enableDoubleClickMaximize?: boolean + /** Alt/Option + "+" maximizes, Alt/Option + "-" folds the active tabset (default true). */ + keyboardShortcuts?: boolean + /** @deprecated Replaced in v2 by `components.TabsetToolbar` (its props become `{ tabset, isRotated, canMaximize, canFold, … }`). See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ + TabsetToolbar?: React.ComponentType +} + +export interface TabsetToolbarProps { + maximized: boolean + folded: boolean + /** The tab bar is drawn as a rotated vertical strip (folded, side-by-side row). */ + rotated: boolean + /** The tabset's row lays its children out side by side. */ + rowIsHorizontal: boolean + showMaximize: boolean + showFold: boolean + onToggleMaximize: () => void + onToggleFold: () => void } /** @deprecated Internal; removed in 2.0. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ @@ -152,4 +184,6 @@ export interface useDynamixLayoutOptions { minTabHeight: number minTabWidth: number tabHeadHeight: number + keyboardShortcuts?: boolean + enableDoubleClickMaximize?: boolean } diff --git a/packages/solid/src/components/Default.tsx b/packages/solid/src/components/Default.tsx index 5cafe14..b2522d4 100644 --- a/packages/solid/src/components/Default.tsx +++ b/packages/solid/src/components/Default.tsx @@ -1,4 +1,5 @@ -import { JSX, createMemo, splitProps } from 'solid-js' +import { JSX, createMemo, onCleanup, onMount, splitProps } from 'solid-js' +import type { TabsetToolbarProps } from '../types' type DivProps = { children?: JSX.Element @@ -267,3 +268,88 @@ export const RootSplitterHoverEl = (
) } + +const ToolbarIcon = (props: { 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 = (props: 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 = () => + (props.rowIsHorizontal ? 0 : 90) + (props.folded ? 180 : 0) + const upright = () => (props.rotated ? -90 : 0) + let toolbar: HTMLDivElement | undefined + + // Stay pinned to the visible end of the tab bar when its tabs overflow + // and it scrolls. + onMount(() => { + const bar = toolbar?.parentElement + if (!toolbar || !bar) return + const pin = () => { + toolbar!.style.transform = `translateX(${bar.scrollLeft}px)` + } + pin() + bar.addEventListener('scroll', pin, { passive: true }) + onCleanup(() => bar.removeEventListener('scroll', pin)) + }) + + return ( +
e.stopPropagation()} + > + {props.showMaximize && ( + + )} + {props.showFold && ( + + )} +
+ ) +} diff --git a/packages/solid/src/components/Layout.tsx b/packages/solid/src/components/Layout.tsx index 4d0a49d..271a217 100644 --- a/packages/solid/src/components/Layout.tsx +++ b/packages/solid/src/components/Layout.tsx @@ -1,4 +1,8 @@ -import { DynamixLayoutCore, NodeOptions } from '@dynamix-layout/core' +import { + DynamixLayoutCore, + NodeOptions, + getTabbarPlacement, +} from '@dynamix-layout/core' import { createMemo, For, ParentComponent } from 'solid-js' import { DefaultHoverElement, @@ -7,6 +11,7 @@ import { DefaultWrapTabHead, DefaultWrapTabLabel, DefaultWrapTabPanel, + DefaultTabsetToolbar, RootSplitterHoverEl, } from './Default' import { useDynamixLayout } from '../hooks/useLayout' @@ -75,6 +80,10 @@ export const DynamixLayout: ParentComponent = (props) => { handleRootSplit, tabsRef, isUpdating, + toggleMaximize, + toggleCollapse, + onRootPointerDown, + onTabbarDoubleClick, } = useDynamixLayout({ tabOutput: tabOutput(), rootId: props.rootId || 'dynamix-layout-root', @@ -98,6 +107,8 @@ export const DynamixLayout: ParentComponent = (props) => { ? true : props.disableResizeTimeout, hoverElementStyles: props.hoverElementStyles, + keyboardShortcuts: props.keyboardShortcuts ?? true, + enableDoubleClickMaximize: props.enableDoubleClickMaximize ?? true, }) /* eslint-disable solid/reactivity */ const WrapTabPanel = props.WrapTabPanel || DefaultWrapTabPanel @@ -106,6 +117,12 @@ export const DynamixLayout: ParentComponent = (props) => { const WrapTabBody = props.WrapTabBody || DefaultWrapTabBody const SliderElement = props.SliderElement || DefaultSliderElement const HoverElement = props.HoverElement || DefaultHoverElement + const TabsetToolbar = props.TabsetToolbar || DefaultTabsetToolbar + const tabHeadHeight = props.tabHeadHeight || DynamixLayoutCore._minH + const enableTabbar = props.enableTabbar ?? true + const enableMaximize = props.enableMaximize ?? true + // Folding needs the tab bar: a folded tabset is only its tab bar. + const canFold = (props.enableCollapse ?? true) && enableTabbar return (
= (props) => { {...props} data-testid={props.rootId || 'dynamix-layout-root'} class={dragging() ? 'is-dragging' : ''} + onPointerDown={onRootPointerDown} style={{ position: 'relative', width: '100%', @@ -123,96 +141,145 @@ export const DynamixLayout: ParentComponent = (props) => { }} > - {(tabset) => ( - <> - { - const id = tabset.uidNode - if (el) panelsRef.set(id, el) - else panelsRef.delete(id) - }} - onDragEnd={onDragEnd} - onDragOver={onDragOver} - onDragEnter={onDragEnter} - onDragLeave={onDragLeave} - onDrop={onDrop} - data-uid={tabset.uidNode} - data-type={tabset.typNode} - class={ - 'hide-scrollbar DefaultWrapTabPanel ' + - (props.tabPanelElementClass || '') - } - style={{ - position: 'absolute', - ...(props.tabPanelElementStyles as object), - width: `${tabset.nodDims.w}px`, - height: `${tabset.nodDims.h}px`, - left: `${tabset.nodDims.x}px`, - top: `${tabset.nodDims.y}px`, - 'background-color': 'transparent', - }} - /> + {(tabset) => { + const placement = getTabbarPlacement(tabset, tabHeadHeight) + const showMaximize = + enableMaximize && !!tabset.nodMaximizable + const showFold = + canFold && !!tabset.nodFoldable && !tabset.nodMaxd + const hasToolbar = showMaximize || showFold - { - const id = tabset.uidNode - if (el) tabsetsRef.set(id, el) - else tabsetsRef.delete(id) - }} - class={ - 'hide-scrollbar ' + - (props.tabHeadElementClass || '') - } - style={{ - ...(props.tabHeadElementStyles as object), - position: 'absolute', - 'z-index': 99, - width: `${tabset.nodDims.w}px`, - height: `${props.tabHeadHeight || DynamixLayoutCore._minH}px`, - left: `${tabset.nodDims.x}px`, - top: `${tabset.nodDims.y}px`, - cursor: isUpdating() ? 'wait' : 'pointer', - }} - > - - {(tab: NodeOptions) => ( - + { + const id = tabset.uidNode + if (el) panelsRef.set(id, el) + else panelsRef.delete(id) + }} + onDragEnd={onDragEnd} + onDragOver={onDragOver} + onDragEnter={onDragEnter} + onDragLeave={onDragLeave} + onDrop={onDrop} + data-uid={tabset.uidNode} + data-type={tabset.typNode} + class={ + 'hide-scrollbar DefaultWrapTabPanel ' + + (props.tabPanelElementClass || '') + } + style={{ + position: 'absolute', + ...(props.tabPanelElementStyles as object), + width: `${tabset.nodDims.w}px`, + height: `${tabset.nodDims.h}px`, + left: `${tabset.nodDims.x}px`, + top: `${tabset.nodDims.y}px`, + 'background-color': 'transparent', + }} + /> + + { + const id = tabset.uidNode + if (el) tabsetsRef.set(id, el) + else tabsetsRef.delete(id) + }} + class={ + 'hide-scrollbar ' + + (props.tabHeadElementClass || '') + } + style={{ + ...(props.tabHeadElementStyles as object), + position: 'absolute', + 'z-index': 99, + width: `${placement.rect.w}px`, + height: `${placement.rect.h}px`, + left: `${placement.rect.x}px`, + top: `${placement.rect.y}px`, + 'transform-origin': '0 0', + // Only add keys when needed so the tab bar + // component keeps its own values. + ...(placement.rotated + ? { transform: 'rotate(90deg)' } + : {}), + ...(hasToolbar + ? { 'padding-right': '64px' } + : {}), + cursor: isUpdating() ? 'wait' : 'pointer', + }} + > + + {(tab: NodeOptions) => ( + + {(props.tabNames && + props.tabNames.get( + tab.nodName + )) || + tab.nodName} + + )} + + {hasToolbar && ( + + toggleMaximize(tabset.uidNode) + } + onToggleFold={() => + toggleCollapse(tabset.uidNode) } - style={{ - cursor: isUpdating() - ? 'wait' - : 'pointer', - ...(props.tabLabelElementStyles as object), - }} - onClick={updateActiveTab} - > - {(props.tabNames && - props.tabNames.get(tab.nodName)) || - tab.nodName} - + /> )} - - - - )} + + + ) + }} @@ -257,6 +324,7 @@ export const DynamixLayout: ParentComponent = (props) => { = (props) => { 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), }} /> diff --git a/packages/solid/src/components/layout.css b/packages/solid/src/components/layout.css index 6c900d7..00c4de8 100644 --- a/packages/solid/src/components/layout.css +++ b/packages/solid/src/components/layout.css @@ -4,26 +4,81 @@ /* --- 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; + background-color: #ffffff; + font-family: 'Poppins', sans-serif; } .DefaultWrapTabLabel[data-state='inactive'] { - background-color: #dfdfdf; - font-family: 'Poppins', sans-serif; + 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; + 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; -} \ No newline at end of file +.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/solid/src/hooks/useLayout.ts b/packages/solid/src/hooks/useLayout.ts index eda0392..bd95261 100644 --- a/packages/solid/src/hooks/useLayout.ts +++ b/packages/solid/src/hooks/useLayout.ts @@ -10,7 +10,9 @@ import { getRootSplitPreview, getTabBodyRect, getTabsetDropPreview, + getTabbarPlacement, isSameDropPreview, + placeTabbar, setElementRect, } from '@dynamix-layout/core' import { createSignal, onCleanup, onMount } from 'solid-js' @@ -31,6 +33,8 @@ export const useDynamixLayout = ({ windowResizeTimeout, disableSliderTimeout, disableResizeTimeout, + keyboardShortcuts = true, + enableDoubleClickMaximize = true, }: useDynamixLayoutOptions) => { const tabsetsRef = new Map() const slidersRef = new Map() @@ -84,6 +88,8 @@ export const useDynamixLayout = ({ bond: bondWidth, uqid: rootId, tabsIds: tabOutput.name, + // A folded tabset shrinks to its tab bar. + collapsedSize: enableTabbar ? tabHeadHeight : minLayoutHeight, }) const updateTabsets = (nodes: Map) => { @@ -107,7 +113,9 @@ export const useDynamixLayout = ({ setElementRect(tabEl, body) // Empty bodies stay hidden so their borders/shadows don't bleed onto // the slider below. - tabEl.style.display = node.nodOpen && body.h > 0 ? 'block' : 'none' + const visible = + node.nodOpen && !node.nodFold && !node.nodHidden && body.h > 0 + tabEl.style.display = visible ? 'block' : 'none' }) } @@ -119,10 +127,11 @@ export const useDynamixLayout = ({ const tabsetEl = tabsetsRef.get(id) if (tabsetEl && enableTabbar) { - setElementRect(tabsetEl, { - ...node.nodDims, - h: tabHeadHeight, - }) + tabsetEl.toggleAttribute('data-dx-hidden', !!node.nodHidden) + placeTabbar( + tabsetEl, + getTabbarPlacement(node, tabHeadHeight) + ) } }) } @@ -136,7 +145,10 @@ export const useDynamixLayout = ({ (nodes: Map) => { nodes.forEach((node: NodeOptions, id: string) => { const sliderEl = slidersRef.get(id) - if (sliderEl) setElementRect(sliderEl, node.nodDims) + if (!sliderEl) return + setElementRect(sliderEl, node.nodDims) + sliderEl.toggleAttribute('data-dx-hidden', !!node.nodHidden) + sliderEl.style.pointerEvents = node.nodLocked ? 'none' : '' }) } ) @@ -222,9 +234,29 @@ export const useDynamixLayout = ({ } 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.childNodes - ) as HTMLDivElement[] + navbarElement.querySelectorAll( + ':scope > [data-type="tab"]' + ) + ) if (!hoverElementRef.current || !dragElemRef) return const preview = getNavbarDropPreview( @@ -275,8 +307,58 @@ export const useDynamixLayout = ({ 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()) + } + + 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) + } + } + const onDragStart = (e: 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) { @@ -510,6 +592,7 @@ export const useDynamixLayout = ({ const handler = () => updateDimension(disableResizeTimeout ?? false) window.addEventListener('resize', handler) + window.addEventListener('keydown', onKeyDown) updateTabsets(Node.cache.nodOpts.get()) updateSliders(Node.cache.bndOpts.get()) @@ -520,6 +603,7 @@ export const useDynamixLayout = ({ onCleanup(() => { window.removeEventListener('resize', handler) + window.removeEventListener('keydown', onKeyDown) offNodes() offBonds() offTabs() @@ -565,5 +649,9 @@ export const useDynamixLayout = ({ updateActiveTab, handleRootSplit, handleNavbarDragOver, + toggleMaximize, + toggleCollapse, + onRootPointerDown, + onTabbarDoubleClick, } } diff --git a/packages/solid/src/types/index.ts b/packages/solid/src/types/index.ts index a997dd8..af6a261 100644 --- a/packages/solid/src/types/index.ts +++ b/packages/solid/src/types/index.ts @@ -94,6 +94,28 @@ export interface LayoutProps { RootSplitterHoverElClass?: string rootId?: string tabNames?: Map + /** Show the maximize button on tab bars (default true). */ + enableMaximize?: boolean + /** Show the fold button on tab bars (default true; needs the tab bar). */ + enableCollapse?: boolean + /** Double-click a tab bar to maximize or restore it (default true). */ + enableDoubleClickMaximize?: boolean + /** Alt/Option + "+" maximizes, Alt/Option + "-" folds the active tabset (default true). */ + keyboardShortcuts?: boolean + TabsetToolbar?: (props: TabsetToolbarProps) => JSX.Element +} + +export interface TabsetToolbarProps { + maximized: boolean + folded: boolean + /** The tab bar is drawn as a rotated vertical strip (folded, side-by-side row). */ + rotated: boolean + /** The tabset's row lays its children out side by side. */ + rowIsHorizontal: boolean + showMaximize: boolean + showFold: boolean + onToggleMaximize: () => void + onToggleFold: () => void } export interface useDynamixLayoutOptions { @@ -112,4 +134,6 @@ export interface useDynamixLayoutOptions { minTabHeight: number minTabWidth: number tabHeadHeight: number + keyboardShortcuts?: boolean + enableDoubleClickMaximize?: boolean }