@valentinkolb/cloud 0.4.0 → 0.5.1
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/package.json +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +116 -13
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +47 -7
- package/src/services/auth-flows/magic-link.ts +92 -20
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/notifications/index.ts +82 -11
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +79 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +58 -0
- package/src/shared/redirect.ts +56 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type DndBuildIntentContext,
|
|
3
|
+
type DndCollisionContext,
|
|
4
|
+
type DndDroppableSnapshot,
|
|
5
|
+
type DndPointer,
|
|
6
|
+
dnd,
|
|
7
|
+
} from "@valentinkolb/stdlib/solid";
|
|
8
|
+
import { children, createMemo, For, type JSX, onCleanup, Show } from "solid-js";
|
|
9
|
+
|
|
10
|
+
const ELEMENT_SLOT = Symbol("Panes.Element");
|
|
11
|
+
const MIN_PANE_SIZE = 8;
|
|
12
|
+
|
|
13
|
+
type MaybeAccessor<T> = T | (() => T);
|
|
14
|
+
|
|
15
|
+
export type PanesLeafPresentation = "single" | "tabs" | "stack";
|
|
16
|
+
|
|
17
|
+
export type PanesLeafNode = {
|
|
18
|
+
type: "leaf";
|
|
19
|
+
id: string;
|
|
20
|
+
elementIds: string[];
|
|
21
|
+
activeElementId?: string;
|
|
22
|
+
presentation?: PanesLeafPresentation;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type PanesSplitNode = {
|
|
26
|
+
type: "split";
|
|
27
|
+
id: string;
|
|
28
|
+
direction: "horizontal" | "vertical";
|
|
29
|
+
sizes: number[];
|
|
30
|
+
children: PanesNode[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type PanesNode = PanesLeafNode | PanesSplitNode;
|
|
34
|
+
|
|
35
|
+
export type PanesValue = {
|
|
36
|
+
root: PanesNode;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type PanesElementSlot = {
|
|
40
|
+
readonly kind: typeof ELEMENT_SLOT;
|
|
41
|
+
id: string;
|
|
42
|
+
title?: string;
|
|
43
|
+
icon?: string;
|
|
44
|
+
closable?: MaybeAccessor<boolean>;
|
|
45
|
+
onClose?: () => void;
|
|
46
|
+
children: JSX.Element;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type PanesRootProps = {
|
|
50
|
+
value: PanesValue;
|
|
51
|
+
onChange: (value: PanesValue) => void;
|
|
52
|
+
children: JSX.Element;
|
|
53
|
+
class?: string;
|
|
54
|
+
keepMounted?: boolean;
|
|
55
|
+
leafPresentation?: PanesLeafPresentation;
|
|
56
|
+
allowResize?: MaybeAccessor<boolean>;
|
|
57
|
+
allowMove?: MaybeAccessor<boolean>;
|
|
58
|
+
allowReorder?: MaybeAccessor<boolean>;
|
|
59
|
+
allowHorizontalSplit?: MaybeAccessor<boolean>;
|
|
60
|
+
allowVerticalSplit?: MaybeAccessor<boolean>;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type PanesElementProps = {
|
|
64
|
+
id: string;
|
|
65
|
+
title?: string;
|
|
66
|
+
icon?: string;
|
|
67
|
+
closable?: MaybeAccessor<boolean>;
|
|
68
|
+
onClose?: () => void;
|
|
69
|
+
children: JSX.Element;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type PanesComponent = ((props: PanesRootProps) => JSX.Element) & {
|
|
73
|
+
Root: (props: PanesRootProps) => JSX.Element;
|
|
74
|
+
Element: (props: PanesElementProps) => JSX.Element;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
type DragMeta = {
|
|
78
|
+
elementId: string;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
type SplitZone = "left" | "right" | "top" | "bottom";
|
|
82
|
+
|
|
83
|
+
type DropMeta =
|
|
84
|
+
| { kind: "leaf"; leafId: string }
|
|
85
|
+
| { kind: "tab"; leafId: string; beforeElementId: string }
|
|
86
|
+
| { kind: "split-gap"; splitId: string; index: number; direction: PanesSplitNode["direction"] };
|
|
87
|
+
|
|
88
|
+
type DropIntent =
|
|
89
|
+
| { kind: "move"; elementId: string; leafId: string; beforeElementId?: string }
|
|
90
|
+
| { kind: "split"; elementId: string; leafId: string; zone: SplitZone }
|
|
91
|
+
| { kind: "insert"; elementId: string; splitId: string; index: number; direction: PanesSplitNode["direction"] };
|
|
92
|
+
|
|
93
|
+
const isElementSlot = (value: unknown): value is PanesElementSlot => !!value && typeof value === "object" && "kind" in value;
|
|
94
|
+
|
|
95
|
+
const collectElementSlots = (value: unknown): PanesElementSlot[] => {
|
|
96
|
+
if (Array.isArray(value)) return value.flatMap(collectElementSlots);
|
|
97
|
+
return isElementSlot(value) ? [value] : [];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const readMaybe = (value: MaybeAccessor<boolean> | undefined, fallback: boolean) =>
|
|
101
|
+
typeof value === "function" ? value() : (value ?? fallback);
|
|
102
|
+
|
|
103
|
+
const iconClass = (icon: string | undefined) => {
|
|
104
|
+
const value = icon?.trim() || "ti-layout-sidebar-right";
|
|
105
|
+
return value.startsWith("ti ") ? value : `ti ${value}`;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const normalizeSizes = (sizes: number[], length: number) => {
|
|
109
|
+
const sanitized = Array.from({ length }, (_, index) => {
|
|
110
|
+
const size = sizes[index] ?? 0;
|
|
111
|
+
return Number.isFinite(size) ? Math.max(0, size) : 0;
|
|
112
|
+
});
|
|
113
|
+
const total = sanitized.reduce((sum, size) => sum + size, 0);
|
|
114
|
+
if (total <= 0) return sanitized.map(() => 100 / Math.max(1, length));
|
|
115
|
+
return sanitized.map((size) => (size / total) * 100);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const leafNode = (id: string, elementIds: string[] = [], presentation?: PanesLeafPresentation): PanesLeafNode => ({
|
|
119
|
+
type: "leaf",
|
|
120
|
+
id,
|
|
121
|
+
elementIds,
|
|
122
|
+
activeElementId: elementIds[0],
|
|
123
|
+
presentation,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export const createPanesValue = (elementIds: string[], presentation: PanesLeafPresentation = "tabs"): PanesValue => ({
|
|
127
|
+
root: leafNode("root", elementIds, presentation),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const pruneNode = (node: PanesNode, allowed: Set<string>, used: Set<string>, presentation: PanesLeafPresentation): PanesNode | null => {
|
|
131
|
+
if (node.type === "leaf") {
|
|
132
|
+
const elementIds = node.elementIds.filter((id) => allowed.has(id) && !used.has(id));
|
|
133
|
+
for (const id of elementIds) used.add(id);
|
|
134
|
+
if (elementIds.length === 0) return null;
|
|
135
|
+
return {
|
|
136
|
+
...node,
|
|
137
|
+
presentation: node.presentation ?? presentation,
|
|
138
|
+
elementIds,
|
|
139
|
+
activeElementId: elementIds.includes(node.activeElementId ?? "") ? node.activeElementId : elementIds[0],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const children = node.children.flatMap((child) => {
|
|
144
|
+
const pruned = pruneNode(child, allowed, used, presentation);
|
|
145
|
+
return pruned ? [pruned] : [];
|
|
146
|
+
});
|
|
147
|
+
if (children.length === 0) return null;
|
|
148
|
+
if (children.length === 1) return children[0]!;
|
|
149
|
+
return { ...node, children, sizes: normalizeSizes(node.sizes, children.length) };
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const normalizePanesValue = (
|
|
153
|
+
value: PanesValue | null | undefined,
|
|
154
|
+
elementIds: string[],
|
|
155
|
+
presentation: PanesLeafPresentation = "tabs",
|
|
156
|
+
): PanesValue => {
|
|
157
|
+
const allowed = new Set(elementIds);
|
|
158
|
+
const used = new Set<string>();
|
|
159
|
+
const root = value?.root ? pruneNode(value.root, allowed, used, presentation) : null;
|
|
160
|
+
const missing = elementIds.filter((id) => !used.has(id));
|
|
161
|
+
if (!root) return createPanesValue(missing, presentation);
|
|
162
|
+
if (missing.length === 0) return { root };
|
|
163
|
+
return {
|
|
164
|
+
root:
|
|
165
|
+
root.type === "leaf"
|
|
166
|
+
? {
|
|
167
|
+
...root,
|
|
168
|
+
elementIds: [...root.elementIds, ...missing],
|
|
169
|
+
activeElementId: root.activeElementId ?? root.elementIds[0] ?? missing[0],
|
|
170
|
+
}
|
|
171
|
+
: {
|
|
172
|
+
type: "split",
|
|
173
|
+
id: root.id,
|
|
174
|
+
direction: root.direction,
|
|
175
|
+
sizes: normalizeSizes([...root.sizes, MIN_PANE_SIZE], root.children.length + 1),
|
|
176
|
+
children: [...root.children, leafNode(`leaf-${missing[0]}`, missing, presentation)],
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const mapNode = (node: PanesNode, targetId: string, update: (node: PanesNode) => PanesNode): PanesNode =>
|
|
182
|
+
node.id === targetId
|
|
183
|
+
? update(node)
|
|
184
|
+
: node.type === "split"
|
|
185
|
+
? { ...node, children: node.children.map((child) => mapNode(child, targetId, update)) }
|
|
186
|
+
: node;
|
|
187
|
+
|
|
188
|
+
const removeEmptyLeaves = (node: PanesNode): PanesNode | null => {
|
|
189
|
+
if (node.type === "leaf") return node.elementIds.length > 0 ? node : null;
|
|
190
|
+
const children = node.children.flatMap((child) => {
|
|
191
|
+
const next = removeEmptyLeaves(child);
|
|
192
|
+
return next ? [next] : [];
|
|
193
|
+
});
|
|
194
|
+
if (children.length === 0) return null;
|
|
195
|
+
if (children.length === 1) return children[0]!;
|
|
196
|
+
return { ...node, children, sizes: normalizeSizes(node.sizes, children.length) };
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const removeElementFromNode = (node: PanesNode, elementId: string): PanesNode => {
|
|
200
|
+
if (node.type === "leaf") {
|
|
201
|
+
const elementIds = node.elementIds.filter((id) => id !== elementId);
|
|
202
|
+
return {
|
|
203
|
+
...node,
|
|
204
|
+
elementIds,
|
|
205
|
+
activeElementId: elementIds.includes(node.activeElementId ?? "") ? node.activeElementId : elementIds[0],
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return { ...node, children: node.children.map((child) => removeElementFromNode(child, elementId)) };
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const findLeaf = (node: PanesNode, leafId: string): PanesLeafNode | null => {
|
|
212
|
+
if (node.type === "leaf") return node.id === leafId ? node : null;
|
|
213
|
+
for (const child of node.children) {
|
|
214
|
+
const leaf = findLeaf(child, leafId);
|
|
215
|
+
if (leaf) return leaf;
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const findElementLocation = (
|
|
221
|
+
node: PanesNode,
|
|
222
|
+
elementId: string,
|
|
223
|
+
parentSplitId?: string,
|
|
224
|
+
childIndex?: number,
|
|
225
|
+
): { leaf: PanesLeafNode; parentSplitId?: string; childIndex?: number; elementIndex: number } | null => {
|
|
226
|
+
if (node.type === "leaf") {
|
|
227
|
+
const elementIndex = node.elementIds.indexOf(elementId);
|
|
228
|
+
return elementIndex >= 0 ? { leaf: node, parentSplitId, childIndex, elementIndex } : null;
|
|
229
|
+
}
|
|
230
|
+
for (let index = 0; index < node.children.length; index++) {
|
|
231
|
+
const location = findElementLocation(node.children[index]!, elementId, node.id, index);
|
|
232
|
+
if (location) return location;
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const insertElement = (node: PanesNode, leafId: string, elementId: string, beforeElementId?: string): PanesNode =>
|
|
238
|
+
mapNode(node, leafId, (target) => {
|
|
239
|
+
if (target.type !== "leaf") return target;
|
|
240
|
+
const elementIds = target.elementIds.filter((id) => id !== elementId);
|
|
241
|
+
const beforeIndex = beforeElementId ? elementIds.indexOf(beforeElementId) : -1;
|
|
242
|
+
if (beforeIndex >= 0) elementIds.splice(beforeIndex, 0, elementId);
|
|
243
|
+
else elementIds.push(elementId);
|
|
244
|
+
return {
|
|
245
|
+
...target,
|
|
246
|
+
elementIds,
|
|
247
|
+
activeElementId: elementId,
|
|
248
|
+
presentation: target.presentation === "single" && elementIds.length > 1 ? "tabs" : target.presentation,
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const splitLeaf = (node: PanesNode, leafId: string, elementId: string, zone: SplitZone, presentation: PanesLeafPresentation): PanesNode =>
|
|
253
|
+
mapNode(node, leafId, (target) => {
|
|
254
|
+
if (target.type !== "leaf") return target;
|
|
255
|
+
const direction = zone === "left" || zone === "right" ? "horizontal" : "vertical";
|
|
256
|
+
const newLeaf = leafNode(`leaf-${elementId}-${Date.now()}`, [elementId], presentation);
|
|
257
|
+
const children = zone === "left" || zone === "top" ? [newLeaf, target] : [target, newLeaf];
|
|
258
|
+
return {
|
|
259
|
+
type: "split",
|
|
260
|
+
id: `split-${target.id}-${Date.now()}`,
|
|
261
|
+
direction,
|
|
262
|
+
sizes: [50, 50],
|
|
263
|
+
children,
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const insertLeafIntoSplit = (
|
|
268
|
+
node: PanesNode,
|
|
269
|
+
splitId: string,
|
|
270
|
+
index: number,
|
|
271
|
+
elementId: string,
|
|
272
|
+
presentation: PanesLeafPresentation,
|
|
273
|
+
): PanesNode =>
|
|
274
|
+
mapNode(node, splitId, (target) => {
|
|
275
|
+
if (target.type !== "split") return target;
|
|
276
|
+
const children = [...target.children];
|
|
277
|
+
const insertIndex = Math.min(Math.max(index + 1, 0), children.length);
|
|
278
|
+
children.splice(insertIndex, 0, leafNode(`leaf-${elementId}-${Date.now()}`, [elementId], presentation));
|
|
279
|
+
const sizes = normalizeSizes(target.sizes, target.children.length);
|
|
280
|
+
const previousSize = sizes[index] ?? 100 / children.length;
|
|
281
|
+
const nextSize = sizes[index + 1] ?? previousSize;
|
|
282
|
+
const insertedSize = Math.max(MIN_PANE_SIZE, Math.min(24, (previousSize + nextSize) / 2));
|
|
283
|
+
return {
|
|
284
|
+
...target,
|
|
285
|
+
children,
|
|
286
|
+
sizes: normalizeSizes([...sizes.slice(0, insertIndex), insertedSize, ...sizes.slice(insertIndex)], children.length),
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const resizeSplit = (node: PanesNode, splitId: string, index: number, delta: number, baseSizes?: number[]): PanesNode =>
|
|
291
|
+
mapNode(node, splitId, (target) => {
|
|
292
|
+
if (target.type !== "split") return target;
|
|
293
|
+
const sizes = normalizeSizes(baseSizes ?? target.sizes, target.children.length);
|
|
294
|
+
const current = sizes[index] ?? 0;
|
|
295
|
+
const next = sizes[index + 1] ?? 0;
|
|
296
|
+
const minDelta = -current + MIN_PANE_SIZE;
|
|
297
|
+
const maxDelta = next - MIN_PANE_SIZE;
|
|
298
|
+
const clampedDelta = Math.min(Math.max(delta, minDelta), maxDelta);
|
|
299
|
+
sizes[index] = current + clampedDelta;
|
|
300
|
+
sizes[index + 1] = next - clampedDelta;
|
|
301
|
+
return { ...target, sizes: normalizeSizes(sizes, target.children.length) };
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const applyIntent = (value: PanesValue, intent: DropIntent, presentation: PanesLeafPresentation): PanesValue => {
|
|
305
|
+
const sourceLocation = findElementLocation(value.root, intent.elementId);
|
|
306
|
+
const sourceLeaf = sourceLocation?.leaf;
|
|
307
|
+
if (intent.kind === "move" && intent.beforeElementId === intent.elementId) return value;
|
|
308
|
+
if (intent.kind === "move" && sourceLeaf?.id === intent.leafId && sourceLeaf.elementIds.length === 1) return value;
|
|
309
|
+
if (intent.kind === "move" && sourceLocation && sourceLeaf?.id === intent.leafId) {
|
|
310
|
+
if (!intent.beforeElementId) return value;
|
|
311
|
+
const beforeIndex = sourceLeaf.elementIds.indexOf(intent.beforeElementId);
|
|
312
|
+
if (beforeIndex === sourceLocation.elementIndex || beforeIndex === sourceLocation.elementIndex + 1) return value;
|
|
313
|
+
}
|
|
314
|
+
if (
|
|
315
|
+
intent.kind === "insert" &&
|
|
316
|
+
sourceLocation?.parentSplitId === intent.splitId &&
|
|
317
|
+
sourceLeaf?.elementIds.length === 1 &&
|
|
318
|
+
(sourceLocation.childIndex === intent.index || sourceLocation.childIndex === intent.index + 1)
|
|
319
|
+
) {
|
|
320
|
+
return value;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (intent.kind === "split") {
|
|
324
|
+
const targetLeaf = findLeaf(value.root, intent.leafId);
|
|
325
|
+
if (targetLeaf?.elementIds.length === 1 && targetLeaf.elementIds[0] === intent.elementId) return value;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const withoutElement = removeEmptyLeaves(removeElementFromNode(value.root, intent.elementId)) ?? leafNode("root", [], presentation);
|
|
329
|
+
if (intent.kind === "move") {
|
|
330
|
+
return {
|
|
331
|
+
root: removeEmptyLeaves(insertElement(withoutElement, intent.leafId, intent.elementId, intent.beforeElementId)) ?? withoutElement,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
if (intent.kind === "insert") {
|
|
335
|
+
const adjustedIndex =
|
|
336
|
+
sourceLocation?.parentSplitId === intent.splitId &&
|
|
337
|
+
sourceLeaf?.elementIds.length === 1 &&
|
|
338
|
+
sourceLocation.childIndex !== undefined &&
|
|
339
|
+
sourceLocation.childIndex < intent.index
|
|
340
|
+
? intent.index - 1
|
|
341
|
+
: intent.index;
|
|
342
|
+
return {
|
|
343
|
+
root:
|
|
344
|
+
removeEmptyLeaves(insertLeafIntoSplit(withoutElement, intent.splitId, adjustedIndex, intent.elementId, presentation)) ??
|
|
345
|
+
withoutElement,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
root: removeEmptyLeaves(splitLeaf(withoutElement, intent.leafId, intent.elementId, intent.zone, presentation)) ?? withoutElement,
|
|
350
|
+
};
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const sameIntent = (a: DropIntent | null, b: DropIntent | null) => JSON.stringify(a) === JSON.stringify(b);
|
|
354
|
+
|
|
355
|
+
const leafEdgeZone = (pointer: DndPointer, rect: DndDroppableSnapshot<DropMeta>["rect"]): SplitZone | null => {
|
|
356
|
+
const threshold = Math.min(40, Math.max(14, Math.min(rect.width, rect.height) * 0.12));
|
|
357
|
+
const distances = [
|
|
358
|
+
["left", pointer.x - rect.left],
|
|
359
|
+
["right", rect.right - pointer.x],
|
|
360
|
+
["top", pointer.y - rect.top],
|
|
361
|
+
["bottom", rect.bottom - pointer.y],
|
|
362
|
+
] as const;
|
|
363
|
+
const edge = distances.filter(([, distance]) => distance >= 0 && distance <= threshold).sort((a, b) => a[1] - b[1])[0];
|
|
364
|
+
return edge?.[0] ?? null;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const nearestDroppable = (entries: DndDroppableSnapshot<DropMeta>[]) =>
|
|
368
|
+
entries.reduce<DndDroppableSnapshot<DropMeta> | null>(
|
|
369
|
+
(winner, entry) => (!winner || entry.distance < winner.distance ? entry : winner),
|
|
370
|
+
null,
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const panesCollisionDetector = (ctx: DndCollisionContext<DragMeta, DropMeta, DropIntent>) => {
|
|
374
|
+
const hits = ctx.droppables.filter((entry) => entry.containsPointer);
|
|
375
|
+
const pool = hits.length > 0 ? hits : ctx.droppables;
|
|
376
|
+
const splitGap = nearestDroppable(pool.filter((entry) => entry.meta.kind === "split-gap"));
|
|
377
|
+
if (splitGap) return splitGap.id;
|
|
378
|
+
const tab = nearestDroppable(pool.filter((entry) => entry.meta.kind === "tab"));
|
|
379
|
+
if (tab) return tab.id;
|
|
380
|
+
return nearestDroppable(pool)?.id ?? null;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const buildIntent = (ctx: DndBuildIntentContext<DragMeta, DropMeta, DropIntent>): DropIntent | null => {
|
|
384
|
+
if (!ctx.over) return null;
|
|
385
|
+
if (ctx.over.meta.kind === "split-gap") {
|
|
386
|
+
return {
|
|
387
|
+
kind: "insert",
|
|
388
|
+
elementId: ctx.active.meta.elementId,
|
|
389
|
+
splitId: ctx.over.meta.splitId,
|
|
390
|
+
index: ctx.over.meta.index,
|
|
391
|
+
direction: ctx.over.meta.direction,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
if (ctx.over.meta.kind === "tab") {
|
|
395
|
+
return {
|
|
396
|
+
kind: "move",
|
|
397
|
+
elementId: ctx.active.meta.elementId,
|
|
398
|
+
leafId: ctx.over.meta.leafId,
|
|
399
|
+
beforeElementId: ctx.over.meta.beforeElementId,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
const zone = leafEdgeZone(ctx.pointer, ctx.over.rect);
|
|
403
|
+
if (zone) return { kind: "split", elementId: ctx.active.meta.elementId, leafId: ctx.over.meta.leafId, zone };
|
|
404
|
+
return { kind: "move", elementId: ctx.active.meta.elementId, leafId: ctx.over.meta.leafId };
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const elementClosable = (element: PanesElementSlot) => !!element.onClose && readMaybe(element.closable, true);
|
|
408
|
+
|
|
409
|
+
const closeButtonClass =
|
|
410
|
+
"flex h-6 w-6 shrink-0 items-center justify-center rounded text-dimmed transition hover:text-red-600 dark:hover:text-red-400";
|
|
411
|
+
|
|
412
|
+
const dragHandleClass =
|
|
413
|
+
"flex shrink-0 cursor-grab items-center justify-center rounded text-dimmed transition-colors hover:text-emerald-600 active:cursor-grabbing dark:hover:text-emerald-400";
|
|
414
|
+
|
|
415
|
+
const tabButtonBaseClass =
|
|
416
|
+
"flex min-w-32 items-center gap-0.5 rounded-lg border px-2 text-xs transition-[background-color,color,border-color,box-shadow] duration-150";
|
|
417
|
+
|
|
418
|
+
const tabButtonClass = (active: boolean) =>
|
|
419
|
+
active
|
|
420
|
+
? `${tabButtonBaseClass} border-blue-200 bg-white font-semibold text-blue-700 hover:border-blue-300 dark:border-blue-800 dark:bg-zinc-900 dark:text-blue-300 dark:hover:border-blue-700`
|
|
421
|
+
: `${tabButtonBaseClass} border-zinc-100 bg-white text-secondary hover:border-zinc-200 hover:text-primary dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700`;
|
|
422
|
+
|
|
423
|
+
const tabButtonShadow = (active: boolean) =>
|
|
424
|
+
active ? "inset 0 0 0 1px rgb(59 130 246 / 0.22), var(--theme-bevel-top)" : "var(--theme-shadow-elevated)";
|
|
425
|
+
|
|
426
|
+
const CloseButton = (props: { element: PanesElementSlot }) => (
|
|
427
|
+
<button
|
|
428
|
+
type="button"
|
|
429
|
+
class={closeButtonClass}
|
|
430
|
+
title={`Close ${props.element.title ?? props.element.id}`}
|
|
431
|
+
aria-label={`Close ${props.element.title ?? props.element.id}`}
|
|
432
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
433
|
+
onClick={(event) => {
|
|
434
|
+
event.stopPropagation();
|
|
435
|
+
props.element.onClose?.();
|
|
436
|
+
}}
|
|
437
|
+
>
|
|
438
|
+
<i class="ti ti-x" />
|
|
439
|
+
</button>
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
function PanesElement(props: PanesElementProps): JSX.Element {
|
|
443
|
+
return {
|
|
444
|
+
kind: ELEMENT_SLOT,
|
|
445
|
+
id: props.id,
|
|
446
|
+
title: props.title,
|
|
447
|
+
icon: props.icon,
|
|
448
|
+
closable: props.closable,
|
|
449
|
+
onClose: props.onClose,
|
|
450
|
+
children: props.children,
|
|
451
|
+
} satisfies PanesElementSlot as unknown as JSX.Element;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const PanesRoot = (props: PanesRootProps) => {
|
|
455
|
+
const resolved = children(() => props.children);
|
|
456
|
+
const slots = createMemo(() => collectElementSlots(resolved.toArray()));
|
|
457
|
+
const elementById = createMemo(() => new Map(slots().map((slot) => [slot.id, slot])));
|
|
458
|
+
const elementIds = createMemo(() => slots().map((slot) => slot.id));
|
|
459
|
+
const presentation = () => props.leafPresentation ?? "tabs";
|
|
460
|
+
const value = createMemo(() => normalizePanesValue(props.value, elementIds(), presentation()));
|
|
461
|
+
const canResize = () => readMaybe(props.allowResize, true);
|
|
462
|
+
const canMove = () => readMaybe(props.allowMove, true);
|
|
463
|
+
const canReorder = () => readMaybe(props.allowReorder, true);
|
|
464
|
+
const canHorizontalSplit = () => readMaybe(props.allowHorizontalSplit, true);
|
|
465
|
+
const canVerticalSplit = () => readMaybe(props.allowVerticalSplit, true);
|
|
466
|
+
|
|
467
|
+
const paneDnd = dnd.create<DragMeta, DropMeta, DropIntent>({
|
|
468
|
+
collisionDetector: panesCollisionDetector,
|
|
469
|
+
buildIntent,
|
|
470
|
+
isSameIntent: sameIntent,
|
|
471
|
+
onDrop: ({ intent }) => {
|
|
472
|
+
if (!intent || !canMove()) return;
|
|
473
|
+
let nextIntent = intent;
|
|
474
|
+
if (nextIntent.kind === "split") {
|
|
475
|
+
const horizontal = nextIntent.zone === "left" || nextIntent.zone === "right";
|
|
476
|
+
if ((horizontal && !canHorizontalSplit()) || (!horizontal && !canVerticalSplit())) {
|
|
477
|
+
if (!canReorder()) return;
|
|
478
|
+
nextIntent = { kind: "move", elementId: nextIntent.elementId, leafId: nextIntent.leafId };
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (nextIntent.kind === "move" && !canReorder()) return;
|
|
482
|
+
if (nextIntent.kind === "insert" && nextIntent.direction === "horizontal" && !canHorizontalSplit()) return;
|
|
483
|
+
if (nextIntent.kind === "insert" && nextIntent.direction === "vertical" && !canVerticalSplit()) return;
|
|
484
|
+
props.onChange(applyIntent(value(), nextIntent, presentation()));
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
onCleanup(() => paneDnd.destroy());
|
|
489
|
+
|
|
490
|
+
const setActive = (leafId: string, elementId: string) => {
|
|
491
|
+
props.onChange({
|
|
492
|
+
root: mapNode(value().root, leafId, (node) => (node.type === "leaf" ? { ...node, activeElementId: elementId } : node)),
|
|
493
|
+
});
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const resize = (splitId: string, index: number, delta: number, baseSizes: number[]) =>
|
|
497
|
+
props.onChange({ root: resizeSplit(value().root, splitId, index, delta, baseSizes) });
|
|
498
|
+
|
|
499
|
+
return (
|
|
500
|
+
<div class={`flex min-h-0 min-w-0 overflow-hidden ${props.class ?? ""}`}>
|
|
501
|
+
<PanesNodeRenderer
|
|
502
|
+
node={() => value().root}
|
|
503
|
+
elementById={elementById()}
|
|
504
|
+
dnd={paneDnd}
|
|
505
|
+
keepMounted={props.keepMounted ?? true}
|
|
506
|
+
canResize={canResize}
|
|
507
|
+
canMove={canMove}
|
|
508
|
+
canReorder={canReorder}
|
|
509
|
+
canHorizontalSplit={canHorizontalSplit}
|
|
510
|
+
canVerticalSplit={canVerticalSplit}
|
|
511
|
+
onActive={setActive}
|
|
512
|
+
onResize={resize}
|
|
513
|
+
/>
|
|
514
|
+
</div>
|
|
515
|
+
);
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
function PanesNodeRenderer(props: {
|
|
519
|
+
node: () => PanesNode;
|
|
520
|
+
elementById: Map<string, PanesElementSlot>;
|
|
521
|
+
dnd: ReturnType<typeof dnd.create<DragMeta, DropMeta, DropIntent>>;
|
|
522
|
+
keepMounted: boolean;
|
|
523
|
+
canResize: () => boolean;
|
|
524
|
+
canMove: () => boolean;
|
|
525
|
+
canReorder: () => boolean;
|
|
526
|
+
canHorizontalSplit: () => boolean;
|
|
527
|
+
canVerticalSplit: () => boolean;
|
|
528
|
+
onActive: (leafId: string, elementId: string) => void;
|
|
529
|
+
onResize: (splitId: string, index: number, delta: number, baseSizes: number[]) => void;
|
|
530
|
+
}) {
|
|
531
|
+
return (
|
|
532
|
+
<Show when={props.node().type === "leaf"} fallback={<PanesSplit {...props} node={() => props.node() as PanesSplitNode} />}>
|
|
533
|
+
<PanesLeaf
|
|
534
|
+
node={() => props.node() as PanesLeafNode}
|
|
535
|
+
elementById={props.elementById}
|
|
536
|
+
dnd={props.dnd}
|
|
537
|
+
keepMounted={props.keepMounted}
|
|
538
|
+
canMove={props.canMove}
|
|
539
|
+
canReorder={props.canReorder}
|
|
540
|
+
canHorizontalSplit={props.canHorizontalSplit}
|
|
541
|
+
canVerticalSplit={props.canVerticalSplit}
|
|
542
|
+
onActive={props.onActive}
|
|
543
|
+
/>
|
|
544
|
+
</Show>
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function PanesSplit(props: {
|
|
549
|
+
node: () => PanesSplitNode;
|
|
550
|
+
elementById: Map<string, PanesElementSlot>;
|
|
551
|
+
dnd: ReturnType<typeof dnd.create<DragMeta, DropMeta, DropIntent>>;
|
|
552
|
+
keepMounted: boolean;
|
|
553
|
+
canResize: () => boolean;
|
|
554
|
+
canMove: () => boolean;
|
|
555
|
+
canReorder: () => boolean;
|
|
556
|
+
canHorizontalSplit: () => boolean;
|
|
557
|
+
canVerticalSplit: () => boolean;
|
|
558
|
+
onActive: (leafId: string, elementId: string) => void;
|
|
559
|
+
onResize: (splitId: string, index: number, delta: number, baseSizes: number[]) => void;
|
|
560
|
+
}) {
|
|
561
|
+
let container: HTMLDivElement | undefined;
|
|
562
|
+
let stopResize: (() => void) | undefined;
|
|
563
|
+
let resizeActive = false;
|
|
564
|
+
const direction = () => props.node().direction;
|
|
565
|
+
const sizes = () => normalizeSizes(props.node().sizes, props.node().children.length);
|
|
566
|
+
const insertIntent = (index: number) => {
|
|
567
|
+
const intent = props.dnd.intent();
|
|
568
|
+
return intent?.kind === "insert" && intent.splitId === props.node().id && intent.index === index;
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const stopActiveResize = () => {
|
|
572
|
+
stopResize?.();
|
|
573
|
+
stopResize = undefined;
|
|
574
|
+
resizeActive = false;
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
onCleanup(() => {
|
|
578
|
+
if (!resizeActive) stopActiveResize();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
const startResize = (event: PointerEvent, index: number) => {
|
|
582
|
+
if (!props.canResize()) return;
|
|
583
|
+
event.preventDefault();
|
|
584
|
+
stopActiveResize();
|
|
585
|
+
resizeActive = true;
|
|
586
|
+
const split = props.node();
|
|
587
|
+
const baseSizes = normalizeSizes(split.sizes, split.children.length);
|
|
588
|
+
const start = split.direction === "horizontal" ? event.clientX : event.clientY;
|
|
589
|
+
const rect = container?.getBoundingClientRect();
|
|
590
|
+
const extent = split.direction === "horizontal" ? (rect?.width ?? 1) : (rect?.height ?? 1);
|
|
591
|
+
const onMove = (move: PointerEvent) => {
|
|
592
|
+
const current = split.direction === "horizontal" ? move.clientX : move.clientY;
|
|
593
|
+
props.onResize(split.id, index, ((current - start) / extent) * 100, baseSizes);
|
|
594
|
+
};
|
|
595
|
+
stopResize = () => {
|
|
596
|
+
window.removeEventListener("pointermove", onMove);
|
|
597
|
+
window.removeEventListener("pointerup", stopActiveResize);
|
|
598
|
+
window.removeEventListener("pointercancel", stopActiveResize);
|
|
599
|
+
window.removeEventListener("blur", stopActiveResize);
|
|
600
|
+
};
|
|
601
|
+
window.addEventListener("pointermove", onMove);
|
|
602
|
+
window.addEventListener("pointerup", stopActiveResize);
|
|
603
|
+
window.addEventListener("pointercancel", stopActiveResize);
|
|
604
|
+
window.addEventListener("blur", stopActiveResize);
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
const keyResizeDelta = (event: KeyboardEvent, index: number) => {
|
|
608
|
+
if (!props.canResize()) return null;
|
|
609
|
+
const split = props.node();
|
|
610
|
+
const baseSizes = normalizeSizes(split.sizes, split.children.length);
|
|
611
|
+
const current = baseSizes[index] ?? 0;
|
|
612
|
+
const next = baseSizes[index + 1] ?? 0;
|
|
613
|
+
const step = event.shiftKey ? 8 : 2;
|
|
614
|
+
if (event.key === "Home") return -current + MIN_PANE_SIZE;
|
|
615
|
+
if (event.key === "End") return next - MIN_PANE_SIZE;
|
|
616
|
+
if (split.direction === "horizontal") {
|
|
617
|
+
if (event.key === "ArrowLeft") return -step;
|
|
618
|
+
if (event.key === "ArrowRight") return step;
|
|
619
|
+
} else {
|
|
620
|
+
if (event.key === "ArrowUp") return -step;
|
|
621
|
+
if (event.key === "ArrowDown") return step;
|
|
622
|
+
}
|
|
623
|
+
return null;
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const onResizeKeyDown = (event: KeyboardEvent, index: number) => {
|
|
627
|
+
const delta = keyResizeDelta(event, index);
|
|
628
|
+
if (delta === null) return;
|
|
629
|
+
event.preventDefault();
|
|
630
|
+
const split = props.node();
|
|
631
|
+
props.onResize(split.id, index, delta, normalizeSizes(split.sizes, split.children.length));
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
return (
|
|
635
|
+
<div ref={container} class={`flex min-h-0 min-w-0 flex-1 ${direction() === "horizontal" ? "flex-row" : "flex-col"}`}>
|
|
636
|
+
<For each={props.node().children}>
|
|
637
|
+
{(child, index) => (
|
|
638
|
+
<>
|
|
639
|
+
<div class="flex min-h-0 min-w-0 overflow-hidden" style={{ flex: `${sizes()[index()] ?? 0} 1 0` }}>
|
|
640
|
+
<PanesNodeRenderer {...props} node={() => props.node().children[index()] ?? child} />
|
|
641
|
+
</div>
|
|
642
|
+
<Show when={index() < props.node().children.length - 1}>
|
|
643
|
+
<button
|
|
644
|
+
ref={(button) => {
|
|
645
|
+
props.dnd.droppable(button, () => ({
|
|
646
|
+
id: `panes-split-gap:${props.node().id}:${index()}`,
|
|
647
|
+
meta: { kind: "split-gap", splitId: props.node().id, index: index(), direction: direction() },
|
|
648
|
+
disabled:
|
|
649
|
+
!props.canMove() ||
|
|
650
|
+
(direction() === "horizontal" && !props.canHorizontalSplit()) ||
|
|
651
|
+
(direction() === "vertical" && !props.canVerticalSplit()),
|
|
652
|
+
}));
|
|
653
|
+
}}
|
|
654
|
+
type="button"
|
|
655
|
+
role="separator"
|
|
656
|
+
aria-orientation={direction() === "horizontal" ? "vertical" : "horizontal"}
|
|
657
|
+
aria-valuemin={MIN_PANE_SIZE}
|
|
658
|
+
aria-valuemax={100 - MIN_PANE_SIZE}
|
|
659
|
+
aria-valuenow={Math.round(sizes()[index()] ?? 0)}
|
|
660
|
+
aria-disabled={!props.canResize()}
|
|
661
|
+
tabIndex={props.canResize() ? 0 : -1}
|
|
662
|
+
class={`group relative z-10 shrink-0 rounded-full bg-transparent transition ${
|
|
663
|
+
direction() === "horizontal" ? "w-2 cursor-col-resize" : "h-2 cursor-row-resize"
|
|
664
|
+
} ${props.canResize() ? "" : "cursor-default"}`}
|
|
665
|
+
style={{ cursor: props.canResize() ? (direction() === "horizontal" ? "col-resize" : "row-resize") : "default" }}
|
|
666
|
+
aria-label="Resize pane"
|
|
667
|
+
onPointerDown={(event) => startResize(event, index())}
|
|
668
|
+
onKeyDown={(event) => onResizeKeyDown(event, index())}
|
|
669
|
+
>
|
|
670
|
+
<span
|
|
671
|
+
class={`pointer-events-none absolute rounded-full transition ${
|
|
672
|
+
direction() === "horizontal" ? "inset-y-2 left-0 right-0" : "inset-x-2 bottom-0 top-0"
|
|
673
|
+
} ${
|
|
674
|
+
insertIntent(index())
|
|
675
|
+
? "bg-emerald-500/80 shadow-[0_0_0_4px_rgba(16,185,129,0.16)]"
|
|
676
|
+
: props.canResize()
|
|
677
|
+
? "group-hover:bg-blue-500/70 group-active:bg-blue-600/80"
|
|
678
|
+
: ""
|
|
679
|
+
}`}
|
|
680
|
+
/>
|
|
681
|
+
</button>
|
|
682
|
+
</Show>
|
|
683
|
+
</>
|
|
684
|
+
)}
|
|
685
|
+
</For>
|
|
686
|
+
</div>
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function PanesLeaf(props: {
|
|
691
|
+
node: () => PanesLeafNode;
|
|
692
|
+
elementById: Map<string, PanesElementSlot>;
|
|
693
|
+
dnd: ReturnType<typeof dnd.create<DragMeta, DropMeta, DropIntent>>;
|
|
694
|
+
keepMounted: boolean;
|
|
695
|
+
canMove: () => boolean;
|
|
696
|
+
canReorder: () => boolean;
|
|
697
|
+
canHorizontalSplit: () => boolean;
|
|
698
|
+
canVerticalSplit: () => boolean;
|
|
699
|
+
onActive: (leafId: string, elementId: string) => void;
|
|
700
|
+
}) {
|
|
701
|
+
const elements = () => props.node().elementIds.flatMap((id) => props.elementById.get(id) ?? []);
|
|
702
|
+
const activeId = () =>
|
|
703
|
+
props.node().elementIds.includes(props.node().activeElementId ?? "") ? props.node().activeElementId : props.node().elementIds[0];
|
|
704
|
+
const presentation = () => props.node().presentation ?? "tabs";
|
|
705
|
+
const activeElement = () => props.elementById.get(activeId() ?? "");
|
|
706
|
+
const mergePreviewElement = () => {
|
|
707
|
+
const intent = props.dnd.intent();
|
|
708
|
+
if (intent?.kind !== "move" || intent.leafId !== props.node().id) return null;
|
|
709
|
+
if (props.node().elementIds.includes(intent.elementId)) return null;
|
|
710
|
+
return props.elementById.get(intent.elementId) ?? null;
|
|
711
|
+
};
|
|
712
|
+
const showTabs = () => (presentation() === "tabs" && elements().length > 1) || !!mergePreviewElement();
|
|
713
|
+
const splitIntent = (zone: "left" | "right" | "top" | "bottom") => {
|
|
714
|
+
const intent = props.dnd.intent();
|
|
715
|
+
return intent?.kind === "split" && intent.leafId === props.node().id && intent.zone === zone;
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
return (
|
|
719
|
+
<section
|
|
720
|
+
ref={(element) => {
|
|
721
|
+
props.dnd.droppable(element, () => ({
|
|
722
|
+
id: `panes-leaf:${props.node().id}`,
|
|
723
|
+
meta: { kind: "leaf", leafId: props.node().id },
|
|
724
|
+
disabled: !props.canMove() || (!props.canReorder() && !props.canHorizontalSplit() && !props.canVerticalSplit()),
|
|
725
|
+
}));
|
|
726
|
+
}}
|
|
727
|
+
class="relative flex h-full min-h-0 min-w-0 flex-1 flex-col gap-1 overflow-hidden"
|
|
728
|
+
>
|
|
729
|
+
<Show
|
|
730
|
+
when={showTabs()}
|
|
731
|
+
fallback={
|
|
732
|
+
<Show when={activeElement()}>
|
|
733
|
+
{(element) => (
|
|
734
|
+
<div
|
|
735
|
+
ref={(header) => {
|
|
736
|
+
props.dnd.draggable(header, () => ({
|
|
737
|
+
id: `panes-element:${element().id}`,
|
|
738
|
+
meta: { elementId: element().id },
|
|
739
|
+
disabled: !props.canMove(),
|
|
740
|
+
handleSelector: "[data-panes-drag-handle]",
|
|
741
|
+
}));
|
|
742
|
+
}}
|
|
743
|
+
class={`h-8 shrink-0 gap-2 ${tabButtonClass(true)} ${
|
|
744
|
+
props.dnd.activeId() === `panes-element:${element().id}` ? "opacity-40" : ""
|
|
745
|
+
}`}
|
|
746
|
+
style={{ "box-shadow": tabButtonShadow(true) }}
|
|
747
|
+
>
|
|
748
|
+
<Show when={props.canMove()}>
|
|
749
|
+
<button
|
|
750
|
+
type="button"
|
|
751
|
+
data-panes-drag-handle
|
|
752
|
+
class={`${dragHandleClass} h-7 w-7`}
|
|
753
|
+
title="Move pane"
|
|
754
|
+
aria-label="Move pane"
|
|
755
|
+
>
|
|
756
|
+
<i class="ti ti-grip-vertical" />
|
|
757
|
+
</button>
|
|
758
|
+
</Show>
|
|
759
|
+
<i class={`${iconClass(element().icon)} shrink-0 text-sm`} />
|
|
760
|
+
<span class="min-w-0 flex-1 truncate">{element().title ?? element().id}</span>
|
|
761
|
+
<Show when={elementClosable(element())}>
|
|
762
|
+
<CloseButton element={element()} />
|
|
763
|
+
</Show>
|
|
764
|
+
</div>
|
|
765
|
+
)}
|
|
766
|
+
</Show>
|
|
767
|
+
}
|
|
768
|
+
>
|
|
769
|
+
<div
|
|
770
|
+
class="panes-tab-strip flex h-8 shrink-0 items-stretch gap-1 overflow-x-auto overflow-y-hidden"
|
|
771
|
+
style={{ "scrollbar-gutter": "stable" }}
|
|
772
|
+
>
|
|
773
|
+
<For each={elements()}>
|
|
774
|
+
{(element) => (
|
|
775
|
+
<div
|
|
776
|
+
ref={(tab) => {
|
|
777
|
+
props.dnd.droppable(tab, () => ({
|
|
778
|
+
id: `panes-tab:${props.node().id}:${element.id}`,
|
|
779
|
+
meta: { kind: "tab", leafId: props.node().id, beforeElementId: element.id },
|
|
780
|
+
disabled: !props.canReorder(),
|
|
781
|
+
}));
|
|
782
|
+
props.dnd.draggable(tab, () => ({
|
|
783
|
+
id: `panes-element:${element.id}`,
|
|
784
|
+
meta: { elementId: element.id },
|
|
785
|
+
disabled: !props.canMove(),
|
|
786
|
+
handleSelector: "[data-panes-drag-handle]",
|
|
787
|
+
}));
|
|
788
|
+
}}
|
|
789
|
+
class={`flex-1 ${tabButtonClass(activeId() === element.id)} ${
|
|
790
|
+
props.dnd.activeId() === `panes-element:${element.id}` ? "opacity-40" : ""
|
|
791
|
+
}`}
|
|
792
|
+
style={{ "box-shadow": tabButtonShadow(activeId() === element.id) }}
|
|
793
|
+
>
|
|
794
|
+
<Show when={props.canMove()}>
|
|
795
|
+
<button
|
|
796
|
+
type="button"
|
|
797
|
+
data-panes-drag-handle
|
|
798
|
+
class={`${dragHandleClass} h-6 w-6`}
|
|
799
|
+
title="Move tab"
|
|
800
|
+
aria-label={`Move ${element.title ?? element.id}`}
|
|
801
|
+
>
|
|
802
|
+
<i class="ti ti-grip-vertical" />
|
|
803
|
+
</button>
|
|
804
|
+
</Show>
|
|
805
|
+
<button
|
|
806
|
+
type="button"
|
|
807
|
+
class="flex h-full min-w-0 flex-1 items-center gap-1.5 rounded text-left"
|
|
808
|
+
onPointerDown={(event) => {
|
|
809
|
+
if (event.button === 0) props.onActive(props.node().id, element.id);
|
|
810
|
+
}}
|
|
811
|
+
onClick={() => props.onActive(props.node().id, element.id)}
|
|
812
|
+
>
|
|
813
|
+
<i class={`${iconClass(element.icon)} shrink-0 text-sm`} />
|
|
814
|
+
<span class="truncate">{element.title ?? element.id}</span>
|
|
815
|
+
</button>
|
|
816
|
+
<Show when={elementClosable(element)}>
|
|
817
|
+
<CloseButton element={element} />
|
|
818
|
+
</Show>
|
|
819
|
+
</div>
|
|
820
|
+
)}
|
|
821
|
+
</For>
|
|
822
|
+
<Show when={mergePreviewElement()}>
|
|
823
|
+
{(element) => (
|
|
824
|
+
<div class="flex min-w-32 flex-1 items-center gap-1.5 rounded border border-emerald-300 bg-emerald-50 px-2 text-xs font-semibold text-emerald-700 shadow-sm dark:border-emerald-700/70 dark:bg-emerald-950/50 dark:text-emerald-300">
|
|
825
|
+
<i class="ti ti-plus shrink-0 text-sm" />
|
|
826
|
+
<i class={`${iconClass(element().icon)} shrink-0 text-sm`} />
|
|
827
|
+
<span class="truncate">{element().title ?? element().id}</span>
|
|
828
|
+
</div>
|
|
829
|
+
)}
|
|
830
|
+
</Show>
|
|
831
|
+
</div>
|
|
832
|
+
</Show>
|
|
833
|
+
|
|
834
|
+
<div class="relative min-h-0 flex-1 overflow-hidden">
|
|
835
|
+
<For each={elements()}>
|
|
836
|
+
{(element) => (
|
|
837
|
+
<div class={`${props.keepMounted ? (activeId() === element.id || presentation() === "stack" ? "contents" : "hidden") : ""}`}>
|
|
838
|
+
<Show when={props.keepMounted || activeId() === element.id || presentation() === "stack"}>{element.children}</Show>
|
|
839
|
+
</div>
|
|
840
|
+
)}
|
|
841
|
+
</For>
|
|
842
|
+
</div>
|
|
843
|
+
|
|
844
|
+
<SplitDropZone zone="left" active={splitIntent("left") && props.canMove() && props.canHorizontalSplit()} />
|
|
845
|
+
<SplitDropZone zone="right" active={splitIntent("right") && props.canMove() && props.canHorizontalSplit()} />
|
|
846
|
+
<SplitDropZone zone="top" active={splitIntent("top") && props.canMove() && props.canVerticalSplit()} />
|
|
847
|
+
<SplitDropZone zone="bottom" active={splitIntent("bottom") && props.canMove() && props.canVerticalSplit()} />
|
|
848
|
+
</section>
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function SplitDropZone(props: { zone: "left" | "right" | "top" | "bottom"; active: boolean }) {
|
|
853
|
+
const vertical = props.zone === "left" || props.zone === "right";
|
|
854
|
+
return (
|
|
855
|
+
<div class="pointer-events-none absolute inset-0">
|
|
856
|
+
<Show when={props.active}>
|
|
857
|
+
<div
|
|
858
|
+
class={`pointer-events-none absolute rounded bg-emerald-500/70 shadow-[0_0_0_4px_rgba(16,185,129,0.16)] ${
|
|
859
|
+
vertical ? "inset-y-2 w-2" : "inset-x-2 h-2"
|
|
860
|
+
} ${props.zone === "left" ? "left-2" : ""} ${props.zone === "right" ? "right-2" : ""} ${props.zone === "top" ? "top-2" : ""} ${
|
|
861
|
+
props.zone === "bottom" ? "bottom-2" : ""
|
|
862
|
+
}`}
|
|
863
|
+
/>
|
|
864
|
+
</Show>
|
|
865
|
+
</div>
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const Panes = PanesRoot as PanesComponent;
|
|
870
|
+
Panes.Root = PanesRoot;
|
|
871
|
+
Panes.Element = PanesElement;
|
|
872
|
+
|
|
873
|
+
export default Panes;
|