feat(desktop): layout-tree renderer — splits, zones, pointer drag-session, tab strip
parent
c388daa665
commit
63a9bde77b
|
|
@ -1,19 +0,0 @@
|
|||
import { createContext } from 'react'
|
||||
|
||||
export interface PaneSlot {
|
||||
side: 'left' | 'right'
|
||||
open: boolean
|
||||
/** Resolved CSS `grid-column` value (e.g. "3 / 4", or a full-side span for a bottom-row pane). */
|
||||
gridColumn: string
|
||||
/** Resolved CSS `grid-row` value ("1 / -1" full-height, "1 / 2" above a bottom row, "2 / 3" the row itself). */
|
||||
gridRow: string
|
||||
/** True when this pane lays out as a horizontal row beneath its rail instead of a vertical column. */
|
||||
bottomRow: boolean
|
||||
}
|
||||
|
||||
export interface PaneShellContextValue {
|
||||
paneById: Map<string, PaneSlot>
|
||||
mainColumn: number
|
||||
}
|
||||
|
||||
export const PaneShellContext = createContext<PaneShellContextValue | null>(null)
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Layout edit mode — the shared toggle for the tree renderer's FancyZones-style
|
||||
* rearrangement (see tree/renderer.tsx). The toggle hotkey is a `keybinds`
|
||||
* contribution (`layout.editMode`, default ⌘⇧\ — the sibling of ⌘\ = flip
|
||||
* panes), so it's rebindable and collision-checked like every other action.
|
||||
* This hook only owns Escape-to-exit.
|
||||
*/
|
||||
|
||||
import { atom } from 'nanostores'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers'
|
||||
|
||||
export const $layoutEditMode = atom(false)
|
||||
|
||||
export function toggleLayoutEditMode() {
|
||||
$layoutEditMode.set(!$layoutEditMode.get())
|
||||
}
|
||||
|
||||
/** Escape exits edit mode. Registered once by the layout root. */
|
||||
export function useLayoutEditHotkey(enabled: boolean) {
|
||||
useEffect(() => {
|
||||
if (!enabled || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
// Own an Escape layer only WHILE edit mode is on, so it doesn't outrank the
|
||||
// narrow-pane reveal the rest of the time.
|
||||
let releaseLayer: (() => void) | null = null
|
||||
|
||||
const unsub = $layoutEditMode.subscribe(on => {
|
||||
if (on && !releaseLayer) {
|
||||
releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.layoutEdit)
|
||||
} else if (!on && releaseLayer) {
|
||||
releaseLayer()
|
||||
releaseLayer = null
|
||||
}
|
||||
})
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape' || e.defaultPrevented || !isTopEscapeLayer(ESCAPE_PRIORITY.layoutEdit)) {
|
||||
return
|
||||
}
|
||||
|
||||
if ($layoutEditMode.get()) {
|
||||
e.preventDefault()
|
||||
$layoutEditMode.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
unsub()
|
||||
releaseLayer?.()
|
||||
}
|
||||
}, [enabled])
|
||||
}
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
export type { PaneShellContextValue, PaneSlot } from './context'
|
||||
export { PaneShellContext } from './context'
|
||||
export { Pane, PANE_TOGGLE_REVEAL_EVENT, PaneMain, PaneShell } from './pane-shell'
|
||||
export type { PaneMainProps, PaneProps, PaneShellProps } from './pane-shell'
|
||||
/**
|
||||
* Cross-surface event: toggle-reveal a collapsed pane. Dispatched by the
|
||||
* keybinds (⌘B / ⌘G / titlebar toggles on narrow viewports) with the pane id
|
||||
* in `detail`; the layout tree's narrow overlays (tree/renderer.tsx) listen
|
||||
* and slide the pane over the grid.
|
||||
*/
|
||||
export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal'
|
||||
|
|
|
|||
|
|
@ -1,333 +0,0 @@
|
|||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $paneStates, setPaneOpen, setPaneWidthOverride } from '@/store/panes'
|
||||
|
||||
import { Pane, PaneMain, PaneShell } from './pane-shell'
|
||||
|
||||
function gridContainer(rendered: ReturnType<typeof render>): HTMLElement {
|
||||
const root = rendered.container.firstElementChild
|
||||
|
||||
if (!(root instanceof HTMLElement)) {
|
||||
throw new Error('PaneShell did not render a root element')
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
function getColumnTemplate(container: HTMLElement): string[] {
|
||||
return (container.style.gridTemplateColumns ?? '').split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
function mockWidth(element: HTMLElement, width: number) {
|
||||
Object.defineProperty(element, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: width,
|
||||
top: 0,
|
||||
width,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('PaneShell composition', () => {
|
||||
beforeEach(() => {
|
||||
$paneStates.set({})
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$paneStates.set({})
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('builds a 2-column grid for one left pane + main', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const tracks = getColumnTemplate(gridContainer(rendered))
|
||||
|
||||
expect(tracks).toEqual(['240px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('orders panes left-to-right by side, preserving source order within a side', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<Pane id="sessions" side="left" width="200px">
|
||||
sessions
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
<Pane id="preview" side="right" width="320px">
|
||||
preview
|
||||
</Pane>
|
||||
<Pane id="inspector" side="right" width="280px">
|
||||
inspector
|
||||
</Pane>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const tracks = getColumnTemplate(gridContainer(rendered))
|
||||
|
||||
expect(tracks).toEqual(['240px', '200px', 'minmax(0,1fr)', '320px', '280px'])
|
||||
})
|
||||
|
||||
it('collapses a closed pane to 0px', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane defaultOpen={false} id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const tracks = getColumnTemplate(gridContainer(rendered))
|
||||
|
||||
expect(tracks).toEqual(['0px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('reads open state from the panes store', () => {
|
||||
setPaneOpen('files', false)
|
||||
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('disabled forces the track to 0px even when the store says open', () => {
|
||||
setPaneOpen('files', true)
|
||||
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane disabled={true} id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('disabled does NOT mutate the store-persisted open state', () => {
|
||||
setPaneOpen('files', true)
|
||||
|
||||
render(
|
||||
<PaneShell>
|
||||
<Pane disabled={true} id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect($paneStates.get().files?.open).toBe(true)
|
||||
})
|
||||
|
||||
it('uses widthOverride from the store when set', () => {
|
||||
setPaneOpen('files', true)
|
||||
setPaneWidthOverride('files', 320)
|
||||
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" resizable side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['320px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('preserves CSS-string widths verbatim (clamp, var, etc.)', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="inspector" side="right" width="clamp(13.5rem,21vw,20rem)">
|
||||
inspector
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const template = gridContainer(rendered).style.gridTemplateColumns
|
||||
|
||||
expect(template).toContain('clamp(13.5rem,21vw,20rem)')
|
||||
})
|
||||
|
||||
it('coerces numeric widths to px', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width={224}>
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['224px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('emits per-pane width as a CSS variable', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const root = gridContainer(rendered)
|
||||
|
||||
expect(root.style.getPropertyValue('--pane-files-width').trim()).toBe('240px')
|
||||
})
|
||||
|
||||
it('places a Pane in the correct grid column via inline style', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
<span data-testid="files-content">files</span>
|
||||
</Pane>
|
||||
<PaneMain>
|
||||
<span data-testid="main-content">main</span>
|
||||
</PaneMain>
|
||||
<Pane id="preview" side="right" width="320px">
|
||||
<span data-testid="preview-content">preview</span>
|
||||
</Pane>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const filesCell = rendered.getByTestId('files-content').parentElement!
|
||||
const mainCell = rendered.getByTestId('main-content').parentElement!
|
||||
const previewCell = rendered.getByTestId('preview-content').parentElement!
|
||||
|
||||
expect(filesCell.style.gridColumn).toBe('1 / 2')
|
||||
expect(mainCell.style.gridColumn).toBe('2 / 3')
|
||||
expect(previewCell.style.gridColumn).toBe('3 / 4')
|
||||
})
|
||||
|
||||
it('marks closed panes aria-hidden', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane defaultOpen={false} id="files" side="left" width="240px">
|
||||
<span data-testid="files-content">files</span>
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const cell = rendered.getByTestId('files-content').parentElement!
|
||||
|
||||
expect(cell.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(cell.getAttribute('data-pane-open')).toBe('false')
|
||||
})
|
||||
|
||||
it('passes through arbitrary non-Pane children for self-placement', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
<div data-testid="floating-overlay" style={{ position: 'absolute' }}>
|
||||
overlay
|
||||
</div>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(rendered.getByTestId('floating-overlay')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows a resize handle only when resizable', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<Pane id="preview" resizable side="right" width="320px">
|
||||
preview
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(rendered.queryByLabelText('Resize files')).toBeNull()
|
||||
expect(rendered.getByLabelText('Resize preview')).toBeDefined()
|
||||
})
|
||||
|
||||
it('dragging a left-pane separator stores a wider width override', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" maxWidth={360} minWidth={200} resizable side="left" width="240px">
|
||||
<span data-testid="files-content">files</span>
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const paneCell = rendered.getByTestId('files-content').parentElement
|
||||
|
||||
if (!(paneCell instanceof HTMLElement)) {
|
||||
throw new Error('Expected pane cell element')
|
||||
}
|
||||
|
||||
mockWidth(paneCell, 240)
|
||||
const separator = rendered.getByLabelText('Resize files')
|
||||
|
||||
fireEvent.pointerDown(separator, { clientX: 240, pointerId: 1 })
|
||||
fireEvent.pointerMove(window, { clientX: 300 })
|
||||
fireEvent.pointerUp(window, { clientX: 300 })
|
||||
|
||||
expect($paneStates.get().files?.widthOverride).toBe(300)
|
||||
})
|
||||
|
||||
it('dragging a right-pane separator clamps to max width', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<PaneMain>main</PaneMain>
|
||||
<Pane id="preview" maxWidth={340} minWidth={220} resizable side="right" width="320px">
|
||||
<span data-testid="preview-content">preview</span>
|
||||
</Pane>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const paneCell = rendered.getByTestId('preview-content').parentElement
|
||||
|
||||
if (!(paneCell instanceof HTMLElement)) {
|
||||
throw new Error('Expected pane cell element')
|
||||
}
|
||||
|
||||
mockWidth(paneCell, 320)
|
||||
const separator = rendered.getByLabelText('Resize preview')
|
||||
|
||||
fireEvent.pointerDown(separator, { clientX: 900, pointerId: 1 })
|
||||
fireEvent.pointerMove(window, { clientX: 760 })
|
||||
fireEvent.pointerUp(window, { clientX: 760 })
|
||||
|
||||
expect($paneStates.get().preview?.widthOverride).toBe(340)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,598 +0,0 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import {
|
||||
Children,
|
||||
type CSSProperties,
|
||||
isValidElement,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $paneStates, ensurePaneRegistered, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes'
|
||||
|
||||
import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context'
|
||||
|
||||
type PaneSide = 'left' | 'right'
|
||||
type WidthValue = string | number
|
||||
|
||||
interface PaneRoleMarker {
|
||||
__paneShellRole?: 'pane' | 'main'
|
||||
}
|
||||
|
||||
export interface PaneProps {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
defaultOpen?: boolean
|
||||
/** Paints a persistent hairline on the resize edge (not just the hover sash) so the pane boundary is always visible. */
|
||||
divider?: boolean
|
||||
/** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */
|
||||
disabled?: boolean
|
||||
/** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */
|
||||
forceCollapsed?: boolean
|
||||
/** When collapsed, float the contents over the main column on hover/focus instead of hiding them (track stays 0px). */
|
||||
hoverReveal?: boolean
|
||||
/**
|
||||
* Lay the pane out as a horizontal row beneath its rail (spanning every column on
|
||||
* its `side`) instead of as a vertical column. The pane then resizes on the Y axis.
|
||||
* Used to drop the terminal under a crowded rail rather than squeezing another column in.
|
||||
*/
|
||||
bottomRow?: boolean
|
||||
/** Default height of a `bottomRow` pane. */
|
||||
height?: WidthValue
|
||||
/** Min/max height clamps for a `bottomRow` pane's vertical resize. */
|
||||
maxHeight?: WidthValue
|
||||
minHeight?: WidthValue
|
||||
/** Width of the collapsed-overlay panel. Defaults to the docked width (or its resize override); set this to render a narrower overlay than the docked pane (e.g. min width on mobile). */
|
||||
overlayWidth?: WidthValue
|
||||
/** Called with true while the pane is a collapsed hover-reveal overlay, so the consumer can keep contents mounted (ready to slide). */
|
||||
onOverlayActiveChange?: (overlayActive: boolean) => void
|
||||
id: string
|
||||
maxWidth?: WidthValue
|
||||
minWidth?: WidthValue
|
||||
resizable?: boolean
|
||||
side: PaneSide
|
||||
width?: WidthValue
|
||||
}
|
||||
|
||||
export interface PaneMainProps {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export interface PaneShellProps {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
interface CollectedPane {
|
||||
bottomRow: boolean
|
||||
defaultOpen: boolean
|
||||
disabled: boolean
|
||||
forceCollapsed: boolean
|
||||
height: string
|
||||
id: string
|
||||
resizable: boolean
|
||||
side: PaneSide
|
||||
width: string
|
||||
}
|
||||
|
||||
const DEFAULT_WIDTH = '16rem'
|
||||
const DEFAULT_HEIGHT = '18rem'
|
||||
const DEFAULT_RESIZE_MIN_WIDTH = 160
|
||||
const DEFAULT_RESIZE_MIN_HEIGHT = 120
|
||||
|
||||
// Resize-sash geometry per axis: `x` is a vertical bar on the inner edge of a
|
||||
// column; `y` is a horizontal bar on the top edge of a bottom row.
|
||||
const SASH = {
|
||||
x: {
|
||||
orientation: 'vertical',
|
||||
bar: 'bottom-0 top-0 w-1 cursor-col-resize',
|
||||
line: 'inset-y-0 left-1/2 w-px -translate-x-1/2',
|
||||
hover: 'inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2'
|
||||
},
|
||||
y: {
|
||||
orientation: 'horizontal',
|
||||
bar: 'inset-x-0 top-0 h-1 -translate-y-1/2 cursor-row-resize',
|
||||
line: 'inset-x-0 top-1/2 h-px -translate-y-1/2',
|
||||
hover: 'inset-x-0 top-1/2 h-(--vscode-sash-hover-size,0.25rem) -translate-y-1/2'
|
||||
}
|
||||
} as const
|
||||
|
||||
// Hover-reveal slide. The enter delay is a pure-CSS hover-intent gate: a fast
|
||||
// pass-by doesn't dwell on the trigger long enough for the delay to elapse.
|
||||
const HOVER_REVEAL_SLIDE_MS = 220
|
||||
const HOVER_REVEAL_ENTER_DELAY_MS = 130
|
||||
const HOVER_REVEAL_EASE = 'cubic-bezier(0.32,0.72,0,1)'
|
||||
// Offset shadow lifting the revealed panel off the content (same both sides;
|
||||
// the mirror axis is offset-x, which is 0). Same color on light + dark.
|
||||
const HOVER_REVEAL_SHADOW = '0px -18px 18px -5px #00000012'
|
||||
// Edge trigger strip, inset past the OS window-resize grab area AND the
|
||||
// adjacent pane's scrollbar (0.5rem, .scrollbar-dt) — the strip overlays the
|
||||
// neighboring scroller's edge, so any overlap makes the scrollbar reveal the
|
||||
// pane on hover and swallow its clicks (#44140).
|
||||
const HOVER_REVEAL_TRIGGER_WIDTH = 14
|
||||
const HOVER_REVEAL_EDGE_GUTTER = 'calc(0.5rem + 2px)'
|
||||
|
||||
// Fired (window CustomEvent<{ id }>) to toggle a force-collapsed pane's reveal
|
||||
// from the keyboard, since its store-open toggle is a no-op while collapsed.
|
||||
export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal'
|
||||
|
||||
const widthToCss = (value: WidthValue | undefined, fallback: string) =>
|
||||
value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value
|
||||
|
||||
const remPx = () =>
|
||||
typeof window === 'undefined'
|
||||
? 16
|
||||
: Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16
|
||||
|
||||
const viewportPx = () => (typeof window === 'undefined' ? 1280 : window.innerWidth)
|
||||
const viewportHeightPx = () => (typeof window === 'undefined' ? 800 : window.innerHeight)
|
||||
|
||||
// Resolves PaneProps min/max (number | "Npx" | "Nrem" | "Nvw" | "Nvh" | "N%") to
|
||||
// pixels for drag clamping. vw/% resolve against window width, vh against height.
|
||||
function widthToPx(value: WidthValue | undefined) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem|vw|vh|%)?$/)
|
||||
|
||||
if (!match) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const n = Number.parseFloat(match[1])
|
||||
|
||||
switch (match[2]) {
|
||||
case 'rem':
|
||||
return n * remPx()
|
||||
|
||||
case 'vh':
|
||||
return (n * viewportHeightPx()) / 100
|
||||
|
||||
case 'vw':
|
||||
|
||||
case '%':
|
||||
return (n * viewportPx()) / 100
|
||||
|
||||
default:
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement {
|
||||
return isValidElement(child) && (child.type as PaneRoleMarker)?.__paneShellRole === role
|
||||
}
|
||||
|
||||
function collectPanes(children: ReactNode) {
|
||||
const left: CollectedPane[] = []
|
||||
const right: CollectedPane[] = []
|
||||
let mainCount = 0
|
||||
|
||||
Children.forEach(children, child => {
|
||||
if (isRole(child, 'main')) {
|
||||
mainCount++
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!isRole(child, 'pane')) {
|
||||
return
|
||||
}
|
||||
|
||||
const props = child.props as PaneProps
|
||||
|
||||
const entry: CollectedPane = {
|
||||
bottomRow: props.bottomRow ?? false,
|
||||
defaultOpen: props.defaultOpen ?? true,
|
||||
disabled: props.disabled ?? false,
|
||||
forceCollapsed: props.forceCollapsed ?? false,
|
||||
height: widthToCss(props.height, DEFAULT_HEIGHT),
|
||||
id: props.id,
|
||||
resizable: props.resizable ?? false,
|
||||
side: props.side,
|
||||
width: widthToCss(props.width, DEFAULT_WIDTH)
|
||||
}
|
||||
|
||||
;(props.side === 'left' ? left : right).push(entry)
|
||||
})
|
||||
|
||||
return { left, mainCount, right }
|
||||
}
|
||||
|
||||
type PaneStoreState = Record<string, { open: boolean; widthOverride?: number; heightOverride?: number }>
|
||||
|
||||
function paneIsOpen(pane: CollectedPane, states: PaneStoreState) {
|
||||
const stateOpen = states[pane.id]?.open ?? pane.defaultOpen
|
||||
|
||||
return !pane.disabled && !pane.forceCollapsed && stateOpen
|
||||
}
|
||||
|
||||
function trackForPane(pane: CollectedPane, states: PaneStoreState) {
|
||||
const open = paneIsOpen(pane, states)
|
||||
|
||||
if (!open) {
|
||||
return { open: false, track: '0px' }
|
||||
}
|
||||
|
||||
const override = pane.resizable ? states[pane.id]?.widthOverride : undefined
|
||||
|
||||
return { open: true, track: override !== undefined ? `${override}px` : pane.width }
|
||||
}
|
||||
|
||||
function heightTrackForPane(pane: CollectedPane, states: PaneStoreState) {
|
||||
const override = pane.resizable ? states[pane.id]?.heightOverride : undefined
|
||||
|
||||
return override !== undefined ? `${override}px` : pane.height
|
||||
}
|
||||
|
||||
export function PaneShell({ children, className, style }: PaneShellProps) {
|
||||
const paneStates = useStore($paneStates)
|
||||
const { left, mainCount, right } = useMemo(() => collectPanes(children), [children])
|
||||
|
||||
if (import.meta.env.DEV && mainCount > 1) {
|
||||
console.warn('[PaneShell] expected at most one <PaneMain>, got', mainCount)
|
||||
}
|
||||
|
||||
const ctxValue = useMemo(() => {
|
||||
const paneById = new Map<string, PaneSlot>()
|
||||
const tracks: string[] = []
|
||||
const cssVars: Record<string, string> = {}
|
||||
let column = 1
|
||||
|
||||
// A bottom-row pane drops out of its rail's column flow and instead spans
|
||||
// every column on its side as a new row below them. The first open one wins
|
||||
// and decides which rail gets split into two rows.
|
||||
const leftCols = left.filter(pane => !pane.bottomRow)
|
||||
const rightCols = right.filter(pane => !pane.bottomRow)
|
||||
const bottomRowPanes = [...left, ...right].filter(pane => pane.bottomRow)
|
||||
const activeBottomRow = bottomRowPanes.find(pane => paneIsOpen(pane, paneStates)) ?? null
|
||||
const bottomRailSide = activeBottomRow?.side ?? null
|
||||
|
||||
// Open column panes on the bottom row's side shrink to the top row; everything
|
||||
// else (main, the other rail, closed / hover-reveal panes) stays full height.
|
||||
const addColumn = (pane: CollectedPane, paneSide: PaneSide) => {
|
||||
const { open, track } = trackForPane(pane, paneStates)
|
||||
tracks.push(track)
|
||||
cssVars[`--pane-${pane.id}-width`] = track
|
||||
const gridRow = open && paneSide === bottomRailSide ? '1 / 2' : '1 / -1'
|
||||
paneById.set(pane.id, {
|
||||
open,
|
||||
side: paneSide,
|
||||
gridColumn: `${column} / ${column + 1}`,
|
||||
gridRow,
|
||||
bottomRow: false
|
||||
})
|
||||
column++
|
||||
}
|
||||
|
||||
for (const pane of leftCols) {
|
||||
addColumn(pane, 'left')
|
||||
}
|
||||
|
||||
tracks.push('minmax(0,1fr)')
|
||||
const mainColumn = column++
|
||||
|
||||
for (const pane of rightCols) {
|
||||
addColumn(pane, 'right')
|
||||
}
|
||||
|
||||
// Place every bottom-row pane: span its rail's columns on the second row.
|
||||
for (const pane of bottomRowPanes) {
|
||||
const gridColumn = pane.side === 'left' ? `1 / ${mainColumn}` : `${mainColumn + 1} / -1`
|
||||
paneById.set(pane.id, {
|
||||
open: pane === activeBottomRow,
|
||||
side: pane.side,
|
||||
gridColumn,
|
||||
gridRow: '2 / 3',
|
||||
bottomRow: true
|
||||
})
|
||||
}
|
||||
|
||||
// Always emit explicit rows so `grid-row: 1 / -1` (full-height) resolves
|
||||
// against a known last line. With a bottom row active there are two tracks;
|
||||
// otherwise a single 1fr track behaves exactly like the old single-row grid.
|
||||
const gridTemplateRows = activeBottomRow
|
||||
? `minmax(0,1fr) ${heightTrackForPane(activeBottomRow, paneStates)}`
|
||||
: 'minmax(0,1fr)'
|
||||
|
||||
return {
|
||||
cssVars,
|
||||
gridTemplate: tracks.join(' '),
|
||||
gridTemplateRows,
|
||||
mainColumn,
|
||||
paneById
|
||||
} satisfies PaneShellContextValue & {
|
||||
cssVars: Record<string, string>
|
||||
gridTemplate: string
|
||||
gridTemplateRows: string
|
||||
}
|
||||
}, [left, paneStates, right])
|
||||
|
||||
const composedStyle = useMemo<CSSProperties>(
|
||||
() => ({
|
||||
...ctxValue.cssVars,
|
||||
...style,
|
||||
gridTemplateColumns: ctxValue.gridTemplate,
|
||||
gridTemplateRows: ctxValue.gridTemplateRows
|
||||
}),
|
||||
[ctxValue.cssVars, ctxValue.gridTemplate, ctxValue.gridTemplateRows, style]
|
||||
)
|
||||
|
||||
return (
|
||||
<PaneShellContext.Provider value={{ mainColumn: ctxValue.mainColumn, paneById: ctxValue.paneById }}>
|
||||
<div className={cn('relative grid h-full min-h-0', className)} data-pane-shell="" style={composedStyle}>
|
||||
{children}
|
||||
</div>
|
||||
</PaneShellContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Pane({
|
||||
children,
|
||||
className,
|
||||
defaultOpen = true,
|
||||
divider = false,
|
||||
disabled = false,
|
||||
hoverReveal = false,
|
||||
maxHeight,
|
||||
minHeight,
|
||||
overlayWidth: overlayWidthProp,
|
||||
id,
|
||||
maxWidth,
|
||||
minWidth,
|
||||
onOverlayActiveChange,
|
||||
resizable = false,
|
||||
width
|
||||
}: PaneProps) {
|
||||
const ctx = useContext(PaneShellContext)
|
||||
const paneStates = useStore($paneStates)
|
||||
const registered = useRef(false)
|
||||
const paneRef = useRef<HTMLDivElement | null>(null)
|
||||
// Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS.
|
||||
const [forced, setForced] = useState(false)
|
||||
|
||||
const slot = ctx?.paneById.get(id)
|
||||
const open = Boolean(slot?.open && !disabled)
|
||||
const side = slot?.side ?? 'left'
|
||||
// Collapsed + hoverReveal: float the pane contents over the main column on
|
||||
// hover/focus instead of hiding them. Honors any persisted resize width.
|
||||
const overlayActive = !open && hoverReveal && !disabled
|
||||
const override = resizable ? paneStates[id]?.widthOverride : undefined
|
||||
|
||||
// Overlay width: an explicit `overlayWidth` (e.g. min width on mobile) wins,
|
||||
// else the persisted resize override, else the docked width.
|
||||
const overlayWidth =
|
||||
overlayWidthProp !== undefined
|
||||
? widthToCss(overlayWidthProp, DEFAULT_WIDTH)
|
||||
: override !== undefined
|
||||
? `${override}px`
|
||||
: widthToCss(width, DEFAULT_WIDTH)
|
||||
|
||||
useEffect(() => {
|
||||
if (registered.current) {
|
||||
return
|
||||
}
|
||||
|
||||
registered.current = true
|
||||
ensurePaneRegistered(id, { open: defaultOpen })
|
||||
}, [defaultOpen, id])
|
||||
|
||||
// Keyboard toggle pins/unpins the reveal while collapsed; clear when no longer
|
||||
// a collapsed overlay (reopened / widened).
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !overlayActive) {
|
||||
setForced(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const onToggle = (e: Event) => {
|
||||
if ((e as CustomEvent<{ id: string }>).detail?.id === id) {
|
||||
setForced(v => !v)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
|
||||
|
||||
return () => window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
|
||||
}, [id, overlayActive])
|
||||
|
||||
// Keep contents mounted while collapsed so reveal is a pure CSS transform.
|
||||
useEffect(() => {
|
||||
onOverlayActiveChange?.(overlayActive)
|
||||
}, [onOverlayActiveChange, overlayActive])
|
||||
|
||||
const isBottomRow = Boolean(slot?.bottomRow)
|
||||
const axis = isBottomRow ? 'y' : 'x'
|
||||
const sash = SASH[axis]
|
||||
const canResize = open && resizable
|
||||
const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH
|
||||
const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY
|
||||
const loH = widthToPx(minHeight) ?? DEFAULT_RESIZE_MIN_HEIGHT
|
||||
const hiH = widthToPx(maxHeight) ?? Number.POSITIVE_INFINITY
|
||||
|
||||
// One pointer-drag for both axes. Columns grow toward the main column (left
|
||||
// rail → right, right rail → left); the bottom row grows up from its top edge.
|
||||
const startResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>, axis: 'x' | 'y') => {
|
||||
const rect = paneRef.current?.getBoundingClientRect()
|
||||
const base = (axis === 'x' ? rect?.width : rect?.height) ?? 0
|
||||
|
||||
if (!canResize || base <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const handle = event.currentTarget
|
||||
const { pointerId } = event
|
||||
const start = axis === 'x' ? event.clientX : event.clientY
|
||||
const dir = axis === 'x' ? (side === 'left' ? 1 : -1) : -1
|
||||
const [min, max] = axis === 'x' ? [lo, hi] : [loH, hiH]
|
||||
const apply = axis === 'x' ? setPaneWidthOverride : setPaneHeightOverride
|
||||
const restoreCursor = document.body.style.cursor
|
||||
const restoreSelect = document.body.style.userSelect
|
||||
|
||||
handle.setPointerCapture?.(pointerId)
|
||||
document.body.style.cursor = axis === 'x' ? 'col-resize' : 'row-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const next = base + ((axis === 'x' ? e.clientX : e.clientY) - start) * dir
|
||||
apply(id, Math.round(Math.min(max, Math.max(min, next))))
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
document.body.style.cursor = restoreCursor
|
||||
document.body.style.userSelect = restoreSelect
|
||||
handle.releasePointerCapture?.(pointerId)
|
||||
window.removeEventListener('pointermove', onMove, true)
|
||||
window.removeEventListener('pointerup', cleanup, true)
|
||||
window.removeEventListener('pointercancel', cleanup, true)
|
||||
window.removeEventListener('blur', cleanup)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove, true)
|
||||
window.addEventListener('pointerup', cleanup, true)
|
||||
window.addEventListener('pointercancel', cleanup, true)
|
||||
window.addEventListener('blur', cleanup)
|
||||
},
|
||||
[canResize, hi, hiH, id, lo, loH, side]
|
||||
)
|
||||
|
||||
if (!ctx) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(`[Pane:${id}] must be rendered inside <PaneShell>`)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (!slot) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Collapsed hover-reveal track: a 0px, pointer-transparent grid cell holding a
|
||||
// thin edge trigger + the floating panel (both absolute, escaping the zero
|
||||
// box). group-hover (or data-forced from the keyboard) drives the slide; the
|
||||
// enter-delay is the hover-intent gate. No JS pointer math.
|
||||
if (overlayActive) {
|
||||
const edge = side === 'left' ? 'left' : 'right'
|
||||
const offscreen = side === 'left' ? '-translate-x-[calc(100%+1rem)]' : 'translate-x-[calc(100%+1rem)]'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('group/reveal pointer-events-none relative min-w-0', className)}
|
||||
data-forced={forced ? '' : undefined}
|
||||
data-pane-hover-reveal={forced ? 'open' : 'closed'}
|
||||
data-pane-id={id}
|
||||
data-pane-open="false"
|
||||
data-pane-side={side}
|
||||
ref={paneRef}
|
||||
style={{ gridColumn: slot.gridColumn, gridRow: slot.gridRow }}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-auto absolute inset-y-0 z-30 [-webkit-app-region:no-drag]"
|
||||
data-pane-reveal-trigger=""
|
||||
style={{ [edge]: HOVER_REVEAL_EDGE_GUTTER, width: HOVER_REVEAL_TRIGGER_WIDTH }}
|
||||
/>
|
||||
|
||||
{/* Keyed on side so flipping panes remounts off-screen on the new edge
|
||||
instead of transitioning the transform across the viewport. */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-y-0 z-30 overflow-hidden transition-transform delay-0',
|
||||
offscreen,
|
||||
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
|
||||
'group-data-[forced]/reveal:pointer-events-auto group-data-[forced]/reveal:translate-x-0 group-data-[forced]/reveal:delay-0 group-data-[forced]/reveal:shadow-[var(--reveal-shadow)]'
|
||||
)}
|
||||
key={edge}
|
||||
style={
|
||||
{
|
||||
[edge]: 0,
|
||||
width: overlayWidth,
|
||||
'--reveal-shadow': HOVER_REVEAL_SHADOW,
|
||||
transitionDuration: `${HOVER_REVEAL_SLIDE_MS}ms`,
|
||||
transitionTimingFunction: HOVER_REVEAL_EASE,
|
||||
'--reveal-enter-delay': `${HOVER_REVEAL_ENTER_DELAY_MS}ms`
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!open}
|
||||
className={cn('relative min-h-0 min-w-0 overflow-hidden', !open && 'pointer-events-none', className)}
|
||||
data-pane-id={id}
|
||||
data-pane-open={open ? 'true' : 'false'}
|
||||
data-pane-side={slot.side}
|
||||
ref={paneRef}
|
||||
style={{ gridColumn: slot.gridColumn, gridRow: slot.gridRow }}
|
||||
>
|
||||
{canResize && (
|
||||
<div
|
||||
aria-label={`Resize ${id}`}
|
||||
aria-orientation={sash.orientation}
|
||||
className={cn(
|
||||
'group absolute z-20 [-webkit-app-region:no-drag]',
|
||||
sash.bar,
|
||||
!isBottomRow && (slot.side === 'left' ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2')
|
||||
)}
|
||||
onPointerDown={e => startResize(e, axis)}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
>
|
||||
{divider && <span className={cn('absolute bg-(--ui-stroke-secondary)', sash.line)} />}
|
||||
<span
|
||||
className={cn(
|
||||
'absolute bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100',
|
||||
sash.hover
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
;(Pane as unknown as PaneRoleMarker).__paneShellRole = 'pane'
|
||||
|
||||
export function PaneMain({ children, className }: PaneMainProps) {
|
||||
const ctx = useContext(PaneShellContext)
|
||||
|
||||
if (!ctx) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[PaneMain] must be rendered inside <PaneShell>')
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex min-h-0 min-w-0 flex-col overflow-hidden', className)}
|
||||
data-pane-main="true"
|
||||
style={{ gridColumn: `${ctx.mainColumn} / ${ctx.mainColumn + 1}`, gridRow: '1 / -1' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
;(PaneMain as unknown as PaneRoleMarker).__paneShellRole = 'main'
|
||||
|
|
@ -0,0 +1,621 @@
|
|||
/**
|
||||
* FancyZones grid model — a faithful port of PowerToys'
|
||||
* `FancyZonesEditor/Models/GridLayoutModel.cs` + `FancyZonesEditor/GridData.cs`
|
||||
* (microsoft/PowerToys, MIT). Function/field names, algorithms, and invariants
|
||||
* follow the C# sources so behavior matches the original editor:
|
||||
*
|
||||
* - A layout is rows x columns with percent tracks summing to MULTIPLIER
|
||||
* (10000) and a cellChildMap assigning each cell to a zone; a zone spanning
|
||||
* multiple cells appears as the same index in adjacent cells.
|
||||
* - Zones are rectangles in the 0..10000 coordinate space (prefix sums).
|
||||
* - Resizers are the shared edges between zones, derived from cellChildMap
|
||||
* discontinuities; dragging one moves every zone touching that edge.
|
||||
* - Merging computes the rectangular CLOSURE of the selection (extending it
|
||||
* until no zone is partially cut) — the signature FancyZones merge feel.
|
||||
*/
|
||||
|
||||
// The sum of row/column percents should be equal to this number.
|
||||
export const MULTIPLIER = 10000
|
||||
|
||||
/** Minimum zone extent in model units (editor ergonomics; C# uses 1). */
|
||||
export const MIN_ZONE_SIZE = 500
|
||||
|
||||
export interface GridLayout {
|
||||
rows: number
|
||||
columns: number
|
||||
rowPercents: number[]
|
||||
columnPercents: number[]
|
||||
/** cellChildMap[row][col] = zone index; spans = same index in adjacent cells. */
|
||||
cellChildMap: number[][]
|
||||
}
|
||||
|
||||
export interface GridZone {
|
||||
index: number
|
||||
left: number
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
}
|
||||
|
||||
export interface GridResizer {
|
||||
orientation: 'horizontal' | 'vertical'
|
||||
/** All zones to the left/up, in order. */
|
||||
negativeSideIndices: number[]
|
||||
/** All zones to the right/down, in order. */
|
||||
positiveSideIndices: number[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GridData helpers (verbatim)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** result[k] is the sum of the first k elements of the given list. */
|
||||
export function prefixSum(list: number[]): number[] {
|
||||
const result: number[] = [0]
|
||||
let sum = 0
|
||||
|
||||
for (const value of list) {
|
||||
sum += value
|
||||
result.push(sum)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** Opposite of prefixSum: differences of consecutive elements. */
|
||||
function adjacentDifference(list: number[]): number[] {
|
||||
if (list.length <= 1) {
|
||||
return []
|
||||
}
|
||||
|
||||
const result: number[] = []
|
||||
|
||||
for (let i = 0; i < list.length - 1; i++) {
|
||||
result.push(list[i + 1] - list[i])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** Contiguous-segment unique (order-preserving), as in GridData.Unique. */
|
||||
function unique(list: number[]): number[] {
|
||||
const result: number[] = []
|
||||
|
||||
if (list.length === 0) {
|
||||
return result
|
||||
}
|
||||
|
||||
let last = list[0]
|
||||
result.push(last)
|
||||
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
if (list[i] !== last) {
|
||||
last = list[i]
|
||||
result.push(last)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model -> zones / resizers (GridData.ModelToZones / ModelToResizers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function modelToZones(model: GridLayout): GridZone[] | null {
|
||||
const { rows, columns: cols, cellChildMap } = model
|
||||
|
||||
let zoneCount = 0
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
zoneCount = Math.max(zoneCount, cellChildMap[row][col])
|
||||
}
|
||||
}
|
||||
|
||||
zoneCount++
|
||||
|
||||
if (zoneCount > rows * cols) {
|
||||
return null
|
||||
}
|
||||
|
||||
const indexCount = new Array<number>(zoneCount).fill(0)
|
||||
const indexRowLow = new Array<number>(zoneCount).fill(Number.MAX_SAFE_INTEGER)
|
||||
const indexRowHigh = new Array<number>(zoneCount).fill(0)
|
||||
const indexColLow = new Array<number>(zoneCount).fill(Number.MAX_SAFE_INTEGER)
|
||||
const indexColHigh = new Array<number>(zoneCount).fill(0)
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const index = cellChildMap[row][col]
|
||||
indexCount[index]++
|
||||
indexRowLow[index] = Math.min(indexRowLow[index], row)
|
||||
indexColLow[index] = Math.min(indexColLow[index], col)
|
||||
indexRowHigh[index] = Math.max(indexRowHigh[index], row)
|
||||
indexColHigh[index] = Math.max(indexColHigh[index], col)
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < zoneCount; index++) {
|
||||
if (indexCount[index] === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Each zone must occupy a full rectangle of cells.
|
||||
if (indexCount[index] !== (indexRowHigh[index] - indexRowLow[index] + 1) * (indexColHigh[index] - indexColLow[index] + 1)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
model.rowPercents.length !== rows ||
|
||||
model.columnPercents.length !== cols ||
|
||||
model.rowPercents.some(x => x < 1) ||
|
||||
model.columnPercents.some(x => x < 1)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rowPrefixSum = prefixSum(model.rowPercents)
|
||||
const colPrefixSum = prefixSum(model.columnPercents)
|
||||
|
||||
if (rowPrefixSum[rows] !== MULTIPLIER || colPrefixSum[cols] !== MULTIPLIER) {
|
||||
return null
|
||||
}
|
||||
|
||||
const zones: GridZone[] = []
|
||||
|
||||
for (let index = 0; index < zoneCount; index++) {
|
||||
zones.push({
|
||||
index,
|
||||
left: colPrefixSum[indexColLow[index]],
|
||||
right: colPrefixSum[indexColHigh[index] + 1],
|
||||
top: rowPrefixSum[indexRowLow[index]],
|
||||
bottom: rowPrefixSum[indexRowHigh[index] + 1]
|
||||
})
|
||||
}
|
||||
|
||||
return zones
|
||||
}
|
||||
|
||||
export function modelToResizers(model: GridLayout): GridResizer[] {
|
||||
const grid = model.cellChildMap
|
||||
const { rows, columns: cols } = model
|
||||
const resizers: GridResizer[] = []
|
||||
|
||||
// Horizontal
|
||||
for (let row = 1; row < rows; row++) {
|
||||
for (let startCol = 0; startCol < cols; ) {
|
||||
if (grid[row - 1][startCol] !== grid[row][startCol]) {
|
||||
let endCol = startCol
|
||||
|
||||
while (endCol + 1 < cols && grid[row - 1][endCol + 1] !== grid[row][endCol + 1]) {
|
||||
endCol++
|
||||
}
|
||||
|
||||
const positive: number[] = []
|
||||
const negative: number[] = []
|
||||
|
||||
for (let col = startCol; col <= endCol; col++) {
|
||||
negative.push(grid[row - 1][col])
|
||||
positive.push(grid[row][col])
|
||||
}
|
||||
|
||||
resizers.push({
|
||||
orientation: 'horizontal',
|
||||
positiveSideIndices: unique(positive),
|
||||
negativeSideIndices: unique(negative)
|
||||
})
|
||||
|
||||
startCol = endCol + 1
|
||||
} else {
|
||||
startCol++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical
|
||||
for (let col = 1; col < cols; col++) {
|
||||
for (let startRow = 0; startRow < rows; ) {
|
||||
if (grid[startRow][col - 1] !== grid[startRow][col]) {
|
||||
let endRow = startRow
|
||||
|
||||
while (endRow + 1 < rows && grid[endRow + 1][col - 1] !== grid[endRow + 1][col]) {
|
||||
endRow++
|
||||
}
|
||||
|
||||
const positive: number[] = []
|
||||
const negative: number[] = []
|
||||
|
||||
for (let row = startRow; row <= endRow; row++) {
|
||||
negative.push(grid[row][col - 1])
|
||||
positive.push(grid[row][col])
|
||||
}
|
||||
|
||||
resizers.push({
|
||||
orientation: 'vertical',
|
||||
positiveSideIndices: unique(positive),
|
||||
negativeSideIndices: unique(negative)
|
||||
})
|
||||
|
||||
startRow = endRow + 1
|
||||
} else {
|
||||
startRow++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resizers
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zones -> model (GridData.ZonesToModel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function zonesToModel(zones: GridZone[]): GridLayout {
|
||||
const xCoords = [...new Set(zones.flatMap(z => [z.left, z.right]))].sort((a, b) => a - b)
|
||||
const yCoords = [...new Set(zones.flatMap(z => [z.top, z.bottom]))].sort((a, b) => a - b)
|
||||
|
||||
const model: GridLayout = {
|
||||
rows: yCoords.length - 1,
|
||||
columns: xCoords.length - 1,
|
||||
rowPercents: adjacentDifference(yCoords),
|
||||
columnPercents: adjacentDifference(xCoords),
|
||||
cellChildMap: Array.from({ length: yCoords.length - 1 }, () => new Array<number>(xCoords.length - 1).fill(0))
|
||||
}
|
||||
|
||||
for (let index = 0; index < zones.length; index++) {
|
||||
const zone = zones[index]
|
||||
const startRow = yCoords.indexOf(zone.top)
|
||||
const endRow = yCoords.indexOf(zone.bottom)
|
||||
const startCol = xCoords.indexOf(zone.left)
|
||||
const endCol = xCoords.indexOf(zone.right)
|
||||
|
||||
for (let row = startRow; row < endRow; row++) {
|
||||
for (let col = startCol; col < endCol; col++) {
|
||||
model.cellChildMap[row][col] = index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Closure + merge (GridData.ComputeClosure / DoMerge)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function computeClosure(zones: GridZone[], indices: number[]): { indices: number[]; zone: GridZone } {
|
||||
let left = Number.MAX_SAFE_INTEGER
|
||||
let right = Number.MIN_SAFE_INTEGER
|
||||
let top = Number.MAX_SAFE_INTEGER
|
||||
let bottom = Number.MIN_SAFE_INTEGER
|
||||
|
||||
if (indices.length === 0) {
|
||||
return { indices: [], zone: { index: -1, left, right, top, bottom } }
|
||||
}
|
||||
|
||||
const extend = (zone: GridZone) => {
|
||||
left = Math.min(left, zone.left)
|
||||
right = Math.max(right, zone.right)
|
||||
top = Math.min(top, zone.top)
|
||||
bottom = Math.max(bottom, zone.bottom)
|
||||
}
|
||||
|
||||
for (const index of indices) {
|
||||
extend(zones[index])
|
||||
}
|
||||
|
||||
let possiblyBroken = true
|
||||
|
||||
while (possiblyBroken) {
|
||||
possiblyBroken = false
|
||||
|
||||
for (const zone of zones) {
|
||||
const area = (zone.bottom - zone.top) * (zone.right - zone.left)
|
||||
|
||||
const cutLeft = Math.max(left, zone.left)
|
||||
const cutRight = Math.min(right, zone.right)
|
||||
const cutTop = Math.max(top, zone.top)
|
||||
const cutBottom = Math.min(bottom, zone.bottom)
|
||||
|
||||
const newArea = Math.max(0, cutBottom - cutTop) * Math.max(0, cutRight - cutLeft)
|
||||
|
||||
if (newArea !== 0 && newArea !== area) {
|
||||
// Bad intersection found, extend.
|
||||
extend(zone)
|
||||
possiblyBroken = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resultIndices = zones
|
||||
.filter(zone => left <= zone.left && zone.right <= right && top <= zone.top && zone.bottom <= bottom)
|
||||
.map(zone => zone.index)
|
||||
|
||||
return { indices: resultIndices, zone: { index: -1, left, right, top, bottom } }
|
||||
}
|
||||
|
||||
export function mergeClosureIndices(model: GridLayout, indices: number[]): number[] {
|
||||
const zones = modelToZones(model)
|
||||
|
||||
return zones ? computeClosure(zones, indices).indices : []
|
||||
}
|
||||
|
||||
export function doMerge(model: GridLayout, indices: number[]): GridLayout {
|
||||
if (indices.length === 0) {
|
||||
return model
|
||||
}
|
||||
|
||||
const zones = modelToZones(model)
|
||||
|
||||
if (!zones) {
|
||||
return model
|
||||
}
|
||||
|
||||
const lowestIndex = Math.min(...indices)
|
||||
const closure = computeClosure(zones, indices)
|
||||
const closureIndices = new Set(closure.indices)
|
||||
|
||||
const remaining = zones.filter(zone => !closureIndices.has(zone.index))
|
||||
remaining.splice(lowestIndex, 0, closure.zone)
|
||||
|
||||
return zonesToModel(remaining)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Split (GridData.CanSplit / Split)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function canSplit(
|
||||
model: GridLayout,
|
||||
zoneIndex: number,
|
||||
position: number,
|
||||
orientation: 'horizontal' | 'vertical'
|
||||
): boolean {
|
||||
const zones = modelToZones(model)
|
||||
|
||||
if (!zones || !zones[zoneIndex]) {
|
||||
return false
|
||||
}
|
||||
|
||||
const zone = zones[zoneIndex]
|
||||
|
||||
if (orientation === 'horizontal') {
|
||||
return zone.top + MIN_ZONE_SIZE <= position && position <= zone.bottom - MIN_ZONE_SIZE
|
||||
}
|
||||
|
||||
return zone.left + MIN_ZONE_SIZE <= position && position <= zone.right - MIN_ZONE_SIZE
|
||||
}
|
||||
|
||||
export function splitZone(
|
||||
model: GridLayout,
|
||||
zoneIndex: number,
|
||||
position: number,
|
||||
orientation: 'horizontal' | 'vertical'
|
||||
): GridLayout {
|
||||
if (!canSplit(model, zoneIndex, position, orientation)) {
|
||||
return model
|
||||
}
|
||||
|
||||
const zones = modelToZones(model)!
|
||||
const zone = zones[zoneIndex]
|
||||
const zone1 = { ...zone }
|
||||
const zone2 = { ...zone }
|
||||
|
||||
zones.splice(zoneIndex, 1)
|
||||
|
||||
if (orientation === 'horizontal') {
|
||||
zone1.bottom = position
|
||||
zone2.top = position
|
||||
} else {
|
||||
zone1.right = position
|
||||
zone2.left = position
|
||||
}
|
||||
|
||||
zones.splice(zoneIndex, 0, zone1)
|
||||
zones.splice(zoneIndex + 1, 0, zone2)
|
||||
|
||||
return zonesToModel(zones)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resizer drag (GridData.CanDrag / Drag)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function canDrag(model: GridLayout, resizerIndex: number, delta: number): boolean {
|
||||
const zones = modelToZones(model)
|
||||
const resizers = modelToResizers(model)
|
||||
const resizer = resizers[resizerIndex]
|
||||
|
||||
if (!zones || !resizer) {
|
||||
return false
|
||||
}
|
||||
|
||||
const getSize = (zoneIndex: number) => {
|
||||
const zone = zones[zoneIndex]
|
||||
|
||||
return resizer.orientation === 'vertical' ? zone.right - zone.left : zone.bottom - zone.top
|
||||
}
|
||||
|
||||
for (const zoneIndex of resizer.positiveSideIndices) {
|
||||
if (getSize(zoneIndex) - delta < MIN_ZONE_SIZE) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for (const zoneIndex of resizer.negativeSideIndices) {
|
||||
if (getSize(zoneIndex) + delta < MIN_ZONE_SIZE) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function dragResizer(model: GridLayout, resizerIndex: number, delta: number): GridLayout {
|
||||
if (!canDrag(model, resizerIndex, delta)) {
|
||||
return model
|
||||
}
|
||||
|
||||
const zones = modelToZones(model)!
|
||||
const resizer = modelToResizers(model)[resizerIndex]
|
||||
|
||||
for (const zoneIndex of resizer.positiveSideIndices) {
|
||||
const zone = zones[zoneIndex]
|
||||
|
||||
if (resizer.orientation === 'horizontal') {
|
||||
zone.top += delta
|
||||
} else {
|
||||
zone.left += delta
|
||||
}
|
||||
}
|
||||
|
||||
for (const zoneIndex of resizer.negativeSideIndices) {
|
||||
const zone = zones[zoneIndex]
|
||||
|
||||
if (resizer.orientation === 'horizontal') {
|
||||
zone.bottom += delta
|
||||
} else {
|
||||
zone.right += delta
|
||||
}
|
||||
}
|
||||
|
||||
return zonesToModel(zones)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Templates + validation (GridLayoutModel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Even track sizes that sum EXACTLY to MULTIPLIER (GridLayoutModel.InitRows note). */
|
||||
function evenPercents(count: number): number[] {
|
||||
const out: number[] = []
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
out.push(Math.floor((MULTIPLIER * (i + 1)) / count) - Math.floor((MULTIPLIER * i) / count))
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export function initColumns(count: number): GridLayout {
|
||||
return {
|
||||
rows: 1,
|
||||
columns: count,
|
||||
rowPercents: [MULTIPLIER],
|
||||
columnPercents: evenPercents(count),
|
||||
cellChildMap: [Array.from({ length: count }, (_, i) => i)]
|
||||
}
|
||||
}
|
||||
|
||||
export function initRows(count: number): GridLayout {
|
||||
return {
|
||||
rows: count,
|
||||
columns: 1,
|
||||
rowPercents: evenPercents(count),
|
||||
columnPercents: [MULTIPLIER],
|
||||
cellChildMap: Array.from({ length: count }, (_, i) => [i])
|
||||
}
|
||||
}
|
||||
|
||||
export function initGrid(zoneCount: number): GridLayout {
|
||||
let rows = 1
|
||||
|
||||
while (Math.floor(zoneCount / rows) >= rows) {
|
||||
rows++
|
||||
}
|
||||
|
||||
rows--
|
||||
let cols = Math.floor(zoneCount / rows)
|
||||
|
||||
if (zoneCount % rows !== 0) {
|
||||
cols++
|
||||
}
|
||||
|
||||
const model: GridLayout = {
|
||||
rows,
|
||||
columns: cols,
|
||||
rowPercents: evenPercents(rows),
|
||||
columnPercents: evenPercents(cols),
|
||||
cellChildMap: Array.from({ length: rows }, () => new Array<number>(cols).fill(0))
|
||||
}
|
||||
|
||||
let index = 0
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
model.cellChildMap[row][col] = index++
|
||||
|
||||
if (index === zoneCount) {
|
||||
index--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Priority Grid" template (GridLayoutModel._priorityData, decoded from
|
||||
* its byte format: percents are `hi * 256 + lo`). Falls back to initGrid for
|
||||
* counts beyond the table, as in the original.
|
||||
*/
|
||||
export function initPriorityGrid(zoneCount: number): GridLayout {
|
||||
if (zoneCount === 2) {
|
||||
return { rows: 1, columns: 2, rowPercents: [MULTIPLIER], columnPercents: [6667, 3333], cellChildMap: [[0, 1]] }
|
||||
}
|
||||
|
||||
if (zoneCount === 3) {
|
||||
return {
|
||||
rows: 1,
|
||||
columns: 3,
|
||||
rowPercents: [MULTIPLIER],
|
||||
columnPercents: [2500, 5000, 2500],
|
||||
cellChildMap: [[0, 1, 2]]
|
||||
}
|
||||
}
|
||||
|
||||
return initGrid(zoneCount)
|
||||
}
|
||||
|
||||
/** GridLayoutModel.IsModelValid, extended with the rectangular-span check. */
|
||||
export function isGridValid(model: unknown): model is GridLayout {
|
||||
if (!model || typeof model !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const m = model as GridLayout
|
||||
|
||||
if (typeof m.rows !== 'number' || typeof m.columns !== 'number' || m.rows <= 0 || m.columns <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(m.rowPercents) ||
|
||||
!Array.isArray(m.columnPercents) ||
|
||||
m.rowPercents.length !== m.rows ||
|
||||
m.columnPercents.length !== m.columns ||
|
||||
m.rowPercents.some(x => typeof x !== 'number' || x < 1) ||
|
||||
m.columnPercents.some(x => typeof x !== 'number' || x < 1)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(m.cellChildMap) ||
|
||||
m.cellChildMap.length !== m.rows ||
|
||||
m.cellChildMap.some(r => !Array.isArray(r) || r.length !== m.columns || r.some(c => typeof c !== 'number'))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const rowPrefix = prefixSum(m.rowPercents)
|
||||
const colPrefix = prefixSum(m.columnPercents)
|
||||
|
||||
if (rowPrefix[m.rows] !== MULTIPLIER || colPrefix[m.columns] !== MULTIPLIER) {
|
||||
return false
|
||||
}
|
||||
|
||||
return modelToZones(m) !== null
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
/**
|
||||
* Grid -> tree bridge. A FancyZones grid whose zones can be produced by
|
||||
* recursive guillotine cuts (every FancyZones template, and almost every
|
||||
* practical layout) converts exactly to our runtime `LayoutNode` tree:
|
||||
* full-length cut lines become splits with weights from the cut positions.
|
||||
* Non-guillotine arrangements (interlocking pinwheels) return null and the
|
||||
* editor disables Save with an explanation.
|
||||
*/
|
||||
|
||||
import type { GridLayout, GridZone } from './grid-model'
|
||||
import { modelToZones } from './grid-model'
|
||||
import { group, type LayoutNode, normalize, split } from './model'
|
||||
|
||||
function cutCandidates(zones: GridZone[], axis: 'x' | 'y'): number[] {
|
||||
const coords = new Set(zones.flatMap(z => (axis === 'x' ? [z.left, z.right] : [z.top, z.bottom])))
|
||||
|
||||
// Interior lines only.
|
||||
return [...coords].sort((a, b) => a - b).slice(1, -1)
|
||||
}
|
||||
|
||||
function isValidCut(zones: GridZone[], axis: 'x' | 'y', at: number): boolean {
|
||||
return zones.every(zone => (axis === 'x' ? zone.right <= at || zone.left >= at : zone.bottom <= at || zone.top >= at))
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively cut the zone set. Collects ALL valid cuts on the chosen axis at
|
||||
* once so "three columns" becomes one flat 3-child split, not nested pairs.
|
||||
*/
|
||||
function cut(zones: GridZone[], assignPane: (zoneIndex: number) => string[]): LayoutNode | null {
|
||||
if (zones.length === 1) {
|
||||
// Zones without panes become EMPTY groups — editor-authored drop targets
|
||||
// that live until the first structural op prunes them (normalize).
|
||||
return group(assignPane(zones[0].index))
|
||||
}
|
||||
|
||||
for (const axis of ['x', 'y'] as const) {
|
||||
const cuts = cutCandidates(zones, axis).filter(at => isValidCut(zones, axis, at))
|
||||
|
||||
if (cuts.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const start = Math.min(...zones.map(z => (axis === 'x' ? z.left : z.top)))
|
||||
const end = Math.max(...zones.map(z => (axis === 'x' ? z.right : z.bottom)))
|
||||
const lines = [start, ...cuts, end]
|
||||
const children: LayoutNode[] = []
|
||||
const weights: number[] = []
|
||||
|
||||
for (let i = 0; i < lines.length - 1; i++) {
|
||||
const lo = lines[i]
|
||||
const hi = lines[i + 1]
|
||||
|
||||
const slice = zones.filter(zone =>
|
||||
axis === 'x' ? zone.left >= lo && zone.right <= hi : zone.top >= lo && zone.bottom <= hi
|
||||
)
|
||||
|
||||
if (slice.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const child = cut(slice, assignPane)
|
||||
|
||||
if (child) {
|
||||
children.push(child)
|
||||
weights.push(hi - lo)
|
||||
}
|
||||
}
|
||||
|
||||
if (children.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (children.length === 1) {
|
||||
return children[0]
|
||||
}
|
||||
|
||||
return split(axis === 'x' ? 'row' : 'column', children, weights)
|
||||
}
|
||||
|
||||
// No full-length cut exists on either axis: non-guillotine (pinwheel).
|
||||
return null
|
||||
}
|
||||
|
||||
export type PanePlacementHint = 'main' | 'left' | 'right' | 'top' | 'bottom'
|
||||
|
||||
export interface PlacedPane {
|
||||
id: string
|
||||
placement?: PanePlacementHint
|
||||
}
|
||||
|
||||
const CENTER = 5000 // MULTIPLIER / 2
|
||||
|
||||
interface ZoneGeo {
|
||||
index: number
|
||||
area: number
|
||||
cx: number
|
||||
cy: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic zone assignment: panes claim zones by ROLE, matched on geometry —
|
||||
* `main` takes the largest zone, `left`/`right`/`top`/`bottom` take zones whose
|
||||
* centroid actually sits on that side (with a small size tiebreak). A hinted
|
||||
* pane with no acceptable zone left STACKS with its role-mates (or main)
|
||||
* instead of squatting in some random cell; unhinted panes fill what remains
|
||||
* biggest-first; extra zones stay empty.
|
||||
*/
|
||||
function assignZones(zones: GridZone[], panes: PlacedPane[]): Map<number, string[]> {
|
||||
const geo: ZoneGeo[] = zones.map(z => ({
|
||||
index: z.index,
|
||||
area: (z.right - z.left) * (z.bottom - z.top),
|
||||
cx: (z.left + z.right) / 2,
|
||||
cy: (z.top + z.bottom) / 2
|
||||
}))
|
||||
|
||||
const remaining = new Map(geo.map(g => [g.index, g]))
|
||||
const assignments = new Map<number, string[]>()
|
||||
const zoneForRole = new Map<string, number>()
|
||||
|
||||
// Score = fit for the role; accept = the zone genuinely sits on that side.
|
||||
const roles: Record<PanePlacementHint, { accept: (g: ZoneGeo) => boolean; score: (g: ZoneGeo) => number }> = {
|
||||
main: { accept: () => true, score: g => g.area },
|
||||
left: { accept: g => g.cx < CENTER, score: g => CENTER - g.cx + g.area / 1e8 },
|
||||
right: { accept: g => g.cx > CENTER, score: g => g.cx - CENTER + g.area / 1e8 },
|
||||
top: { accept: g => g.cy < CENTER, score: g => CENTER - g.cy + g.area / 1e8 },
|
||||
bottom: { accept: g => g.cy > CENTER, score: g => g.cy - CENTER + g.area / 1e8 }
|
||||
}
|
||||
|
||||
const claim = (pane: PlacedPane, role: PanePlacementHint | '_') => {
|
||||
const spec = role === '_' ? roles.main : roles[role]
|
||||
let best: ZoneGeo | null = null
|
||||
|
||||
for (const g of remaining.values()) {
|
||||
if (spec.accept(g) && (!best || spec.score(g) > spec.score(best))) {
|
||||
best = g
|
||||
}
|
||||
}
|
||||
|
||||
if (best) {
|
||||
remaining.delete(best.index)
|
||||
assignments.set(best.index, [pane.id])
|
||||
zoneForRole.set(role, best.index)
|
||||
|
||||
if (role === 'main' || !zoneForRole.has('main')) {
|
||||
zoneForRole.set('main', zoneForRole.get('main') ?? best.index)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// No acceptable zone left: stack with role-mates, else with main, else last.
|
||||
const home = zoneForRole.get(role) ?? zoneForRole.get('main') ?? [...assignments.keys()].pop()
|
||||
|
||||
if (home !== undefined) {
|
||||
assignments.get(home)?.push(pane.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Placement priority: main anchors first, then sided panes, then the rest.
|
||||
const rank = (p: PlacedPane) => (p.placement === 'main' ? 0 : p.placement ? 1 : 2)
|
||||
|
||||
for (const pane of [...panes].sort((a, b) => rank(a) - rank(b))) {
|
||||
claim(pane, pane.placement ?? '_')
|
||||
}
|
||||
|
||||
return assignments
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a grid to a tree. Panes carry placement hints (from their
|
||||
* contribution's `data.placement`) and land in geometrically fitting zones;
|
||||
* zones beyond the pane count stay as EMPTY zones.
|
||||
*/
|
||||
export function gridToTree(gridModel: GridLayout, panes: PlacedPane[]): LayoutNode | null {
|
||||
const zones = modelToZones(gridModel)
|
||||
|
||||
if (!zones || zones.length === 0 || panes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const assignments = assignZones(zones, panes)
|
||||
const result = cut(zones, zoneIndex => assignments.get(zoneIndex) ?? [])
|
||||
|
||||
return result ? normalize(result) : null
|
||||
}
|
||||
|
||||
/** True when the grid is expressible as a tree (guillotine-cuttable). */
|
||||
export function gridIsTreeExpressible(gridModel: GridLayout): boolean {
|
||||
const zones = modelToZones(gridModel)
|
||||
|
||||
if (!zones) {
|
||||
return false
|
||||
}
|
||||
|
||||
const probe = cut(zones, () => ['probe'])
|
||||
|
||||
return probe !== null
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* Layout presets — the FancyZones treatment.
|
||||
*
|
||||
* A preset is a CONTRIBUTION (`area: 'layouts'`, `data: LayoutNode`): the app
|
||||
* registers its bundled presets as `source: 'core'`, plugins register theirs
|
||||
* exactly the same way, and user-saved presets round-trip through localStorage
|
||||
* and re-register as `source: 'user'`. The picker (renderer.tsx) reads one
|
||||
* uniform list via `useContributions('layouts')`.
|
||||
*/
|
||||
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { readJson, writeJson, writeKey } from '@/lib/storage'
|
||||
|
||||
import { isLayoutNode, type LayoutNode } from './model'
|
||||
import { $layoutTree, applyTree, markActivePreset } from './store'
|
||||
|
||||
export const LAYOUTS_AREA = 'layouts'
|
||||
|
||||
// v2: v1 presets predate semantic placement (see store.ts) — retire them.
|
||||
const USER_KEY = 'hermes.desktop.layoutPresets.v2'
|
||||
|
||||
writeKey('hermes.desktop.layoutPresets.v1', null)
|
||||
|
||||
interface StoredPreset {
|
||||
name: string
|
||||
tree: LayoutNode
|
||||
}
|
||||
|
||||
const userDisposers = new Map<string, () => void>()
|
||||
|
||||
function loadUserPresets(): Record<string, StoredPreset> {
|
||||
const parsed = readJson<Record<string, StoredPreset>>(USER_KEY) ?? {}
|
||||
const out: Record<string, StoredPreset> = {}
|
||||
|
||||
for (const [id, preset] of Object.entries(parsed)) {
|
||||
if (preset && typeof preset.name === 'string' && isLayoutNode(preset.tree)) {
|
||||
out[id] = preset
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function persistUserPresets(presets: Record<string, StoredPreset>) {
|
||||
writeJson(USER_KEY, presets)
|
||||
}
|
||||
|
||||
function registerUserPreset(id: string, preset: StoredPreset) {
|
||||
userDisposers.get(id)?.()
|
||||
userDisposers.set(
|
||||
id,
|
||||
registry.register({ id, area: LAYOUTS_AREA, source: 'user', title: preset.name, data: preset.tree })
|
||||
)
|
||||
}
|
||||
|
||||
// Register persisted user presets at module load.
|
||||
const userPresets = loadUserPresets()
|
||||
|
||||
for (const [id, preset] of Object.entries(userPresets)) {
|
||||
registerUserPreset(id, preset)
|
||||
}
|
||||
|
||||
/** Save any tree as a named user preset (and make it active). */
|
||||
export function saveLayoutPresetTree(name: string, tree: LayoutNode): string | null {
|
||||
const trimmed = name.trim()
|
||||
|
||||
if (!tree || !trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const id = `user-${
|
||||
trimmed
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || Date.now().toString(36)
|
||||
}`
|
||||
|
||||
userPresets[id] = { name: trimmed, tree }
|
||||
persistUserPresets(userPresets)
|
||||
registerUserPreset(id, userPresets[id])
|
||||
markActivePreset(id)
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
/** Save the CURRENT tree as a named user preset (and make it active). */
|
||||
export function saveCurrentLayoutAs(name: string) {
|
||||
const tree = $layoutTree.get()
|
||||
|
||||
if (tree) {
|
||||
saveLayoutPresetTree(name, tree)
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteUserPreset(id: string) {
|
||||
if (!(id in userPresets)) {
|
||||
return
|
||||
}
|
||||
|
||||
delete userPresets[id]
|
||||
persistUserPresets(userPresets)
|
||||
userDisposers.get(id)?.()
|
||||
userDisposers.delete(id)
|
||||
}
|
||||
|
||||
export const isUserPreset = (id: string) => id in userPresets
|
||||
|
||||
/** Apply a preset's tree (deep-cloned so live edits never mutate the preset). */
|
||||
export function applyLayoutPreset(id: string, tree: LayoutNode) {
|
||||
applyTree(structuredClone(tree), id)
|
||||
}
|
||||
|
|
@ -0,0 +1,588 @@
|
|||
/**
|
||||
* THE in-app drag primitive. One pointer-capture session (`startDragSession`)
|
||||
* owns the machinery every in-app drag shares — threshold, rAF-coalesced
|
||||
* moves, cursor/user-select chrome, ghost chip, Esc-as-top-escape-layer,
|
||||
* hint publishing, teardown — and a per-kind RESOLVER supplies the semantics:
|
||||
* what the pointer is over (`resolveMove` → DropHint) and what a release
|
||||
* does (`onCommit`). Pane/tab drags (below) are the first resolver; the
|
||||
* sidebar session drag (app/chat/session-drag.ts) is the second. Native
|
||||
* HTML5 DnD is reserved for true OS boundaries (Finder file drops) — in-app
|
||||
* drags never ride it, so no snap-back animation, no hostile-library
|
||||
* armor, and Esc aborts synchronously.
|
||||
*
|
||||
* Pane drags use the FancyZones engine (zones-engine.ts, ported verbatim):
|
||||
* sensitivity-radius hit testing, HighlightedZones state machine, Shift =
|
||||
* select-many (combined zone range), ClosestCenter primary on drop. The
|
||||
* LAYOUT STAYS FIXED and every zone lights up as a whole-region drop target;
|
||||
* NOTHING moves until release (tab reorder included — the strip shows an
|
||||
* insertion divider, not a live shuffle). Over a zone's TAB STRIP the drop
|
||||
* stacks at the divider's slot; elsewhere the radial position picks
|
||||
* center/edge.
|
||||
*
|
||||
* PERFORMANCE CONTRACT: the layout never restructures mid-drag, so every
|
||||
* rect a resolver needs is snapshotted once at drag start (zones AND tab
|
||||
* strips) and each pointermove is pure math against those caches — no
|
||||
* elementsFromPoint, no getBoundingClientRect, no style writes unless a
|
||||
* value actually changed. Moves are coalesced to one hit-test per animation
|
||||
* frame, with the pending move flushed synchronously on release so the drop
|
||||
* commits at the exact final position.
|
||||
*/
|
||||
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react'
|
||||
|
||||
import { ESCAPE_PRIORITY, pushEscapeLayer } from '@/lib/escape-layers'
|
||||
import { reorderCommitHaptic, reorderStepHaptic } from '@/lib/reorder'
|
||||
|
||||
import type { DropPosition } from '../model'
|
||||
import { $dropHint, $treeDragging, type DropHint, mergeTreeZones, moveTreePane, reorderTreePane } from '../store'
|
||||
import { type EngineZone, HighlightedZones, primaryZone, type ZoneRect } from '../zones-engine'
|
||||
|
||||
const DRAG_THRESHOLD_PX = 4
|
||||
|
||||
/** Normalized radius of the elliptical CENTER region (stack/link). Outside it
|
||||
* the drop targets the dominant-axis edge — the boundary curves with the
|
||||
* zone's aspect ratio instead of snapping at a rigid pixel band, and corners
|
||||
* ease into their nearest edge along the quadrant diagonals. */
|
||||
const CENTER_RADIUS = 0.62
|
||||
|
||||
export function snapshotZones(): EngineZone[] {
|
||||
return [...document.querySelectorAll<HTMLElement>('[data-tree-group]')].map(el => {
|
||||
const r = el.getBoundingClientRect()
|
||||
|
||||
return { id: el.dataset.treeGroup!, rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom } }
|
||||
})
|
||||
}
|
||||
|
||||
/** Radial drop position within `rect`: inside the center ellipse = the center
|
||||
* action (stack/link); outside, the dominant axis picks the edge (VS Code
|
||||
* dock-preview geometry). `centerRadius` sizes the ellipse — larger = more
|
||||
* center, slimmer curved edge bands. */
|
||||
export function radialPosition(
|
||||
rect: { left: number; top: number; right: number; bottom: number },
|
||||
x: number,
|
||||
y: number,
|
||||
centerRadius = CENTER_RADIUS
|
||||
): DropPosition {
|
||||
// Zone-centered coordinates, ±1 at the edge midpoints.
|
||||
const dx = ((x - rect.left) / Math.max(1, rect.right - rect.left)) * 2 - 1
|
||||
const dy = ((y - rect.top) / Math.max(1, rect.bottom - rect.top)) * 2 - 1
|
||||
|
||||
if (Math.hypot(dx, dy) < centerRadius) {
|
||||
return 'center'
|
||||
}
|
||||
|
||||
return Math.abs(dx) >= Math.abs(dy) ? (dx < 0 ? 'left' : 'right') : (dy < 0 ? 'top' : 'bottom')
|
||||
}
|
||||
|
||||
/** Sub-zone drop position within the zone `groupId` (radial hit-testing). */
|
||||
export function subZonePosition(zones: EngineZone[], groupId: string, x: number, y: number): DropPosition {
|
||||
const rect = zones.find(zone => zone.id === groupId)?.rect
|
||||
|
||||
return rect ? radialPosition(rect, x, y) : 'center'
|
||||
}
|
||||
|
||||
/** One tab's insertion geometry: its pane id + horizontal midpoint. */
|
||||
interface StripSlot {
|
||||
id: string
|
||||
mid: number
|
||||
}
|
||||
|
||||
const stripSlots = (strip: HTMLElement): StripSlot[] =>
|
||||
[...strip.querySelectorAll<HTMLElement>('[data-tree-tab]')].map(tab => {
|
||||
const r = tab.getBoundingClientRect()
|
||||
|
||||
return { id: tab.dataset.treeTab ?? '', mid: r.left + r.width / 2 }
|
||||
})
|
||||
|
||||
/** Insertion slot from the pointer x against the OTHER tabs' midpoints:
|
||||
* stack BEFORE the returned pane id (`null` = append). */
|
||||
export function slotBefore(slots: StripSlot[], x: number, excludePaneId = ''): { before: null | string } {
|
||||
for (const slot of slots) {
|
||||
if (slot.id === excludePaneId) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (x < slot.mid) {
|
||||
return { before: slot.id }
|
||||
}
|
||||
}
|
||||
|
||||
return { before: null }
|
||||
}
|
||||
|
||||
/** Drag-start snapshot of one zone's tab strip. Strips never overlap and the
|
||||
* layout never restructures mid-drag, so rect containment replaces a
|
||||
* per-move elementsFromPoint hit test. A drop on a strip STACKS at the
|
||||
* divider's slot — the strip is where tabs live, so it wins over the radial
|
||||
* top-edge band that would otherwise read as "split top". */
|
||||
export interface StripSnapshot {
|
||||
groupId: string
|
||||
rect: ZoneRect
|
||||
slots: StripSlot[]
|
||||
}
|
||||
|
||||
export function snapshotStrips(): StripSnapshot[] {
|
||||
return [...document.querySelectorAll<HTMLElement>('[data-zone-tabstrip]')].map(el => {
|
||||
const r = el.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
groupId: el.dataset.zoneTabstrip!,
|
||||
rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom },
|
||||
slots: stripSlots(el)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const rectContains = (rect: ZoneRect, x: number, y: number, pad = 0) =>
|
||||
x >= rect.left - pad && x <= rect.right + pad && y >= rect.top - pad && y <= rect.bottom + pad
|
||||
|
||||
const sameHint = (a: DropHint | null, b: DropHint | null) =>
|
||||
a?.groupId === b?.groupId &&
|
||||
a?.pos === b?.pos &&
|
||||
a?.stack?.before === b?.stack?.before &&
|
||||
(a?.stack === undefined) === (b?.stack === undefined) &&
|
||||
(a?.groupIds?.length ?? 0) === (b?.groupIds?.length ?? 0) &&
|
||||
(a?.groupIds ?? []).every((id, i) => b?.groupIds?.[i] === id)
|
||||
|
||||
/** Double-tap detection for drag handles. Pane handles preventDefault
|
||||
* pointerdown, which suppresses native `dblclick` — so rapid same-handle
|
||||
* taps are detected here instead. */
|
||||
const DOUBLE_TAP_MS = 400
|
||||
let lastTap: { key: string; time: number } | null = null
|
||||
|
||||
export interface DoubleTapContext {
|
||||
/** Two sub-threshold releases with the same key within DOUBLE_TAP_MS. */
|
||||
key: string
|
||||
onDoubleTap: () => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The generic drag session (machinery) — resolvers plug in below / elsewhere.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DragSessionSpec {
|
||||
/** Movement crossed the drag threshold: snapshot geometry, set the drag
|
||||
* store(s), dim/mark the source. Runs once. */
|
||||
onEngage(x: number, y: number): void
|
||||
/** Per-frame target resolution — pure math against drag-start snapshots.
|
||||
* Returns the hint to publish; `null` = deny area (no-drop cursor, a
|
||||
* release commits nothing). */
|
||||
resolveMove(x: number, y: number, shift: boolean): DropHint | null
|
||||
/** Release over the final published hint (already flushed to the exact
|
||||
* release position). Only called for engaged drags. */
|
||||
onCommit(hint: DropHint | null): void
|
||||
/** Teardown for both commit and abort — undo whatever onEngage marked. */
|
||||
onEnd?(): void
|
||||
/** Sub-threshold release = a click on the handle. */
|
||||
onTap?(): void
|
||||
double?: DoubleTapContext
|
||||
/** Floating chip following the pointer — for drags whose source doesn't
|
||||
* stay visibly "held" (a sidebar row, unlike a dimmed tab). */
|
||||
ghost?: { label: string }
|
||||
}
|
||||
|
||||
/** The engaged drag's ghost chip: plain DOM (no React), themed via the same
|
||||
* tokens as DropPill, moved with a transform — trivially cheap, and removal
|
||||
* on Esc is synchronous. */
|
||||
function createGhost(label: string): HTMLElement {
|
||||
const ghost = document.createElement('div')
|
||||
|
||||
ghost.textContent = label
|
||||
ghost.style.cssText =
|
||||
'position:fixed;left:0;top:0;z-index:9999;pointer-events:none;max-width:16rem;overflow:hidden;' +
|
||||
'text-overflow:ellipsis;white-space:nowrap;padding:0.25rem 0.75rem;border-radius:9999px;' +
|
||||
'border:1px solid color-mix(in srgb,var(--dt-composer-ring) 45%,transparent);' +
|
||||
'background:color-mix(in srgb,var(--dt-card) 92%,transparent);color:var(--ui-text-primary);' +
|
||||
'font-size:0.75rem;font-weight:500;box-shadow:0 4px 16px rgba(0,0,0,0.25);will-change:transform'
|
||||
document.body.appendChild(ghost)
|
||||
|
||||
return ghost
|
||||
}
|
||||
|
||||
/** After an ENGAGED drag, the release still synthesizes a `click` on the
|
||||
* capture element — swallow exactly that one so a drag can never double as
|
||||
* an activation (row resume, tab close). Committed drags see the click in
|
||||
* the same task burst as pointerup; an Esc abort's click arrives with the
|
||||
* eventual release, so the trap disarms right after it. */
|
||||
function suppressDragClick(committed: boolean) {
|
||||
const swallow = (ev: MouseEvent) => {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
}
|
||||
|
||||
window.addEventListener('click', swallow, { capture: true, once: true })
|
||||
|
||||
const disarm = () => window.setTimeout(() => window.removeEventListener('click', swallow, true), 0)
|
||||
|
||||
if (committed) {
|
||||
disarm()
|
||||
} else {
|
||||
window.addEventListener('pointerup', disarm, { capture: true, once: true })
|
||||
window.addEventListener('pointercancel', disarm, { capture: true, once: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin a drag session from a handle's pointerdown. A sub-threshold release
|
||||
* is a click (`onTap` / `double.onDoubleTap`); past the threshold the spec's
|
||||
* resolver owns targeting and the machinery owns everything else. Esc aborts
|
||||
* instantly: the session registers as the TOP escape layer, tears down
|
||||
* synchronously, and nothing commits.
|
||||
*/
|
||||
export function startDragSession(e: ReactPointerEvent<HTMLElement>, spec: DragSessionSpec) {
|
||||
if (e.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const handle = e.currentTarget
|
||||
const { pointerId } = e
|
||||
const sx = e.clientX
|
||||
const sy = e.clientY
|
||||
const restoreCursor = document.body.style.cursor
|
||||
const restoreSelect = document.body.style.userSelect
|
||||
let engaged = false
|
||||
let releaseEscapeLayer: (() => void) | null = null
|
||||
let ghost: HTMLElement | null = null
|
||||
let cursor: string | null = null
|
||||
// rAF-coalesced move processing: the raw handler only records the latest
|
||||
// point; all hit testing happens at most once per frame.
|
||||
let pending: { x: number; y: number; shift: boolean } | null = null
|
||||
let raf = 0
|
||||
|
||||
// Cursor writes are per-frame; only touch the style when the value changes.
|
||||
const setCursor = (value: string) => {
|
||||
if (cursor !== value) {
|
||||
cursor = value
|
||||
document.body.style.cursor = value
|
||||
}
|
||||
}
|
||||
|
||||
const publishHint = (next: DropHint | null) => {
|
||||
if (!sameHint($dropHint.get(), next)) {
|
||||
if (next?.stack !== undefined && $dropHint.get()?.stack?.before !== next.stack.before) {
|
||||
reorderStepHaptic()
|
||||
}
|
||||
|
||||
$dropHint.set(next)
|
||||
}
|
||||
}
|
||||
|
||||
const engage = (x: number, y: number) => {
|
||||
engaged = true
|
||||
|
||||
// Capture only once ENGAGED: pre-threshold pointer events must stay
|
||||
// untouched so a plain click on the handle (and its children — a row
|
||||
// body's own onClick) keeps working. Window-level listeners track the
|
||||
// gesture either way.
|
||||
try {
|
||||
handle.setPointerCapture?.(pointerId)
|
||||
} catch {
|
||||
// Synthetic events (automation) have no active pointer.
|
||||
}
|
||||
|
||||
setCursor('grabbing')
|
||||
document.body.style.userSelect = 'none'
|
||||
// While dragging, Esc belongs to the drag ALONE — lower layers (edit
|
||||
// mode, overlays) must not also fire on the same press.
|
||||
releaseEscapeLayer = pushEscapeLayer(ESCAPE_PRIORITY.drag)
|
||||
|
||||
if (spec.ghost) {
|
||||
ghost = createGhost(spec.ghost.label)
|
||||
}
|
||||
|
||||
spec.onEngage(x, y)
|
||||
}
|
||||
|
||||
const processMove = (x: number, y: number, shift: boolean) => {
|
||||
if (!engaged) {
|
||||
if (Math.hypot(x - sx, y - sy) < DRAG_THRESHOLD_PX) {
|
||||
return
|
||||
}
|
||||
|
||||
engage(x, y)
|
||||
}
|
||||
|
||||
if (ghost) {
|
||||
ghost.style.transform = `translate3d(${x + 14}px, ${y + 12}px, 0)`
|
||||
}
|
||||
|
||||
const hint = spec.resolveMove(x, y, shift)
|
||||
|
||||
// Over a deny area (no target — titlebar / statusbar / gutters /
|
||||
// off-window) the release cancels; the cursor says so up front.
|
||||
setCursor(hint ? 'grabbing' : 'no-drop')
|
||||
publishHint(hint)
|
||||
}
|
||||
|
||||
const flushMove = () => {
|
||||
raf = 0
|
||||
|
||||
if (pending) {
|
||||
const { shift, x, y } = pending
|
||||
pending = null
|
||||
processMove(x, y, shift)
|
||||
}
|
||||
}
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
pending = { shift: ev.shiftKey, x: ev.clientX, y: ev.clientY }
|
||||
raf ||= requestAnimationFrame(flushMove)
|
||||
}
|
||||
|
||||
const finish = (commit: boolean) => {
|
||||
if (raf) {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = 0
|
||||
}
|
||||
|
||||
// The drop must land at the FINAL pointer position, not the last painted
|
||||
// frame's — flush the pending move before reading the hint. An abort
|
||||
// (Esc / pointercancel) skips it: everything is discarded anyway.
|
||||
if (commit) {
|
||||
flushMove()
|
||||
}
|
||||
|
||||
document.body.style.cursor = restoreCursor
|
||||
document.body.style.userSelect = restoreSelect
|
||||
ghost?.remove()
|
||||
ghost = null
|
||||
releaseEscapeLayer?.()
|
||||
releaseEscapeLayer = null
|
||||
|
||||
try {
|
||||
handle.releasePointerCapture?.(pointerId)
|
||||
} catch {
|
||||
// Mirror of the capture guard.
|
||||
}
|
||||
|
||||
window.removeEventListener('pointermove', onMove, true)
|
||||
window.removeEventListener('pointerup', onUp, true)
|
||||
window.removeEventListener('pointercancel', onCancel, true)
|
||||
window.removeEventListener('keydown', onKey, true)
|
||||
|
||||
if (engaged) {
|
||||
suppressDragClick(commit)
|
||||
|
||||
if (commit) {
|
||||
spec.onCommit($dropHint.get())
|
||||
}
|
||||
} else if (commit) {
|
||||
const now = Date.now()
|
||||
|
||||
if (spec.double && lastTap?.key === spec.double.key && now - lastTap.time < DOUBLE_TAP_MS) {
|
||||
lastTap = null
|
||||
spec.double.onDoubleTap()
|
||||
} else {
|
||||
lastTap = spec.double ? { key: spec.double.key, time: now } : null
|
||||
spec.onTap?.()
|
||||
}
|
||||
}
|
||||
|
||||
spec.onEnd?.()
|
||||
$dropHint.set(null)
|
||||
$treeDragging.set(null)
|
||||
}
|
||||
|
||||
const onUp = () => finish(true)
|
||||
const onCancel = () => finish(false)
|
||||
|
||||
// Esc aborts the drag — the target selection vanishes and nothing moves,
|
||||
// the universal "never mind" for an in-flight drag. Capture-phase + stop so
|
||||
// it doesn't also close a pane/overlay behind the drag (the escape layer
|
||||
// covers contract-following handlers; the stop covers the rest).
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
if (ev.key === 'Escape') {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
finish(false)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove, true)
|
||||
window.addEventListener('pointerup', onUp, true)
|
||||
window.addEventListener('pointercancel', onCancel, true)
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pane drag — the tree's resolver over the generic session.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ReorderContext {
|
||||
groupId: string
|
||||
/** The tab-strip element; tabs carry `data-tree-tab={paneId}`. */
|
||||
strip: HTMLElement
|
||||
}
|
||||
|
||||
/** How far (px) the pointer may stray from the strip before a tab drag stops
|
||||
* being a reorder and becomes a zone move (browser-tab tear-off feel). */
|
||||
const TEAR_OFF_SLACK_PX = 18
|
||||
|
||||
/**
|
||||
* Begin a pane drag from any handle. A sub-threshold release is a click
|
||||
* (`onTap`, used to activate tabs; rapid repeat fires `double.onDoubleTap`
|
||||
* instead). With a `reorder` context (tab drags), movement inside the strip
|
||||
* targets an insertion slot — the strip renders a divider at it, NOTHING
|
||||
* moves until release (placement-on-release, like every other drop); tearing
|
||||
* away from the strip converts the drag into a zone move. Zone mode: zones
|
||||
* light up, the target's tab strip stacks at its divider slot, Shift extends
|
||||
* the highlight range, release drops into the ClosestCenter primary zone.
|
||||
* Esc aborts either mode.
|
||||
*/
|
||||
export function startPaneDrag(
|
||||
paneId: string,
|
||||
e: ReactPointerEvent<HTMLElement>,
|
||||
onTap?: () => void,
|
||||
reorder?: ReorderContext,
|
||||
double?: DoubleTapContext
|
||||
) {
|
||||
if (e.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const highlighted = new HighlightedZones()
|
||||
let zones: EngineZone[] = []
|
||||
let strips: StripSnapshot[] = []
|
||||
let mode: 'reorder' | 'zone' | null = null
|
||||
let dimmed: HTMLElement | null = null
|
||||
|
||||
const markSource = () => {
|
||||
// The dragged tab dims for the drag's life — the divider says where it
|
||||
// GOES, the dim says what MOVES. No live shuffle (placement-on-release).
|
||||
dimmed ??= reorder?.strip.querySelector<HTMLElement>(`[data-tree-tab="${CSS.escape(paneId)}"]`) ?? null
|
||||
dimmed?.style.setProperty('opacity', '0.45')
|
||||
}
|
||||
|
||||
const enterZoneMode = () => {
|
||||
mode = 'zone'
|
||||
// The layout never restructures mid-drag, so zone/strip rects are stable.
|
||||
zones = snapshotZones()
|
||||
strips = snapshotStrips()
|
||||
$treeDragging.set(paneId)
|
||||
markSource()
|
||||
}
|
||||
|
||||
// The reorder strip's geometry, snapshotted on first use (same fixed-layout
|
||||
// guarantee as the zone snapshots).
|
||||
let reorderSnap: { rect: ZoneRect; slots: StripSlot[] } | null = null
|
||||
|
||||
const reorderStrip = () => {
|
||||
if (!reorderSnap) {
|
||||
const r = reorder!.strip.getBoundingClientRect()
|
||||
reorderSnap = {
|
||||
rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom },
|
||||
slots: stripSlots(reorder!.strip)
|
||||
}
|
||||
}
|
||||
|
||||
return reorderSnap
|
||||
}
|
||||
|
||||
const withinStrip = (x: number, y: number) =>
|
||||
Boolean(reorder) && rectContains(reorderStrip().rect, x, y, TEAR_OFF_SLACK_PX)
|
||||
|
||||
startDragSession(e, {
|
||||
double,
|
||||
onTap,
|
||||
|
||||
onEngage(x, y) {
|
||||
if (reorder && withinStrip(x, y)) {
|
||||
mode = 'reorder'
|
||||
markSource()
|
||||
} else {
|
||||
enterZoneMode()
|
||||
}
|
||||
},
|
||||
|
||||
resolveMove(x, y, shift) {
|
||||
if (mode === 'reorder') {
|
||||
if (withinStrip(x, y)) {
|
||||
return {
|
||||
kind: 'group',
|
||||
groupId: reorder!.groupId,
|
||||
groupIds: [reorder!.groupId],
|
||||
pos: 'center',
|
||||
stack: slotBefore(reorderStrip().slots, x, paneId)
|
||||
}
|
||||
}
|
||||
|
||||
// Tear-off: the tab leaves the strip and becomes a zone move.
|
||||
enterZoneMode()
|
||||
}
|
||||
|
||||
// The hint updates on highlight-set changes AND on sub-zone position
|
||||
// changes (center/edge regions within the same primary zone).
|
||||
const point = { x, y }
|
||||
highlighted.update(zones, point, shift)
|
||||
let groupIds = [...highlighted.zones()]
|
||||
|
||||
// Spanning multiple zones is EXPLICIT (Shift). Without it, the seam-
|
||||
// proximity capture (sensitivity radius grabs both neighbors near a
|
||||
// shared edge) collapses to the primary zone — otherwise a drop near a
|
||||
// seam silently merges zones the user never asked to merge.
|
||||
if (!shift && groupIds.length > 1) {
|
||||
const collapsed = primaryZone(zones, groupIds, point)
|
||||
groupIds = collapsed ? [collapsed] : []
|
||||
}
|
||||
|
||||
const groupId = groupIds.length > 0 ? (primaryZone(zones, groupIds, point) ?? undefined) : undefined
|
||||
|
||||
// Over the target's TAB STRIP the drop stacks at the divider's slot;
|
||||
// sub-positions only make sense for a single-zone drop (a Shift-span
|
||||
// always merges, pos ignored).
|
||||
const strip =
|
||||
groupIds.length === 1 && groupId ? strips.find(s => s.groupId === groupId && rectContains(s.rect, x, y)) : null
|
||||
|
||||
const stack = strip ? slotBefore(strip.slots, x, paneId) : undefined
|
||||
|
||||
const pos: DropPosition = stack
|
||||
? 'center'
|
||||
: groupIds.length === 1 && groupId
|
||||
? subZonePosition(zones, groupId, x, y)
|
||||
: 'center'
|
||||
|
||||
return groupIds.length > 0 ? { kind: 'group', groupId, groupIds, pos, stack } : null
|
||||
},
|
||||
|
||||
onCommit(hint) {
|
||||
if (mode === 'reorder' && reorder && hint?.stack !== undefined) {
|
||||
// Slot -> index among the OTHER tabs (reorderPaneInGroup inserts there).
|
||||
const others = [...reorder.strip.querySelectorAll<HTMLElement>('[data-tree-tab]')]
|
||||
.map(el => el.dataset.treeTab)
|
||||
.filter((id): id is string => Boolean(id) && id !== paneId)
|
||||
|
||||
const toIndex = hint.stack.before ? others.indexOf(hint.stack.before) : others.length
|
||||
|
||||
if (toIndex >= 0) {
|
||||
reorderTreePane(reorder.groupId, paneId, toIndex)
|
||||
reorderCommitHaptic()
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'zone') {
|
||||
// Drop what the hint SHOWS — the overlay and the commit share one
|
||||
// truth (the raw highlight set can hold both seam neighbors; the hint
|
||||
// already collapsed that to the primary unless Shift made the span
|
||||
// explicit).
|
||||
const targets = hint?.groupIds ?? []
|
||||
|
||||
if (targets.length > 1) {
|
||||
// Shift-span: merge the highlighted zones, dropping the pane across them.
|
||||
mergeTreeZones([...targets], paneId, hint?.groupId ?? null)
|
||||
} else if (hint?.groupId) {
|
||||
// strip = stack at the divider slot; center = join the stack;
|
||||
// an edge = split the zone and land there.
|
||||
moveTreePane(paneId, { groupId: hint.groupId, pos: hint.pos ?? 'center', before: hint.stack?.before })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onEnd() {
|
||||
dimmed?.style.removeProperty('opacity')
|
||||
highlighted.reset()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Edit-mode palette — the floating, draggable "Layouts" card shown while
|
||||
* layout edit mode is on. It hosts the layout picker and the reset/done
|
||||
* actions; its header doubles as the drag handle. Position survives edit-mode
|
||||
* toggles within a session.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type PointerEvent as ReactPointerEvent, useCallback, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { $bindings, bindingsFor } from '@/store/keybinds'
|
||||
|
||||
import { $layoutEditMode } from '../../edit-mode'
|
||||
import { resetLayoutTree } from '../store'
|
||||
|
||||
import { LayoutPicker } from './layout-picker'
|
||||
|
||||
// Palette position survives edit-mode toggles within a session; null = centered.
|
||||
let lastPalettePos: { x: number; y: number } | null = null
|
||||
|
||||
export function TreeEditBar() {
|
||||
const { t } = useI18n()
|
||||
const editMode = useStore($layoutEditMode)
|
||||
const bindings = useStore($bindings)
|
||||
const [pos, setPos] = useState(lastPalettePos)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
// The toggle is a `keybinds` contribution (`layout.editMode`) — show the
|
||||
// user's LIVE binding, not a hardcoded chord.
|
||||
const toggleCombo = bindingsFor('layout.editMode', bindings)[0]
|
||||
|
||||
const startMove = useCallback((e: ReactPointerEvent<HTMLElement>) => {
|
||||
if (e.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const card = cardRef.current
|
||||
const parent = card?.parentElement
|
||||
|
||||
if (!card || !parent) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
const dx = e.clientX - cardRect.x
|
||||
const dy = e.clientY - cardRect.y
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const parentRect = parent.getBoundingClientRect()
|
||||
|
||||
const next = {
|
||||
x: Math.max(4, Math.min(parentRect.width - cardRect.width - 4, ev.clientX - parentRect.x - dx)),
|
||||
y: Math.max(4, Math.min(parentRect.height - 40, ev.clientY - parentRect.y - dy))
|
||||
}
|
||||
|
||||
lastPalettePos = next
|
||||
setPos(next)
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
window.removeEventListener('pointermove', onMove, true)
|
||||
window.removeEventListener('pointerup', onUp, true)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove, true)
|
||||
window.addEventListener('pointerup', onUp, true)
|
||||
}, [])
|
||||
|
||||
if (!editMode) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-50 flex w-[26rem] max-w-[calc(100%-2rem)] flex-col rounded-xl border border-(--ui-stroke-secondary) bg-popover text-popover-foreground shadow-2xl [-webkit-app-region:no-drag]"
|
||||
ref={cardRef}
|
||||
style={pos ? { left: pos.x, top: pos.y } : { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' }}
|
||||
>
|
||||
{/* Header doubles as the drag handle (Panel-style title + actions). */}
|
||||
<header
|
||||
className="flex shrink-0 cursor-grab select-none items-start justify-between gap-3 px-4 pb-2 pt-3 active:cursor-grabbing"
|
||||
onPointerDown={startMove}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">{t.zones.editTitle}</h2>
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t.zones.editHint}{' '}
|
||||
{toggleCombo && (
|
||||
<kbd className="rounded border border-(--ui-stroke-secondary) bg-foreground/5 px-1 font-mono text-[10px]">
|
||||
{formatCombo(toggleCombo)}
|
||||
</kbd>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5" onPointerDown={e => e.stopPropagation()}>
|
||||
<Button onClick={resetLayoutTree} size="sm" variant="ghost">
|
||||
{t.zones.reset}
|
||||
</Button>
|
||||
<Button onClick={() => $layoutEditMode.set(false)} size="sm" variant="outline">
|
||||
{t.common.done}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="px-4 pb-4">
|
||||
<LayoutPicker />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Layout tree renderer (root).
|
||||
*
|
||||
* - `split` -> flex row/column; 1px seams between siblings double as resize
|
||||
* sashes (the seam IS the boundary — junction-owned, never doubled). See
|
||||
* tree-split.tsx.
|
||||
* - `group` -> a ZONE: header strip (tabs when stacked, minimize chevron) +
|
||||
* the active pane's content, resolved from the contribution registry
|
||||
* (`area: 'panes'`). Empty zones exist only in editor-authored trees. See
|
||||
* tree-group.tsx.
|
||||
*
|
||||
* Dragging is FancyZones-style (drag-session.ts): the LAYOUT STAYS FIXED and
|
||||
* every zone lights up as a whole-region drop target; dropping moves the pane
|
||||
* into that zone (joining its tab stack). Structure changes (splitting/merging/
|
||||
* resizing zones) belong to the zone editor, not the drag.
|
||||
*
|
||||
* This file owns only the composition: the recursive tree, the narrow-viewport
|
||||
* overlays, the edit palette, and the zone editor. The pieces live in sibling
|
||||
* modules — track-model (sizing), drag-session (drag), tree-split / tree-group
|
||||
* (nodes), layout-picker + edit-bar (edit mode), narrow-overlays.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, useEffect } from 'react'
|
||||
|
||||
import { useLayoutEditHotkey } from '../../edit-mode'
|
||||
import { publishWorkspaceGeometry } from '../../geometry'
|
||||
import { $layoutTree, trackActiveTreeGroup } from '../store'
|
||||
import { ZoneEditor } from '../zone-editor'
|
||||
|
||||
import { TreeEditBar } from './edit-bar'
|
||||
import { NarrowOverlays } from './narrow-overlays'
|
||||
import { TreeNode } from './tree-node'
|
||||
|
||||
export function LayoutTreeRoot({ children }: { children?: ReactNode }) {
|
||||
const tree = useStore($layoutTree)
|
||||
|
||||
useLayoutEditHotkey(true)
|
||||
// Track the interacted zone so ⌘W closes the right tab even when nothing is
|
||||
// DOM-focused.
|
||||
useEffect(trackActiveTreeGroup, [])
|
||||
// Publish --workspace-left/right so chrome (titlebar title) aligns to the
|
||||
// main pane's geometry in plain CSS.
|
||||
useEffect(publishWorkspaceGeometry, [])
|
||||
|
||||
if (!tree) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1">
|
||||
{/* ZonesOverlay::GetAnimationAlpha ramp: clamp(t / 200ms, 0.001, 1). */}
|
||||
<style>{`@keyframes hermes-zone-fade { from { opacity: 0.001 } to { opacity: 1 } }`}</style>
|
||||
{/* THE SEAM INVARIANT: boundaries are drawn by the tree (one sash
|
||||
hairline per seam) — content mounted in a zone must not paint its
|
||||
own edge chrome. App components (asides, the shadcn sidebar) carry
|
||||
edge borders + inset highlights for the OLD shell's geometry; this
|
||||
neutralizes all of them at the zone boundary, for every current and
|
||||
future pane, instead of per-pane class surgery. */}
|
||||
<style>{`
|
||||
[data-tree-group] :is(aside, [data-slot=sidebar]) {
|
||||
border-left-width: 0;
|
||||
border-right-width: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
/* Old-shell titlebar BANDS (chat's session header et al size to
|
||||
--titlebar-height, which is 0 inside zones): a zero-height band is
|
||||
non-functional but still paints its border-b — a stray hairline
|
||||
doubling the zone's top seam. Remove the band entirely. */
|
||||
[data-tree-group] header[class*="h-(--titlebar-height)"] {
|
||||
display: none;
|
||||
}
|
||||
`}</style>
|
||||
<TreeNode node={tree} root />
|
||||
<NarrowOverlays />
|
||||
<TreeEditBar />
|
||||
<ZoneEditor />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* Layout picker — the preset card grid inside the edit palette. Thumbnails
|
||||
* are a miniature render of each preset's layout tree; clicking a card
|
||||
* applies it, and "Save current arrangement" captures the live tree as a
|
||||
* user preset. The "New grid layout" button opens the zone editor.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import type { Contribution } from '@/contrib/types'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { LayoutNode } from '../model'
|
||||
import { isLayoutNode } from '../model'
|
||||
import { applyLayoutPreset, deleteUserPreset, isUserPreset, LAYOUTS_AREA, saveCurrentLayoutAs } from '../presets'
|
||||
import { $activePresetId } from '../store'
|
||||
import { $zoneEditorOpen } from '../zone-editor'
|
||||
|
||||
/** Miniature render of a layout tree — the preset card thumbnail. */
|
||||
function TreeThumbnail({ node }: { node: LayoutNode }) {
|
||||
if (node.type === 'group') {
|
||||
return (
|
||||
// currentColor-derived fill: light zones on dark themes, dark zones on
|
||||
// light — legible everywhere without leaning on the accent.
|
||||
<div
|
||||
className="min-h-0 min-w-0 flex-1 rounded-[2px]"
|
||||
style={{ background: 'color-mix(in srgb, currentColor 16%, transparent)' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-h-0 min-w-0 flex-1 gap-px', node.orientation === 'row' ? 'flex-row' : 'flex-col')}>
|
||||
{node.children.map((child, i) => (
|
||||
<div
|
||||
className="flex min-h-0 min-w-0"
|
||||
key={child.id}
|
||||
style={{ flex: `${node.weights[i]} ${node.weights[i]} 0px` }}
|
||||
>
|
||||
<TreeThumbnail node={child} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Small-caps section heading — the app's SidebarPanelLabel voice. */
|
||||
function PickerSectionLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="text-[0.6rem] font-semibold uppercase tracking-[0.16em] text-(--ui-text-quaternary)">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function PresetCard({ preset }: { preset: Contribution }) {
|
||||
const { t } = useI18n()
|
||||
const activeId = useStore($activePresetId)
|
||||
const tree = isLayoutNode(preset.data) ? preset.data : null
|
||||
|
||||
if (!tree) {
|
||||
return null
|
||||
}
|
||||
|
||||
const active = preset.id === activeId
|
||||
|
||||
return (
|
||||
<div className="group/preset relative">
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full flex-col gap-1.5 rounded-lg border p-1.5 text-left transition-colors',
|
||||
active
|
||||
? 'border-(--ui-accent) bg-(--ui-row-active-background)'
|
||||
: 'border-(--ui-stroke-secondary) hover:border-(--ui-stroke-primary) hover:bg-(--ui-row-hover-background)'
|
||||
)}
|
||||
onClick={() => applyLayoutPreset(preset.id, tree)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex h-12 w-full">
|
||||
<TreeThumbnail node={tree} />
|
||||
</div>
|
||||
<span
|
||||
className={cn('truncate text-[0.68rem] font-medium', active ? 'text-foreground' : 'text-muted-foreground/80')}
|
||||
>
|
||||
{preset.title ?? preset.id}
|
||||
</span>
|
||||
</button>
|
||||
{isUserPreset(preset.id) && (
|
||||
<button
|
||||
aria-label={t.zones.deletePreset(preset.title ?? preset.id)}
|
||||
// Hover-reveal (opacity, not display) — stays laid out + clickable,
|
||||
// appears on card hover or keyboard focus.
|
||||
className="absolute right-1 top-1 z-10 grid size-5 place-items-center rounded-md bg-(--ui-bg-elevated) text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 group-hover/preset:opacity-100"
|
||||
onClick={() => deleteUserPreset(preset.id)}
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.7rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LayoutPicker() {
|
||||
const { t } = useI18n()
|
||||
const presets = useContributions(LAYOUTS_AREA)
|
||||
const [name, setName] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const templates = presets.filter(p => !isUserPreset(p.id) && isLayoutNode(p.data))
|
||||
const custom = presets.filter(p => isUserPreset(p.id) && isLayoutNode(p.data))
|
||||
|
||||
const commitSave = () => {
|
||||
if (!name.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
saveCurrentLayoutAs(name)
|
||||
setName('')
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<section className="flex flex-col gap-2">
|
||||
<PickerSectionLabel>{t.zones.templates}</PickerSectionLabel>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{templates.map(preset => (
|
||||
<PresetCard key={`${preset.source ?? 'core'}:${preset.id}`} preset={preset} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<PickerSectionLabel>{t.zones.custom}</PickerSectionLabel>
|
||||
{custom.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{custom.map(preset => (
|
||||
<PresetCard key={`${preset.source ?? 'core'}:${preset.id}`} preset={preset} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
className="h-8 w-full justify-center gap-1.5 border border-dashed border-(--ui-stroke-secondary) text-muted-foreground hover:border-(--ui-stroke-primary) hover:text-foreground"
|
||||
onClick={() => $zoneEditorOpen.set(true)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="0.875rem" />
|
||||
{t.zones.newGridLayout}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
{/* Save-current lives behind a reveal so the raw input doesn't clash
|
||||
with the card grid until it's actually needed. */}
|
||||
{saving ? (
|
||||
<form
|
||||
className="flex items-center gap-1.5"
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
commitSave()
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-7 flex-1 text-xs"
|
||||
onChange={e => setName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') {
|
||||
setSaving(false)
|
||||
setName('')
|
||||
}
|
||||
}}
|
||||
placeholder={t.zones.nameLayoutPlaceholder}
|
||||
value={name}
|
||||
/>
|
||||
<Button disabled={!name.trim()} size="sm" type="submit" variant="outline">
|
||||
{t.common.save}
|
||||
</Button>
|
||||
<Button onClick={() => setSaving(false)} size="sm" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
className="flex items-center gap-1.5 self-start text-xs text-muted-foreground/80 transition-colors hover:text-foreground"
|
||||
onClick={() => setSaving(true)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="save" size="0.8125rem" />
|
||||
{t.zones.saveCurrentAs}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* Narrow-viewport edge overlays — the tree's take on the app's hover-reveal
|
||||
* collapse. Collapsible panes leave the grid below the sidebar-collapse
|
||||
* breakpoint; an edge strip (hover) or PANE_TOGGLE_REVEAL_EVENT (⌘B / ⌘G /
|
||||
* titlebar toggles route here on narrow) slides the pane OVER the layout
|
||||
* instead of squeezing it. Event reveals pin; hover reveals follow the mouse.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { ContribBoundary } from '@/contrib/react/boundary'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import type { Contribution } from '@/contrib/types'
|
||||
import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { PANE_TOGGLE_REVEAL_EVENT } from '../..'
|
||||
import { allPaneIds } from '../model'
|
||||
import { $hiddenTreePanes, $layoutTree, $narrowViewport } from '../store'
|
||||
|
||||
import { paneChrome } from './track-model'
|
||||
|
||||
export function NarrowOverlays() {
|
||||
const narrow = useStore($narrowViewport)
|
||||
const tree = useStore($layoutTree)
|
||||
const panes = useContributions('panes')
|
||||
const hiddenPanes = useStore($hiddenTreePanes)
|
||||
const [reveal, setReveal] = useState<{ id: string; pinned: boolean } | null>(null)
|
||||
|
||||
// Own an Escape layer only while something is revealed, so Escape closes the
|
||||
// overlay only when it's the top layer (never under a dialog / edit mode).
|
||||
const revealActive = reveal !== null
|
||||
useEffect(() => (revealActive ? pushEscapeLayer(ESCAPE_PRIORITY.narrowOverlay) : undefined), [revealActive])
|
||||
|
||||
const inTree = useMemo(() => new Set(tree ? allPaneIds(tree) : []), [tree])
|
||||
|
||||
const collapsibles = useMemo(
|
||||
() => panes.filter(p => paneChrome(p).collapsible && inTree.has(p.id) && !hiddenPanes.has(p.id)),
|
||||
[panes, inTree, hiddenPanes]
|
||||
)
|
||||
|
||||
const collapsiblesRef = useRef(collapsibles)
|
||||
collapsiblesRef.current = collapsibles
|
||||
|
||||
// ⌘B / ⌘G's narrow branch dispatches the app's toggle-reveal event with the
|
||||
// REAL pane id — accept those via each contribution's revealAliases.
|
||||
useEffect(() => {
|
||||
if (!narrow) {
|
||||
setReveal(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const onToggle = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ id?: string; mode?: 'close' | 'open' | 'toggle' }>).detail
|
||||
const id = detail?.id
|
||||
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
const match = collapsiblesRef.current.find(p => p.id === id || paneChrome(p).revealAliases?.includes(id))
|
||||
|
||||
if (!match) {
|
||||
return
|
||||
}
|
||||
|
||||
// `open`/`close` are explicit intents (programmatic reveal, titlebar show);
|
||||
// `toggle` (default) is the ⌘B/⌘G flip.
|
||||
const mode = detail?.mode ?? 'toggle'
|
||||
setReveal(current => {
|
||||
if (mode === 'open') {
|
||||
return { id: match.id, pinned: true }
|
||||
}
|
||||
|
||||
if (mode === 'close') {
|
||||
return current?.id === match.id ? null : current
|
||||
}
|
||||
|
||||
return current?.id === match.id && current.pinned ? null : { id: match.id, pinned: true }
|
||||
})
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented || !isTopEscapeLayer(ESCAPE_PRIORITY.narrowOverlay)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
setReveal(null)
|
||||
}
|
||||
|
||||
window.addEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [narrow])
|
||||
|
||||
if (!narrow || collapsibles.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sideOf = (c: Contribution) => (paneChrome(c).placement === 'left' ? 'left' : 'right')
|
||||
const revealed = reveal ? collapsibles.find(p => p.id === reveal.id) : undefined
|
||||
const sides = [...new Set(collapsibles.map(sideOf))]
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hover-intent strips on each edge that has a collapsed pane. */}
|
||||
{sides.map(side => (
|
||||
<div
|
||||
className={cn('absolute inset-y-0 z-30 w-1.5', side === 'left' ? 'left-0' : 'right-0')}
|
||||
key={side}
|
||||
onMouseEnter={() => {
|
||||
const first = collapsibles.find(p => sideOf(p) === side)
|
||||
|
||||
if (first) {
|
||||
setReveal(current => (current?.pinned ? current : { id: first.id, pinned: false }))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{revealed && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-y-0 z-40 flex flex-col overflow-hidden bg-(--ui-sidebar-surface-background) shadow-2xl',
|
||||
sideOf(revealed) === 'left'
|
||||
? 'left-0 border-r border-(--ui-stroke-secondary)'
|
||||
: 'right-0 border-l border-(--ui-stroke-secondary)'
|
||||
)}
|
||||
onMouseLeave={() => setReveal(current => (current?.pinned ? current : null))}
|
||||
// Match the pane's docked width (sessions ~237px, files its rail
|
||||
// width) instead of a fat fixed 20rem — capped for tiny screens.
|
||||
style={{ width: `min(${(revealed.data as { width?: string } | undefined)?.width ?? '18rem'}, 85vw)` }}
|
||||
>
|
||||
<ContribBoundary id={revealed.id}>{revealed.render?.()}</ContribBoundary>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
/**
|
||||
* The TRACK MODEL — how a layout node resolves its size along a split axis.
|
||||
*
|
||||
* A node is a FIXED track when it resolves to a CSS length (sidebars keep
|
||||
* their declared size) and a FLEX track when it doesn't (weight-shared
|
||||
* leftover). Everything here is pure geometry over the layout tree + the
|
||||
* live pane contributions; the React split renderer reads it per render.
|
||||
*/
|
||||
|
||||
import type * as React from 'react'
|
||||
|
||||
import type { Contribution } from '@/contrib/types'
|
||||
|
||||
import type { GroupNode, LayoutNode } from '../model'
|
||||
import { allPaneIds } from '../model'
|
||||
|
||||
export const MIN_PANE_PX = 80
|
||||
|
||||
/** Optional CSS sizing a pane contributes (`data.width` / `data.minWidth`…).
|
||||
* Applied to the pane's GROUP along the axis of the split that contains it —
|
||||
* the same semantics as the app's `Pane width/minWidth/maxWidth` props:
|
||||
* a `width`/`height` makes the zone a FIXED track (sidebar-style — it keeps
|
||||
* its size and the weighted zones absorb the rest); without one the zone
|
||||
* shares leftover space by weight. */
|
||||
export interface PaneSizing {
|
||||
width?: string
|
||||
height?: string
|
||||
minWidth?: string
|
||||
maxWidth?: string
|
||||
minHeight?: string
|
||||
maxHeight?: string
|
||||
}
|
||||
|
||||
/** Chrome behavior flags a pane contributes. Read via `paneChrome`. */
|
||||
interface PaneChrome {
|
||||
/** Leaves the grid on narrow viewports; revealed as an edge overlay. */
|
||||
collapsible?: boolean
|
||||
/** Extra ids accepted from PANE_TOGGLE_REVEAL_EVENT (the real app's pane
|
||||
* ids, e.g. `chat-sidebar` for `sessions`). */
|
||||
revealAliases?: string[]
|
||||
placement?: string
|
||||
/** No Close in the tab menu — the one surface the app can't lose (the
|
||||
* main workspace). Session tiles share `placement: 'main'` but close. */
|
||||
uncloseable?: boolean
|
||||
/** Wrap this pane's TAB (e.g. in a domain context menu — a session tile's
|
||||
* pin/branch/rename/archive/delete). The wrapper must render `tab` as its
|
||||
* interactive child; the zone's own strip menu still owns non-tab space. */
|
||||
tabWrap?: (tab: React.ReactElement) => React.ReactNode
|
||||
/** Suppress the zone header while THIS pane is active — full-page views
|
||||
* (artifacts/skills/plugin pages) are not tab-able surfaces. The flag is
|
||||
* live: the workspace contribution re-registers it on route changes. */
|
||||
headerVeto?: boolean
|
||||
}
|
||||
|
||||
export const paneChrome = (c: Contribution | undefined) => (c?.data ?? {}) as PaneChrome
|
||||
|
||||
/** Resolve a computed style length ("237px" / "none" / "auto") to px. */
|
||||
export function computedPx(value: string, fallback: number): number {
|
||||
const n = Number.parseFloat(value)
|
||||
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
/** Resolve an AUTHORED CSS length ("237px", "38vh", "clamp(18rem,36vw,32rem)")
|
||||
* to px by measuring a probe inside `container` — handles every unit and
|
||||
* math function the browser does. */
|
||||
export function resolveCssPx(container: HTMLElement, css: number | string, horizontal: boolean): number | null {
|
||||
if (typeof css === 'number') {
|
||||
return css
|
||||
}
|
||||
|
||||
const probe = document.createElement('div')
|
||||
probe.style.position = 'absolute'
|
||||
probe.style.visibility = 'hidden'
|
||||
probe.style.pointerEvents = 'none'
|
||||
|
||||
if (horizontal) {
|
||||
probe.style.width = css
|
||||
} else {
|
||||
probe.style.height = css
|
||||
}
|
||||
|
||||
container.appendChild(probe)
|
||||
const rect = probe.getBoundingClientRect()
|
||||
probe.remove()
|
||||
const px = horizontal ? rect.width : rect.height
|
||||
|
||||
return Number.isFinite(px) && px > 0 ? px : null
|
||||
}
|
||||
|
||||
/** Everything fixed-track resolution needs about the current view state. */
|
||||
export interface TrackContext {
|
||||
paneFor: (id: string) => Contribution | undefined
|
||||
paneGone: (id: string) => boolean
|
||||
overrides: Record<string, { widthOverride?: number; heightOverride?: number }>
|
||||
}
|
||||
|
||||
/** A group's panes that are actually on screen (not hidden / narrow-collapsed
|
||||
* / unregistered). The one place the "shown" filter lives. */
|
||||
export const shownPaneIds = (group: GroupNode, ctx: TrackContext): string[] =>
|
||||
group.panes.filter(id => !ctx.paneGone(id))
|
||||
|
||||
/** max() of the defined CSS lengths (deduped); undefined when none — the
|
||||
* largest-tenant basis a fixed stack and its clamps both size from. */
|
||||
export const cssMax = (values: (string | null | undefined)[]): string | undefined => {
|
||||
const unique = [...new Set(values.filter((v): v is string => Boolean(v)))]
|
||||
|
||||
return unique.length === 0 ? undefined : unique.length === 1 ? unique[0] : `max(${unique.join(', ')})`
|
||||
}
|
||||
|
||||
/**
|
||||
* THE TRACK MODEL. A node's size along `axis` is FIXED when it resolves to a
|
||||
* CSS length, and FLEX (weight-shared leftover) when null:
|
||||
*
|
||||
* - zone: the max() of its shown panes' declared `width`/`height` (a live px
|
||||
* override from a sash drag wins) — sidebars keep their size, main flexes,
|
||||
* and the zone never resizes when tabs switch or a drop fronts a pane.
|
||||
* - split ALONG the axis: the sum of its visible children — fixed only when
|
||||
* every child is (one flex child makes the run flex).
|
||||
* - split ACROSS the axis: the max of its visible fixed children (flex
|
||||
* children just stretch to the container); flex only when none are fixed.
|
||||
*
|
||||
* This is how "two right sidebars over a terminal row" sizes itself from its
|
||||
* content (237px, or 474px when review is visible) instead of taking a
|
||||
* fraction of the window.
|
||||
*/
|
||||
/** A minimized zone IS its strip: the vertical rail (row) / header (column)
|
||||
* are both 28px thick. */
|
||||
export const MINIMIZED_TRACK = '1.75rem'
|
||||
|
||||
export function fixedTrackSize(node: LayoutNode, axis: 'row' | 'column', ctx: TrackContext): string | null {
|
||||
if (node.type === 'group') {
|
||||
// Ancestor splits must size a minimized zone as its strip, not as its
|
||||
// panes' declared widths — otherwise the outer track keeps reserving the
|
||||
// full sidebar width and the collapsed rail floats in a dead column.
|
||||
if (node.minimized) {
|
||||
return MINIMIZED_TRACK
|
||||
}
|
||||
|
||||
const overrideKey = axis === 'row' ? 'widthOverride' : 'heightOverride'
|
||||
|
||||
const declared = (id: string) => {
|
||||
const sizing = (ctx.paneFor(id)?.data ?? {}) as PaneSizing
|
||||
const css = (axis === 'row' ? sizing.width : sizing.height) ?? null
|
||||
const override = ctx.overrides[id]?.[overrideKey]
|
||||
|
||||
// An override only refines a pane that DECLARES a size along this axis
|
||||
// (sash drags write overrides to fixed zones only). One without a
|
||||
// declaration is stale data from another surface — honoring it would
|
||||
// turn a flex-at-heart zone (main!) into a fixed track and hand the
|
||||
// whole leftover to the run's absorber.
|
||||
if (css !== null && override !== undefined) {
|
||||
return `${override}px`
|
||||
}
|
||||
|
||||
return css
|
||||
}
|
||||
|
||||
// Which zones are FIXED tracks:
|
||||
// - a MAIN-bearing zone (workspace/tile stacked in) is flex-at-heart —
|
||||
// mixing a sidebar pane into it (files fronted in the Focus mono-stack)
|
||||
// must NOT snap the whole zone to sidebar width;
|
||||
// - any other zone stays fixed as long as SOME tenant declares a size —
|
||||
// dropping a size-less pane (the terminal has height but no width)
|
||||
// into the 237px files sidebar must not balloon it to a flex track.
|
||||
const ids = shownPaneIds(node, ctx)
|
||||
const sizes = ids.map(declared)
|
||||
const declaredSizes = sizes.filter((size): size is string => size !== null)
|
||||
|
||||
if (declaredSizes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (sizes.length !== declaredSizes.length && ids.some(id => paneChrome(ctx.paneFor(id)).placement === 'main')) {
|
||||
return null
|
||||
}
|
||||
|
||||
// A STACK sizes to its LARGEST tenant (CSS max()), never the active tab:
|
||||
// dropping a pane into a zone — the drop fronts it — or switching tabs
|
||||
// must not resize the container (dropping sessions into a wider fixed
|
||||
// zone used to snap the whole zone down to sidebar width).
|
||||
return cssMax(declaredSizes) ?? null
|
||||
}
|
||||
|
||||
const visible = node.children.filter(child => !subtreeGone(child, ctx))
|
||||
const sizes = visible.map(child => fixedTrackSize(child, axis, ctx))
|
||||
|
||||
if (node.orientation === axis) {
|
||||
if (sizes.length === 0 || sizes.some(size => size === null)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return sizes.length === 1 ? sizes[0] : `calc(${sizes.join(' + ')})`
|
||||
}
|
||||
|
||||
// Across the axis a flex child just stretches; the fixed ones set the size.
|
||||
return cssMax(sizes) ?? null
|
||||
}
|
||||
|
||||
/** True when every pane in the subtree is hidden/narrow-collapsed. */
|
||||
export function subtreeGone(node: LayoutNode, ctx: TrackContext): boolean {
|
||||
const ids = allPaneIds(node)
|
||||
|
||||
return ids.length > 0 && ids.every(ctx.paneGone)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which chrome toggle owns a root-row child — SEMANTIC, not positional:
|
||||
* ⌘B is the sessions/nav column (any `placement: 'left'` pane) wherever a
|
||||
* flip or drag puts it; ⌘J is every other side column. `null` = contains
|
||||
* the main zone, never side-collapsed. This is what keeps the titlebar
|
||||
* toggles and reveals 100% main-compatible through ⌘\ flips.
|
||||
*/
|
||||
export function rootChildSide(
|
||||
child: LayoutNode,
|
||||
paneFor: (id: string) => Contribution | undefined
|
||||
): 'left' | 'right' | null {
|
||||
const placements = allPaneIds(child).map(id => paneChrome(paneFor(id)).placement)
|
||||
|
||||
if (placements.includes('main')) {
|
||||
return null
|
||||
}
|
||||
|
||||
return placements.includes('left') ? 'left' : 'right'
|
||||
}
|
||||
|
||||
/**
|
||||
* The FIXED zone that owns `edge` of this subtree along `axis` — the zone a
|
||||
* sash on that boundary actually resizes (dragging the seam between main and
|
||||
* a nested right section resizes the section's edge sidebar, VS Code-style).
|
||||
*/
|
||||
export function edgeFixedZone(
|
||||
node: LayoutNode,
|
||||
edge: 'start' | 'end',
|
||||
axis: 'row' | 'column',
|
||||
ctx: TrackContext
|
||||
): GroupNode | null {
|
||||
if (node.type === 'group') {
|
||||
return fixedTrackSize(node, axis, ctx) !== null ? node : null
|
||||
}
|
||||
|
||||
const visible = node.children.filter(child => !subtreeGone(child, ctx))
|
||||
|
||||
if (node.orientation === axis) {
|
||||
const child = edge === 'start' ? visible[0] : visible[visible.length - 1]
|
||||
|
||||
return child ? edgeFixedZone(child, edge, axis, ctx) : null
|
||||
}
|
||||
|
||||
// Cross-axis: every child touches the edge — the first fixed one owns it.
|
||||
for (const child of visible) {
|
||||
const zone = edgeFixedZone(child, edge, axis, ctx)
|
||||
|
||||
if (zone) {
|
||||
return zone
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -0,0 +1,656 @@
|
|||
/**
|
||||
* Group node renderer — a ZONE: header strip (tabs when stacked, minimize
|
||||
* chevron) + the active pane's content, resolved from the contribution
|
||||
* registry (`area: 'panes'`). Empty zones exist only in editor-authored
|
||||
* trees (drop targets until the first structural op prunes them).
|
||||
*
|
||||
* Dragging is FancyZones-style (drag-session.ts): the layout stays fixed and
|
||||
* every zone lights up as a whole-region drop target. Right-click opens the
|
||||
* contextual zone menu (split/move + header/minimize toggles).
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type CSSProperties, Fragment, type ReactNode, type RefObject, useRef, useState } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { DecodeText } from '@/components/ui/decode-text'
|
||||
import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS, DropPill } from '@/components/ui/drop-affordance'
|
||||
import { PANE_TAB_STRIP_LINE, PANE_TAB_STRIP_LINE_LEFT, PANE_TAB_STRIP_LINE_RIGHT, PaneTab, PaneTabLabel } from '@/components/ui/pane-tab'
|
||||
import { ContribBoundary } from '@/contrib/react/boundary'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { $layoutEditMode } from '../../edit-mode'
|
||||
import { useWindowControlsOverlap } from '../../geometry'
|
||||
import type { DropPosition, GroupNode, RootEdge } from '../model'
|
||||
import { adjacentGroup } from '../model'
|
||||
import {
|
||||
$dropHint,
|
||||
$hiddenTreePanes,
|
||||
$layoutTree,
|
||||
$narrowViewport,
|
||||
$treeDragging,
|
||||
activateTreePane,
|
||||
closeTreePane,
|
||||
moveTreePane,
|
||||
SESSION_TILE_DRAG,
|
||||
setTreeGroupHeaderHidden,
|
||||
splitTreeZone,
|
||||
toggleTreeGroupMinimized
|
||||
} from '../store'
|
||||
|
||||
import { type DoubleTapContext, startPaneDrag } from './drag-session'
|
||||
import { paneChrome } from './track-model'
|
||||
|
||||
/** A directional action in the zone menu (computed per group state). */
|
||||
interface ZoneMenuDirection {
|
||||
side: RootEdge
|
||||
label: string
|
||||
run: () => void
|
||||
}
|
||||
|
||||
const DIRECTION_ORDER: readonly RootEdge[] = ['right', 'bottom', 'left', 'top']
|
||||
const DIRECTION_ARROW: Record<RootEdge, string> = { bottom: '↓', left: '←', right: '→', top: '↑' }
|
||||
|
||||
/** Right-click zone menu: directional actions + header toggle + minimize.
|
||||
* The directions are CONTEXTUAL (computed by TreeGroup): a stacked group
|
||||
* offers "Split <dir>" (carve a new zone with the clicked pane — VS Code
|
||||
* split-and-move in one gesture); a single-pane group offers "Move <dir>"
|
||||
* into the zone actually sitting on that side — directions with no visible
|
||||
* neighbor aren't offered, so no action ever appears to do nothing. */
|
||||
function ZoneMenu({
|
||||
children,
|
||||
closable,
|
||||
minimizable = true,
|
||||
directions,
|
||||
headerHidden,
|
||||
minimized,
|
||||
nodeId
|
||||
}: {
|
||||
children: ReactNode
|
||||
/** The pane the menu closes (the right-clicked chip / the active pane);
|
||||
* undefined = not closable (the main zone). */
|
||||
closable?: () => string | undefined
|
||||
/** False for the zone hosting the uncloseable workspace — collapsing the
|
||||
* MAIN pane strands the app behind a strip. */
|
||||
minimizable?: boolean
|
||||
directions: ZoneMenuDirection[]
|
||||
headerHidden?: boolean
|
||||
minimized?: boolean
|
||||
nodeId: string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{directions.map(direction => (
|
||||
<ContextMenuItem key={direction.side} onSelect={direction.run}>
|
||||
{direction.label}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
<ContextMenuItem onSelect={() => setTreeGroupHeaderHidden(nodeId, !headerHidden)}>
|
||||
{headerHidden ? t.zones.showHeader : t.zones.hideHeader}
|
||||
</ContextMenuItem>
|
||||
{minimizable && (
|
||||
<ContextMenuItem onSelect={() => toggleTreeGroupMinimized(nodeId, !minimized)}>
|
||||
{minimized ? t.zones.restore : t.zones.minimize}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{/* Resolved at render: the menu mounts on open, after the right-click
|
||||
set menuPane — so an uncloseable target hides the item instead
|
||||
of offering a dead action. */}
|
||||
{closable?.() !== undefined && (
|
||||
<ContextMenuItem
|
||||
onSelect={() => {
|
||||
const paneId = closable?.()
|
||||
|
||||
if (paneId) {
|
||||
closeTreePane(paneId)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t.common.close}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function TreeGroup({
|
||||
node,
|
||||
parentAxis,
|
||||
railSide = 'left'
|
||||
}: {
|
||||
node: GroupNode
|
||||
parentAxis?: 'column' | 'row'
|
||||
railSide?: 'left' | 'right'
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const stripRef = useRef<HTMLDivElement>(null)
|
||||
// The chip under the last right-click — the pane the zone menu's Split
|
||||
// actions carry into the new zone (header background = the active pane).
|
||||
// STATE, not a ref: the menu items (incl. Close's visibility) are JSX
|
||||
// evaluated during THIS component's render — a ref write on right-click
|
||||
// doesn't re-render, so the menu showed the PREVIOUS target's items (Close
|
||||
// missing on an inactive tile tab whose zone-active was the uncloseable
|
||||
// workspace).
|
||||
const [menuPane, setMenuPane] = useState<string | undefined>(undefined)
|
||||
const panes = useContributions('panes')
|
||||
// Coarse drag flag only (set once at drag start/end). The per-frame drop
|
||||
// HINT lives in ZoneDropOverlay so a moving pointer re-renders the tiny
|
||||
// overlay, not every zone's header/body (and not the menuDirections walk).
|
||||
const dragging = useStore($treeDragging)
|
||||
const editMode = useStore($layoutEditMode)
|
||||
const wcOverlap = useWindowControlsOverlap(ref, true)
|
||||
|
||||
const hiddenPanes = useStore($hiddenTreePanes)
|
||||
const narrow = useStore($narrowViewport)
|
||||
|
||||
const paneFor = (id: string) => panes.find(p => p.id === id)
|
||||
|
||||
// Unregistered (plugin not loaded), chrome-toggled-off, and narrow-collapsed
|
||||
// panes drop out of the header; the active pane falls back to the first
|
||||
// shown one (render-side — the tree keeps `active`).
|
||||
// Edit mode forces toggle-hidden panes visible so they can be rearranged
|
||||
// (mirrors tree-split's paneGone) — restores itself on exit.
|
||||
const paneShown = (id: string) =>
|
||||
Boolean(paneFor(id)) && (editMode || !hiddenPanes.has(id)) && !(narrow && paneChrome(paneFor(id)).collapsible)
|
||||
|
||||
const shown = node.panes.filter(paneShown)
|
||||
const activeId = shown.includes(node.active) ? node.active : (shown[0] ?? node.active)
|
||||
const active = paneFor(activeId)
|
||||
const isEmpty = node.panes.length === 0
|
||||
|
||||
// ONE header style: the app's compact pane-header. DEFAULT is contextual —
|
||||
// a single pane isn't a "tab", so its header auto-hides; a stack shows its
|
||||
// chips. EXCEPTION: a lone TILE (closeable, placement 'main' — a session/page
|
||||
// split) always shows its header, so it has a tab + close X — a tile in its
|
||||
// own zone was otherwise unclosable (the "3rd tile has no tab" trap). Chrome
|
||||
// panes (sessions/files/terminal…) and the uncloseable workspace keep the
|
||||
// clean no-tab default. Double-click toggles it either way; a minimized
|
||||
// group always shows its header (it IS the header).
|
||||
const hasLoneTile = shown.some(id => {
|
||||
const chrome = paneChrome(paneFor(id))
|
||||
|
||||
return !chrome.uncloseable && chrome.placement === 'main'
|
||||
})
|
||||
|
||||
// A full-page view (headerVeto) suppresses the strip while it's the active
|
||||
// pane — a page is not a tab-able surface; the bar returns with the chat.
|
||||
const headerHidden = paneChrome(active).headerVeto || (node.headerHidden ?? (shown.length <= 1 && !hasLoneTile))
|
||||
|
||||
// A group collapses ALONG its parent split's axis. In a row that means the
|
||||
// WIDTH collapses — a full-width horizontal header would strand a tall
|
||||
// empty column, so the minimized form is a narrow vertical rail instead
|
||||
// (tabs reading top-to-bottom). In a column (stacked zones) the horizontal
|
||||
// header IS the collapsed form, exactly as before.
|
||||
const verticalCollapse = Boolean(node.minimized) && parentAxis === 'row' && !isEmpty
|
||||
const headerVisible = !isEmpty && !verticalCollapse && (Boolean(node.minimized) || !headerHidden)
|
||||
|
||||
// Drag handles preventDefault pointerdown (no native dblclick), so the
|
||||
// header + chips share a synthesized double-tap: restore if collapsed
|
||||
// (undoing the first tap's minimize toggle) and hide the chrome.
|
||||
const hideHeaderDoubleTap: DoubleTapContext = {
|
||||
key: `hide-header-${node.id}`,
|
||||
onDoubleTap: () => {
|
||||
toggleTreeGroupMinimized(node.id, false)
|
||||
setTreeGroupHeaderHidden(node.id, true)
|
||||
}
|
||||
}
|
||||
|
||||
const dirWord: Record<RootEdge, string> = {
|
||||
bottom: t.zones.dirDown,
|
||||
left: t.zones.dirLeft,
|
||||
right: t.zones.dirRight,
|
||||
top: t.zones.dirUp
|
||||
}
|
||||
|
||||
// Zone-menu directions, contextual to this group's state:
|
||||
// - stacked panes -> "Split <dir>": carve a new zone on that side with the
|
||||
// right-clicked chip's pane in it (split + move, one gesture);
|
||||
// - a single pane -> "Move <dir>": join the zone visually adjacent on that
|
||||
// side (splitting here would only make an invisible empty zone). Sides
|
||||
// with no visible neighbor are omitted entirely.
|
||||
const tree = useStore($layoutTree)
|
||||
|
||||
const menuDirections: ZoneMenuDirection[] =
|
||||
shown.length > 1
|
||||
? DIRECTION_ORDER.map(side => ({
|
||||
side,
|
||||
label: `${t.zones.split(dirWord[side])} ${DIRECTION_ARROW[side]}`,
|
||||
run: () => splitTreeZone(node.id, side, menuPane ?? activeId)
|
||||
}))
|
||||
: DIRECTION_ORDER.flatMap(side => {
|
||||
const neighbor = tree ? adjacentGroup(tree, node.id, side, g => g.panes.some(paneShown)) : null
|
||||
|
||||
if (!neighbor || neighbor.id === node.id) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
side,
|
||||
label: `${t.zones.move(dirWord[side])} ${DIRECTION_ARROW[side]}`,
|
||||
run: () => moveTreePane(activeId, { groupId: neighbor.id, pos: 'center' })
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Close targets the right-clicked chip (falling back to the active pane);
|
||||
// only panes that declare `uncloseable` (the main workspace) are exempt.
|
||||
const closable = () => {
|
||||
const paneId = menuPane ?? activeId
|
||||
|
||||
return paneChrome(paneFor(paneId)).uncloseable ? undefined : paneId
|
||||
}
|
||||
|
||||
// The zone hosting the uncloseable workspace never minimizes — collapsing
|
||||
// MAIN strands the whole app behind a strip.
|
||||
const minimizable = !shown.some(id => paneChrome(paneFor(id)).uncloseable)
|
||||
|
||||
// Same menu on the header strip and the edit veil — one prop bag.
|
||||
const zoneMenu = {
|
||||
closable,
|
||||
directions: menuDirections,
|
||||
headerHidden,
|
||||
minimizable,
|
||||
minimized: node.minimized,
|
||||
nodeId: node.id
|
||||
}
|
||||
|
||||
// NO body double-click toggle: virtualized content (the thread) recreates
|
||||
// its nodes between clicks, so the gesture was hopelessly unreliable. The
|
||||
// bar's lifecycle is explicit instead — gaining a tab sticky-shows it
|
||||
// (insertAtGroup pins headerHidden false), the main tab's context menu
|
||||
// hides it, and full-page views veto it via paneChrome.headerVeto.
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-(--ui-bg-editor)"
|
||||
data-tree-group={node.id}
|
||||
// Advertises the visible tab strip so panes can drop their own
|
||||
// self-naming labels (see [data-pane-self-label] in styles.css).
|
||||
data-zone-header={headerVisible || undefined}
|
||||
ref={ref}
|
||||
style={wcOverlap ? { paddingTop: wcOverlap.y + wcOverlap.height } : undefined}
|
||||
>
|
||||
{wcOverlap && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute z-10 [-webkit-app-region:drag]"
|
||||
style={{ height: wcOverlap.height, left: wcOverlap.x, top: wcOverlap.y, width: wcOverlap.width }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Minimized in a ROW: a narrow vertical rail — same PaneTab shell as
|
||||
the horizontal strip, just `vertical`. Click a tab to restore +
|
||||
activate; click anywhere else on the rail to restore. */}
|
||||
{verticalCollapse && (
|
||||
<ZoneMenu {...zoneMenu}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full w-7 shrink-0 cursor-pointer select-none flex-col items-stretch bg-(--pane-tab-strip-bg) [--pane-tab-strip-bg:var(--theme-card-seed)]',
|
||||
// Strip line faces the content the zone collapsed away from.
|
||||
railSide === 'right' ? PANE_TAB_STRIP_LINE_LEFT : PANE_TAB_STRIP_LINE_RIGHT
|
||||
)}
|
||||
onClick={() => toggleTreeGroupMinimized(node.id, false)}
|
||||
title={t.zones.restore}
|
||||
>
|
||||
<div
|
||||
className="flex min-h-0 flex-col overflow-y-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
role="tablist"
|
||||
>
|
||||
{shown.map(paneId => {
|
||||
const closeable = !paneChrome(paneFor(paneId)).uncloseable
|
||||
const title = paneFor(paneId)?.title ?? paneId
|
||||
|
||||
return (
|
||||
<PaneTab
|
||||
// Match the horizontal minimized strip: no tab is "active"
|
||||
// while collapsed (there's no content surface to merge into).
|
||||
aria-selected={paneId === activeId}
|
||||
data-tree-tab={paneId}
|
||||
key={paneId}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
toggleTreeGroupMinimized(node.id, false)
|
||||
activateTreePane(node.id, paneId)
|
||||
}}
|
||||
onClose={closeable ? () => closeTreePane(paneId) : undefined}
|
||||
role="tab"
|
||||
side={railSide}
|
||||
vertical
|
||||
>
|
||||
<PaneTabLabel>{title}</PaneTabLabel>
|
||||
</PaneTab>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</ZoneMenu>
|
||||
)}
|
||||
|
||||
{/* Header: the file-preview tab strip (PaneTab), one shared component. */}
|
||||
{headerVisible && (
|
||||
<ZoneMenu {...zoneMenu}>
|
||||
<div
|
||||
// Active = sidebar surface (merges into body). Strip =
|
||||
// `--theme-card-seed` (VS Code `tab.inactiveBackground`). Line =
|
||||
// PANE_TAB_STRIP_LINE; active tab cuts through it.
|
||||
// data-zone-tabstrip: a drop over here STACKS (drag-session reads it).
|
||||
className={cn(
|
||||
'group/pane-header relative flex h-7 shrink-0 select-none bg-(--pane-tab-strip-bg) [-webkit-app-region:no-drag] [--pane-tab-active-bg:var(--ui-sidebar-surface-background)] [--pane-tab-strip-bg:var(--theme-card-seed)]',
|
||||
PANE_TAB_STRIP_LINE
|
||||
)}
|
||||
data-zone-tabstrip={node.id}
|
||||
onContextMenu={e => {
|
||||
setMenuPane((e.target as HTMLElement).closest('[data-tree-tab]')?.getAttribute('data-tree-tab') ?? undefined)
|
||||
}}
|
||||
onPointerDown={e =>
|
||||
// Tap the header to collapse to it / expand back — the DetailPane
|
||||
// / sidebar-section gesture (never for the main zone). Double-tap
|
||||
// hides the header entirely. Drag still moves the pane.
|
||||
startPaneDrag(
|
||||
activeId,
|
||||
e,
|
||||
() => minimizable && toggleTreeGroupMinimized(node.id, !node.minimized),
|
||||
undefined,
|
||||
hideHeaderDoubleTap
|
||||
)
|
||||
}
|
||||
ref={stripRef}
|
||||
style={{ cursor: 'grab' }}
|
||||
>
|
||||
<div
|
||||
className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
role="tablist"
|
||||
>
|
||||
{shown.map(paneId => {
|
||||
const isActive = paneId === activeId && !node.minimized
|
||||
const chrome = paneChrome(paneFor(paneId))
|
||||
const closeable = !chrome.uncloseable
|
||||
const title = paneFor(paneId)?.title ?? paneId
|
||||
|
||||
const tab = (
|
||||
<PaneTab
|
||||
active={isActive}
|
||||
aria-selected={isActive}
|
||||
data-tree-tab={paneId}
|
||||
key={paneId}
|
||||
onClose={closeable ? () => closeTreePane(paneId) : undefined}
|
||||
onPointerDown={e =>
|
||||
startPaneDrag(
|
||||
paneId,
|
||||
e,
|
||||
() => {
|
||||
// Tabs ACTIVATE (restoring a collapsed group).
|
||||
// Minimize lives on the chevron / single-pane label
|
||||
// — overloading the active tab made double-click a
|
||||
// minimize/restore/hide lottery.
|
||||
if (node.minimized) {
|
||||
toggleTreeGroupMinimized(node.id, false)
|
||||
}
|
||||
|
||||
activateTreePane(node.id, paneId)
|
||||
},
|
||||
stripRef.current ? { groupId: node.id, strip: stripRef.current } : undefined,
|
||||
hideHeaderDoubleTap
|
||||
)
|
||||
}
|
||||
role="tab"
|
||||
style={{ cursor: 'grab' }}
|
||||
>
|
||||
<PaneTabLabel>{title}</PaneTabLabel>
|
||||
</PaneTab>
|
||||
)
|
||||
|
||||
// A pane may wrap ITS tab in a domain menu (session verbs on a
|
||||
// tile tab); the wrapper needs the key since it's the root.
|
||||
return <Fragment key={paneId}>{chrome.tabWrap ? chrome.tabWrap(tab) : tab}</Fragment>
|
||||
})}
|
||||
</div>
|
||||
{minimizable && (
|
||||
<button
|
||||
aria-label={node.minimized ? t.zones.restore : t.zones.minimize}
|
||||
className="mx-1 grid size-5 shrink-0 place-items-center self-center rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 group-hover/pane-header:opacity-100"
|
||||
onClick={() => toggleTreeGroupMinimized(node.id, !node.minimized)}
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name={node.minimized ? 'chevron-down' : 'chevron-up'} size="0.75rem" />
|
||||
</button>
|
||||
)}
|
||||
<StripDropCaret groupId={node.id} stripRef={stripRef} />
|
||||
</div>
|
||||
</ZoneMenu>
|
||||
)}
|
||||
|
||||
{/* Body: the active pane's contributed content, or the empty zone. */}
|
||||
{!node.minimized && (
|
||||
<div className="relative min-h-0 min-w-0 flex-1 overflow-auto">
|
||||
{isEmpty ? (
|
||||
<div className="grid h-full place-items-center">
|
||||
{/* Same decode primitive as the CONNECTING boot overlay. */}
|
||||
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="HERMES" />
|
||||
</div>
|
||||
) : active?.render ? (
|
||||
<ContribBoundary id={active.id}>{active.render()}</ContribBoundary>
|
||||
) : (
|
||||
<div className="p-3 font-mono text-[11px] text-(--ui-text-quaternary)">{t.zones.missingPane(activeId)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit-mode veil: the BODY is a drag handle for the active pane. It
|
||||
starts below the header so tabs/headers stay directly interactive
|
||||
(drag any tab, right-click for the zone menu). */}
|
||||
{editMode && !dragging && !isEmpty && !node.minimized && (
|
||||
<ZoneMenu {...zoneMenu}>
|
||||
<div
|
||||
// z-50: pane CONTENT may carry its own stacked chrome (the
|
||||
// terminal rail is z-40) — the edit veil must cover all of it.
|
||||
// The scrim mixes the accent over the CHROME BG (not transparent)
|
||||
// so it properly dims content in dark themes instead of leaving a
|
||||
// barely-tinted wash; the light blur reads as "edit mode" the same
|
||||
// way the zone editor's backdrop does.
|
||||
className="absolute inset-x-0 bottom-0 z-50 flex cursor-grab items-center justify-center outline-1 -outline-offset-2 outline-dashed backdrop-blur-[2px]"
|
||||
onPointerDown={e => startPaneDrag(activeId, e)}
|
||||
style={{
|
||||
top: headerVisible ? 28 : 0,
|
||||
background:
|
||||
'color-mix(in srgb, var(--ui-accent) 6%, color-mix(in srgb, var(--ui-bg-chrome) 55%, transparent))',
|
||||
outlineColor: 'color-mix(in srgb, var(--ui-accent) 55%, transparent)'
|
||||
}}
|
||||
>
|
||||
<span className="flex max-w-[calc(100%-1rem)] items-center gap-1.5 rounded-md border border-(--ui-stroke-secondary) bg-popover px-2 py-1 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-(--ui-text-secondary)">
|
||||
<Codicon className="shrink-0" name="gripper" size="0.8125rem" />
|
||||
<span className="min-w-0 truncate">{active?.title ?? activeId}</span>
|
||||
</span>
|
||||
</div>
|
||||
</ZoneMenu>
|
||||
)}
|
||||
|
||||
{/* FancyZones drop overlay — its own component so the per-frame drop
|
||||
hint re-renders only this (tiny) node, not the whole zone. */}
|
||||
<ZoneDropOverlay isEmpty={isEmpty} node={node} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab-strip insertion caret
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The insertion divider for a stack drop: a 2px vertical line at the slot the
|
||||
* dragged tab will land in (before `stack.before`, or after the last tab).
|
||||
* Absolute over the strip — pure overlay, zero layout shift. #000 on light,
|
||||
* #FFF on dark. Split out so per-pointermove `$dropHint` churn re-renders
|
||||
* only this node (same isolation contract as ZoneDropOverlay).
|
||||
*/
|
||||
function StripDropCaret({ groupId, stripRef }: { groupId: string; stripRef: RefObject<HTMLDivElement | null> }) {
|
||||
const hint = useStore($dropHint)
|
||||
const strip = stripRef.current
|
||||
const stack = hint?.groupId === groupId ? hint.stack : undefined
|
||||
|
||||
if (stack === undefined || !strip) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Slot x: the before-tab's left edge, or the last tab's right edge.
|
||||
const tabs = [...strip.querySelectorAll<HTMLElement>('[data-tree-tab]')]
|
||||
const target = stack.before ? tabs.find(el => el.dataset.treeTab === stack.before) : tabs.at(-1)
|
||||
|
||||
if (!target) {
|
||||
return null
|
||||
}
|
||||
|
||||
const stripRect = strip.getBoundingClientRect()
|
||||
const targetRect = target.getBoundingClientRect()
|
||||
const x = (stack.before ? targetRect.left : targetRect.right) - stripRect.left
|
||||
|
||||
// A short centered tick (~60% of the tab), not a full-height wall — reads
|
||||
// as an insertion point between labels, browser-tab style.
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute z-50 w-px -translate-x-1/2 bg-black dark:bg-white"
|
||||
style={{
|
||||
height: targetRect.height * 0.6,
|
||||
left: x,
|
||||
top: targetRect.top - stripRect.top + targetRect.height * 0.2
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FancyZones drop overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Overlay entry fade. FancyZones ships 200ms (FADE_IN_DURATION_MILLIS in
|
||||
* zones-engine); on a drag that starts under the cursor that ramp reads as
|
||||
* lag, so the sheets snap in far faster — same softening, instant feel. */
|
||||
const OVERLAY_FADE_MS = 80
|
||||
|
||||
/** Sheet inset from the zone edge (px). */
|
||||
const REGION_PAD = 6
|
||||
|
||||
/** The sheet's box per drop position — longhand insets so CSS transitions can
|
||||
* interpolate the px↔% change: the target GLIDES between the full zone and
|
||||
* the hovered half instead of snapping (VS Code dock preview). */
|
||||
const REGION: Record<DropPosition, CSSProperties> = {
|
||||
bottom: { bottom: REGION_PAD, left: REGION_PAD, right: REGION_PAD, top: '50%' },
|
||||
center: { bottom: REGION_PAD, left: REGION_PAD, right: REGION_PAD, top: REGION_PAD },
|
||||
left: { bottom: REGION_PAD, left: REGION_PAD, right: '50%', top: REGION_PAD },
|
||||
right: { bottom: REGION_PAD, left: '50%', right: REGION_PAD, top: REGION_PAD },
|
||||
top: { bottom: '50%', left: REGION_PAD, right: REGION_PAD, top: REGION_PAD }
|
||||
}
|
||||
|
||||
const EDGE_ARROW: Record<Exclude<DropPosition, 'center'>, string> = {
|
||||
bottom: 'arrow-down',
|
||||
left: 'arrow-left',
|
||||
right: 'arrow-right',
|
||||
top: 'arrow-up'
|
||||
}
|
||||
|
||||
/**
|
||||
* The FancyZones drop overlay for one zone. Split out of TreeGroup so the
|
||||
* per-pointermove `$dropHint` churn re-renders only this lightweight node —
|
||||
* the zone's header, body, and menu-direction walk stay put during a drag.
|
||||
*
|
||||
* ONE dashed sheet per zone, in the attachment dropzone's design language
|
||||
* (DROP_SHEET_CLASS + DropPill — the composer drop and the zone targets speak
|
||||
* identically): a quiet outline over every eligible zone, accent-lit over the
|
||||
* target, morphing to the hovered half for an edge split. The pill names the
|
||||
* outcome; edges get their arrow.
|
||||
*/
|
||||
function ZoneDropOverlay({ isEmpty, node }: { isEmpty: boolean; node: GroupNode }) {
|
||||
const { t } = useI18n()
|
||||
const dragging = useStore($treeDragging)
|
||||
const hint = useStore($dropHint)
|
||||
|
||||
if (dragging === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// A session drag (sidebar row) reuses this exact overlay — over ANY zone
|
||||
// now (stack into its tabs / split its edges); only a CHAT zone's center is
|
||||
// a link-to-chat (the composer overlay owns that visual).
|
||||
const sessionDrag = dragging === SESSION_TILE_DRAG
|
||||
const chatZone = node.panes.some(p => p === 'workspace' || p.startsWith('session-tile:'))
|
||||
|
||||
const isDragSource = node.panes.includes(dragging)
|
||||
|
||||
// The source zone, when it holds only the dragged pane, has nothing to drop.
|
||||
if (isDragSource && node.panes.length === 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const primary = hint?.groupId === node.id
|
||||
|
||||
// Hovering the target's TAB STRIP: the insertion caret (StripDropCaret)
|
||||
// owns the affordance — the zone sheet stands down so the two never stack.
|
||||
if (primary && hint?.stack !== undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const active = hint?.groupIds?.includes(node.id) ?? false
|
||||
const multi = (hint?.groupIds?.length ?? 0) > 1
|
||||
// Sub-positions only exist for a single-zone target (a Shift-span merges).
|
||||
const pos = primary && !multi ? (hint?.pos ?? 'center') : 'center'
|
||||
// Session drag over a CHAT zone's CENTER: the "link to chat" overlay inside
|
||||
// the surface (ChatDropOverlay — the same sheet + pill) owns that region;
|
||||
// this sheet fades out so the two never stack. A non-chat zone's center has
|
||||
// no chat to link, so it shows the normal stack sheet. Edges act like a tab.
|
||||
const centerLink = sessionDrag && primary && pos === 'center' && chatZone
|
||||
|
||||
const pill =
|
||||
!primary || centerLink
|
||||
? null
|
||||
: multi
|
||||
? { icon: 'combine', label: t.zones.spanHere }
|
||||
: pos !== 'center'
|
||||
? { icon: EDGE_ARROW[pos], label: sessionDrag ? t.zones.openHere : t.zones.splitHere }
|
||||
: isDragSource
|
||||
? { icon: 'discard', label: t.zones.staysHere }
|
||||
: { icon: 'layers', label: isEmpty ? t.zones.moveHere : t.zones.stackHere }
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-40"
|
||||
style={{ animation: `hermes-zone-fade ${OVERLAY_FADE_MS}ms linear both` }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
DROP_SHEET_CLASS,
|
||||
// Transition ONLY the box + colors. `transition-all` also animated
|
||||
// backdrop-filter, and a blur interpolating while the insets glide
|
||||
// re-blurs half a zone every frame — the single most expensive
|
||||
// paint in the whole drag.
|
||||
'absolute flex items-center justify-center transition-[top,right,bottom,left,background-color,border-color,opacity] duration-150 ease-out',
|
||||
// Blur only the live target — idle outlines must not fog the app.
|
||||
active && !centerLink && DROP_SHEET_BLUR_CLASS,
|
||||
centerLink && 'opacity-0'
|
||||
)}
|
||||
style={{
|
||||
...REGION[pos],
|
||||
// Accent over a card wash so the fill dims content on dark themes
|
||||
// (a bare accent alpha disappears there).
|
||||
background: active
|
||||
? 'color-mix(in srgb, var(--ui-accent) 18%, color-mix(in srgb, var(--dt-card) 55%, transparent))'
|
||||
: 'color-mix(in srgb, var(--ui-accent) 5%, color-mix(in srgb, var(--dt-card) 25%, transparent))',
|
||||
borderColor: `color-mix(in srgb, var(--ui-accent) ${active ? 75 : 28}%, transparent)`
|
||||
}}
|
||||
>
|
||||
{pill && <DropPill icon={pill.icon}>{pill.label}</DropPill>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import type { LayoutNode } from '../model'
|
||||
|
||||
import { TreeGroup } from './tree-group'
|
||||
import { TreeSplit } from './tree-split'
|
||||
|
||||
/** Dispatch a layout node to its renderer — the split/group recursion point.
|
||||
* `root` marks the tree's top split (side collapse applies only there).
|
||||
* `parentAxis` is the containing split's orientation — a group collapses
|
||||
* ALONG that axis, so it picks the minimized form (row → vertical rail,
|
||||
* column → horizontal header). `railSide` is which half of that row the
|
||||
* child sits in — the rail's divider stroke faces the content side. */
|
||||
export function TreeNode({
|
||||
node,
|
||||
parentAxis,
|
||||
railSide,
|
||||
root
|
||||
}: {
|
||||
node: LayoutNode
|
||||
parentAxis?: 'column' | 'row'
|
||||
railSide?: 'left' | 'right'
|
||||
root?: boolean
|
||||
}) {
|
||||
return node.type === 'split' ? (
|
||||
<TreeSplit node={node} root={root} />
|
||||
) : (
|
||||
<TreeGroup node={node} parentAxis={parentAxis} railSide={railSide} />
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,518 @@
|
|||
/**
|
||||
* Split node renderer — a flex row/column whose 1px seams double as resize
|
||||
* sashes (the seam IS the boundary — junction-owned, never doubled). Sizing
|
||||
* is the TRACK MODEL (track-model.ts): fixed tracks keep their declared size,
|
||||
* flex tracks share the leftover by weight, and an all-fixed run lets its
|
||||
* last track absorb the slack (VS Code style).
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type PointerEvent as ReactPointerEvent, useCallback, useMemo, useRef, useSyncExternalStore } from 'react'
|
||||
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $paneStates, type PaneStateSnapshot, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes'
|
||||
|
||||
import { $layoutEditMode } from '../../edit-mode'
|
||||
import type { LayoutNode, SplitNode } from '../model'
|
||||
import { allPaneIds } from '../model'
|
||||
import {
|
||||
$collapsedTreeSides,
|
||||
$hiddenTreePanes,
|
||||
$narrowViewport,
|
||||
persistTree,
|
||||
presetSplitWeights,
|
||||
setTreeSplitWeights
|
||||
} from '../store'
|
||||
|
||||
import {
|
||||
computedPx,
|
||||
cssMax,
|
||||
edgeFixedZone,
|
||||
fixedTrackSize,
|
||||
MIN_PANE_PX,
|
||||
paneChrome,
|
||||
type PaneSizing,
|
||||
resolveCssPx,
|
||||
rootChildSide,
|
||||
shownPaneIds,
|
||||
subtreeGone,
|
||||
type TrackContext
|
||||
} from './track-model'
|
||||
import { TreeNode } from './tree-node'
|
||||
|
||||
/**
|
||||
* The size overrides for a fixed set of panes, referentially stable until one
|
||||
* of THEM changes. Sash drags churn `$paneStates` every frame; subscribing the
|
||||
* whole map would re-render every split — this narrows each split to its own
|
||||
* subtree via a signature-gated snapshot.
|
||||
*/
|
||||
function useSubtreeOverrides(paneIds: readonly string[]): TrackContext['overrides'] {
|
||||
const key = paneIds.join(',')
|
||||
const cache = useRef<{ sig: string; value: Record<string, PaneStateSnapshot> }>({ sig: '\0', value: {} })
|
||||
|
||||
const snapshot = useCallback(() => {
|
||||
const all = $paneStates.get()
|
||||
const sig = paneIds.map(id => `${id}:${all[id]?.widthOverride ?? ''}:${all[id]?.heightOverride ?? ''}`).join('|')
|
||||
|
||||
if (cache.current.sig !== sig) {
|
||||
cache.current = { sig, value: Object.fromEntries(paneIds.flatMap(id => (all[id] ? [[id, all[id]]] : []))) }
|
||||
}
|
||||
|
||||
return cache.current.value
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key])
|
||||
|
||||
return useSyncExternalStore(cb => $paneStates.listen(cb), snapshot, snapshot)
|
||||
}
|
||||
|
||||
export function TreeSplit({ node, root }: { node: SplitNode; root?: boolean }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const panes = useContributions('panes')
|
||||
const hiddenPanes = useStore($hiddenTreePanes)
|
||||
const narrow = useStore($narrowViewport)
|
||||
// Scoped to THIS subtree's panes: a sash drag writes size overrides on every
|
||||
// pointermove, but only the splits whose subtree actually resized should
|
||||
// re-render — not every split in the tree.
|
||||
const overrides = useSubtreeOverrides(useMemo(() => allPaneIds(node), [node]))
|
||||
const editMode = useStore($layoutEditMode)
|
||||
const collapsedSides = useStore($collapsedTreeSides)
|
||||
const horizontal = node.orientation === 'row'
|
||||
const axis = node.orientation
|
||||
|
||||
// A pane leaves the grid when its contribution isn't registered (yet) — a
|
||||
// runtime plugin's pane collapses until the plugin loads, then appears; no
|
||||
// placeholder flash — when a chrome toggle hides it, or when the viewport
|
||||
// is narrow and the pane is collapsible (edge overlay instead).
|
||||
const paneFor = (id: string) => panes.find(p => p.id === id)
|
||||
|
||||
// Layout-edit mode forces toggle-hidden panes (terminal off, review/preview
|
||||
// closed) visible so they're rearrangeable — only truly-absent (unregistered)
|
||||
// or narrow-collapsed panes stay gone. Restores itself on exit (render-only).
|
||||
const paneGone = (id: string) =>
|
||||
!paneFor(id) || (!editMode && hiddenPanes.has(id)) || (narrow && Boolean(paneChrome(paneFor(id)).collapsible))
|
||||
|
||||
const trackCtx: TrackContext = { paneFor, paneGone, overrides }
|
||||
|
||||
// Chrome-toggle collapse: a subtree whose every pane is gone renders
|
||||
// display:none (content stays MOUNTED — toggling back is instant), and its
|
||||
// siblings absorb the space. Narrow-collapse UNMOUNTS instead, so the edge
|
||||
// overlay owns the single live instance of the pane's content.
|
||||
// EMPTY zones only exist in editor-authored trees (normalize prunes them on
|
||||
// every structural op) — they take space in edit mode as drop targets.
|
||||
const isEmptyZone = (child: LayoutNode) => child.type === 'group' && child.panes.length === 0
|
||||
const isCollapsed = (child: LayoutNode) => subtreeGone(child, trackCtx) || (isEmptyZone(child) && !editMode)
|
||||
|
||||
// Min/max clamps come from a direct GROUP child's panes (the same clamps
|
||||
// the app's Pane props express) — but ONLY when they can speak for the
|
||||
// zone: a fixed track (pure sidebar stack) or a single-pane zone. A sidebar
|
||||
// pane fronted in a mixed flex stack must not cap it. A fixed STACK
|
||||
// aggregates its panes' clamps (largest-tenant semantics, mirroring the
|
||||
// max() track basis) — the active tab's caps must never resize the zone.
|
||||
const sizingFor = (child: LayoutNode, track: string | null): PaneSizing | null => {
|
||||
if (child.type !== 'group' || child.panes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const shownIds = shownPaneIds(child, trackCtx)
|
||||
|
||||
if (track === null && shownIds.length !== 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (shownIds.length <= 1) {
|
||||
return (paneFor(shownIds[0])?.data as PaneSizing | undefined) ?? null
|
||||
}
|
||||
|
||||
// Fixed STACK: floors take the largest declared min; caps stay unbounded
|
||||
// unless EVERY pane declares one (a single uncapped tenant uncaps the
|
||||
// zone). Same largest-tenant basis as the track size — never per-tab.
|
||||
const all = shownIds.map(id => (paneFor(id)?.data ?? {}) as PaneSizing)
|
||||
|
||||
const cap = (pick: (s: PaneSizing) => string | undefined) =>
|
||||
all.every(pick) ? cssMax(all.map(pick)) : undefined
|
||||
|
||||
return {
|
||||
minWidth: cssMax(all.map(s => s.minWidth)),
|
||||
maxWidth: cap(s => s.maxWidth),
|
||||
minHeight: cssMax(all.map(s => s.minHeight)),
|
||||
maxHeight: cap(s => s.maxHeight)
|
||||
}
|
||||
}
|
||||
|
||||
// Sashes pair each visible child with its nearest visible PREVIOUS sibling
|
||||
// (`aIndex`/`bIndex`), not blindly `i-1`/`i` — a collapsed zone in between
|
||||
// (e.g. the closed preview pane parked between main and the right rail)
|
||||
// must not swallow the seam its visible neighbors share.
|
||||
const startSash = useCallback(
|
||||
(aIndex: number, bIndex: number, e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const container = containerRef.current
|
||||
|
||||
if (!container || e.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
const handle = e.currentTarget
|
||||
const { pointerId } = e
|
||||
const rect = container.getBoundingClientRect()
|
||||
const totalPx = horizontal ? rect.width : rect.height
|
||||
const totalWeight = node.weights.reduce((a, b) => a + b, 0) || 1
|
||||
const pxPerWeight = totalPx / totalWeight
|
||||
const start = horizontal ? e.clientX : e.clientY
|
||||
const restoreCursor = document.body.style.cursor
|
||||
const restoreSelect = document.body.style.userSelect
|
||||
|
||||
// Each side of the seam resolves to a RESIZE TARGET: a fixed zone (the
|
||||
// sash writes its px override — sidebar semantics) or the flex run
|
||||
// (the sash writes weights). Sizes/clamps read from the live DOM of
|
||||
// whichever element actually owns the boundary.
|
||||
const sizeOf = (el: HTMLElement) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
|
||||
return horizontal ? r.width : r.height
|
||||
}
|
||||
|
||||
const sideFor = (child: LayoutNode, wrapper: HTMLElement, edge: 'start' | 'end') => {
|
||||
const fixed = fixedTrackSize(child, axis, trackCtx) !== null
|
||||
const zone = fixed ? edgeFixedZone(child, edge, axis, trackCtx) : null
|
||||
const zoneEl = zone ? container.querySelector<HTMLElement>(`[data-tree-group="${zone.id}"]`) : null
|
||||
// Clamps live on the zone's split-child WRAPPER (where we render them).
|
||||
const el = zoneEl?.parentElement ?? wrapper
|
||||
const cs = window.getComputedStyle(el)
|
||||
|
||||
return {
|
||||
// EVERY shown pane of the zone: the zone's track is the max() of its
|
||||
// panes' sizes, so the sash writes the same px to all of them —
|
||||
// writing only the active pane would leave the zone pinned at a
|
||||
// larger sibling's width.
|
||||
paneIds: zone ? shownPaneIds(zone, trackCtx) : [],
|
||||
fixed: Boolean(zone),
|
||||
size: sizeOf(zoneEl ?? wrapper),
|
||||
min: Math.max(MIN_PANE_PX, computedPx(horizontal ? cs.minWidth : cs.minHeight, 0)),
|
||||
max: computedPx(horizontal ? cs.maxWidth : cs.maxHeight, Number.POSITIVE_INFINITY)
|
||||
}
|
||||
}
|
||||
|
||||
const kidA = container.children[aIndex] as HTMLElement | undefined
|
||||
const kidB = container.children[bIndex] as HTMLElement | undefined
|
||||
|
||||
if (!kidA || !kidB) {
|
||||
return
|
||||
}
|
||||
|
||||
const a = sideFor(node.children[aIndex], kidA, 'end')
|
||||
const b = sideFor(node.children[bIndex], kidB, 'start')
|
||||
const a0px = a.fixed ? a.size : sizeOf(kidA)
|
||||
const b0px = b.fixed ? b.size : sizeOf(kidB)
|
||||
const lo = Math.max(a.min - a0px, b0px - b.max)
|
||||
const hi = Math.min(a.max - a0px, b0px - b.min)
|
||||
|
||||
const setOverride = horizontal ? setPaneWidthOverride : setPaneHeightOverride
|
||||
|
||||
try {
|
||||
handle.setPointerCapture?.(pointerId)
|
||||
} catch {
|
||||
// Synthetic events.
|
||||
}
|
||||
|
||||
document.body.style.cursor = horizontal ? 'col-resize' : 'row-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const shiftPx = Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start))
|
||||
|
||||
if (a.fixed) {
|
||||
a.paneIds.forEach(id => setOverride(id, Math.round(a0px + shiftPx)))
|
||||
}
|
||||
|
||||
if (b.fixed) {
|
||||
b.paneIds.forEach(id => setOverride(id, Math.round(b0px - shiftPx)))
|
||||
}
|
||||
|
||||
if (!a.fixed && !b.fixed) {
|
||||
const weights = [...node.weights]
|
||||
// Convert the CLAMPED pixel sizes back to weights so the persisted
|
||||
// weights always agree with what's on screen.
|
||||
weights[aIndex] = (a0px + shiftPx) / pxPerWeight
|
||||
weights[bIndex] = (b0px - shiftPx) / pxPerWeight
|
||||
setTreeSplitWeights(node.id, weights)
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
document.body.style.cursor = restoreCursor
|
||||
document.body.style.userSelect = restoreSelect
|
||||
|
||||
try {
|
||||
handle.releasePointerCapture?.(pointerId)
|
||||
} catch {
|
||||
// Mirror.
|
||||
}
|
||||
|
||||
window.removeEventListener('pointermove', onMove, true)
|
||||
window.removeEventListener('pointerup', cleanup, true)
|
||||
window.removeEventListener('pointercancel', cleanup, true)
|
||||
persistTree()
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove, true)
|
||||
window.addEventListener('pointerup', cleanup, true)
|
||||
window.addEventListener('pointercancel', cleanup, true)
|
||||
},
|
||||
// trackCtx is derived state rebuilt per render; the drag captures it once.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[axis, editMode, horizontal, node.children, node.id, node.weights, hiddenPanes, narrow, overrides, panes]
|
||||
)
|
||||
|
||||
// Double-click a sash: every neighbor returns to its DEFAULT size.
|
||||
// - fixed zones (sidebar stacks): clear the drag override -> the declared
|
||||
// width (237px etc.) comes back;
|
||||
// - flex zones fronted by a size-declaring pane (a sidebar in a mixed
|
||||
// stack): pin the weight so the zone lands EXACTLY on that size;
|
||||
// - everything else: the preset's weights for this split (rearranging
|
||||
// panes keeps the applied preset's split ids), else even distribution.
|
||||
const resetBoundary = useCallback(
|
||||
(aIndex: number, bIndex: number) => {
|
||||
const container = containerRef.current
|
||||
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
const setOverride = horizontal ? setPaneWidthOverride : setPaneHeightOverride
|
||||
|
||||
for (const [child, edge] of [
|
||||
[node.children[aIndex], 'end'],
|
||||
[node.children[bIndex], 'start']
|
||||
] as const) {
|
||||
const zone = edgeFixedZone(child, edge, axis, trackCtx)
|
||||
|
||||
for (const paneId of zone ? shownPaneIds(zone, trackCtx) : []) {
|
||||
setOverride(paneId, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const preset = presetSplitWeights(node.id, node.weights.length)
|
||||
const weights = preset ?? [...node.weights]
|
||||
|
||||
const rect = container.getBoundingClientRect()
|
||||
const totalPx = horizontal ? rect.width : rect.height
|
||||
let pinned = false
|
||||
|
||||
for (const i of [aIndex, bIndex]) {
|
||||
const child = node.children[i]
|
||||
|
||||
// Fixed tracks size themselves from the declared width (override
|
||||
// cleared above) — weights only matter for FLEX zones.
|
||||
if (child.type !== 'group' || fixedTrackSize(child, axis, trackCtx) !== null) {
|
||||
continue
|
||||
}
|
||||
|
||||
// The zone's natural default = the largest size any of its panes
|
||||
// declares along this axis (a sessions+terminal stack is still a
|
||||
// 237px sidebar at heart, whichever chip is fronted).
|
||||
let px: number | null = null
|
||||
|
||||
for (const paneId of shownPaneIds(child, trackCtx)) {
|
||||
const sizing = (paneFor(paneId)?.data ?? {}) as PaneSizing
|
||||
const css = horizontal ? sizing.width : sizing.height
|
||||
const resolved = css ? resolveCssPx(container, css, horizontal) : null
|
||||
|
||||
if (resolved !== null) {
|
||||
px = Math.max(px ?? 0, resolved)
|
||||
}
|
||||
}
|
||||
|
||||
if (px === null || px <= 0 || px >= totalPx) {
|
||||
continue
|
||||
}
|
||||
|
||||
const others = weights.reduce((sum, w, j) => (j === i ? sum : sum + w), 0)
|
||||
|
||||
if (others > 0) {
|
||||
weights[i] = (px * others) / (totalPx - px)
|
||||
pinned = true
|
||||
}
|
||||
}
|
||||
|
||||
setTreeSplitWeights(node.id, !preset && !pinned ? weights.map(() => 1) : weights)
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[axis, editMode, horizontal, node.children, node.id, node.weights, hiddenPanes, narrow, overrides, panes]
|
||||
)
|
||||
|
||||
// A run of ONLY fixed tracks can't fill the container (grow-0 all around
|
||||
// leaves dead space — e.g. terminal + logs split into two 38vh zones with
|
||||
// the rail above them collapsed). The LAST visible track absorbs the
|
||||
// leftover, VS Code style.
|
||||
const isMinimized = (child: LayoutNode) => child.type === 'group' && Boolean(child.minimized)
|
||||
|
||||
// SEMANTIC side collapse (titlebar toggles / ⌘B / ⌘J): at the ROOT row,
|
||||
// ⌘B owns the sessions column and ⌘J the other side columns — by pane
|
||||
// placement, NOT position, so a ⌘\ flip moves the columns without
|
||||
// rewiring the toggles (main parity). In edit mode sides stay visible.
|
||||
const semanticSides = root && horizontal && collapsedSides.size > 0 && !editMode
|
||||
|
||||
const sideGone = (i: number) => {
|
||||
if (!semanticSides) {
|
||||
return false
|
||||
}
|
||||
|
||||
const side = rootChildSide(node.children[i], paneFor)
|
||||
|
||||
return side !== null && collapsedSides.has(side)
|
||||
}
|
||||
|
||||
// One pass per child: collapse/minimize state, resolved fixed track, clamps,
|
||||
// and narrow-unmount flag. fixedTrackSize + subtreeGone each re-walk the
|
||||
// subtree, so resolve them ONCE here instead of per read below.
|
||||
const tracks = node.children.map((child, i) => {
|
||||
const minimized = isMinimized(child)
|
||||
const collapsed = isCollapsed(child) || sideGone(i)
|
||||
const track = minimized || collapsed ? null : fixedTrackSize(child, axis, trackCtx)
|
||||
const sizing = minimized || collapsed ? null : sizingFor(child, track)
|
||||
// Narrow-collapse UNMOUNTS (the edge overlay owns the live instance) — but
|
||||
// only for panes the breakpoint collapsed, not ones a chrome toggle hid.
|
||||
const narrowCollapsed = narrow && collapsed && allPaneIds(child).some(id => !hiddenPanes.has(id))
|
||||
|
||||
return { child, collapsed, minimized, narrowCollapsed, sizing, track }
|
||||
})
|
||||
|
||||
const growable = tracks.map((_, i) => i).filter(i => !tracks[i].collapsed && !tracks[i].minimized)
|
||||
const allFixed = growable.length > 0 && growable.every(i => tracks[i].track !== null)
|
||||
const absorberIndex = allFixed ? growable[growable.length - 1] : -1
|
||||
|
||||
// Weights are RATIOS, but CSS flex-grow is absolute: a run whose grows sum
|
||||
// below 1 fills only that fraction of the leftover (normalize's flatten
|
||||
// scales weights into the parent slot — a dock-split nested into an
|
||||
// existing column can leave grow 0.5, i.e. dead space). Renormalize the
|
||||
// flex run so its grows always sum to 1.
|
||||
const flexTotal = growable.reduce((sum, i) => sum + (tracks[i].track === null ? node.weights[i] : 0), 0)
|
||||
const grow = (i: number) => node.weights[i] / (flexTotal || 1)
|
||||
|
||||
// The seam partner for a visible child: the nearest VISIBLE previous
|
||||
// sibling. Collapsed zones (a hidden pane parked mid-row) are skipped, so
|
||||
// their visible neighbors keep a shared, draggable boundary.
|
||||
const seamPartner = (i: number): number => {
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
if (!tracks[j].collapsed) {
|
||||
return j
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
// Which half of this row a visible child sits in — a minimized zone's rail
|
||||
// hugs the app edge it collapsed toward, so its divider stroke must face
|
||||
// the content side (left rail → stroke right, right rail → stroke left).
|
||||
const visibleOrder = tracks.map((t, j) => (t.collapsed ? -1 : j)).filter(j => j >= 0)
|
||||
|
||||
const railSideFor = (i: number): 'left' | 'right' => {
|
||||
const pos = visibleOrder.indexOf(i)
|
||||
|
||||
return pos >= 0 && (pos + 0.5) / visibleOrder.length > 0.5 ? 'right' : 'left'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex min-h-0 min-w-0 flex-1', horizontal ? 'flex-row' : 'flex-col')}
|
||||
data-tree-split={node.id}
|
||||
ref={containerRef}
|
||||
>
|
||||
{tracks.map(({ child, collapsed, minimized, narrowCollapsed, sizing, track }, i) => {
|
||||
const partner = collapsed ? -1 : seamPartner(i)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex min-h-0 min-w-0"
|
||||
key={child.id}
|
||||
style={
|
||||
collapsed
|
||||
? { display: 'none' }
|
||||
: minimized
|
||||
? { flex: '0 0 auto' }
|
||||
: {
|
||||
// One flexbox formula for everything: a sized zone is
|
||||
// grow-0 shrink-1 from its preferred basis (it yields
|
||||
// gracefully on tight windows, floored by min-width);
|
||||
// everything else splits the leftover by weight. In an
|
||||
// all-fixed run the last track grows into the leftover.
|
||||
flex: track
|
||||
? `${i === absorberIndex ? 1 : 0} 1 ${track}`
|
||||
: `${grow(i)} ${grow(i)} 0px`,
|
||||
// Pane-declared clamps apply along THIS split's axis only
|
||||
// (a rail's width clamp shouldn't constrain its height).
|
||||
// The absorber drops its max clamp — it exists to fill
|
||||
// the leftover, and clamping would recreate the gap.
|
||||
minWidth: (horizontal && sizing?.minWidth) || 0,
|
||||
maxWidth: horizontal && i !== absorberIndex ? sizing?.maxWidth : undefined,
|
||||
minHeight: (!horizontal && sizing?.minHeight) || 0,
|
||||
maxHeight: horizontal || i === absorberIndex ? undefined : sizing?.maxHeight
|
||||
}
|
||||
}
|
||||
>
|
||||
{partner >= 0 && (
|
||||
<Sash
|
||||
disabled={minimized || tracks[partner].minimized}
|
||||
horizontal={horizontal}
|
||||
onDoubleClick={() => resetBoundary(partner, i)}
|
||||
onPointerDown={e => startSash(partner, i, e)}
|
||||
/>
|
||||
)}
|
||||
{!narrowCollapsed && (
|
||||
<TreeNode node={child} parentAxis={axis} railSide={horizontal ? railSideFor(i) : undefined} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Sash({
|
||||
disabled,
|
||||
horizontal,
|
||||
onDoubleClick,
|
||||
onPointerDown
|
||||
}: {
|
||||
disabled?: boolean
|
||||
horizontal: boolean
|
||||
onDoubleClick?: () => void
|
||||
onPointerDown: (e: ReactPointerEvent<HTMLDivElement>) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group absolute z-20 [-webkit-app-region:no-drag]',
|
||||
horizontal ? 'inset-y-0 left-0 w-[9px] -translate-x-1/2' : 'inset-x-0 top-0 h-[9px] -translate-y-1/2',
|
||||
disabled ? 'pointer-events-none' : horizontal ? 'cursor-col-resize' : 'cursor-row-resize'
|
||||
)}
|
||||
onDoubleClick={disabled ? undefined : onDoubleClick}
|
||||
onPointerDown={disabled ? undefined : onPointerDown}
|
||||
role="separator"
|
||||
>
|
||||
{/* Persistent hairline: same token as PaneShell's divider sash
|
||||
(--ui-stroke-secondary) so every seam — vertical or horizontal —
|
||||
reads identically. */}
|
||||
<span
|
||||
className={cn(
|
||||
'absolute bg-(--ui-stroke-secondary)',
|
||||
horizontal ? 'inset-y-0 left-1/2 w-px -translate-x-1/2' : 'inset-x-0 top-1/2 h-px -translate-y-1/2'
|
||||
)}
|
||||
/>
|
||||
{!disabled && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100',
|
||||
horizontal
|
||||
? 'inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2'
|
||||
: 'inset-x-0 top-1/2 h-(--vscode-sash-hover-size,0.25rem) -translate-y-1/2'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,507 @@
|
|||
/**
|
||||
* Zone editor — the FancyZones grid editor experience, ported from PowerToys'
|
||||
* `GridEditor.xaml.cs` interactions onto the verbatim model port (grid-model.ts):
|
||||
*
|
||||
* - A full-screen canvas of numbered translucent zones.
|
||||
* - A splitter line follows the cursor inside the hovered zone; CLICK splits
|
||||
* at that position. Hold SHIFT to flip the line horizontal (row split).
|
||||
* - DRAG across zones to rubber-band select; the selection expands to its
|
||||
* rectangular closure (ComputeClosure) and a Merge button appears.
|
||||
* - Shared zone edges are draggable resizer thumbs (multi-zone edges move
|
||||
* together, min-size clamped) — GridData.Drag semantics.
|
||||
* - Templates: Columns / Rows / Grid / Priority with a zone-count stepper.
|
||||
*
|
||||
* Saving converts the grid to our runtime layout tree (guillotine cuts) and
|
||||
* registers it as a user preset; non-guillotine arrangements (pinwheels)
|
||||
* disable Save with an explanation.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
canSplit,
|
||||
doMerge,
|
||||
dragResizer,
|
||||
type GridLayout,
|
||||
type GridResizer,
|
||||
type GridZone,
|
||||
initColumns,
|
||||
initGrid,
|
||||
initPriorityGrid,
|
||||
initRows,
|
||||
mergeClosureIndices,
|
||||
modelToResizers,
|
||||
modelToZones,
|
||||
MULTIPLIER,
|
||||
splitZone
|
||||
} from './grid-model'
|
||||
import { gridIsTreeExpressible, gridToTree, type PanePlacementHint } from './grid-to-tree'
|
||||
import { allPaneIds } from './model'
|
||||
import { applyLayoutPreset, saveLayoutPresetTree } from './presets'
|
||||
import { $layoutTree } from './store'
|
||||
|
||||
export const $zoneEditorOpen = atom(false)
|
||||
|
||||
const SPLIT_SNAP = 50 // model units (0.5%)
|
||||
|
||||
const pct = (v: number) => `${(v / MULTIPLIER) * 100}%`
|
||||
|
||||
interface SplitPreview {
|
||||
zoneIndex: number
|
||||
orientation: 'horizontal' | 'vertical'
|
||||
position: number
|
||||
}
|
||||
|
||||
interface SelectBox {
|
||||
x0: number
|
||||
y0: number
|
||||
x1: number
|
||||
y1: number
|
||||
}
|
||||
|
||||
/** Resizer screen geometry derived from its zones (model units). */
|
||||
function resizerGeometry(resizer: GridResizer, zones: GridZone[]) {
|
||||
const all = [...resizer.negativeSideIndices, ...resizer.positiveSideIndices].map(i => zones[i])
|
||||
|
||||
if (resizer.orientation === 'horizontal') {
|
||||
return {
|
||||
at: zones[resizer.positiveSideIndices[0]].top,
|
||||
from: Math.min(...all.map(z => z.left)),
|
||||
to: Math.max(...all.map(z => z.right))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
at: zones[resizer.positiveSideIndices[0]].left,
|
||||
from: Math.min(...all.map(z => z.top)),
|
||||
to: Math.max(...all.map(z => z.bottom))
|
||||
}
|
||||
}
|
||||
|
||||
export function ZoneEditor() {
|
||||
const { t } = useI18n()
|
||||
const open = useStore($zoneEditorOpen)
|
||||
const [model, setModel] = useState<GridLayout>(() => initPriorityGrid(3))
|
||||
const [templateCount, setTemplateCount] = useState(3)
|
||||
const [shift, setShift] = useState(false)
|
||||
const [splitPreview, setSplitPreview] = useState<SplitPreview | null>(null)
|
||||
const [selectBox, setSelectBox] = useState<SelectBox | null>(null)
|
||||
const [selection, setSelection] = useState<number[]>([])
|
||||
const [mergeAt, setMergeAt] = useState<{ x: number; y: number } | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const zones = modelToZones(model) ?? []
|
||||
const resizers = modelToResizers(model)
|
||||
const treeExpressible = gridIsTreeExpressible(model)
|
||||
|
||||
// Shift flips the splitter orientation (FancyZones behavior); Esc closes.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
const releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.zoneEditor)
|
||||
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') {
|
||||
setShift(true)
|
||||
}
|
||||
|
||||
// Skip when a nested field (the name Input) already handled Escape, or a
|
||||
// higher layer owns it.
|
||||
if (e.key === 'Escape' && !e.defaultPrevented && isTopEscapeLayer(ESCAPE_PRIORITY.zoneEditor)) {
|
||||
e.preventDefault()
|
||||
$zoneEditorOpen.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
const up = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') {
|
||||
setShift(false)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', down)
|
||||
window.addEventListener('keyup', up)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', down)
|
||||
window.removeEventListener('keyup', up)
|
||||
releaseLayer()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const toModelPoint = useCallback((clientX: number, clientY: number) => {
|
||||
const rect = canvasRef.current!.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
x: Math.round(((clientX - rect.x) / rect.width) * MULTIPLIER),
|
||||
y: Math.round(((clientY - rect.y) / rect.height) * MULTIPLIER)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
const zoneAt = (x: number, y: number) =>
|
||||
zones.find(z => x >= z.left && x < z.right && y >= z.top && y < z.bottom) ?? null
|
||||
|
||||
const updateSplitPreview = (clientX: number, clientY: number) => {
|
||||
const p = toModelPoint(clientX, clientY)
|
||||
const zone = zoneAt(p.x, p.y)
|
||||
|
||||
if (!zone) {
|
||||
setSplitPreview(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const orientation = shift ? 'horizontal' : 'vertical'
|
||||
const raw = orientation === 'horizontal' ? p.y : p.x
|
||||
const position = Math.round(raw / SPLIT_SNAP) * SPLIT_SNAP
|
||||
|
||||
setSplitPreview(
|
||||
canSplit(model, zone.index, position, orientation) ? { zoneIndex: zone.index, orientation, position } : null
|
||||
)
|
||||
}
|
||||
|
||||
const onCanvasPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0 || (e.target as HTMLElement).dataset.resizer !== undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
setMergeAt(null)
|
||||
setSelection([])
|
||||
|
||||
const start = toModelPoint(e.clientX, e.clientY)
|
||||
let dragged = false
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const cur = toModelPoint(ev.clientX, ev.clientY)
|
||||
|
||||
if (!dragged && Math.hypot(cur.x - start.x, cur.y - start.y) < 60) {
|
||||
return
|
||||
}
|
||||
|
||||
dragged = true
|
||||
|
||||
const box = {
|
||||
x0: Math.min(start.x, cur.x),
|
||||
y0: Math.min(start.y, cur.y),
|
||||
x1: Math.max(start.x, cur.x),
|
||||
y1: Math.max(start.y, cur.y)
|
||||
}
|
||||
|
||||
setSelectBox(box)
|
||||
|
||||
// Zones intersecting the rubber band, expanded to rectangular closure.
|
||||
const picked = zones
|
||||
.filter(z => z.left < box.x1 && z.right > box.x0 && z.top < box.y1 && z.bottom > box.y0)
|
||||
.map(z => z.index)
|
||||
|
||||
setSelection(mergeClosureIndices(model, picked))
|
||||
}
|
||||
|
||||
const onUp = (ev: PointerEvent) => {
|
||||
window.removeEventListener('pointermove', onMove, true)
|
||||
window.removeEventListener('pointerup', onUp, true)
|
||||
setSelectBox(null)
|
||||
|
||||
if (!dragged) {
|
||||
// Plain click: split at the previewed line.
|
||||
if (splitPreview) {
|
||||
setModel(m => splitZone(m, splitPreview.zoneIndex, splitPreview.position, splitPreview.orientation))
|
||||
setSplitPreview(null)
|
||||
}
|
||||
|
||||
setSelection([])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const rect = canvasRef.current!.getBoundingClientRect()
|
||||
setMergeAt({ x: ev.clientX - rect.x, y: ev.clientY - rect.y })
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove, true)
|
||||
window.addEventListener('pointerup', onUp, true)
|
||||
}
|
||||
|
||||
const startResizerDrag = (index: number, e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const rect = canvasRef.current!.getBoundingClientRect()
|
||||
const resizer = resizers[index]
|
||||
const horizontal = resizer.orientation === 'horizontal'
|
||||
const start = horizontal ? e.clientY : e.clientX
|
||||
let applied = 0
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const px = (horizontal ? ev.clientY : ev.clientX) - start
|
||||
const total = Math.round((px / (horizontal ? rect.height : rect.width)) * MULTIPLIER)
|
||||
const step = total - applied
|
||||
|
||||
if (step !== 0) {
|
||||
setModel(m => {
|
||||
const next = dragResizer(m, index, step)
|
||||
|
||||
if (next !== m) {
|
||||
applied = total
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
window.removeEventListener('pointermove', onMove, true)
|
||||
window.removeEventListener('pointerup', onUp, true)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove, true)
|
||||
window.addEventListener('pointerup', onUp, true)
|
||||
}
|
||||
|
||||
const merge = () => {
|
||||
setModel(m => doMerge(m, selection))
|
||||
setSelection([])
|
||||
setMergeAt(null)
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
const paneIds = allPaneIds($layoutTree.get() ?? { type: 'group', id: 'tmp', panes: [], active: '' })
|
||||
// Placement hints ride on the pane contributions (`data.placement`), so
|
||||
// zones are assigned by ROLE (main/left/right/bottom), not index order.
|
||||
const contributions = registry.getArea('panes')
|
||||
|
||||
const placed = paneIds.map(id => ({
|
||||
id,
|
||||
placement: (contributions.find(c => c.id === id)?.data as { placement?: PanePlacementHint } | undefined)
|
||||
?.placement
|
||||
}))
|
||||
|
||||
const tree = gridToTree(model, placed)
|
||||
|
||||
if (!tree) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = saveLayoutPresetTree(name || t.zones.customZoneName(zones.length), tree)
|
||||
|
||||
if (id) {
|
||||
applyLayoutPreset(id, tree)
|
||||
}
|
||||
|
||||
$zoneEditorOpen.set(false)
|
||||
}
|
||||
|
||||
const templates = [
|
||||
{ label: t.zones.templateColumns, make: initColumns },
|
||||
{ label: t.zones.templateRows, make: initRows },
|
||||
{ label: t.zones.templateGrid, make: initGrid },
|
||||
{ label: t.zones.templatePriority, make: initPriorityGrid }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-[70] flex flex-col gap-3 p-6 [-webkit-app-region:no-drag]" style={{ background: 'color-mix(in srgb, var(--ui-bg-chrome) 88%, transparent)', backdropFilter: 'blur(6px)' }}>
|
||||
{/* Toolbar — Panel-style title + hint, template chooser on the right. */}
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">{t.zones.zoneEditorTitle}</h2>
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t.zones.editorHintPre}
|
||||
<kbd className="rounded border border-(--ui-stroke-secondary) bg-foreground/5 px-1 font-mono text-[10px]">
|
||||
⇧
|
||||
</kbd>
|
||||
{t.zones.editorHintPost}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{templates.map(t => (
|
||||
<Button
|
||||
key={t.label}
|
||||
onClick={() => {
|
||||
setSelection([])
|
||||
setMergeAt(null)
|
||||
setModel(t.make(templateCount))
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t.label}
|
||||
</Button>
|
||||
))}
|
||||
<Input
|
||||
className="h-7 w-14 text-center text-xs"
|
||||
max={8}
|
||||
min={1}
|
||||
onChange={e => setTemplateCount(Math.max(1, Math.min(8, Number(e.target.value) || 1)))}
|
||||
type="number"
|
||||
value={templateCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<div
|
||||
className="relative min-h-0 flex-1 cursor-crosshair overflow-hidden rounded-lg border border-(--ui-stroke-secondary)"
|
||||
onPointerDown={onCanvasPointerDown}
|
||||
onPointerLeave={() => setSplitPreview(null)}
|
||||
onPointerMove={e => updateSplitPreview(e.clientX, e.clientY)}
|
||||
ref={canvasRef}
|
||||
>
|
||||
{zones.map(zone => {
|
||||
const selected = selection.includes(zone.index)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute flex items-center justify-center rounded-[3px] border transition-colors"
|
||||
key={zone.index}
|
||||
style={{
|
||||
left: pct(zone.left),
|
||||
top: pct(zone.top),
|
||||
width: pct(zone.right - zone.left),
|
||||
height: pct(zone.bottom - zone.top),
|
||||
// Accent over an elevated base — zones stay legible on dark
|
||||
// themes where a bare accent wash sinks into the canvas.
|
||||
background: `color-mix(in srgb, var(--ui-accent) ${selected ? 34 : 12}%, var(--ui-bg-elevated))`,
|
||||
borderColor: `color-mix(in srgb, var(--ui-accent) ${selected ? 90 : 40}%, transparent)`
|
||||
}}
|
||||
>
|
||||
{/* Quiet zone tag — the app's small-caps label voice, not a
|
||||
billboard number. */}
|
||||
<span className="select-none text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-(--ui-text-tertiary)">
|
||||
{t.zones.zoneTag(zone.index + 1)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Splitter preview line following the cursor. */}
|
||||
{splitPreview &&
|
||||
(() => {
|
||||
const zone = zones[splitPreview.zoneIndex]
|
||||
|
||||
if (!zone) {
|
||||
return null
|
||||
}
|
||||
|
||||
const horizontal = splitPreview.orientation === 'horizontal'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
left: horizontal ? pct(zone.left) : pct(splitPreview.position),
|
||||
top: horizontal ? pct(splitPreview.position) : pct(zone.top),
|
||||
width: horizontal ? pct(zone.right - zone.left) : 2,
|
||||
height: horizontal ? 2 : pct(zone.bottom - zone.top),
|
||||
background: 'var(--ui-accent)'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Rubber-band selection box. */}
|
||||
{selectBox && (
|
||||
<div
|
||||
className="pointer-events-none absolute border"
|
||||
style={{
|
||||
left: pct(selectBox.x0),
|
||||
top: pct(selectBox.y0),
|
||||
width: pct(selectBox.x1 - selectBox.x0),
|
||||
height: pct(selectBox.y1 - selectBox.y0),
|
||||
background: 'color-mix(in srgb, var(--ui-accent) 10%, transparent)',
|
||||
borderColor: 'var(--ui-accent)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Resizer thumbs on shared edges. */}
|
||||
{resizers.map((resizer, i) => {
|
||||
const geo = resizerGeometry(resizer, zones)
|
||||
const horizontal = resizer.orientation === 'horizontal'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute z-10 flex items-center justify-center',
|
||||
horizontal ? 'h-[10px] -translate-y-1/2 cursor-row-resize' : 'w-[10px] -translate-x-1/2 cursor-col-resize'
|
||||
)}
|
||||
data-resizer={i}
|
||||
key={`r-${i}`}
|
||||
onPointerDown={e => startResizerDrag(i, e)}
|
||||
style={
|
||||
horizontal
|
||||
? { top: pct(geo.at), left: pct(geo.from), width: pct(geo.to - geo.from) }
|
||||
: { left: pct(geo.at), top: pct(geo.from), height: pct(geo.to - geo.from) }
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn('pointer-events-none rounded-full', horizontal ? 'h-[4px] w-10' : 'h-10 w-[4px]')}
|
||||
style={{ background: 'color-mix(in srgb, var(--ui-text-primary) 55%, transparent)' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Merge affordance at drag-release point. */}
|
||||
{mergeAt && selection.length > 1 && (
|
||||
<div className="absolute z-20 flex gap-1" style={{ left: mergeAt.x, top: mergeAt.y }}>
|
||||
<Button
|
||||
className="shadow-lg"
|
||||
onClick={merge}
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t.zones.mergeZones(selection.length)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: save / cancel. */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
className="h-7 w-64 text-xs"
|
||||
onChange={e => setName(e.target.value)}
|
||||
// Escape while typing clears the field and yields — it must not bubble
|
||||
// up to close the whole editor and lose the name.
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setName('')
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
placeholder={t.zones.layoutNamePlaceholder(t.zones.customZoneName(zones.length))}
|
||||
value={name}
|
||||
/>
|
||||
<Button disabled={!treeExpressible} onClick={save} size="sm" variant="outline">
|
||||
{t.zones.saveApply}
|
||||
</Button>
|
||||
<Button onClick={() => $zoneEditorOpen.set(false)} size="sm" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
{!treeExpressible && <span className="text-xs text-muted-foreground/80">{t.zones.notExpressible}</span>}
|
||||
<span className="ml-auto text-xs text-muted-foreground/60">{t.zones.zoneCount(zones.length)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,367 @@
|
|||
/**
|
||||
* FancyZones runtime engine — verbatim port of the drag/highlight internals
|
||||
* from PowerToys `FancyZonesLib` (microsoft/PowerToys, MIT):
|
||||
*
|
||||
* - `Layout.cpp` -> `zonesFromPoint` (sensitivity-radius capture, the
|
||||
* "captured but not strictly captured" rejection, the overlap resolution
|
||||
* algorithms incl. ClosestCenter with OVERLAPPING_CENTERS_SENSITIVITY),
|
||||
* `getCombinedZoneRange`, `getCombinedZonesRect`.
|
||||
* - `HighlightedZones.cpp` -> the `HighlightedZones` update state machine
|
||||
* (initial-zone latch + combined range while "select many" is held).
|
||||
* - `ZonesOverlay.cpp` -> animation timing: FadeInDurationMillis = 200,
|
||||
* FlashZonesDurationMillis = 700, alpha = clamp(t/200, 0.001, 1), and the
|
||||
* fill-alpha rule (highlightOpacity applies to BOTH inactive + highlight).
|
||||
* - `Colors.cpp` -> ZoneColors resolution (system-theme mode maps accent to
|
||||
* border+highlight and background to primary; number color auto-contrasts).
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants (ZonesOverlay.cpp / Layout.cpp / FancyZones defaults)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FADE_IN_DURATION_MILLIS = 200
|
||||
export const FLASH_ZONES_DURATION_MILLIS = 700
|
||||
/** LayoutDefaultSettings::DefaultSensitivityRadius. */
|
||||
export const DEFAULT_SENSITIVITY_RADIUS = 20
|
||||
/** ZoneSelectionAlgorithms::OVERLAPPING_CENTERS_SENSITIVITY. */
|
||||
const OVERLAPPING_CENTERS_SENSITIVITY = 75
|
||||
|
||||
export type OverlappingZonesAlgorithm = 'Smallest' | 'Largest' | 'Positional' | 'ClosestCenter'
|
||||
|
||||
export interface ZoneRect {
|
||||
left: number
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
}
|
||||
|
||||
export interface EngineZone {
|
||||
id: string
|
||||
rect: ZoneRect
|
||||
}
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const zoneArea = (z: EngineZone) => Math.max(0, z.rect.right - z.rect.left) * Math.max(0, z.rect.bottom - z.rect.top)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ZonesOverlay::GetAnimationAlpha (verbatim ramp)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getAnimationAlpha(startedAtMs: number, nowMs: number, autoHide: boolean): number {
|
||||
const millis = nowMs - startedAtMs
|
||||
|
||||
if (autoHide && millis > FLASH_ZONES_DURATION_MILLIS) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Return a positive value to avoid hiding.
|
||||
return Math.min(Math.max(millis / FADE_IN_DURATION_MILLIS, 0.001), 1)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colors (Colors.cpp + FancyZones settings defaults)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ZoneColors {
|
||||
primaryColor: string
|
||||
borderColor: string
|
||||
highlightColor: string
|
||||
numberColor: string
|
||||
/** 0..100, applied as fill alpha to BOTH primary and highlight fills. */
|
||||
highlightOpacity: number
|
||||
}
|
||||
|
||||
/** FancyZonesSettings defaults (custom-color mode). */
|
||||
export const FANCYZONES_DEFAULT_COLORS: ZoneColors = {
|
||||
primaryColor: '#F5FCFF',
|
||||
borderColor: '#FFFFFF',
|
||||
highlightColor: '#008CFF',
|
||||
numberColor: '#000000',
|
||||
highlightOpacity: 50
|
||||
}
|
||||
|
||||
/**
|
||||
* Colors::GetZoneColors systemTheme branch, mapped to our design tokens:
|
||||
* accent -> border + highlight, surface -> primary, number auto-contrast
|
||||
* (CSS light-dark handles what the C++ does with the black-background check).
|
||||
*/
|
||||
export function systemThemeZoneColors(): ZoneColors {
|
||||
return {
|
||||
primaryColor: 'var(--ui-bg-editor)',
|
||||
borderColor: 'var(--ui-accent)',
|
||||
highlightColor: 'var(--ui-accent)',
|
||||
numberColor: 'var(--ui-text-primary)',
|
||||
highlightOpacity: 50
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ZoneSelectionAlgorithms (Layout.cpp, verbatim)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function zoneSelectPriority(
|
||||
zones: Map<string, EngineZone>,
|
||||
capturedZones: string[],
|
||||
compare: (a: EngineZone, b: EngineZone) => boolean
|
||||
): string[] {
|
||||
let chosen = 0
|
||||
|
||||
for (let i = 1; i < capturedZones.length; i++) {
|
||||
if (compare(zones.get(capturedZones[i])!, zones.get(capturedZones[chosen])!)) {
|
||||
chosen = i
|
||||
}
|
||||
}
|
||||
|
||||
return [capturedZones[chosen]]
|
||||
}
|
||||
|
||||
function zoneSelectSubregion(
|
||||
zones: Map<string, EngineZone>,
|
||||
capturedZones: string[],
|
||||
pt: Point,
|
||||
sensitivityRadius: number
|
||||
): string[] {
|
||||
const expand = (rect: ZoneRect): ZoneRect => ({
|
||||
top: rect.top - sensitivityRadius / 2,
|
||||
bottom: rect.bottom + sensitivityRadius / 2,
|
||||
left: rect.left - sensitivityRadius / 2,
|
||||
right: rect.right + sensitivityRadius / 2
|
||||
})
|
||||
|
||||
// Compute the overlapped rectangle.
|
||||
let overlap = expand(zones.get(capturedZones[0])!.rect)
|
||||
|
||||
for (let i = 1; i < capturedZones.length; i++) {
|
||||
const current = expand(zones.get(capturedZones[i])!.rect)
|
||||
overlap = {
|
||||
top: Math.max(overlap.top, current.top),
|
||||
left: Math.max(overlap.left, current.left),
|
||||
bottom: Math.min(overlap.bottom, current.bottom),
|
||||
right: Math.min(overlap.right, current.right)
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid division by zero.
|
||||
const width = Math.max(overlap.right - overlap.left, 1)
|
||||
const height = Math.max(overlap.bottom - overlap.top, 1)
|
||||
|
||||
const verticalSplit = height > width
|
||||
let zoneIndex: number
|
||||
|
||||
if (verticalSplit) {
|
||||
zoneIndex = Math.floor(((pt.y - overlap.top) * capturedZones.length) / height)
|
||||
} else {
|
||||
zoneIndex = Math.floor(((pt.x - overlap.left) * capturedZones.length) / width)
|
||||
}
|
||||
|
||||
zoneIndex = Math.min(Math.max(zoneIndex, 0), capturedZones.length - 1)
|
||||
|
||||
return [capturedZones[zoneIndex]]
|
||||
}
|
||||
|
||||
function zoneSelectClosestCenter(zones: Map<string, EngineZone>, capturedZones: string[], pt: Point): string[] {
|
||||
const getCenter = (zone: EngineZone): Point => ({
|
||||
x: (zone.rect.right + zone.rect.left) / 2,
|
||||
y: (zone.rect.top + zone.rect.bottom) / 2
|
||||
})
|
||||
|
||||
const pointDifference = (a: Point, b: Point) => (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y)
|
||||
const distanceFromCenter = (zone: EngineZone) => pointDifference(getCenter(zone), pt)
|
||||
|
||||
const closerToCenter = (zone1: EngineZone, zone2: EngineZone) => {
|
||||
if (pointDifference(getCenter(zone1), getCenter(zone2)) > OVERLAPPING_CENTERS_SENSITIVITY) {
|
||||
return distanceFromCenter(zone1) < distanceFromCenter(zone2)
|
||||
}
|
||||
|
||||
return zoneArea(zone1) < zoneArea(zone2)
|
||||
}
|
||||
|
||||
return zoneSelectPriority(zones, capturedZones, closerToCenter)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout::ZonesFromPoint (verbatim)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function zonesFromPoint(
|
||||
zoneList: EngineZone[],
|
||||
pt: Point,
|
||||
sensitivityRadius = DEFAULT_SENSITIVITY_RADIUS,
|
||||
overlappingAlgorithm: OverlappingZonesAlgorithm = 'Smallest'
|
||||
): string[] {
|
||||
const zones = new Map(zoneList.map(z => [z.id, z]))
|
||||
const capturedZones: string[] = []
|
||||
const strictlyCapturedZones: string[] = []
|
||||
|
||||
for (const zone of zoneList) {
|
||||
const zoneRect = zone.rect
|
||||
|
||||
if (
|
||||
zoneRect.left - sensitivityRadius <= pt.x &&
|
||||
pt.x <= zoneRect.right + sensitivityRadius &&
|
||||
zoneRect.top - sensitivityRadius <= pt.y &&
|
||||
pt.y <= zoneRect.bottom + sensitivityRadius
|
||||
) {
|
||||
capturedZones.push(zone.id)
|
||||
}
|
||||
|
||||
if (zoneRect.left <= pt.x && pt.x < zoneRect.right && zoneRect.top <= pt.y && pt.y < zoneRect.bottom) {
|
||||
strictlyCapturedZones.push(zone.id)
|
||||
}
|
||||
}
|
||||
|
||||
// If only one zone is captured, but it's not strictly captured
|
||||
// don't consider it as captured.
|
||||
if (capturedZones.length === 1 && strictlyCapturedZones.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// If captured zones do not overlap, return all of them.
|
||||
// Otherwise, return one of them based on the chosen selection algorithm.
|
||||
let overlap = false
|
||||
|
||||
outer: for (let i = 0; i < capturedZones.length; i++) {
|
||||
for (let j = i + 1; j < capturedZones.length; j++) {
|
||||
const rectI = zones.get(capturedZones[i])!.rect
|
||||
const rectJ = zones.get(capturedZones[j])!.rect
|
||||
|
||||
if (
|
||||
Math.max(rectI.top, rectJ.top) + sensitivityRadius < Math.min(rectI.bottom, rectJ.bottom) &&
|
||||
Math.max(rectI.left, rectJ.left) + sensitivityRadius < Math.min(rectI.right, rectJ.right)
|
||||
) {
|
||||
overlap = true
|
||||
|
||||
break outer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (overlap) {
|
||||
switch (overlappingAlgorithm) {
|
||||
case 'Smallest':
|
||||
return zoneSelectPriority(zones, capturedZones, (a, b) => zoneArea(a) < zoneArea(b))
|
||||
|
||||
case 'Largest':
|
||||
return zoneSelectPriority(zones, capturedZones, (a, b) => zoneArea(a) > zoneArea(b))
|
||||
|
||||
case 'Positional':
|
||||
return zoneSelectSubregion(zones, capturedZones, pt, sensitivityRadius)
|
||||
|
||||
case 'ClosestCenter':
|
||||
return zoneSelectClosestCenter(zones, capturedZones, pt)
|
||||
}
|
||||
}
|
||||
|
||||
return capturedZones
|
||||
}
|
||||
|
||||
/** The single zone a drop lands in: ClosestCenter among the highlighted set. */
|
||||
export function primaryZone(zoneList: EngineZone[], highlighted: string[], pt: Point): string | null {
|
||||
if (highlighted.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (highlighted.length === 1) {
|
||||
return highlighted[0]
|
||||
}
|
||||
|
||||
const zones = new Map(zoneList.map(z => [z.id, z]))
|
||||
|
||||
return zoneSelectClosestCenter(zones, highlighted, pt)[0] ?? highlighted[0]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout::GetCombinedZoneRange (verbatim)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getCombinedZoneRange(zoneList: EngineZone[], initialZones: string[], finalZones: string[]): string[] {
|
||||
const zones = new Map(zoneList.map(z => [z.id, z]))
|
||||
const combinedZones = [...new Set([...initialZones, ...finalZones])]
|
||||
|
||||
let boundingRect: ZoneRect | null = null
|
||||
|
||||
for (const zoneId of combinedZones) {
|
||||
const zone = zones.get(zoneId)
|
||||
|
||||
if (zone) {
|
||||
const rect = zone.rect
|
||||
|
||||
if (!boundingRect) {
|
||||
boundingRect = { ...rect }
|
||||
} else {
|
||||
boundingRect.left = Math.min(boundingRect.left, rect.left)
|
||||
boundingRect.top = Math.min(boundingRect.top, rect.top)
|
||||
boundingRect.right = Math.max(boundingRect.right, rect.right)
|
||||
boundingRect.bottom = Math.max(boundingRect.bottom, rect.bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result: string[] = []
|
||||
|
||||
if (boundingRect) {
|
||||
for (const zone of zoneList) {
|
||||
const rect = zone.rect
|
||||
|
||||
if (
|
||||
boundingRect.left <= rect.left &&
|
||||
rect.right <= boundingRect.right &&
|
||||
boundingRect.top <= rect.top &&
|
||||
rect.bottom <= boundingRect.bottom
|
||||
) {
|
||||
result.push(zone.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HighlightedZones (HighlightedZones.cpp, verbatim state machine)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class HighlightedZones {
|
||||
private highlightZone: string[] = []
|
||||
private initialHighlightZone: string[] = []
|
||||
|
||||
zones(): readonly string[] {
|
||||
return this.highlightZone
|
||||
}
|
||||
|
||||
empty(): boolean {
|
||||
return this.highlightZone.length === 0
|
||||
}
|
||||
|
||||
/** Returns true when the highlight set changed. */
|
||||
update(zoneList: EngineZone[], point: Point, selectManyZones: boolean): boolean {
|
||||
let highlightZone = zonesFromPoint(zoneList, point)
|
||||
|
||||
if (selectManyZones) {
|
||||
if (this.initialHighlightZone.length === 0) {
|
||||
// First time.
|
||||
this.initialHighlightZone = highlightZone
|
||||
} else {
|
||||
highlightZone = getCombinedZoneRange(zoneList, this.initialHighlightZone, highlightZone)
|
||||
}
|
||||
} else {
|
||||
this.initialHighlightZone = []
|
||||
}
|
||||
|
||||
const updated =
|
||||
highlightZone.length !== this.highlightZone.length || highlightZone.some((z, i) => z !== this.highlightZone[i])
|
||||
|
||||
this.highlightZone = highlightZone
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.highlightZone = []
|
||||
this.initialHighlightZone = []
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Inset bottom stroke for a horizontal tab strip — titlebar color, cut by the active tab. */
|
||||
export const PANE_TAB_STRIP_LINE = 'shadow-[inset_0_-1px_0_var(--ui-stroke-tertiary)]'
|
||||
|
||||
/** Inset stroke for a vertical tab rail — content-facing edge. */
|
||||
export const PANE_TAB_STRIP_LINE_LEFT = 'shadow-[inset_1px_0_0_var(--ui-stroke-tertiary)]'
|
||||
export const PANE_TAB_STRIP_LINE_RIGHT = 'shadow-[inset_-1px_0_0_var(--ui-stroke-tertiary)]'
|
||||
|
||||
const TAB =
|
||||
'group/tab relative flex shrink-0 items-center border-transparent bg-(--tab-bg) text-[0.6875rem] font-medium [-webkit-app-region:no-drag]'
|
||||
|
||||
const TAB_HORIZONTAL =
|
||||
'h-full min-w-0 max-w-48 border-b not-first:border-l not-first:border-l-(--ui-stroke-quaternary)'
|
||||
|
||||
const TAB_VERTICAL =
|
||||
'w-full max-h-48 justify-center not-first:border-t not-first:border-t-(--ui-stroke-quaternary) [writing-mode:vertical-rl]'
|
||||
|
||||
const TAB_ACTIVE =
|
||||
'text-foreground [--tab-bg:var(--pane-tab-active-bg,var(--ui-editor-surface-background))]'
|
||||
|
||||
// Inactive = gutter. Hover = 4% translucent wash (VS Code/GitHub alpha hover),
|
||||
// not an opaque recolor — and never touch borders.
|
||||
const TAB_IDLE =
|
||||
'text-(--ui-text-tertiary) [--tab-bg:var(--pane-tab-strip-bg,var(--theme-card-seed))] hover:shadow-[inset_0_0_0_100vmax_color-mix(in_srgb,var(--ui-base)_4%,transparent)] hover:text-(--ui-text-secondary)'
|
||||
|
||||
interface PaneTabProps extends React.ComponentProps<'div'> {
|
||||
active?: boolean
|
||||
dirty?: boolean
|
||||
/** Middle-click close (no hover X — too easy to hit on small tabs). */
|
||||
onClose?: () => void
|
||||
/** Vertical rail form (collapsed sidebar zones). */
|
||||
vertical?: boolean
|
||||
/** Content-facing edge of a vertical rail — the strip line the active tab cuts. */
|
||||
side?: 'left' | 'right'
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor tab shell — preview rail + zone headers + collapsed vertical rails.
|
||||
*
|
||||
* Strip sets `--pane-tab-active-bg` (content surface) and `--pane-tab-strip-bg`
|
||||
* (gutter; prefer `--theme-card-seed` = VS Code `tab.inactiveBackground`).
|
||||
* Active merges into content; inactive sits flush in the gutter.
|
||||
*/
|
||||
export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function PaneTab(
|
||||
{
|
||||
active = false,
|
||||
dirty = false,
|
||||
onClose,
|
||||
onAuxClick,
|
||||
onMouseDown,
|
||||
vertical = false,
|
||||
side = 'left',
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
// Content-facing edge: horizontal cuts the bottom strip line; vertical cuts
|
||||
// the side that faces the editor (left rail → right edge, right rail → left).
|
||||
const edge = vertical ? (side === 'right' ? 'border-l' : 'border-r') : 'border-b'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
TAB,
|
||||
vertical ? TAB_VERTICAL : TAB_HORIZONTAL,
|
||||
edge,
|
||||
active ? TAB_ACTIVE : cn(TAB_IDLE, `${edge}-(--ui-stroke-tertiary)`),
|
||||
className
|
||||
)}
|
||||
data-active={active}
|
||||
data-vertical={vertical || undefined}
|
||||
onAuxClick={event => {
|
||||
// Middle-click closes (browser/IDE). Swallow mousedown so Chromium
|
||||
// doesn't autoscroll.
|
||||
if (onClose && event.button === 1) {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
|
||||
onAuxClick?.(event)
|
||||
}}
|
||||
onMouseDown={event => {
|
||||
if (onClose && event.button === 1) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
onMouseDown?.(event)
|
||||
}}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{dirty && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute grid size-4 place-items-center',
|
||||
vertical ? 'bottom-1.5 left-1/2 -translate-x-1/2' : 'right-1.5 top-1/2 -translate-y-1/2'
|
||||
)}
|
||||
>
|
||||
<span className="size-2 rounded-full bg-amber-500 shadow-[0_0_0_2px_var(--tab-bg),0_1px_2px_rgba(0,0,0,0.45)] dark:bg-amber-400" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
interface PaneTabLabelProps extends React.ComponentProps<'button'> {
|
||||
/** `button` when the label is the activation target (preview rail);
|
||||
* default `span` defers to the shell (zone drag/activate). */
|
||||
as?: 'button' | 'span'
|
||||
}
|
||||
|
||||
/** Truncating label inside a `PaneTab`. `className` merges into the text span
|
||||
* (e.g. `normal-case tracking-normal` for filenames). */
|
||||
export const PaneTabLabel = React.forwardRef<HTMLElement, PaneTabLabelProps>(function PaneTabLabel(
|
||||
{ as = 'span', className, children, ...props },
|
||||
ref
|
||||
) {
|
||||
const Comp = as as React.ElementType
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className="flex h-full min-w-0 max-w-full items-center overflow-hidden px-2 text-left outline-none group-data-[vertical]/tab:h-auto group-data-[vertical]/tab:w-full group-data-[vertical]/tab:justify-center group-data-[vertical]/tab:py-2"
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<span className={cn('block min-w-0 truncate text-[9px] font-medium tracking-wide uppercase', className)}>
|
||||
{children}
|
||||
</span>
|
||||
</Comp>
|
||||
)
|
||||
})
|
||||
Loading…
Reference in New Issue