@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,289 @@
1
+ /**
2
+ * @blorkfield/blork-tabs - SnapChain
3
+ * Manages snap relationships between panels forming linked chains
4
+ */
5
+
6
+ import type {
7
+ PanelState,
8
+ SnapTarget,
9
+ SnapSide,
10
+ ResolvedTabManagerConfig,
11
+ } from './types';
12
+
13
+ /**
14
+ * Get all panels connected to a given panel (the entire snap chain)
15
+ * Returns panels ordered from left to right
16
+ */
17
+ export function getConnectedGroup(
18
+ startPanel: PanelState,
19
+ panels: Map<string, PanelState>
20
+ ): PanelState[] {
21
+ const group: PanelState[] = [];
22
+ const visited = new Set<string>();
23
+
24
+ // Traverse left via snappedFrom
25
+ let current: PanelState | undefined = startPanel;
26
+ while (current && !visited.has(current.id)) {
27
+ visited.add(current.id);
28
+ group.unshift(current);
29
+ if (current.snappedFrom) {
30
+ current = panels.get(current.snappedFrom);
31
+ } else {
32
+ break;
33
+ }
34
+ }
35
+
36
+ // Traverse right via snappedTo (skip start panel, already added)
37
+ current = startPanel.snappedTo ? panels.get(startPanel.snappedTo) : undefined;
38
+ while (current && !visited.has(current.id)) {
39
+ visited.add(current.id);
40
+ group.push(current);
41
+ if (current.snappedTo) {
42
+ current = panels.get(current.snappedTo);
43
+ } else {
44
+ break;
45
+ }
46
+ }
47
+
48
+ return group;
49
+ }
50
+
51
+ /**
52
+ * Detach a panel from its snap chain
53
+ * Clears both incoming and outgoing snap relationships
54
+ */
55
+ export function detachFromGroup(
56
+ panel: PanelState,
57
+ panels: Map<string, PanelState>
58
+ ): void {
59
+ // Clear outgoing snap (to right panel)
60
+ if (panel.snappedTo) {
61
+ const rightPanel = panels.get(panel.snappedTo);
62
+ if (rightPanel) {
63
+ rightPanel.snappedFrom = null;
64
+ }
65
+ panel.snappedTo = null;
66
+ }
67
+
68
+ // Clear incoming snap (from left panel)
69
+ if (panel.snappedFrom) {
70
+ const leftPanel = panels.get(panel.snappedFrom);
71
+ if (leftPanel) {
72
+ leftPanel.snappedTo = null;
73
+ }
74
+ panel.snappedFrom = null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Find a snap target for the moving panels
80
+ * Checks if moving panels are close enough to snap to a stationary panel
81
+ */
82
+ export function findSnapTarget(
83
+ movingPanels: PanelState[],
84
+ panels: Map<string, PanelState>,
85
+ config: ResolvedTabManagerConfig
86
+ ): SnapTarget | null {
87
+ if (movingPanels.length === 0) return null;
88
+
89
+ const leftmostPanel = movingPanels[0];
90
+ const leftmostRect = leftmostPanel.element.getBoundingClientRect();
91
+
92
+ // Calculate total width of moving group
93
+ let totalWidth = 0;
94
+ for (const p of movingPanels) {
95
+ totalWidth += p.element.offsetWidth + config.panelGap;
96
+ }
97
+ totalWidth -= config.panelGap;
98
+
99
+ const movingIds = new Set(movingPanels.map((p) => p.id));
100
+ const x = leftmostRect.left;
101
+ const y = leftmostRect.top;
102
+
103
+ for (const [id, targetState] of panels) {
104
+ // Skip panels that are part of the moving group
105
+ if (movingIds.has(id)) continue;
106
+
107
+ const targetRect = targetState.element.getBoundingClientRect();
108
+
109
+ // Check if vertically aligned (within threshold)
110
+ const verticalOverlap = Math.abs(y - targetRect.top) < config.snapThreshold * 2;
111
+ if (!verticalOverlap) continue;
112
+
113
+ // Check snap to left side of target (moving group goes to the left)
114
+ // The rightmost panel of moving group attaches to target's left
115
+ const snapToLeftX = targetRect.left - totalWidth - config.panelGap;
116
+ if (
117
+ Math.abs(x - snapToLeftX) < config.snapThreshold &&
118
+ !targetState.snappedFrom
119
+ ) {
120
+ return { targetId: id, side: 'left', x: snapToLeftX, y: targetRect.top };
121
+ }
122
+
123
+ // Check snap to right side of target (moving group goes to the right)
124
+ // The leftmost panel of moving group attaches to target's right
125
+ const snapToRightX = targetRect.right + config.panelGap;
126
+ if (
127
+ Math.abs(x - snapToRightX) < config.snapThreshold &&
128
+ !targetState.snappedTo
129
+ ) {
130
+ return { targetId: id, side: 'right', x: snapToRightX, y: targetRect.top };
131
+ }
132
+ }
133
+
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Execute a snap operation - position panels and establish relationships
139
+ */
140
+ export function snapPanelsToTarget(
141
+ movingPanels: PanelState[],
142
+ targetId: string,
143
+ side: SnapSide,
144
+ x: number,
145
+ y: number,
146
+ panels: Map<string, PanelState>,
147
+ config: ResolvedTabManagerConfig
148
+ ): void {
149
+ const targetState = panels.get(targetId);
150
+ if (!targetState) return;
151
+
152
+ const leftmostPanel = movingPanels[0];
153
+ const rightmostPanel = movingPanels[movingPanels.length - 1];
154
+
155
+ // Position all moving panels in a row
156
+ let currentX = x;
157
+ for (const p of movingPanels) {
158
+ p.element.style.left = `${currentX}px`;
159
+ p.element.style.top = `${y}px`;
160
+ currentX += p.element.offsetWidth + config.panelGap;
161
+ }
162
+
163
+ // Establish snap relationship
164
+ if (side === 'left') {
165
+ // Moving group goes to the LEFT of target
166
+ // Rightmost panel of group connects to target
167
+ rightmostPanel.snappedTo = targetId;
168
+ targetState.snappedFrom = rightmostPanel.id;
169
+ } else {
170
+ // Moving group goes to the RIGHT of target
171
+ // Leftmost panel of group connects to target
172
+ leftmostPanel.snappedFrom = targetId;
173
+ targetState.snappedTo = leftmostPanel.id;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Update positions of all snapped panels when one changes size
179
+ * Traverses from rightmost panel leftward, repositioning as needed
180
+ */
181
+ export function updateSnappedPositions(
182
+ panels: Map<string, PanelState>,
183
+ config: ResolvedTabManagerConfig
184
+ ): void {
185
+ // Find the rightmost panel in any chain (one with no snappedTo but has snappedFrom)
186
+ let rightmost: PanelState | null = null;
187
+
188
+ for (const state of panels.values()) {
189
+ if (state.snappedTo === null && state.snappedFrom !== null) {
190
+ rightmost = state;
191
+ break;
192
+ }
193
+ }
194
+
195
+ // Also check for chains starting from panels with snappedFrom
196
+ if (!rightmost) {
197
+ for (const state of panels.values()) {
198
+ if (state.snappedFrom !== null) {
199
+ rightmost = state;
200
+ break;
201
+ }
202
+ }
203
+ }
204
+
205
+ if (!rightmost) return;
206
+
207
+ // Traverse left and update positions
208
+ let current: PanelState | null = rightmost;
209
+ while (current && current.snappedFrom) {
210
+ const leftPanel = panels.get(current.snappedFrom);
211
+ if (!leftPanel) break;
212
+
213
+ const currentRect = current.element.getBoundingClientRect();
214
+ leftPanel.element.style.left = `${currentRect.left - leftPanel.element.offsetWidth - config.panelGap}px`;
215
+ leftPanel.element.style.top = `${currentRect.top}px`;
216
+
217
+ current = leftPanel;
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Get the leftmost panel in a chain
223
+ */
224
+ export function getLeftmostPanel(
225
+ panel: PanelState,
226
+ panels: Map<string, PanelState>
227
+ ): PanelState {
228
+ let current = panel;
229
+ while (current.snappedFrom) {
230
+ const left = panels.get(current.snappedFrom);
231
+ if (!left) break;
232
+ current = left;
233
+ }
234
+ return current;
235
+ }
236
+
237
+ /**
238
+ * Get the rightmost panel in a chain
239
+ */
240
+ export function getRightmostPanel(
241
+ panel: PanelState,
242
+ panels: Map<string, PanelState>
243
+ ): PanelState {
244
+ let current = panel;
245
+ while (current.snappedTo) {
246
+ const right = panels.get(current.snappedTo);
247
+ if (!right) break;
248
+ current = right;
249
+ }
250
+ return current;
251
+ }
252
+
253
+ /**
254
+ * Check if two panels are in the same snap chain
255
+ */
256
+ export function areInSameChain(
257
+ panel1: PanelState,
258
+ panel2: PanelState,
259
+ panels: Map<string, PanelState>
260
+ ): boolean {
261
+ const chain = getConnectedGroup(panel1, panels);
262
+ return chain.some((p) => p.id === panel2.id);
263
+ }
264
+
265
+ /**
266
+ * Establish a snap relationship between two panels
267
+ */
268
+ export function snapPanels(
269
+ leftPanel: PanelState,
270
+ rightPanel: PanelState
271
+ ): void {
272
+ leftPanel.snappedTo = rightPanel.id;
273
+ rightPanel.snappedFrom = leftPanel.id;
274
+ }
275
+
276
+ /**
277
+ * Break the snap relationship between two specific panels
278
+ */
279
+ export function unsnap(
280
+ leftPanel: PanelState,
281
+ rightPanel: PanelState
282
+ ): void {
283
+ if (leftPanel.snappedTo === rightPanel.id) {
284
+ leftPanel.snappedTo = null;
285
+ }
286
+ if (rightPanel.snappedFrom === leftPanel.id) {
287
+ rightPanel.snappedFrom = null;
288
+ }
289
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @blorkfield/blork-tabs - SnapPreview
3
+ * Visual feedback for snap targets during drag operations
4
+ */
5
+
6
+ import type {
7
+ SnapTarget,
8
+ PanelState,
9
+ CSSClasses,
10
+ } from './types';
11
+
12
+ /**
13
+ * Manages the visual snap preview indicator
14
+ */
15
+ export class SnapPreview {
16
+ private element: HTMLDivElement | null = null;
17
+ private classes: CSSClasses;
18
+ private container: HTMLElement;
19
+ private panels: Map<string, PanelState>;
20
+
21
+ constructor(
22
+ container: HTMLElement,
23
+ classes: CSSClasses,
24
+ panels: Map<string, PanelState>
25
+ ) {
26
+ this.container = container;
27
+ this.classes = classes;
28
+ this.panels = panels;
29
+ }
30
+
31
+ /**
32
+ * Create the preview element lazily
33
+ */
34
+ private ensureElement(): HTMLDivElement {
35
+ if (!this.element) {
36
+ this.element = document.createElement('div');
37
+ this.element.className = this.classes.snapPreview;
38
+ this.container.appendChild(this.element);
39
+ }
40
+ return this.element;
41
+ }
42
+
43
+ /**
44
+ * Update the preview to show a snap target
45
+ */
46
+ show(snapTarget: SnapTarget): void {
47
+ const preview = this.ensureElement();
48
+ const targetState = this.panels.get(snapTarget.targetId);
49
+
50
+ if (!targetState) {
51
+ this.hide();
52
+ return;
53
+ }
54
+
55
+ const targetRect = targetState.element.getBoundingClientRect();
56
+ preview.style.top = `${targetRect.top}px`;
57
+ preview.style.height = `${targetRect.height}px`;
58
+ preview.style.left = `${
59
+ snapTarget.side === 'left' ? targetRect.left - 2 : targetRect.right - 2
60
+ }px`;
61
+ preview.classList.add(this.classes.snapPreviewVisible);
62
+ }
63
+
64
+ /**
65
+ * Hide the preview
66
+ */
67
+ hide(): void {
68
+ if (this.element) {
69
+ this.element.classList.remove(this.classes.snapPreviewVisible);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Update based on current snap target (convenience method)
75
+ */
76
+ update(snapTarget: SnapTarget | null): void {
77
+ if (snapTarget) {
78
+ this.show(snapTarget);
79
+ } else {
80
+ this.hide();
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Clean up
86
+ */
87
+ destroy(): void {
88
+ this.element?.remove();
89
+ this.element = null;
90
+ }
91
+ }