quilt-vanilla 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -39
- package/dist/chrome-size.d.ts +7 -0
- package/dist/chrome-size.d.ts.map +1 -0
- package/dist/chrome-size.js +86 -0
- package/dist/chrome-size.js.map +1 -0
- package/dist/close.d.ts.map +1 -1
- package/dist/close.js +3 -1
- package/dist/close.js.map +1 -1
- package/dist/corners.d.ts +2 -1
- package/dist/corners.d.ts.map +1 -1
- package/dist/corners.js +2 -2
- package/dist/corners.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/panes.d.ts.map +1 -1
- package/dist/panes.js +84 -52
- package/dist/panes.js.map +1 -1
- package/dist/registry.d.ts +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +4 -3
- package/dist/registry.js.map +1 -1
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +94 -29
- package/dist/renderer.js.map +1 -1
- package/dist/resize.d.ts +2 -1
- package/dist/resize.d.ts.map +1 -1
- package/dist/resize.js +2 -2
- package/dist/resize.js.map +1 -1
- package/dist/styles.css +49 -0
- package/dist/types.d.ts +29 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/workspace-api.d.ts +180 -0
- package/dist/workspace-api.d.ts.map +1 -0
- package/dist/workspace-api.js +395 -0
- package/dist/workspace-api.js.map +1 -0
- package/package.json +2 -2
- package/src/chrome-size.ts +99 -0
- package/src/close.ts +6 -1
- package/src/corners.ts +2 -1
- package/src/index.ts +25 -4
- package/src/panes.ts +82 -55
- package/src/registry.ts +6 -4
- package/src/renderer.ts +141 -48
- package/src/resize.ts +2 -1
- package/src/styles.css +49 -0
- package/src/types.ts +24 -9
- package/src/workspace-api.ts +561 -0
package/src/resize.ts
CHANGED
|
@@ -6,6 +6,7 @@ export function isolatedResize(
|
|
|
6
6
|
layout: Layout,
|
|
7
7
|
splitId: string,
|
|
8
8
|
extent: (node: Node, axis: Axis) => number,
|
|
9
|
+
measureBounds: typeof bounds = bounds,
|
|
9
10
|
) {
|
|
10
11
|
const split = findNode(layout.root, splitId);
|
|
11
12
|
if (split?.kind !== 'split') throw new Error('Split not found');
|
|
@@ -22,7 +23,7 @@ export function isolatedResize(
|
|
|
22
23
|
capture(split);
|
|
23
24
|
function limits(node: Node, start: boolean): [number, number] {
|
|
24
25
|
if (node.kind === 'group') {
|
|
25
|
-
const b =
|
|
26
|
+
const b = measureBounds(node, layout),
|
|
26
27
|
size = sizes.get(node.id)!;
|
|
27
28
|
return axis === 'horizontal'
|
|
28
29
|
? [b.minWidth - size, b.maxWidth - size]
|
package/src/styles.css
CHANGED
|
@@ -831,3 +831,52 @@
|
|
|
831
831
|
flex: 0 0 calc(var(--layouts-icon-size, 16px) + 16px);
|
|
832
832
|
min-width: calc(var(--layouts-icon-size, 16px) + 16px);
|
|
833
833
|
}
|
|
834
|
+
|
|
835
|
+
/* Chrome remains usable at the renderer's minimum region size, even with larger icons. */
|
|
836
|
+
.layouts-group[data-tab-placement='top'] > .layouts-header {
|
|
837
|
+
min-height: calc(var(--layouts-icon-size, 16px) + 9px);
|
|
838
|
+
}
|
|
839
|
+
.layouts-group[data-tab-placement='top'][data-tab-display='automatic'] .layouts-tab-item {
|
|
840
|
+
min-width: max(32px, calc(var(--layouts-icon-size, 16px) + 8px));
|
|
841
|
+
}
|
|
842
|
+
.layouts-group[data-tab-placement='top'][data-tab-display='automatic']
|
|
843
|
+
.layouts-tab-item[data-active='true']:has(> .layouts-tab-close) {
|
|
844
|
+
min-width: max(
|
|
845
|
+
56px,
|
|
846
|
+
calc(var(--layouts-icon-size, 16px) + 11px + max(22px, var(--layouts-icon-size, 16px)))
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
.layouts-group[data-tab-placement='left'] > .layouts-header {
|
|
850
|
+
min-width: max(32px, calc(var(--layouts-icon-size, 16px) + 10px));
|
|
851
|
+
}
|
|
852
|
+
.layouts-group[data-tab-placement='left'] .layouts-tab-item,
|
|
853
|
+
.layouts-group[data-tab-placement='left'] > .layouts-header > .layouts-button {
|
|
854
|
+
flex-basis: max(30px, calc(var(--layouts-icon-size, 16px) + 8px));
|
|
855
|
+
height: max(30px, calc(var(--layouts-icon-size, 16px) + 8px));
|
|
856
|
+
}
|
|
857
|
+
.layouts-group[data-tab-placement='left'][data-tab-display='automatic']
|
|
858
|
+
.layouts-tab-item[data-active='true']:has(> .layouts-tab-close) {
|
|
859
|
+
flex-basis: calc(
|
|
860
|
+
max(30px, var(--layouts-icon-size, 16px) + 8px) + max(22px, var(--layouts-icon-size, 16px))
|
|
861
|
+
);
|
|
862
|
+
height: calc(
|
|
863
|
+
max(30px, var(--layouts-icon-size, 16px) + 8px) + max(22px, var(--layouts-icon-size, 16px))
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
.layouts-group[data-tab-placement='left'][data-tab-display='automatic']
|
|
867
|
+
.layouts-tab-item[data-active='true']:has(> .layouts-tab-close)
|
|
868
|
+
> .layouts-tab {
|
|
869
|
+
flex-basis: max(30px, calc(var(--layouts-icon-size, 16px) + 8px));
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
.layouts-tab-item .layouts-tab-close {
|
|
873
|
+
flex-basis: max(22px, var(--layouts-icon-size, 16px));
|
|
874
|
+
width: max(22px, var(--layouts-icon-size, 16px));
|
|
875
|
+
max-height: max(24px, var(--layouts-icon-size, 16px));
|
|
876
|
+
}
|
|
877
|
+
.layouts-group[data-tab-placement='left'][data-tab-display='automatic']
|
|
878
|
+
.layouts-tab-item[data-active='true']
|
|
879
|
+
> .layouts-tab-close {
|
|
880
|
+
flex-basis: max(22px, var(--layouts-icon-size, 16px));
|
|
881
|
+
height: max(22px, var(--layouts-icon-size, 16px));
|
|
882
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -3,9 +3,13 @@ import type { WorkspacePreset } from './workspace.js';
|
|
|
3
3
|
import type { Messages } from './messages.js';
|
|
4
4
|
import type { TabRegistry } from './registry.js';
|
|
5
5
|
import type { LayoutTheme } from './theme.js';
|
|
6
|
-
import type { LayoutStore, Pane, WindowPlacement } from 'quilt-core';
|
|
6
|
+
import type { LayoutStore, Layout, Pane, WindowPlacement } from 'quilt-core';
|
|
7
7
|
export interface PaneContext<State = unknown> {
|
|
8
8
|
element: HTMLElement;
|
|
9
|
+
/** Aborted when this view is torn down, including a failed mount. */
|
|
10
|
+
signal: AbortSignal;
|
|
11
|
+
/** Register view-owned cleanup; callbacks run once in reverse order. */
|
|
12
|
+
onCleanup(callback: () => void): void;
|
|
9
13
|
reportError(error: unknown): void;
|
|
10
14
|
document: Document;
|
|
11
15
|
window: Window;
|
|
@@ -21,7 +25,7 @@ export interface PaneView {
|
|
|
21
25
|
resize?(width: number, height: number): void;
|
|
22
26
|
update?(pane: Pane): void;
|
|
23
27
|
}
|
|
24
|
-
export type PaneRenderer<State = unknown> = (context: PaneContext<State>) => PaneView;
|
|
28
|
+
export type PaneRenderer<State = unknown> = (context: PaneContext<State>) => PaneView | void;
|
|
25
29
|
export interface TabBarStyle {
|
|
26
30
|
/** Left placement uses an icon-only rail; mode and shape apply in either placement. */
|
|
27
31
|
placement?: 'top' | 'left';
|
|
@@ -40,13 +44,10 @@ export interface TabBarOptions extends TabBarStyle {
|
|
|
40
44
|
/** Partial overrides keyed by stable group ID; not serialized in Layout JSON. */
|
|
41
45
|
regions?: Record<string, TabBarStyle>;
|
|
42
46
|
}
|
|
43
|
-
|
|
47
|
+
/** Live appearance, interactions, and application integration callbacks. */
|
|
48
|
+
export interface WorkspaceSettings<State = unknown> {
|
|
44
49
|
tabBar?: TabBarOptions;
|
|
45
|
-
store: LayoutStore;
|
|
46
|
-
tabs?: TabRegistry;
|
|
47
50
|
theme?: LayoutTheme;
|
|
48
|
-
renderers?: Record<string, PaneRenderer<State>>;
|
|
49
|
-
registry?: PaneRegistry<PaneRenderer<State>>;
|
|
50
51
|
messages?: Messages;
|
|
51
52
|
popouts?: boolean;
|
|
52
53
|
confirmClose?: (request: CloseRequest) => boolean | Promise<boolean>;
|
|
@@ -68,12 +69,25 @@ export interface ResolvedLayoutOptions<State = unknown> {
|
|
|
68
69
|
/** Override window creation for applications with a managed same-origin host. Must return synchronously. */
|
|
69
70
|
openWindow?: (pane: Pane, placement: WindowPlacement) => Window | null;
|
|
70
71
|
}
|
|
72
|
+
/** Internal renderer configuration; mounted workspaces create this model themselves. */
|
|
73
|
+
export interface ResolvedLayoutOptions<State = unknown> extends WorkspaceSettings<State> {
|
|
74
|
+
store: LayoutStore;
|
|
75
|
+
tabs?: TabRegistry;
|
|
76
|
+
renderers?: Record<string, PaneRenderer<State>>;
|
|
77
|
+
registry?: PaneRegistry<PaneRenderer<State>>;
|
|
78
|
+
}
|
|
79
|
+
/** Initial configuration is consumed once. Use workspace commands for later changes. */
|
|
80
|
+
export type InitialConfiguration =
|
|
81
|
+
| { initialLayout?: Layout; initialWorkspace?: never }
|
|
82
|
+
| { initialLayout?: never; initialWorkspace: WorkspacePreset };
|
|
83
|
+
/** Commands and subscriptions for a workspace-owned store. */
|
|
84
|
+
export type WorkspaceStore = Omit<LayoutStore, 'dispose'>;
|
|
71
85
|
/** Choose a unified registry OR the low-level renderer and creation maps. */
|
|
72
86
|
export type LayoutOptions<State = unknown> = Omit<
|
|
73
87
|
ResolvedLayoutOptions<State>,
|
|
74
|
-
'registry' | 'renderers' | 'tabs' | 'getPaneState'
|
|
88
|
+
'store' | 'registry' | 'renderers' | 'tabs' | 'getPaneState'
|
|
75
89
|
> &
|
|
76
|
-
(unknown extends State
|
|
90
|
+
InitialConfiguration & { store?: never } & (unknown extends State
|
|
77
91
|
? { getPaneState?: (paneId: string) => State }
|
|
78
92
|
: { getPaneState: (paneId: string) => State }) &
|
|
79
93
|
(
|
|
@@ -99,6 +113,7 @@ export interface CloseRequest {
|
|
|
99
113
|
signal: AbortSignal;
|
|
100
114
|
}
|
|
101
115
|
export interface MountedLayout<State = unknown> {
|
|
116
|
+
readonly store: WorkspaceStore;
|
|
102
117
|
updateOptions(options: LayoutOptionUpdates<State>): void;
|
|
103
118
|
exportWorkspace(): WorkspacePreset;
|
|
104
119
|
loadWorkspace(input: unknown): void;
|
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
import { groups, findNode } from 'quilt-core';
|
|
2
|
+
import type {
|
|
3
|
+
LayoutStore,
|
|
4
|
+
Pane,
|
|
5
|
+
Group,
|
|
6
|
+
WindowPlacement,
|
|
7
|
+
AutoCollapse,
|
|
8
|
+
Axis,
|
|
9
|
+
CommandOptions,
|
|
10
|
+
JoinOptions,
|
|
11
|
+
Capability,
|
|
12
|
+
Layout,
|
|
13
|
+
} from 'quilt-core';
|
|
14
|
+
import { mountLayout } from './renderer.js';
|
|
15
|
+
import { PaneRegistry, createPaneId } from './registry.js';
|
|
16
|
+
import type { PaneRegistration } from './registry.js';
|
|
17
|
+
import { dockLayout } from './workspace.js';
|
|
18
|
+
import type { LayoutTheme } from './theme.js';
|
|
19
|
+
import type {
|
|
20
|
+
LayoutOptions,
|
|
21
|
+
LayoutOptionUpdates,
|
|
22
|
+
MountedLayout,
|
|
23
|
+
PaneRenderer,
|
|
24
|
+
TabBarOptions,
|
|
25
|
+
WorkspaceSettings,
|
|
26
|
+
} from './types.js';
|
|
27
|
+
|
|
28
|
+
export type PaneType<T> = Omit<PaneRegistration<T>, 'type' | 'view'> & { render: T };
|
|
29
|
+
export type PaneTypes<T> = Record<string, PaneType<T>>;
|
|
30
|
+
export type WorkspaceOptions<State = unknown> = WorkspaceSettings<State> &
|
|
31
|
+
import('./types.js').InitialConfiguration & {
|
|
32
|
+
container: HTMLElement;
|
|
33
|
+
store?: never;
|
|
34
|
+
} & (unknown extends State ? {} : { getPaneState: (paneId: string) => State }) &
|
|
35
|
+
(
|
|
36
|
+
| {
|
|
37
|
+
paneTypes: PaneTypes<PaneRenderer<State>>;
|
|
38
|
+
registry?: never;
|
|
39
|
+
renderers?: never;
|
|
40
|
+
tabs?: never;
|
|
41
|
+
}
|
|
42
|
+
| {
|
|
43
|
+
paneTypes?: undefined;
|
|
44
|
+
registry: PaneRegistry<PaneRenderer<State>>;
|
|
45
|
+
renderers?: never;
|
|
46
|
+
tabs?: never;
|
|
47
|
+
}
|
|
48
|
+
| {
|
|
49
|
+
paneTypes?: undefined;
|
|
50
|
+
registry?: undefined;
|
|
51
|
+
renderers?: Record<string, PaneRenderer<State>>;
|
|
52
|
+
tabs?: import('./registry.js').TabRegistry;
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
export type WorkspaceOptionUpdates<State = unknown> = {
|
|
56
|
+
[Key in keyof WorkspaceSettings<State>]?: WorkspaceSettings<State>[Key] | undefined;
|
|
57
|
+
} & (
|
|
58
|
+
| {
|
|
59
|
+
paneTypes?: PaneTypes<PaneRenderer<State>> | undefined;
|
|
60
|
+
registry?: undefined;
|
|
61
|
+
renderers?: undefined;
|
|
62
|
+
tabs?: undefined;
|
|
63
|
+
}
|
|
64
|
+
| {
|
|
65
|
+
paneTypes?: undefined;
|
|
66
|
+
registry?: PaneRegistry<PaneRenderer<State>> | undefined;
|
|
67
|
+
renderers?: undefined;
|
|
68
|
+
tabs?: undefined;
|
|
69
|
+
}
|
|
70
|
+
| {
|
|
71
|
+
paneTypes?: undefined;
|
|
72
|
+
registry?: undefined;
|
|
73
|
+
renderers?: Record<string, PaneRenderer<State>> | undefined;
|
|
74
|
+
tabs?: import('./registry.js').TabRegistry | undefined;
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
export interface WorkspaceChange {
|
|
78
|
+
action: string;
|
|
79
|
+
changes: readonly ('layout' | 'theme' | 'tabBar' | 'autoCollapse')[];
|
|
80
|
+
}
|
|
81
|
+
export interface WorkspaceEvents {
|
|
82
|
+
change: WorkspaceChange;
|
|
83
|
+
error: unknown;
|
|
84
|
+
}
|
|
85
|
+
export type AddPaneOptions = Partial<Omit<Pane, 'type'>> & { groupId?: string };
|
|
86
|
+
export interface CloseOptions {
|
|
87
|
+
force?: boolean;
|
|
88
|
+
}
|
|
89
|
+
export interface PaneHandle {
|
|
90
|
+
readonly id: string;
|
|
91
|
+
readonly isDisposed: boolean;
|
|
92
|
+
getSnapshot(): Pane;
|
|
93
|
+
update(patch: Partial<Omit<Pane, 'id'>>): void;
|
|
94
|
+
setTitle(title: string): void;
|
|
95
|
+
activate(): void;
|
|
96
|
+
move(
|
|
97
|
+
groupId: string,
|
|
98
|
+
position?: 'tab' | 'left' | 'right' | 'top' | 'bottom',
|
|
99
|
+
index?: number,
|
|
100
|
+
): void;
|
|
101
|
+
popout(placement?: WindowPlacement): Promise<boolean>;
|
|
102
|
+
return(): void;
|
|
103
|
+
retry(): void;
|
|
104
|
+
close(options?: CloseOptions): Promise<boolean>;
|
|
105
|
+
}
|
|
106
|
+
/** The mounted API. The model and its lifetime remain private to Quilt. */
|
|
107
|
+
export interface WorkspaceHandle<State = unknown> {
|
|
108
|
+
readonly isDisposed: boolean;
|
|
109
|
+
/** Subscribe to configuration changes or errors. The returned function unsubscribes. */
|
|
110
|
+
on<K extends keyof WorkspaceEvents>(
|
|
111
|
+
event: K,
|
|
112
|
+
listener: (event: WorkspaceEvents[K]) => void,
|
|
113
|
+
): () => void;
|
|
114
|
+
/** Stable immutable arrangement snapshot, including live detached records. */
|
|
115
|
+
getLayout(): Layout;
|
|
116
|
+
/** Mutable arrangement copy. Use exportWorkspace for persistence with appearance. */
|
|
117
|
+
exportLayout(): Layout;
|
|
118
|
+
/** Validate and replace the arrangement; detached records restore docked. Invalidates old pane handles on success. */
|
|
119
|
+
loadLayout(input: unknown): void;
|
|
120
|
+
/** Restore the initial arrangement and appearance. */
|
|
121
|
+
reset(): void;
|
|
122
|
+
exportWorkspace(): import('./workspace.js').WorkspacePreset;
|
|
123
|
+
/** Apply a complete preset atomically; emit one consolidated change event. */
|
|
124
|
+
loadWorkspace(input: unknown): void;
|
|
125
|
+
updateOptions(options: WorkspaceOptionUpdates<State>): void;
|
|
126
|
+
setTheme(theme: LayoutTheme): void;
|
|
127
|
+
setTabBar(tabBar: TabBarOptions): void;
|
|
128
|
+
refreshTheme(): void;
|
|
129
|
+
getAutoCollapse(): AutoCollapse;
|
|
130
|
+
setAutoCollapse(mode: AutoCollapse): void;
|
|
131
|
+
getPane(id: string): PaneHandle | undefined;
|
|
132
|
+
getPanes(): PaneHandle[];
|
|
133
|
+
/** Create from a registered type; undefined means its factory cancelled. */
|
|
134
|
+
addPane(type: string, options?: AddPaneOptions): PaneHandle | undefined;
|
|
135
|
+
insertPane(pane: Pane, groupId: string, options?: CommandOptions): void;
|
|
136
|
+
updatePane(pane: Pane): void;
|
|
137
|
+
activatePane(id: string): void;
|
|
138
|
+
movePane(
|
|
139
|
+
paneId: string,
|
|
140
|
+
groupId: string,
|
|
141
|
+
position?: 'tab' | 'left' | 'right' | 'top' | 'bottom',
|
|
142
|
+
index?: number,
|
|
143
|
+
options?: CommandOptions,
|
|
144
|
+
): void;
|
|
145
|
+
splitGroup(
|
|
146
|
+
groupId: string,
|
|
147
|
+
axis: Axis,
|
|
148
|
+
pane?: Pane | null,
|
|
149
|
+
options?: CommandOptions & { before?: boolean; ratio?: number },
|
|
150
|
+
): string;
|
|
151
|
+
removeEmptyGroup(groupId: string): void;
|
|
152
|
+
joinGroup(groupId: string, options?: CommandOptions): void;
|
|
153
|
+
joinGroups(receiverId: string, otherId: string, options?: JoinOptions): void;
|
|
154
|
+
resize(splitId: string, ratio: number, options?: CommandOptions): void;
|
|
155
|
+
resizeMany(ratios: Record<string, number>, options?: CommandOptions): void;
|
|
156
|
+
maximize(groupId: string | null): void;
|
|
157
|
+
setTabDisplay(groupId: string, display: Group['tabDisplay']): void;
|
|
158
|
+
setTabPlacement(groupId: string, placement: Group['tabPlacement']): void;
|
|
159
|
+
can(paneId: string, capability: Capability): boolean;
|
|
160
|
+
canRestoreClosedPane(): boolean;
|
|
161
|
+
restoreClosedPane(): void;
|
|
162
|
+
/** Confirm and check permissions unless force is explicitly supplied. */
|
|
163
|
+
closePane(id: string, options?: CloseOptions): Promise<boolean>;
|
|
164
|
+
closeGroup(id: string, options?: CloseOptions): Promise<boolean>;
|
|
165
|
+
/** Call during a user gesture. Resolves after destination readiness or rollback. */
|
|
166
|
+
popout(id: string, placement?: WindowPlacement): Promise<boolean>;
|
|
167
|
+
returnPane(id: string): void;
|
|
168
|
+
retryPane(id: string): void;
|
|
169
|
+
/** Idempotently cancel pending work and release views, companions, and the private model. */
|
|
170
|
+
dispose(): void;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
type InternalOptions<State> = LayoutOptionUpdates<State> & {
|
|
174
|
+
paneTypes?: PaneTypes<PaneRenderer<State>> | undefined;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** A mounted workspace. Call dispose once when its application container is removed. */
|
|
178
|
+
export class Workspace<State = unknown> implements WorkspaceHandle<State> {
|
|
179
|
+
#mounted: MountedLayout<State>;
|
|
180
|
+
#disposed = false;
|
|
181
|
+
#handles = new Map<string, PaneHandle>();
|
|
182
|
+
#listeners = {
|
|
183
|
+
change: new Set<(event: WorkspaceChange) => void>(),
|
|
184
|
+
error: new Set<(event: unknown) => void>(),
|
|
185
|
+
};
|
|
186
|
+
#cleanups: (() => void)[] = [];
|
|
187
|
+
#options: InternalOptions<State>;
|
|
188
|
+
#activeGroup: string | undefined;
|
|
189
|
+
#batch = false;
|
|
190
|
+
#initial: ReturnType<MountedLayout['exportWorkspace']>;
|
|
191
|
+
constructor(options: WorkspaceOptions<State>) {
|
|
192
|
+
if (!options?.container?.ownerDocument?.defaultView)
|
|
193
|
+
throw new Error('Workspace requires a container in a live document');
|
|
194
|
+
const { container, ...input } = options;
|
|
195
|
+
const { initialLayout: _layout, initialWorkspace: _workspace, ...updates } = input;
|
|
196
|
+
this.#options = { ...updates };
|
|
197
|
+
const adapted = this.#adapt(this.#options);
|
|
198
|
+
this.#mounted = mountLayout(container, {
|
|
199
|
+
...input,
|
|
200
|
+
...adapted,
|
|
201
|
+
onError: this.#report,
|
|
202
|
+
} as unknown as LayoutOptions<State>);
|
|
203
|
+
this.#initial = this.#mounted.exportWorkspace();
|
|
204
|
+
this.#cleanups.push(
|
|
205
|
+
this.#model.subscribe((event) => {
|
|
206
|
+
if (event.action === 'load') {
|
|
207
|
+
this.#handles.clear();
|
|
208
|
+
this.#activeGroup = undefined;
|
|
209
|
+
} else
|
|
210
|
+
for (const id of this.#handles.keys())
|
|
211
|
+
if (!Object.hasOwn(event.layout.panes, id)) this.#handles.delete(id);
|
|
212
|
+
if (!this.#batch) this.#emit({ action: event.action, changes: ['layout'] });
|
|
213
|
+
}),
|
|
214
|
+
this.#model.onError(this.#report),
|
|
215
|
+
);
|
|
216
|
+
const activate = (event: Event) => {
|
|
217
|
+
const target = event.target as Element | null;
|
|
218
|
+
const id = target?.closest?.('[data-node-id]')?.getAttribute('data-node-id');
|
|
219
|
+
if (id && findNode(this.getLayout().root, id)?.kind === 'group') this.#activeGroup = id;
|
|
220
|
+
};
|
|
221
|
+
container.addEventListener('pointerdown', activate);
|
|
222
|
+
container.addEventListener('focusin', activate);
|
|
223
|
+
this.#cleanups.push(
|
|
224
|
+
() => container.removeEventListener('pointerdown', activate),
|
|
225
|
+
() => container.removeEventListener('focusin', activate),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
get #model(): LayoutStore {
|
|
229
|
+
return this.#mounted.store as LayoutStore;
|
|
230
|
+
}
|
|
231
|
+
#alive() {
|
|
232
|
+
if (this.#disposed) throw new Error('Workspace has been disposed');
|
|
233
|
+
}
|
|
234
|
+
#report = (error: unknown) => {
|
|
235
|
+
try {
|
|
236
|
+
this.#options.onError?.(error);
|
|
237
|
+
} catch {
|
|
238
|
+
/* Reporting must not interrupt cleanup. */
|
|
239
|
+
}
|
|
240
|
+
for (const listener of [...this.#listeners.error]) {
|
|
241
|
+
if (!this.#listeners.error.has(listener)) continue;
|
|
242
|
+
try {
|
|
243
|
+
listener(error);
|
|
244
|
+
} catch {
|
|
245
|
+
/* Isolate reporters. */
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
#emit(event: WorkspaceChange) {
|
|
250
|
+
for (const listener of [...this.#listeners.change]) {
|
|
251
|
+
if (!this.#listeners.change.has(listener)) continue;
|
|
252
|
+
try {
|
|
253
|
+
listener(event);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
this.#report(error);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
#adapt(options: InternalOptions<State>): LayoutOptionUpdates<State> {
|
|
260
|
+
const { paneTypes, ...rest } = options;
|
|
261
|
+
if (paneTypes !== undefined) {
|
|
262
|
+
if (rest.registry || rest.renderers || rest.tabs)
|
|
263
|
+
throw new Error('paneTypes cannot be combined with registry, renderers, or tabs');
|
|
264
|
+
return {
|
|
265
|
+
...rest,
|
|
266
|
+
registry: new PaneRegistry(
|
|
267
|
+
Object.entries(paneTypes).map(([type, { render, ...entry }]) => ({
|
|
268
|
+
...entry,
|
|
269
|
+
type,
|
|
270
|
+
view: render,
|
|
271
|
+
})),
|
|
272
|
+
),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
return { ...rest, registry: rest.registry };
|
|
276
|
+
}
|
|
277
|
+
get isDisposed() {
|
|
278
|
+
return this.#disposed;
|
|
279
|
+
}
|
|
280
|
+
on<K extends keyof WorkspaceEvents>(
|
|
281
|
+
event: K,
|
|
282
|
+
listener: (event: WorkspaceEvents[K]) => void,
|
|
283
|
+
): () => void {
|
|
284
|
+
this.#alive();
|
|
285
|
+
const listeners = this.#listeners[event] as Set<(event: WorkspaceEvents[K]) => void>;
|
|
286
|
+
listeners.add(listener);
|
|
287
|
+
return () => {
|
|
288
|
+
listeners.delete(listener);
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/** Stable immutable layout snapshot. */
|
|
292
|
+
getLayout = () => {
|
|
293
|
+
this.#alive();
|
|
294
|
+
return this.#model.getSnapshot();
|
|
295
|
+
};
|
|
296
|
+
exportLayout() {
|
|
297
|
+
this.#alive();
|
|
298
|
+
return this.#model.export();
|
|
299
|
+
}
|
|
300
|
+
loadLayout(input: unknown) {
|
|
301
|
+
this.#alive();
|
|
302
|
+
this.#model.load(dockLayout(input));
|
|
303
|
+
}
|
|
304
|
+
reset() {
|
|
305
|
+
this.loadWorkspace(this.#initial);
|
|
306
|
+
}
|
|
307
|
+
exportWorkspace() {
|
|
308
|
+
this.#alive();
|
|
309
|
+
return this.#mounted.exportWorkspace();
|
|
310
|
+
}
|
|
311
|
+
loadWorkspace(input: unknown) {
|
|
312
|
+
this.#alive();
|
|
313
|
+
this.#batch = true;
|
|
314
|
+
try {
|
|
315
|
+
this.#mounted.loadWorkspace(input);
|
|
316
|
+
} finally {
|
|
317
|
+
this.#batch = false;
|
|
318
|
+
}
|
|
319
|
+
this.#emit({ action: 'loadWorkspace', changes: ['layout', 'theme', 'tabBar', 'autoCollapse'] });
|
|
320
|
+
}
|
|
321
|
+
updateOptions(next: WorkspaceOptionUpdates<State>) {
|
|
322
|
+
this.#alive();
|
|
323
|
+
if ('container' in next) throw new Error('Workspace container cannot be updated');
|
|
324
|
+
const candidate = { ...this.#options, ...next };
|
|
325
|
+
const adapted = this.#adapt(candidate);
|
|
326
|
+
const changes: WorkspaceChange['changes'][number][] = [];
|
|
327
|
+
const before = this.exportWorkspace();
|
|
328
|
+
// Initial appearance is mount-only: only explicitly updated appearance keys apply.
|
|
329
|
+
if (!('theme' in next)) delete adapted.theme;
|
|
330
|
+
if (!('tabBar' in next)) delete adapted.tabBar;
|
|
331
|
+
this.#mounted.updateOptions({ ...adapted, onError: this.#report });
|
|
332
|
+
this.#options = candidate;
|
|
333
|
+
const after = this.exportWorkspace();
|
|
334
|
+
if (JSON.stringify(before.theme) !== JSON.stringify(after.theme)) changes.push('theme');
|
|
335
|
+
if (JSON.stringify(before.tabBar) !== JSON.stringify(after.tabBar)) changes.push('tabBar');
|
|
336
|
+
if (changes.length) this.#emit({ action: 'updateOptions', changes });
|
|
337
|
+
}
|
|
338
|
+
setTheme(theme: LayoutTheme) {
|
|
339
|
+
this.updateOptions({ theme });
|
|
340
|
+
}
|
|
341
|
+
setTabBar(tabBar: TabBarOptions) {
|
|
342
|
+
this.updateOptions({ tabBar });
|
|
343
|
+
}
|
|
344
|
+
refreshTheme() {
|
|
345
|
+
this.#alive();
|
|
346
|
+
this.#mounted.refreshTheme();
|
|
347
|
+
}
|
|
348
|
+
getAutoCollapse() {
|
|
349
|
+
this.#alive();
|
|
350
|
+
return this.#model.getAutoCollapse();
|
|
351
|
+
}
|
|
352
|
+
setAutoCollapse(mode: AutoCollapse) {
|
|
353
|
+
this.#alive();
|
|
354
|
+
const previous = this.#model.getAutoCollapse();
|
|
355
|
+
this.#model.setAutoCollapse(mode);
|
|
356
|
+
if (mode !== previous) this.#emit({ action: 'setAutoCollapse', changes: ['autoCollapse'] });
|
|
357
|
+
}
|
|
358
|
+
getPane(id: string): PaneHandle | undefined {
|
|
359
|
+
this.#alive();
|
|
360
|
+
if (!Object.hasOwn(this.getLayout().panes, id)) return undefined;
|
|
361
|
+
let handle = this.#handles.get(id);
|
|
362
|
+
if (handle) return handle;
|
|
363
|
+
const workspace = this;
|
|
364
|
+
const alive = () => {
|
|
365
|
+
workspace.#alive();
|
|
366
|
+
if (workspace.#handles.get(id) !== handle) throw new Error(`Pane ${id} has been disposed`);
|
|
367
|
+
};
|
|
368
|
+
handle = {
|
|
369
|
+
id,
|
|
370
|
+
get isDisposed() {
|
|
371
|
+
return workspace.#disposed || workspace.#handles.get(id) !== handle;
|
|
372
|
+
},
|
|
373
|
+
getSnapshot() {
|
|
374
|
+
alive();
|
|
375
|
+
return workspace.getLayout().panes[id]!;
|
|
376
|
+
},
|
|
377
|
+
update(patch) {
|
|
378
|
+
alive();
|
|
379
|
+
workspace.updatePane({ ...workspace.getLayout().panes[id]!, ...patch, id });
|
|
380
|
+
},
|
|
381
|
+
setTitle(title) {
|
|
382
|
+
this.update({ title });
|
|
383
|
+
},
|
|
384
|
+
activate() {
|
|
385
|
+
alive();
|
|
386
|
+
workspace.activatePane(id);
|
|
387
|
+
},
|
|
388
|
+
move(groupId, position, index) {
|
|
389
|
+
alive();
|
|
390
|
+
workspace.movePane(id, groupId, position, index);
|
|
391
|
+
},
|
|
392
|
+
async popout(placement) {
|
|
393
|
+
alive();
|
|
394
|
+
return workspace.popout(id, placement);
|
|
395
|
+
},
|
|
396
|
+
return() {
|
|
397
|
+
alive();
|
|
398
|
+
workspace.returnPane(id);
|
|
399
|
+
},
|
|
400
|
+
retry() {
|
|
401
|
+
alive();
|
|
402
|
+
workspace.retryPane(id);
|
|
403
|
+
},
|
|
404
|
+
async close(options) {
|
|
405
|
+
alive();
|
|
406
|
+
return workspace.closePane(id, options);
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
Object.freeze(handle);
|
|
410
|
+
this.#handles.set(id, handle);
|
|
411
|
+
return handle;
|
|
412
|
+
}
|
|
413
|
+
getPanes(): PaneHandle[] {
|
|
414
|
+
return Object.keys(this.getLayout().panes).map((id) => this.getPane(id)!);
|
|
415
|
+
}
|
|
416
|
+
addPane(type: string, options: AddPaneOptions = {}): PaneHandle | undefined {
|
|
417
|
+
this.#alive();
|
|
418
|
+
const layout = this.getLayout();
|
|
419
|
+
const all = groups(layout.root);
|
|
420
|
+
const groupId =
|
|
421
|
+
options.groupId ?? all.find((group) => group.id === this.#activeGroup)?.id ?? all[0]!.id;
|
|
422
|
+
const group = all.find((group) => group.id === groupId);
|
|
423
|
+
if (!group) throw new Error(`Group not found: ${groupId}`);
|
|
424
|
+
const registry = this.#adapt(this.#options).registry;
|
|
425
|
+
const entry = registry?.list().find((entry) => entry.type === type);
|
|
426
|
+
if (!entry) throw new Error(`Pane type is not registered: ${type}`);
|
|
427
|
+
const supplied = entry.create
|
|
428
|
+
? entry.create({ groupId, source: group.active ? layout.panes[group.active] : undefined })
|
|
429
|
+
: {};
|
|
430
|
+
if (supplied === undefined) return undefined;
|
|
431
|
+
const { groupId: _groupId, ...patch } = options;
|
|
432
|
+
const pane = {
|
|
433
|
+
title: entry.title,
|
|
434
|
+
...(entry.icon === undefined ? {} : { icon: entry.icon }),
|
|
435
|
+
...supplied,
|
|
436
|
+
...patch,
|
|
437
|
+
type,
|
|
438
|
+
id: patch.id ?? supplied.id ?? createPaneId(),
|
|
439
|
+
};
|
|
440
|
+
this.insertPane(pane, groupId);
|
|
441
|
+
return this.getPane(pane.id);
|
|
442
|
+
}
|
|
443
|
+
/** Insert a fully specified serializable record (advanced model integration). */
|
|
444
|
+
insertPane(pane: Pane, groupId: string, options: CommandOptions = {}) {
|
|
445
|
+
this.#alive();
|
|
446
|
+
this.#model.add(pane, groupId, options);
|
|
447
|
+
}
|
|
448
|
+
updatePane(pane: Pane) {
|
|
449
|
+
this.#alive();
|
|
450
|
+
this.#model.updatePane(pane);
|
|
451
|
+
}
|
|
452
|
+
activatePane(id: string) {
|
|
453
|
+
const group = groups(this.getLayout().root).find((group) => group.panes.includes(id));
|
|
454
|
+
if (!group) throw new Error(`Pane is not docked: ${id}`);
|
|
455
|
+
this.#activeGroup = group.id;
|
|
456
|
+
this.#model.activate(group.id, id);
|
|
457
|
+
}
|
|
458
|
+
movePane(
|
|
459
|
+
paneId: string,
|
|
460
|
+
groupId: string,
|
|
461
|
+
position: 'tab' | 'left' | 'right' | 'top' | 'bottom' = 'tab',
|
|
462
|
+
index?: number,
|
|
463
|
+
options: CommandOptions = {},
|
|
464
|
+
) {
|
|
465
|
+
this.#alive();
|
|
466
|
+
this.#model.move(paneId, groupId, position, index, options);
|
|
467
|
+
}
|
|
468
|
+
splitGroup(
|
|
469
|
+
groupId: string,
|
|
470
|
+
axis: Axis,
|
|
471
|
+
pane: Pane | null = null,
|
|
472
|
+
options: CommandOptions & { before?: boolean; ratio?: number } = {},
|
|
473
|
+
) {
|
|
474
|
+
this.#alive();
|
|
475
|
+
return this.#model.split(groupId, axis, pane, options);
|
|
476
|
+
}
|
|
477
|
+
removeEmptyGroup(groupId: string) {
|
|
478
|
+
this.#alive();
|
|
479
|
+
this.#model.removeEmptyGroup(groupId);
|
|
480
|
+
}
|
|
481
|
+
joinGroup(groupId: string, options: CommandOptions = {}) {
|
|
482
|
+
this.#alive();
|
|
483
|
+
this.#model.join(groupId, options);
|
|
484
|
+
}
|
|
485
|
+
joinGroups(receiverId: string, otherId: string, options: JoinOptions = {}) {
|
|
486
|
+
this.#alive();
|
|
487
|
+
this.#model.joinRegions(receiverId, otherId, options);
|
|
488
|
+
}
|
|
489
|
+
resize(splitId: string, ratio: number, options: CommandOptions = {}) {
|
|
490
|
+
this.#alive();
|
|
491
|
+
this.#model.resize(splitId, ratio, options);
|
|
492
|
+
}
|
|
493
|
+
resizeMany(ratios: Record<string, number>, options: CommandOptions = {}) {
|
|
494
|
+
this.#alive();
|
|
495
|
+
this.#model.resizeMany(ratios, options);
|
|
496
|
+
}
|
|
497
|
+
maximize(groupId: string | null) {
|
|
498
|
+
this.#alive();
|
|
499
|
+
this.#model.maximize(groupId);
|
|
500
|
+
}
|
|
501
|
+
setTabDisplay(groupId: string, display: Group['tabDisplay']) {
|
|
502
|
+
this.#alive();
|
|
503
|
+
this.#model.setTabDisplay(groupId, display);
|
|
504
|
+
}
|
|
505
|
+
setTabPlacement(groupId: string, placement: Group['tabPlacement']) {
|
|
506
|
+
this.#alive();
|
|
507
|
+
this.#model.setTabPlacement(groupId, placement);
|
|
508
|
+
}
|
|
509
|
+
can(paneId: string, capability: Capability) {
|
|
510
|
+
this.#alive();
|
|
511
|
+
return this.#model.can(paneId, capability);
|
|
512
|
+
}
|
|
513
|
+
canRestoreClosedPane() {
|
|
514
|
+
this.#alive();
|
|
515
|
+
return this.#model.canRestoreClosedTab();
|
|
516
|
+
}
|
|
517
|
+
restoreClosedPane() {
|
|
518
|
+
this.#alive();
|
|
519
|
+
this.#model.restoreClosedTab();
|
|
520
|
+
}
|
|
521
|
+
async closePane(id: string, options: CloseOptions = {}): Promise<boolean> {
|
|
522
|
+
this.#alive();
|
|
523
|
+
if (options.force) {
|
|
524
|
+
this.#model.close(id);
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
return this.#mounted.requestClose(id);
|
|
528
|
+
}
|
|
529
|
+
async closeGroup(id: string, options: CloseOptions = {}): Promise<boolean> {
|
|
530
|
+
this.#alive();
|
|
531
|
+
if (options.force) {
|
|
532
|
+
this.#model.closeGroup(id);
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
return this.#mounted.requestClose(id, 'group');
|
|
536
|
+
}
|
|
537
|
+
async popout(id: string, placement?: WindowPlacement): Promise<boolean> {
|
|
538
|
+
this.#alive();
|
|
539
|
+
return this.#mounted.popout(id, placement);
|
|
540
|
+
}
|
|
541
|
+
returnPane(id: string) {
|
|
542
|
+
this.#alive();
|
|
543
|
+
this.#mounted.returnPane(id);
|
|
544
|
+
}
|
|
545
|
+
retryPane(id: string) {
|
|
546
|
+
this.#alive();
|
|
547
|
+
this.#mounted.retryPane(id);
|
|
548
|
+
}
|
|
549
|
+
dispose() {
|
|
550
|
+
if (this.#disposed) return;
|
|
551
|
+
this.#disposed = true;
|
|
552
|
+
this.#handles.clear();
|
|
553
|
+
for (const cleanup of this.#cleanups.splice(0)) cleanup();
|
|
554
|
+
try {
|
|
555
|
+
this.#mounted.dispose();
|
|
556
|
+
} finally {
|
|
557
|
+
this.#listeners.change.clear();
|
|
558
|
+
this.#listeners.error.clear();
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|