@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
package/src/SnapChain.ts
ADDED
|
@@ -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
|
+
}
|