@blorkfield/blork-tabs 0.3.0 → 0.4.0
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/README.md +138 -5
- package/dist/index.cjs +238 -69
- package/dist/index.d.cts +116 -4
- package/dist/index.d.ts +116 -4
- package/dist/index.js +234 -69
- package/dist/styles.css +66 -35
- package/package.json +1 -1
- package/src/AutoHideManager.ts +45 -2
- package/src/DebugPanel.ts +185 -15
- package/src/DragManager.ts +13 -13
- package/src/Panel.ts +37 -0
- package/src/SnapChain.ts +45 -0
- package/src/TabManager.ts +54 -71
- package/src/index.ts +6 -1
- package/src/types.ts +48 -0
package/src/AutoHideManager.ts
CHANGED
|
@@ -19,6 +19,7 @@ export class AutoHideManager {
|
|
|
19
19
|
private classes: CSSClasses;
|
|
20
20
|
private callbacks: AutoHideCallbacks;
|
|
21
21
|
private hideTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
|
22
|
+
private pausedPanels: Set<string> = new Set();
|
|
22
23
|
private boundActivityHandler: () => void;
|
|
23
24
|
private listenersAttached = false;
|
|
24
25
|
|
|
@@ -49,6 +50,10 @@ export class AutoHideManager {
|
|
|
49
50
|
*/
|
|
50
51
|
private handleActivity(): void {
|
|
51
52
|
for (const panel of this.panels.values()) {
|
|
53
|
+
// Pinned panels are immune to activity-based show/hide
|
|
54
|
+
if (panel.isPinned) continue;
|
|
55
|
+
// Skip paused panels - they handle their own timing
|
|
56
|
+
if (this.pausedPanels.has(panel.id)) continue;
|
|
52
57
|
// Only process panels that participate in auto-hide
|
|
53
58
|
if (panel.resolvedAutoHideDelay !== undefined || panel.isHidden) {
|
|
54
59
|
this.show(panel, 'activity');
|
|
@@ -62,7 +67,7 @@ export class AutoHideManager {
|
|
|
62
67
|
/**
|
|
63
68
|
* Schedule a panel to hide after its delay
|
|
64
69
|
*/
|
|
65
|
-
|
|
70
|
+
scheduleHide(panel: PanelState): void {
|
|
66
71
|
this.clearTimer(panel.id);
|
|
67
72
|
if (panel.resolvedAutoHideDelay === undefined) return;
|
|
68
73
|
|
|
@@ -75,7 +80,7 @@ export class AutoHideManager {
|
|
|
75
80
|
/**
|
|
76
81
|
* Clear hide timer for a panel
|
|
77
82
|
*/
|
|
78
|
-
|
|
83
|
+
clearTimer(panelId: string): void {
|
|
79
84
|
const timer = this.hideTimers.get(panelId);
|
|
80
85
|
if (timer) {
|
|
81
86
|
clearTimeout(timer);
|
|
@@ -83,6 +88,25 @@ export class AutoHideManager {
|
|
|
83
88
|
}
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Pause auto-hide timer for a panel (e.g., during debug hover)
|
|
93
|
+
*/
|
|
94
|
+
pauseTimer(panelId: string): void {
|
|
95
|
+
this.clearTimer(panelId);
|
|
96
|
+
this.pausedPanels.add(panelId);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resume auto-hide timer for a panel
|
|
101
|
+
*/
|
|
102
|
+
resumeTimer(panelId: string): void {
|
|
103
|
+
this.pausedPanels.delete(panelId);
|
|
104
|
+
const panel = this.panels.get(panelId);
|
|
105
|
+
if (panel && panel.resolvedAutoHideDelay !== undefined) {
|
|
106
|
+
this.scheduleHide(panel);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
86
110
|
/**
|
|
87
111
|
* Show a panel
|
|
88
112
|
*/
|
|
@@ -97,10 +121,29 @@ export class AutoHideManager {
|
|
|
97
121
|
*/
|
|
98
122
|
hide(panel: PanelState, trigger: 'timeout' | 'api'): void {
|
|
99
123
|
if (panel.isHidden) return;
|
|
124
|
+
if (panel.isPinned) return; // Pinned panels are always visible
|
|
100
125
|
hidePanel(panel, this.classes);
|
|
101
126
|
this.callbacks.onHide?.(panel, trigger);
|
|
102
127
|
}
|
|
103
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Called when a panel's pin state changes.
|
|
131
|
+
* Pinning cancels the hide timer and reveals the panel if hidden.
|
|
132
|
+
* Unpinning restarts the timer if the panel participates in auto-hide.
|
|
133
|
+
*/
|
|
134
|
+
onPanelPinChanged(panel: PanelState): void {
|
|
135
|
+
if (panel.isPinned) {
|
|
136
|
+
this.clearTimer(panel.id);
|
|
137
|
+
if (panel.isHidden) {
|
|
138
|
+
this.show(panel, 'api');
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
if (!panel.isHidden && panel.resolvedAutoHideDelay !== undefined) {
|
|
142
|
+
this.scheduleHide(panel);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
104
147
|
/**
|
|
105
148
|
* Initialize a newly added panel's auto-hide state
|
|
106
149
|
*/
|
package/src/DebugPanel.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* In-browser debug log panel for environments without console access
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { DebugPanelConfig, DebugPanel, DebugLogLevel, PanelState, CSSClasses } from './types';
|
|
6
|
+
import type { DebugPanelConfig, DebugPanel, DebugLog, DebugLogConfig, DebugLogLevel, PanelState, CSSClasses } from './types';
|
|
7
7
|
|
|
8
8
|
export interface DebugPanelElements {
|
|
9
9
|
logContainer: HTMLDivElement;
|
|
@@ -30,7 +30,8 @@ export function createDebugPanelContent(
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Create the interface for interacting with a debug panel
|
|
33
|
+
* Create the interface for interacting with a debug panel.
|
|
34
|
+
* Uses the shared createDebugLogInterface internally.
|
|
34
35
|
*/
|
|
35
36
|
export function createDebugPanelInterface(
|
|
36
37
|
panel: PanelState,
|
|
@@ -38,12 +39,46 @@ export function createDebugPanelInterface(
|
|
|
38
39
|
config: DebugPanelConfig,
|
|
39
40
|
classes: CSSClasses
|
|
40
41
|
): DebugPanel {
|
|
42
|
+
const debugLog = createDebugLogInterface(elements.logContainer, config, classes);
|
|
43
|
+
return {
|
|
44
|
+
panel,
|
|
45
|
+
...debugLog,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Escape HTML special characters to prevent XSS
|
|
51
|
+
*/
|
|
52
|
+
function escapeHtml(str: string): string {
|
|
53
|
+
const div = document.createElement('div');
|
|
54
|
+
div.textContent = str;
|
|
55
|
+
return div.innerHTML;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Shared interface config for debug log creation
|
|
60
|
+
*/
|
|
61
|
+
interface DebugLogInterfaceConfig {
|
|
62
|
+
maxEntries?: number;
|
|
63
|
+
showTimestamps?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create the logging interface for a debug log container.
|
|
68
|
+
* This is the shared implementation used by both standalone panels and embedded logs.
|
|
69
|
+
*/
|
|
70
|
+
export function createDebugLogInterface(
|
|
71
|
+
logContainer: HTMLElement,
|
|
72
|
+
config: DebugLogInterfaceConfig,
|
|
73
|
+
classes: CSSClasses
|
|
74
|
+
): DebugLog {
|
|
41
75
|
const maxEntries = config.maxEntries ?? 50;
|
|
42
76
|
const showTimestamps = config.showTimestamps ?? false;
|
|
77
|
+
const entryClass = classes.debugLogEntry;
|
|
43
78
|
|
|
44
79
|
function addEntry(level: DebugLogLevel, eventName: string, data?: Record<string, unknown>): void {
|
|
45
80
|
const entry = document.createElement('div');
|
|
46
|
-
entry.className =
|
|
81
|
+
entry.className = entryClass;
|
|
47
82
|
|
|
48
83
|
// Add level-specific class for color coding
|
|
49
84
|
if (level === 'warn') entry.classList.add(classes.debugLogEntryWarn);
|
|
@@ -69,30 +104,165 @@ export function createDebugPanelInterface(
|
|
|
69
104
|
}
|
|
70
105
|
|
|
71
106
|
entry.innerHTML = html;
|
|
72
|
-
|
|
73
|
-
|
|
107
|
+
logContainer.appendChild(entry);
|
|
108
|
+
logContainer.scrollTop = logContainer.scrollHeight;
|
|
74
109
|
|
|
75
|
-
// Remove oldest entries if over limit
|
|
76
|
-
|
|
77
|
-
|
|
110
|
+
// Remove oldest entries if over limit (only count actual log entries, not the close button)
|
|
111
|
+
const entries = logContainer.querySelectorAll(`.${entryClass}`);
|
|
112
|
+
if (entries.length > maxEntries) {
|
|
113
|
+
const toRemove = entries.length - maxEntries;
|
|
114
|
+
for (let i = 0; i < toRemove; i++) {
|
|
115
|
+
entries[i].remove();
|
|
116
|
+
}
|
|
78
117
|
}
|
|
79
118
|
}
|
|
80
119
|
|
|
120
|
+
function clearEntries(): void {
|
|
121
|
+
// Only remove log entries, preserve the close button
|
|
122
|
+
const entries = logContainer.querySelectorAll(`.${entryClass}`);
|
|
123
|
+
entries.forEach(entry => entry.remove());
|
|
124
|
+
}
|
|
125
|
+
|
|
81
126
|
return {
|
|
82
|
-
panel,
|
|
83
127
|
log: (name, data) => addEntry('log', name, data),
|
|
84
128
|
info: (name, data) => addEntry('info', name, data),
|
|
85
129
|
warn: (name, data) => addEntry('warn', name, data),
|
|
86
130
|
error: (name, data) => addEntry('error', name, data),
|
|
87
|
-
clear:
|
|
131
|
+
clear: clearEntries,
|
|
88
132
|
};
|
|
89
133
|
}
|
|
90
134
|
|
|
135
|
+
export interface DebugLogSetup {
|
|
136
|
+
debugLog: DebugLog;
|
|
137
|
+
logContainer: HTMLDivElement;
|
|
138
|
+
}
|
|
139
|
+
|
|
91
140
|
/**
|
|
92
|
-
*
|
|
141
|
+
* Create an embeddable debug log in any container element
|
|
93
142
|
*/
|
|
94
|
-
function
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
143
|
+
export function createDebugLog(
|
|
144
|
+
container: HTMLElement,
|
|
145
|
+
config: DebugLogConfig,
|
|
146
|
+
classes: CSSClasses
|
|
147
|
+
): DebugLogSetup {
|
|
148
|
+
// Create log container
|
|
149
|
+
const logContainer = document.createElement('div');
|
|
150
|
+
logContainer.className = classes.debugLog;
|
|
151
|
+
container.appendChild(logContainer);
|
|
152
|
+
|
|
153
|
+
// Use shared interface creation
|
|
154
|
+
const debugLog = createDebugLogInterface(logContainer, config, classes);
|
|
155
|
+
|
|
156
|
+
return { debugLog, logContainer };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface HoverEnlargeConfig {
|
|
160
|
+
/** The log container element (.blork-tabs-debug-log) to enlarge */
|
|
161
|
+
logContainer: HTMLElement;
|
|
162
|
+
/** Hover delay in ms (0 = disable) */
|
|
163
|
+
hoverDelay: number;
|
|
164
|
+
/** Container to append backdrop to */
|
|
165
|
+
backdropContainer: HTMLElement;
|
|
166
|
+
/** CSS classes */
|
|
167
|
+
classes: CSSClasses;
|
|
168
|
+
/** Called when hovering starts */
|
|
169
|
+
onHoverStart?: () => void;
|
|
170
|
+
/** Called when hovering ends (without enlarging) */
|
|
171
|
+
onHoverEnd?: () => void;
|
|
172
|
+
/** Called when enlarged view is closed */
|
|
173
|
+
onClose?: () => void;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Set up hover-to-enlarge behavior for a debug log container.
|
|
178
|
+
* Works the same for both standalone debug panels and embedded logs.
|
|
179
|
+
*/
|
|
180
|
+
export function setupHoverEnlarge(config: HoverEnlargeConfig): void {
|
|
181
|
+
const { logContainer, hoverDelay, backdropContainer, classes, onHoverStart, onHoverEnd, onClose } = config;
|
|
182
|
+
|
|
183
|
+
// Add debug panel class for hover border effect
|
|
184
|
+
logContainer.classList.add(classes.debugPanel);
|
|
185
|
+
|
|
186
|
+
// Create close button inside the log container
|
|
187
|
+
const closeBtn = document.createElement('button');
|
|
188
|
+
closeBtn.className = classes.debugClearButton;
|
|
189
|
+
closeBtn.textContent = '×';
|
|
190
|
+
closeBtn.title = 'Close enlarged view';
|
|
191
|
+
logContainer.appendChild(closeBtn);
|
|
192
|
+
|
|
193
|
+
let hoverTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
194
|
+
let isEnlarged = false;
|
|
195
|
+
let backdrop: HTMLDivElement | null = null;
|
|
196
|
+
let originalParent: HTMLElement | null = null;
|
|
197
|
+
let placeholder: Comment | null = null;
|
|
198
|
+
|
|
199
|
+
const closeEnlarged = () => {
|
|
200
|
+
if (!isEnlarged) return;
|
|
201
|
+
isEnlarged = false;
|
|
202
|
+
logContainer.classList.remove(classes.debugPanelEnlarged);
|
|
203
|
+
|
|
204
|
+
// Move log container back to original parent
|
|
205
|
+
if (originalParent && placeholder) {
|
|
206
|
+
originalParent.insertBefore(logContainer, placeholder);
|
|
207
|
+
placeholder.remove();
|
|
208
|
+
placeholder = null;
|
|
209
|
+
originalParent = null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (backdrop) {
|
|
213
|
+
backdrop.remove();
|
|
214
|
+
backdrop = null;
|
|
215
|
+
}
|
|
216
|
+
onClose?.();
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const openEnlarged = () => {
|
|
220
|
+
if (isEnlarged) return;
|
|
221
|
+
isEnlarged = true;
|
|
222
|
+
|
|
223
|
+
// Create backdrop
|
|
224
|
+
backdrop = document.createElement('div');
|
|
225
|
+
backdrop.className = classes.debugBackdrop;
|
|
226
|
+
backdropContainer.appendChild(backdrop);
|
|
227
|
+
|
|
228
|
+
// Click backdrop to close
|
|
229
|
+
backdrop.addEventListener('click', closeEnlarged);
|
|
230
|
+
|
|
231
|
+
// Move log container to body to escape parent stacking context
|
|
232
|
+
originalParent = logContainer.parentElement;
|
|
233
|
+
placeholder = document.createComment('debug-log-placeholder');
|
|
234
|
+
originalParent?.insertBefore(placeholder, logContainer);
|
|
235
|
+
backdropContainer.appendChild(logContainer);
|
|
236
|
+
|
|
237
|
+
// Add enlarged class to log container
|
|
238
|
+
logContainer.classList.add(classes.debugPanelEnlarged);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// Hover to start enlarge timer
|
|
242
|
+
logContainer.addEventListener('mouseenter', () => {
|
|
243
|
+
onHoverStart?.();
|
|
244
|
+
if (isEnlarged) return;
|
|
245
|
+
if (hoverDelay > 0) {
|
|
246
|
+
hoverTimeout = setTimeout(() => {
|
|
247
|
+
openEnlarged();
|
|
248
|
+
}, hoverDelay);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Cancel timer if mouse leaves
|
|
253
|
+
logContainer.addEventListener('mouseleave', () => {
|
|
254
|
+
if (hoverTimeout) {
|
|
255
|
+
clearTimeout(hoverTimeout);
|
|
256
|
+
hoverTimeout = null;
|
|
257
|
+
}
|
|
258
|
+
if (!isEnlarged) {
|
|
259
|
+
onHoverEnd?.();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Close button closes enlarged view
|
|
264
|
+
closeBtn.addEventListener('click', (e) => {
|
|
265
|
+
e.stopPropagation();
|
|
266
|
+
closeEnlarged();
|
|
267
|
+
});
|
|
98
268
|
}
|
package/src/DragManager.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type {
|
|
|
13
13
|
ResolvedTabManagerConfig,
|
|
14
14
|
} from './types';
|
|
15
15
|
import {
|
|
16
|
-
|
|
16
|
+
getMovingGroupRespectingPins,
|
|
17
17
|
detachFromGroup,
|
|
18
18
|
findSnapTarget,
|
|
19
19
|
snapPanelsToTarget,
|
|
@@ -76,24 +76,24 @@ export class DragManager {
|
|
|
76
76
|
e.preventDefault();
|
|
77
77
|
e.stopPropagation();
|
|
78
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
79
|
let movingPanels: PanelState[];
|
|
89
80
|
|
|
90
81
|
if (mode === 'single') {
|
|
91
|
-
// Detach this panel from its group
|
|
82
|
+
// Detach this panel from its group and move it alone
|
|
92
83
|
detachFromGroup(panel, this.panels);
|
|
93
84
|
movingPanels = [panel];
|
|
94
85
|
} else {
|
|
95
|
-
//
|
|
96
|
-
|
|
86
|
+
// Group drag: collect panels on either side of the grabbed panel,
|
|
87
|
+
// splitting at any pinned panel and severing those bonds
|
|
88
|
+
movingPanels = getMovingGroupRespectingPins(panel, this.panels);
|
|
89
|
+
if (movingPanels.length === 0) return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Store initial positions for the panels that will actually move
|
|
93
|
+
const initialGroupPositions = new Map<string, Position>();
|
|
94
|
+
for (const p of movingPanels) {
|
|
95
|
+
const rect = p.element.getBoundingClientRect();
|
|
96
|
+
initialGroupPositions.set(p.id, { x: rect.left, y: rect.top });
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
const rect = panel.element.getBoundingClientRect();
|
package/src/Panel.ts
CHANGED
|
@@ -9,6 +9,9 @@ import type {
|
|
|
9
9
|
CSSClasses,
|
|
10
10
|
} from './types';
|
|
11
11
|
|
|
12
|
+
const PIN_ICON_UNPINNED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17H19V16L17 11V4H7L5 11V16Z"/><line x1="5" y1="11" x2="19" y2="11"/></svg>`;
|
|
13
|
+
const PIN_ICON_PINNED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;transform:rotate(90deg)"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17H19V16L17 11V4H7L5 11V16Z"/><line x1="5" y1="11" x2="19" y2="11"/></svg>`;
|
|
14
|
+
|
|
12
15
|
/**
|
|
13
16
|
* Creates the default panel DOM structure
|
|
14
17
|
*/
|
|
@@ -18,6 +21,7 @@ export function createPanelElement(
|
|
|
18
21
|
): {
|
|
19
22
|
element: HTMLDivElement;
|
|
20
23
|
dragHandle: HTMLDivElement;
|
|
24
|
+
pinButton: HTMLButtonElement | null;
|
|
21
25
|
collapseButton: HTMLButtonElement | null;
|
|
22
26
|
contentWrapper: HTMLDivElement;
|
|
23
27
|
detachGrip: HTMLDivElement | null;
|
|
@@ -50,6 +54,16 @@ export function createPanelElement(
|
|
|
50
54
|
header.appendChild(title);
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
// Pin button (only if explicitly pinnable)
|
|
58
|
+
let pinButton: HTMLButtonElement | null = null;
|
|
59
|
+
if (config.pinnable === true) {
|
|
60
|
+
pinButton = document.createElement('button');
|
|
61
|
+
pinButton.className = classes.pinButton;
|
|
62
|
+
pinButton.id = `${config.id}-pin-btn`;
|
|
63
|
+
pinButton.innerHTML = config.startPinned === true ? PIN_ICON_PINNED : PIN_ICON_UNPINNED;
|
|
64
|
+
header.appendChild(pinButton);
|
|
65
|
+
}
|
|
66
|
+
|
|
53
67
|
// Collapse button (if collapsible)
|
|
54
68
|
let collapseButton: HTMLButtonElement | null = null;
|
|
55
69
|
if (config.collapsible !== false) {
|
|
@@ -84,6 +98,7 @@ export function createPanelElement(
|
|
|
84
98
|
return {
|
|
85
99
|
element,
|
|
86
100
|
dragHandle: header,
|
|
101
|
+
pinButton,
|
|
87
102
|
collapseButton,
|
|
88
103
|
contentWrapper,
|
|
89
104
|
detachGrip,
|
|
@@ -100,6 +115,7 @@ export function createPanelState(
|
|
|
100
115
|
): PanelState {
|
|
101
116
|
let element: HTMLDivElement;
|
|
102
117
|
let dragHandle: HTMLDivElement;
|
|
118
|
+
let pinButton: HTMLButtonElement | null;
|
|
103
119
|
let collapseButton: HTMLButtonElement | null;
|
|
104
120
|
let contentWrapper: HTMLDivElement;
|
|
105
121
|
let detachGrip: HTMLDivElement | null;
|
|
@@ -108,6 +124,7 @@ export function createPanelState(
|
|
|
108
124
|
// Use existing DOM elements
|
|
109
125
|
element = config.element;
|
|
110
126
|
dragHandle = config.dragHandle ?? (element.querySelector(`.${classes.panelHeader}`) as HTMLDivElement);
|
|
127
|
+
pinButton = config.pinButton ?? (element.querySelector(`.${classes.pinButton}`) as HTMLButtonElement | null);
|
|
111
128
|
collapseButton = config.collapseButton ?? (element.querySelector(`.${classes.collapseButton}`) as HTMLButtonElement | null);
|
|
112
129
|
contentWrapper = config.contentWrapper ?? (element.querySelector(`.${classes.panelContent}`) as HTMLDivElement);
|
|
113
130
|
detachGrip = config.detachGrip ?? (element.querySelector(`.${classes.detachGrip}`) as HTMLDivElement | null);
|
|
@@ -116,6 +133,7 @@ export function createPanelState(
|
|
|
116
133
|
const created = createPanelElement(config, classes);
|
|
117
134
|
element = created.element;
|
|
118
135
|
dragHandle = created.dragHandle;
|
|
136
|
+
pinButton = created.pinButton;
|
|
119
137
|
collapseButton = created.collapseButton;
|
|
120
138
|
contentWrapper = created.contentWrapper;
|
|
121
139
|
detachGrip = created.detachGrip;
|
|
@@ -136,9 +154,11 @@ export function createPanelState(
|
|
|
136
154
|
id: config.id,
|
|
137
155
|
element,
|
|
138
156
|
dragHandle,
|
|
157
|
+
pinButton,
|
|
139
158
|
collapseButton,
|
|
140
159
|
contentWrapper,
|
|
141
160
|
detachGrip,
|
|
161
|
+
isPinned: config.startPinned === true,
|
|
142
162
|
isCollapsed: config.startCollapsed !== false,
|
|
143
163
|
snappedTo: null,
|
|
144
164
|
snappedFrom: null,
|
|
@@ -172,6 +192,23 @@ export function toggleCollapse(
|
|
|
172
192
|
return newState;
|
|
173
193
|
}
|
|
174
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Toggle panel pin state
|
|
197
|
+
*/
|
|
198
|
+
export function togglePin(
|
|
199
|
+
state: PanelState,
|
|
200
|
+
pinned?: boolean
|
|
201
|
+
): boolean {
|
|
202
|
+
const newState = pinned ?? !state.isPinned;
|
|
203
|
+
state.isPinned = newState;
|
|
204
|
+
|
|
205
|
+
if (state.pinButton) {
|
|
206
|
+
state.pinButton.innerHTML = newState ? PIN_ICON_PINNED : PIN_ICON_UNPINNED;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return newState;
|
|
210
|
+
}
|
|
211
|
+
|
|
175
212
|
/**
|
|
176
213
|
* Show a hidden panel
|
|
177
214
|
*/
|
package/src/SnapChain.ts
CHANGED
|
@@ -273,6 +273,51 @@ export function snapPanels(
|
|
|
273
273
|
rightPanel.snappedFrom = leftPanel.id;
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Get the subset of connected panels that should move when a panel is grabbed,
|
|
278
|
+
* stopping at any pinned panel in the chain.
|
|
279
|
+
*
|
|
280
|
+
* Pinned panels act as immoveable barriers — the chain splits at each one,
|
|
281
|
+
* and only the panels on the same side as the grabbed panel are returned.
|
|
282
|
+
* The snap bonds at each pin boundary are severed as a side effect.
|
|
283
|
+
*
|
|
284
|
+
* Example: chain [A, B, P(pinned), C, D], grab B → returns [A, B], unseats B↔P
|
|
285
|
+
* Example: chain [A, P(pinned), B, C], grab C → returns [B, C], unseats P↔B
|
|
286
|
+
*/
|
|
287
|
+
export function getMovingGroupRespectingPins(
|
|
288
|
+
grabbedPanel: PanelState,
|
|
289
|
+
panels: Map<string, PanelState>
|
|
290
|
+
): PanelState[] {
|
|
291
|
+
if (grabbedPanel.isPinned) return [];
|
|
292
|
+
|
|
293
|
+
const fullGroup = getConnectedGroup(grabbedPanel, panels);
|
|
294
|
+
const grabbedIndex = fullGroup.indexOf(grabbedPanel);
|
|
295
|
+
|
|
296
|
+
// Walk left from the grabbed panel, stopping before any pinned panel
|
|
297
|
+
const leftPanels: PanelState[] = [];
|
|
298
|
+
for (let i = grabbedIndex; i >= 0; i--) {
|
|
299
|
+
if (fullGroup[i].isPinned) {
|
|
300
|
+
// Sever the bond between this pinned panel and the next moveable panel
|
|
301
|
+
unsnap(fullGroup[i], fullGroup[i + 1]);
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
leftPanels.unshift(fullGroup[i]);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Walk right from the grabbed panel, stopping before any pinned panel
|
|
308
|
+
const rightPanels: PanelState[] = [];
|
|
309
|
+
for (let i = grabbedIndex + 1; i < fullGroup.length; i++) {
|
|
310
|
+
if (fullGroup[i].isPinned) {
|
|
311
|
+
// Sever the bond between the last moveable panel and this pinned panel
|
|
312
|
+
unsnap(fullGroup[i - 1], fullGroup[i]);
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
rightPanels.push(fullGroup[i]);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return [...leftPanels, ...rightPanels];
|
|
319
|
+
}
|
|
320
|
+
|
|
276
321
|
/**
|
|
277
322
|
* Break the snap relationship between two specific panels
|
|
278
323
|
*/
|