@blorkfield/blork-tabs 0.1.3
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/LICENSE +21 -0
- package/README.md +166 -0
- package/dist/index.cjs +1194 -0
- package/dist/index.d.cts +669 -0
- package/dist/index.d.ts +669 -0
- package/dist/index.js +1143 -0
- package/dist/styles.css +186 -0
- package/package.json +62 -0
- package/src/AnchorManager.ts +395 -0
- package/src/DragManager.ts +251 -0
- package/src/Panel.ts +211 -0
- package/src/SnapChain.ts +289 -0
- package/src/SnapPreview.ts +91 -0
- package/src/TabManager.ts +507 -0
- package/src/index.test.ts +9 -0
- package/src/index.ts +105 -0
- package/src/types.ts +320 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blorkfield/blork-tabs - DragManager
|
|
3
|
+
* Handles drag operations for panels including single and group modes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
PanelState,
|
|
8
|
+
DragState,
|
|
9
|
+
DragMode,
|
|
10
|
+
Position,
|
|
11
|
+
SnapTarget,
|
|
12
|
+
AnchorSnapResult,
|
|
13
|
+
ResolvedTabManagerConfig,
|
|
14
|
+
} from './types';
|
|
15
|
+
import {
|
|
16
|
+
getConnectedGroup,
|
|
17
|
+
detachFromGroup,
|
|
18
|
+
findSnapTarget,
|
|
19
|
+
snapPanelsToTarget,
|
|
20
|
+
} from './SnapChain';
|
|
21
|
+
import { setPanelZIndex, getDragZIndex, getDefaultZIndex } from './Panel';
|
|
22
|
+
|
|
23
|
+
export interface DragCallbacks {
|
|
24
|
+
onDragStart?: (state: DragState) => void;
|
|
25
|
+
onDragMove?: (
|
|
26
|
+
state: DragState,
|
|
27
|
+
position: Position,
|
|
28
|
+
snapTarget: SnapTarget | null,
|
|
29
|
+
anchorResult: AnchorSnapResult | null
|
|
30
|
+
) => void;
|
|
31
|
+
onDragEnd?: (
|
|
32
|
+
state: DragState,
|
|
33
|
+
snapTarget: SnapTarget | null,
|
|
34
|
+
anchorResult: AnchorSnapResult | null
|
|
35
|
+
) => void;
|
|
36
|
+
findAnchorTarget?: (movingPanels: PanelState[]) => AnchorSnapResult | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Creates and manages drag operations
|
|
41
|
+
*/
|
|
42
|
+
export class DragManager {
|
|
43
|
+
private panels: Map<string, PanelState>;
|
|
44
|
+
private config: ResolvedTabManagerConfig;
|
|
45
|
+
private callbacks: DragCallbacks;
|
|
46
|
+
private activeDrag: DragState | null = null;
|
|
47
|
+
|
|
48
|
+
private boundMouseMove: (e: MouseEvent) => void;
|
|
49
|
+
private boundMouseUp: (e: MouseEvent) => void;
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
panels: Map<string, PanelState>,
|
|
53
|
+
config: ResolvedTabManagerConfig,
|
|
54
|
+
callbacks: DragCallbacks
|
|
55
|
+
) {
|
|
56
|
+
this.panels = panels;
|
|
57
|
+
this.config = config;
|
|
58
|
+
this.callbacks = callbacks;
|
|
59
|
+
|
|
60
|
+
this.boundMouseMove = this.handleMouseMove.bind(this);
|
|
61
|
+
this.boundMouseUp = this.handleMouseUp.bind(this);
|
|
62
|
+
|
|
63
|
+
// Attach global listeners
|
|
64
|
+
document.addEventListener('mousemove', this.boundMouseMove);
|
|
65
|
+
document.addEventListener('mouseup', this.boundMouseUp);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Start a drag operation
|
|
70
|
+
*/
|
|
71
|
+
startDrag(
|
|
72
|
+
e: MouseEvent,
|
|
73
|
+
panel: PanelState,
|
|
74
|
+
mode: DragMode
|
|
75
|
+
): void {
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
e.stopPropagation();
|
|
78
|
+
|
|
79
|
+
const connectedPanels = getConnectedGroup(panel, this.panels);
|
|
80
|
+
|
|
81
|
+
// Store initial positions
|
|
82
|
+
const initialGroupPositions = new Map<string, Position>();
|
|
83
|
+
for (const p of connectedPanels) {
|
|
84
|
+
const rect = p.element.getBoundingClientRect();
|
|
85
|
+
initialGroupPositions.set(p.id, { x: rect.left, y: rect.top });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let movingPanels: PanelState[];
|
|
89
|
+
|
|
90
|
+
if (mode === 'single') {
|
|
91
|
+
// Detach this panel from its group
|
|
92
|
+
detachFromGroup(panel, this.panels);
|
|
93
|
+
movingPanels = [panel];
|
|
94
|
+
} else {
|
|
95
|
+
// Move entire group
|
|
96
|
+
movingPanels = connectedPanels;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const rect = panel.element.getBoundingClientRect();
|
|
100
|
+
|
|
101
|
+
this.activeDrag = {
|
|
102
|
+
grabbedPanel: panel,
|
|
103
|
+
offsetX: e.clientX - rect.left,
|
|
104
|
+
offsetY: e.clientY - rect.top,
|
|
105
|
+
initialGroupPositions,
|
|
106
|
+
movingPanels,
|
|
107
|
+
mode,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Raise moving panels z-index
|
|
111
|
+
for (const p of movingPanels) {
|
|
112
|
+
setPanelZIndex(p, getDragZIndex(p));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Disable text selection during drag
|
|
116
|
+
document.body.style.userSelect = 'none';
|
|
117
|
+
|
|
118
|
+
this.callbacks.onDragStart?.(this.activeDrag);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Handle mouse movement during drag
|
|
123
|
+
*/
|
|
124
|
+
private handleMouseMove(e: MouseEvent): void {
|
|
125
|
+
if (!this.activeDrag) return;
|
|
126
|
+
|
|
127
|
+
const { grabbedPanel, movingPanels, initialGroupPositions, mode } = this.activeDrag;
|
|
128
|
+
const panel = grabbedPanel.element;
|
|
129
|
+
|
|
130
|
+
const x = e.clientX - this.activeDrag.offsetX;
|
|
131
|
+
const y = e.clientY - this.activeDrag.offsetY;
|
|
132
|
+
|
|
133
|
+
// Clamp to window bounds
|
|
134
|
+
const maxX = window.innerWidth - panel.offsetWidth;
|
|
135
|
+
const maxY = window.innerHeight - panel.offsetHeight;
|
|
136
|
+
const clampedX = Math.max(0, Math.min(x, maxX));
|
|
137
|
+
const clampedY = Math.max(0, Math.min(y, maxY));
|
|
138
|
+
|
|
139
|
+
// Move the grabbed panel
|
|
140
|
+
panel.style.left = `${clampedX}px`;
|
|
141
|
+
panel.style.top = `${clampedY}px`;
|
|
142
|
+
|
|
143
|
+
// If group mode, move other panels to maintain formation
|
|
144
|
+
if (mode === 'group' && movingPanels.length > 1) {
|
|
145
|
+
const grabbedInitialPos = initialGroupPositions.get(grabbedPanel.id)!;
|
|
146
|
+
const deltaX = clampedX - grabbedInitialPos.x;
|
|
147
|
+
const deltaY = clampedY - grabbedInitialPos.y;
|
|
148
|
+
|
|
149
|
+
for (const p of movingPanels) {
|
|
150
|
+
if (p === grabbedPanel) continue;
|
|
151
|
+
const initialPos = initialGroupPositions.get(p.id)!;
|
|
152
|
+
const newX = Math.max(
|
|
153
|
+
0,
|
|
154
|
+
Math.min(initialPos.x + deltaX, window.innerWidth - p.element.offsetWidth)
|
|
155
|
+
);
|
|
156
|
+
const newY = Math.max(
|
|
157
|
+
0,
|
|
158
|
+
Math.min(initialPos.y + deltaY, window.innerHeight - p.element.offsetHeight)
|
|
159
|
+
);
|
|
160
|
+
p.element.style.left = `${newX}px`;
|
|
161
|
+
p.element.style.top = `${newY}px`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Check for snap targets
|
|
166
|
+
const snapTarget = findSnapTarget(movingPanels, this.panels, this.config);
|
|
167
|
+
|
|
168
|
+
// Check for anchor targets (only if no panel snap)
|
|
169
|
+
const anchorResult = snapTarget
|
|
170
|
+
? null
|
|
171
|
+
: this.callbacks.findAnchorTarget?.(movingPanels) ?? null;
|
|
172
|
+
|
|
173
|
+
this.callbacks.onDragMove?.(
|
|
174
|
+
this.activeDrag,
|
|
175
|
+
{ x: clampedX, y: clampedY },
|
|
176
|
+
snapTarget,
|
|
177
|
+
anchorResult
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Handle mouse up - finalize drag
|
|
183
|
+
*/
|
|
184
|
+
private handleMouseUp(_e: MouseEvent): void {
|
|
185
|
+
if (!this.activeDrag) return;
|
|
186
|
+
|
|
187
|
+
const { movingPanels } = this.activeDrag;
|
|
188
|
+
|
|
189
|
+
// Check for snap targets
|
|
190
|
+
const snapTarget = findSnapTarget(movingPanels, this.panels, this.config);
|
|
191
|
+
|
|
192
|
+
let anchorResult: AnchorSnapResult | null = null;
|
|
193
|
+
|
|
194
|
+
if (snapTarget) {
|
|
195
|
+
// Snap the group to the target panel
|
|
196
|
+
snapPanelsToTarget(
|
|
197
|
+
movingPanels,
|
|
198
|
+
snapTarget.targetId,
|
|
199
|
+
snapTarget.side,
|
|
200
|
+
snapTarget.x,
|
|
201
|
+
snapTarget.y,
|
|
202
|
+
this.panels,
|
|
203
|
+
this.config
|
|
204
|
+
);
|
|
205
|
+
} else {
|
|
206
|
+
// Check anchor snap
|
|
207
|
+
anchorResult = this.callbacks.findAnchorTarget?.(movingPanels) ?? null;
|
|
208
|
+
if (anchorResult) {
|
|
209
|
+
// Apply the pre-calculated positions
|
|
210
|
+
for (let i = 0; i < movingPanels.length; i++) {
|
|
211
|
+
movingPanels[i].element.style.left = `${anchorResult.positions[i].x}px`;
|
|
212
|
+
movingPanels[i].element.style.top = `${anchorResult.positions[i].y}px`;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Restore z-index
|
|
218
|
+
for (const p of movingPanels) {
|
|
219
|
+
setPanelZIndex(p, getDefaultZIndex(p));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Restore text selection
|
|
223
|
+
document.body.style.userSelect = '';
|
|
224
|
+
|
|
225
|
+
this.callbacks.onDragEnd?.(this.activeDrag, snapTarget, anchorResult);
|
|
226
|
+
|
|
227
|
+
this.activeDrag = null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Check if a drag is currently in progress
|
|
232
|
+
*/
|
|
233
|
+
isActive(): boolean {
|
|
234
|
+
return this.activeDrag !== null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get the current drag state
|
|
239
|
+
*/
|
|
240
|
+
getState(): DragState | null {
|
|
241
|
+
return this.activeDrag;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Clean up event listeners
|
|
246
|
+
*/
|
|
247
|
+
destroy(): void {
|
|
248
|
+
document.removeEventListener('mousemove', this.boundMouseMove);
|
|
249
|
+
document.removeEventListener('mouseup', this.boundMouseUp);
|
|
250
|
+
}
|
|
251
|
+
}
|
package/src/Panel.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blorkfield/blork-tabs - Panel
|
|
3
|
+
* Individual panel component with collapse/expand functionality
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
PanelConfig,
|
|
8
|
+
PanelState,
|
|
9
|
+
CSSClasses,
|
|
10
|
+
} from './types';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates the default panel DOM structure
|
|
14
|
+
*/
|
|
15
|
+
export function createPanelElement(
|
|
16
|
+
config: PanelConfig,
|
|
17
|
+
classes: CSSClasses
|
|
18
|
+
): {
|
|
19
|
+
element: HTMLDivElement;
|
|
20
|
+
dragHandle: HTMLDivElement;
|
|
21
|
+
collapseButton: HTMLButtonElement | null;
|
|
22
|
+
contentWrapper: HTMLDivElement;
|
|
23
|
+
detachGrip: HTMLDivElement | null;
|
|
24
|
+
} {
|
|
25
|
+
const element = document.createElement('div');
|
|
26
|
+
element.className = classes.panel;
|
|
27
|
+
element.id = `${config.id}-panel`;
|
|
28
|
+
element.style.width = `${config.width ?? 300}px`;
|
|
29
|
+
element.style.zIndex = `${config.zIndex ?? 1000}`;
|
|
30
|
+
|
|
31
|
+
// Header
|
|
32
|
+
const header = document.createElement('div');
|
|
33
|
+
header.className = classes.panelHeader;
|
|
34
|
+
header.id = `${config.id}-header`;
|
|
35
|
+
|
|
36
|
+
// Detach grip (if detachable)
|
|
37
|
+
let detachGrip: HTMLDivElement | null = null;
|
|
38
|
+
if (config.detachable !== false) {
|
|
39
|
+
detachGrip = document.createElement('div');
|
|
40
|
+
detachGrip.className = classes.detachGrip;
|
|
41
|
+
detachGrip.id = `${config.id}-detach-grip`;
|
|
42
|
+
header.appendChild(detachGrip);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Title
|
|
46
|
+
if (config.title) {
|
|
47
|
+
const title = document.createElement('span');
|
|
48
|
+
title.className = classes.panelTitle;
|
|
49
|
+
title.textContent = config.title;
|
|
50
|
+
header.appendChild(title);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Collapse button (if collapsible)
|
|
54
|
+
let collapseButton: HTMLButtonElement | null = null;
|
|
55
|
+
if (config.collapsible !== false) {
|
|
56
|
+
collapseButton = document.createElement('button');
|
|
57
|
+
collapseButton.className = classes.collapseButton;
|
|
58
|
+
collapseButton.id = `${config.id}-collapse-btn`;
|
|
59
|
+
collapseButton.textContent = config.startCollapsed !== false ? '+' : '−';
|
|
60
|
+
header.appendChild(collapseButton);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
element.appendChild(header);
|
|
64
|
+
|
|
65
|
+
// Content wrapper
|
|
66
|
+
const contentWrapper = document.createElement('div');
|
|
67
|
+
contentWrapper.className = classes.panelContent;
|
|
68
|
+
contentWrapper.id = `${config.id}-content`;
|
|
69
|
+
if (config.startCollapsed !== false) {
|
|
70
|
+
contentWrapper.classList.add(classes.panelContentCollapsed);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Add content if provided
|
|
74
|
+
if (config.content) {
|
|
75
|
+
if (typeof config.content === 'string') {
|
|
76
|
+
contentWrapper.innerHTML = config.content;
|
|
77
|
+
} else {
|
|
78
|
+
contentWrapper.appendChild(config.content);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
element.appendChild(contentWrapper);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
element,
|
|
86
|
+
dragHandle: header,
|
|
87
|
+
collapseButton,
|
|
88
|
+
contentWrapper,
|
|
89
|
+
detachGrip,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Creates a PanelState from config, using existing elements or creating new ones
|
|
95
|
+
*/
|
|
96
|
+
export function createPanelState(
|
|
97
|
+
config: PanelConfig,
|
|
98
|
+
classes: CSSClasses
|
|
99
|
+
): PanelState {
|
|
100
|
+
let element: HTMLDivElement;
|
|
101
|
+
let dragHandle: HTMLDivElement;
|
|
102
|
+
let collapseButton: HTMLButtonElement | null;
|
|
103
|
+
let contentWrapper: HTMLDivElement;
|
|
104
|
+
let detachGrip: HTMLDivElement | null;
|
|
105
|
+
|
|
106
|
+
if (config.element) {
|
|
107
|
+
// Use existing DOM elements
|
|
108
|
+
element = config.element;
|
|
109
|
+
dragHandle = config.dragHandle ?? (element.querySelector(`.${classes.panelHeader}`) as HTMLDivElement);
|
|
110
|
+
collapseButton = config.collapseButton ?? (element.querySelector(`.${classes.collapseButton}`) as HTMLButtonElement | null);
|
|
111
|
+
contentWrapper = config.contentWrapper ?? (element.querySelector(`.${classes.panelContent}`) as HTMLDivElement);
|
|
112
|
+
detachGrip = config.detachGrip ?? (element.querySelector(`.${classes.detachGrip}`) as HTMLDivElement | null);
|
|
113
|
+
} else {
|
|
114
|
+
// Create new DOM structure
|
|
115
|
+
const created = createPanelElement(config, classes);
|
|
116
|
+
element = created.element;
|
|
117
|
+
dragHandle = created.dragHandle;
|
|
118
|
+
collapseButton = created.collapseButton;
|
|
119
|
+
contentWrapper = created.contentWrapper;
|
|
120
|
+
detachGrip = created.detachGrip;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
id: config.id,
|
|
125
|
+
element,
|
|
126
|
+
dragHandle,
|
|
127
|
+
collapseButton,
|
|
128
|
+
contentWrapper,
|
|
129
|
+
detachGrip,
|
|
130
|
+
isCollapsed: config.startCollapsed !== false,
|
|
131
|
+
snappedTo: null,
|
|
132
|
+
snappedFrom: null,
|
|
133
|
+
config,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Toggle panel collapse state
|
|
139
|
+
*/
|
|
140
|
+
export function toggleCollapse(
|
|
141
|
+
state: PanelState,
|
|
142
|
+
classes: CSSClasses,
|
|
143
|
+
collapsed?: boolean
|
|
144
|
+
): boolean {
|
|
145
|
+
const newState = collapsed ?? !state.isCollapsed;
|
|
146
|
+
state.isCollapsed = newState;
|
|
147
|
+
|
|
148
|
+
if (newState) {
|
|
149
|
+
state.contentWrapper.classList.add(classes.panelContentCollapsed);
|
|
150
|
+
} else {
|
|
151
|
+
state.contentWrapper.classList.remove(classes.panelContentCollapsed);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (state.collapseButton) {
|
|
155
|
+
state.collapseButton.textContent = newState ? '+' : '−';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return newState;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Set panel position
|
|
163
|
+
*/
|
|
164
|
+
export function setPanelPosition(
|
|
165
|
+
state: PanelState,
|
|
166
|
+
x: number,
|
|
167
|
+
y: number
|
|
168
|
+
): void {
|
|
169
|
+
state.element.style.left = `${x}px`;
|
|
170
|
+
state.element.style.top = `${y}px`;
|
|
171
|
+
state.element.style.right = 'auto';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get panel position from DOM
|
|
176
|
+
*/
|
|
177
|
+
export function getPanelPosition(state: PanelState): { x: number; y: number } {
|
|
178
|
+
const rect = state.element.getBoundingClientRect();
|
|
179
|
+
return { x: rect.left, y: rect.top };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Get panel dimensions
|
|
184
|
+
*/
|
|
185
|
+
export function getPanelDimensions(state: PanelState): { width: number; height: number } {
|
|
186
|
+
return {
|
|
187
|
+
width: state.element.offsetWidth,
|
|
188
|
+
height: state.element.offsetHeight,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Set panel z-index
|
|
194
|
+
*/
|
|
195
|
+
export function setPanelZIndex(state: PanelState, zIndex: number): void {
|
|
196
|
+
state.element.style.zIndex = `${zIndex}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Get default z-index for panel
|
|
201
|
+
*/
|
|
202
|
+
export function getDefaultZIndex(state: PanelState): number {
|
|
203
|
+
return state.config.zIndex ?? 1000;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Get drag z-index for panel
|
|
208
|
+
*/
|
|
209
|
+
export function getDragZIndex(state: PanelState): number {
|
|
210
|
+
return state.config.dragZIndex ?? 1002;
|
|
211
|
+
}
|