@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.
@@ -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
- private scheduleHide(panel: PanelState): void {
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
- private clearTimer(panelId: string): void {
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 = classes.debugLogEntry;
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
- elements.logContainer.appendChild(entry);
73
- elements.logContainer.scrollTop = elements.logContainer.scrollHeight;
107
+ logContainer.appendChild(entry);
108
+ logContainer.scrollTop = logContainer.scrollHeight;
74
109
 
75
- // Remove oldest entries if over limit
76
- while (elements.logContainer.children.length > maxEntries) {
77
- elements.logContainer.removeChild(elements.logContainer.children[0]);
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: () => { elements.logContainer.innerHTML = ''; }
131
+ clear: clearEntries,
88
132
  };
89
133
  }
90
134
 
135
+ export interface DebugLogSetup {
136
+ debugLog: DebugLog;
137
+ logContainer: HTMLDivElement;
138
+ }
139
+
91
140
  /**
92
- * Escape HTML special characters to prevent XSS
141
+ * Create an embeddable debug log in any container element
93
142
  */
94
- function escapeHtml(str: string): string {
95
- const div = document.createElement('div');
96
- div.textContent = str;
97
- return div.innerHTML;
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
  }
@@ -13,7 +13,7 @@ import type {
13
13
  ResolvedTabManagerConfig,
14
14
  } from './types';
15
15
  import {
16
- getConnectedGroup,
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
- // Move entire group
96
- movingPanels = connectedPanels;
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
  */