Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/maximize-and-fold-core.md
Original file line number Diff line number Diff line change
@@ -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).
12 changes: 12 additions & 0 deletions .changeset/maximize-and-fold-ui.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions packages/core/src/app/comparator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 32 additions & 1 deletion packages/core/src/app/dom.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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<NodeOptions, 'nodDims' | 'nodFold' | 'nodeDir'>,
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<T> {
/** Remember `value`; `onFlush` runs with the latest value on the next frame. */
schedule: (value: T) => void
Expand Down
38 changes: 38 additions & 0 deletions packages/core/src/app/dynamix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 = {
Expand All @@ -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,
Expand Down Expand Up @@ -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 }
Expand Down
58 changes: 50 additions & 8 deletions packages/core/src/app/geometry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { Node } from './node'
import { layoutState } from './state'
import {
canCollapse,
canMaximize,
getMaximizedTabset,
normalizeViewState,
} from './view-state'
import type {
Dimension,
NodeOptions,
Expand All @@ -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()
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -162,38 +187,53 @@ 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,
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 = {
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)
}
Expand All @@ -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,
},
Expand Down
23 changes: 20 additions & 3 deletions packages/core/src/app/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/app/slider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/app/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
23 changes: 23 additions & 0 deletions packages/core/src/app/tree-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading