@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.
@@ -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
+ }