@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/dist/styles.css
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blorkfield/blork-tabs - Default Styles
|
|
3
|
+
*
|
|
4
|
+
* These styles provide a sensible default appearance for blork-tabs panels.
|
|
5
|
+
* You can override these styles or use your own by not importing this file.
|
|
6
|
+
*
|
|
7
|
+
* CSS Custom Properties (variables) you can override:
|
|
8
|
+
* --blork-tabs-panel-bg: Panel background color
|
|
9
|
+
* --blork-tabs-panel-border: Panel border
|
|
10
|
+
* --blork-tabs-panel-radius: Panel border radius
|
|
11
|
+
* --blork-tabs-panel-shadow: Panel box shadow
|
|
12
|
+
* --blork-tabs-header-bg: Header background color
|
|
13
|
+
* --blork-tabs-header-color: Header text color
|
|
14
|
+
* --blork-tabs-content-bg: Content background color
|
|
15
|
+
* --blork-tabs-accent: Accent color for interactive elements
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/* Panel Container */
|
|
19
|
+
.blork-tabs-panel {
|
|
20
|
+
position: fixed;
|
|
21
|
+
width: 300px;
|
|
22
|
+
background: var(--blork-tabs-panel-bg, #1a1a2e);
|
|
23
|
+
border: var(--blork-tabs-panel-border, 1px solid #2a2a4a);
|
|
24
|
+
border-radius: var(--blork-tabs-panel-radius, 8px);
|
|
25
|
+
box-shadow: var(--blork-tabs-panel-shadow, 0 4px 20px rgba(0, 0, 0, 0.4));
|
|
26
|
+
z-index: 1000;
|
|
27
|
+
overflow: hidden;
|
|
28
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
29
|
+
font-size: 14px;
|
|
30
|
+
color: var(--blork-tabs-header-color, #e0e0e0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* Panel Header */
|
|
34
|
+
.blork-tabs-header {
|
|
35
|
+
display: flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
padding: 8px 12px;
|
|
38
|
+
background: var(--blork-tabs-header-bg, #2a2a4a);
|
|
39
|
+
cursor: grab;
|
|
40
|
+
user-select: none;
|
|
41
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.blork-tabs-header:active {
|
|
45
|
+
cursor: grabbing;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* Title */
|
|
49
|
+
.blork-tabs-title {
|
|
50
|
+
flex: 1;
|
|
51
|
+
font-weight: 500;
|
|
52
|
+
font-size: 13px;
|
|
53
|
+
text-transform: uppercase;
|
|
54
|
+
letter-spacing: 0.5px;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* Detach Grip */
|
|
58
|
+
.blork-tabs-detach-grip {
|
|
59
|
+
width: 12px;
|
|
60
|
+
height: 100%;
|
|
61
|
+
margin-right: 8px;
|
|
62
|
+
cursor: grab;
|
|
63
|
+
display: flex;
|
|
64
|
+
flex-direction: column;
|
|
65
|
+
justify-content: center;
|
|
66
|
+
align-items: center;
|
|
67
|
+
gap: 2px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.blork-tabs-detach-grip::before,
|
|
71
|
+
.blork-tabs-detach-grip::after {
|
|
72
|
+
content: '';
|
|
73
|
+
width: 8px;
|
|
74
|
+
height: 2px;
|
|
75
|
+
background: rgba(255, 255, 255, 0.3);
|
|
76
|
+
border-radius: 1px;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.blork-tabs-detach-grip:hover::before,
|
|
80
|
+
.blork-tabs-detach-grip:hover::after {
|
|
81
|
+
background: var(--blork-tabs-accent, #4a90d9);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* Collapse Button */
|
|
85
|
+
.blork-tabs-collapse-btn {
|
|
86
|
+
width: 24px;
|
|
87
|
+
height: 24px;
|
|
88
|
+
border: none;
|
|
89
|
+
background: transparent;
|
|
90
|
+
color: var(--blork-tabs-header-color, #e0e0e0);
|
|
91
|
+
font-size: 16px;
|
|
92
|
+
font-weight: bold;
|
|
93
|
+
cursor: pointer;
|
|
94
|
+
border-radius: 4px;
|
|
95
|
+
display: flex;
|
|
96
|
+
align-items: center;
|
|
97
|
+
justify-content: center;
|
|
98
|
+
transition: background 0.2s;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.blork-tabs-collapse-btn:hover {
|
|
102
|
+
background: rgba(255, 255, 255, 0.1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* Content Area */
|
|
106
|
+
.blork-tabs-content {
|
|
107
|
+
background: var(--blork-tabs-content-bg, #1a1a2e);
|
|
108
|
+
padding: 12px;
|
|
109
|
+
max-height: 400px;
|
|
110
|
+
overflow-y: auto;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.blork-tabs-content-collapsed {
|
|
114
|
+
display: none;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* Snap Preview */
|
|
118
|
+
.blork-tabs-snap-preview {
|
|
119
|
+
position: fixed;
|
|
120
|
+
width: 4px;
|
|
121
|
+
background: var(--blork-tabs-accent, #4a90d9);
|
|
122
|
+
border-radius: 2px;
|
|
123
|
+
pointer-events: none;
|
|
124
|
+
z-index: 2000;
|
|
125
|
+
opacity: 0;
|
|
126
|
+
transition: opacity 0.15s;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.blork-tabs-snap-preview-visible {
|
|
130
|
+
opacity: 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* Anchor Indicator */
|
|
134
|
+
.blork-tabs-anchor-indicator {
|
|
135
|
+
position: fixed;
|
|
136
|
+
width: 40px;
|
|
137
|
+
height: 40px;
|
|
138
|
+
border: 2px dashed rgba(255, 255, 255, 0.2);
|
|
139
|
+
border-radius: 8px;
|
|
140
|
+
pointer-events: none;
|
|
141
|
+
z-index: 999;
|
|
142
|
+
opacity: 0;
|
|
143
|
+
transition: opacity 0.15s, border-color 0.15s, background 0.15s;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.blork-tabs-anchor-indicator-visible {
|
|
147
|
+
opacity: 1;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.blork-tabs-anchor-indicator-active {
|
|
151
|
+
border-color: var(--blork-tabs-accent, #4a90d9);
|
|
152
|
+
background: rgba(74, 144, 217, 0.1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* Scrollbar styling for content */
|
|
156
|
+
.blork-tabs-content::-webkit-scrollbar {
|
|
157
|
+
width: 6px;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.blork-tabs-content::-webkit-scrollbar-track {
|
|
161
|
+
background: transparent;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.blork-tabs-content::-webkit-scrollbar-thumb {
|
|
165
|
+
background: rgba(255, 255, 255, 0.2);
|
|
166
|
+
border-radius: 3px;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.blork-tabs-content::-webkit-scrollbar-thumb:hover {
|
|
170
|
+
background: rgba(255, 255, 255, 0.3);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* Light theme variant - add class 'blork-tabs-light' to container */
|
|
174
|
+
.blork-tabs-light .blork-tabs-panel {
|
|
175
|
+
--blork-tabs-panel-bg: #ffffff;
|
|
176
|
+
--blork-tabs-panel-border: 1px solid #e0e0e0;
|
|
177
|
+
--blork-tabs-header-bg: #f5f5f5;
|
|
178
|
+
--blork-tabs-header-color: #333333;
|
|
179
|
+
--blork-tabs-content-bg: #ffffff;
|
|
180
|
+
--blork-tabs-panel-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Utility: Disable transitions during drag for better performance */
|
|
184
|
+
.blork-tabs-dragging .blork-tabs-panel {
|
|
185
|
+
transition: none !important;
|
|
186
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@blorkfield/blork-tabs",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "A framework-agnostic tab/panel management system with snapping and docking",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.cjs",
|
|
7
|
+
"module": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
},
|
|
15
|
+
"./styles.css": "./dist/styles.css"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"src"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsup src/index.ts --format cjs,esm --dts --clean && cp styles.css dist/",
|
|
23
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
24
|
+
"lint": "eslint src/",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:watch": "vitest",
|
|
27
|
+
"prepublishOnly": "npm run build"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"tabs",
|
|
31
|
+
"panels",
|
|
32
|
+
"docking",
|
|
33
|
+
"snapping",
|
|
34
|
+
"ui",
|
|
35
|
+
"drag-and-drop",
|
|
36
|
+
"window-management"
|
|
37
|
+
],
|
|
38
|
+
"author": "Blorkfield",
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "https://github.com/blorkfield/blork-tabs.git"
|
|
43
|
+
},
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/blorkfield/blork-tabs/issues"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/blorkfield/blork-tabs#readme",
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@changesets/cli": "^2.27.0",
|
|
50
|
+
"@eslint/js": "^9.0.0",
|
|
51
|
+
"@types/node": "^20.0.0",
|
|
52
|
+
"eslint": "^9.0.0",
|
|
53
|
+
"happy-dom": "^20.1.0",
|
|
54
|
+
"tsup": "^8.0.0",
|
|
55
|
+
"typescript": "^5.3.0",
|
|
56
|
+
"typescript-eslint": "^8.0.0",
|
|
57
|
+
"vitest": "^1.0.0"
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=18.0.0"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blorkfield/blork-tabs - AnchorManager
|
|
3
|
+
* Manages screen anchor points for panel docking
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
AnchorConfig,
|
|
8
|
+
AnchorState,
|
|
9
|
+
AnchorSnapResult,
|
|
10
|
+
AnchorPreset,
|
|
11
|
+
PanelState,
|
|
12
|
+
Position,
|
|
13
|
+
ResolvedTabManagerConfig,
|
|
14
|
+
CSSClasses,
|
|
15
|
+
} from './types';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Creates the default anchor positions
|
|
19
|
+
*/
|
|
20
|
+
export function getDefaultAnchorConfigs(
|
|
21
|
+
config: ResolvedTabManagerConfig
|
|
22
|
+
): AnchorConfig[] {
|
|
23
|
+
const { panelMargin, defaultPanelWidth } = config;
|
|
24
|
+
|
|
25
|
+
return [
|
|
26
|
+
{
|
|
27
|
+
id: 'top-left',
|
|
28
|
+
getPosition: () => ({ x: panelMargin, y: panelMargin }),
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'top-right',
|
|
32
|
+
getPosition: () => ({
|
|
33
|
+
x: window.innerWidth - panelMargin - defaultPanelWidth,
|
|
34
|
+
y: panelMargin,
|
|
35
|
+
}),
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'bottom-left',
|
|
39
|
+
getPosition: () => ({
|
|
40
|
+
x: panelMargin,
|
|
41
|
+
y: window.innerHeight - panelMargin,
|
|
42
|
+
}),
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'bottom-right',
|
|
46
|
+
getPosition: () => ({
|
|
47
|
+
x: window.innerWidth - panelMargin - defaultPanelWidth,
|
|
48
|
+
y: window.innerHeight - panelMargin,
|
|
49
|
+
}),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'top-center',
|
|
53
|
+
getPosition: () => ({
|
|
54
|
+
x: (window.innerWidth - defaultPanelWidth) / 2,
|
|
55
|
+
y: panelMargin,
|
|
56
|
+
}),
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create an anchor preset config
|
|
63
|
+
*/
|
|
64
|
+
export function createPresetAnchor(
|
|
65
|
+
preset: AnchorPreset,
|
|
66
|
+
config: ResolvedTabManagerConfig
|
|
67
|
+
): AnchorConfig {
|
|
68
|
+
const { panelMargin, defaultPanelWidth } = config;
|
|
69
|
+
|
|
70
|
+
const presets: Record<AnchorPreset, () => Position> = {
|
|
71
|
+
'top-left': () => ({ x: panelMargin, y: panelMargin }),
|
|
72
|
+
'top-right': () => ({
|
|
73
|
+
x: window.innerWidth - panelMargin - defaultPanelWidth,
|
|
74
|
+
y: panelMargin,
|
|
75
|
+
}),
|
|
76
|
+
'top-center': () => ({
|
|
77
|
+
x: (window.innerWidth - defaultPanelWidth) / 2,
|
|
78
|
+
y: panelMargin,
|
|
79
|
+
}),
|
|
80
|
+
'bottom-left': () => ({
|
|
81
|
+
x: panelMargin,
|
|
82
|
+
y: window.innerHeight - panelMargin,
|
|
83
|
+
}),
|
|
84
|
+
'bottom-right': () => ({
|
|
85
|
+
x: window.innerWidth - panelMargin - defaultPanelWidth,
|
|
86
|
+
y: window.innerHeight - panelMargin,
|
|
87
|
+
}),
|
|
88
|
+
'bottom-center': () => ({
|
|
89
|
+
x: (window.innerWidth - defaultPanelWidth) / 2,
|
|
90
|
+
y: window.innerHeight - panelMargin,
|
|
91
|
+
}),
|
|
92
|
+
'center-left': () => ({
|
|
93
|
+
x: panelMargin,
|
|
94
|
+
y: window.innerHeight / 2,
|
|
95
|
+
}),
|
|
96
|
+
'center-right': () => ({
|
|
97
|
+
x: window.innerWidth - panelMargin - defaultPanelWidth,
|
|
98
|
+
y: window.innerHeight / 2,
|
|
99
|
+
}),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
id: preset,
|
|
104
|
+
getPosition: presets[preset],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Manages anchor points and docking behavior
|
|
110
|
+
*/
|
|
111
|
+
export class AnchorManager {
|
|
112
|
+
private anchors: AnchorState[] = [];
|
|
113
|
+
private config: ResolvedTabManagerConfig;
|
|
114
|
+
private classes: CSSClasses;
|
|
115
|
+
private container: HTMLElement;
|
|
116
|
+
|
|
117
|
+
constructor(
|
|
118
|
+
config: ResolvedTabManagerConfig,
|
|
119
|
+
classes: CSSClasses
|
|
120
|
+
) {
|
|
121
|
+
this.config = config;
|
|
122
|
+
this.classes = classes;
|
|
123
|
+
this.container = config.container;
|
|
124
|
+
|
|
125
|
+
// Set up resize handler
|
|
126
|
+
window.addEventListener('resize', this.updateIndicatorPositions.bind(this));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Create an anchor indicator element
|
|
131
|
+
*/
|
|
132
|
+
private createIndicator(): HTMLDivElement {
|
|
133
|
+
const indicator = document.createElement('div');
|
|
134
|
+
indicator.className = this.classes.anchorIndicator;
|
|
135
|
+
this.container.appendChild(indicator);
|
|
136
|
+
return indicator;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Add an anchor point
|
|
141
|
+
*/
|
|
142
|
+
addAnchor(anchorConfig: AnchorConfig): AnchorState {
|
|
143
|
+
const indicator =
|
|
144
|
+
anchorConfig.showIndicator !== false ? this.createIndicator() : null;
|
|
145
|
+
|
|
146
|
+
const state: AnchorState = {
|
|
147
|
+
config: anchorConfig,
|
|
148
|
+
indicator,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
this.anchors.push(state);
|
|
152
|
+
this.updateIndicatorPosition(state);
|
|
153
|
+
|
|
154
|
+
return state;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Add multiple default anchors
|
|
159
|
+
*/
|
|
160
|
+
addDefaultAnchors(): void {
|
|
161
|
+
const defaults = getDefaultAnchorConfigs(this.config);
|
|
162
|
+
for (const config of defaults) {
|
|
163
|
+
this.addAnchor(config);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Add anchor from preset
|
|
169
|
+
*/
|
|
170
|
+
addPresetAnchor(preset: AnchorPreset): AnchorState {
|
|
171
|
+
return this.addAnchor(createPresetAnchor(preset, this.config));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Remove an anchor
|
|
176
|
+
*/
|
|
177
|
+
removeAnchor(id: string): boolean {
|
|
178
|
+
const index = this.anchors.findIndex((a) => a.config.id === id);
|
|
179
|
+
if (index === -1) return false;
|
|
180
|
+
|
|
181
|
+
const anchor = this.anchors[index];
|
|
182
|
+
anchor.indicator?.remove();
|
|
183
|
+
this.anchors.splice(index, 1);
|
|
184
|
+
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Update position of a single indicator
|
|
190
|
+
*/
|
|
191
|
+
private updateIndicatorPosition(anchor: AnchorState): void {
|
|
192
|
+
if (!anchor.indicator) return;
|
|
193
|
+
|
|
194
|
+
const pos = anchor.config.getPosition();
|
|
195
|
+
const indicator = anchor.indicator;
|
|
196
|
+
|
|
197
|
+
// Anchor marks where grip (left edge) lands
|
|
198
|
+
indicator.style.left = `${pos.x - 20}px`; // Center indicator on anchor point
|
|
199
|
+
|
|
200
|
+
if (anchor.config.id.includes('bottom')) {
|
|
201
|
+
indicator.style.top = `${pos.y - 40}px`;
|
|
202
|
+
} else {
|
|
203
|
+
indicator.style.top = `${pos.y}px`;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Update all indicator positions (call on window resize)
|
|
209
|
+
*/
|
|
210
|
+
updateIndicatorPositions(): void {
|
|
211
|
+
for (const anchor of this.anchors) {
|
|
212
|
+
this.updateIndicatorPosition(anchor);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Find the nearest anchor for a group of moving panels
|
|
218
|
+
*/
|
|
219
|
+
findNearestAnchor(movingPanels: PanelState[]): AnchorSnapResult | null {
|
|
220
|
+
if (movingPanels.length === 0 || this.anchors.length === 0) return null;
|
|
221
|
+
|
|
222
|
+
let bestResult: AnchorSnapResult | null = null;
|
|
223
|
+
let bestDist = Infinity;
|
|
224
|
+
|
|
225
|
+
// Get bounding box of the entire group
|
|
226
|
+
const firstRect = movingPanels[0].element.getBoundingClientRect();
|
|
227
|
+
const lastRect = movingPanels[movingPanels.length - 1].element.getBoundingClientRect();
|
|
228
|
+
const groupLeft = firstRect.left;
|
|
229
|
+
const groupRight = lastRect.right;
|
|
230
|
+
const groupTop = firstRect.top;
|
|
231
|
+
const groupBottom = firstRect.bottom;
|
|
232
|
+
|
|
233
|
+
for (const anchor of this.anchors) {
|
|
234
|
+
const anchorPos = anchor.config.getPosition();
|
|
235
|
+
|
|
236
|
+
// Check if ANY part of the group is near this anchor
|
|
237
|
+
const nearGroup =
|
|
238
|
+
anchorPos.x >= groupLeft - this.config.anchorThreshold &&
|
|
239
|
+
anchorPos.x <= groupRight + this.config.anchorThreshold &&
|
|
240
|
+
anchorPos.y >= groupTop - this.config.anchorThreshold &&
|
|
241
|
+
anchorPos.y <= groupBottom + this.config.anchorThreshold;
|
|
242
|
+
|
|
243
|
+
if (!nearGroup) continue;
|
|
244
|
+
|
|
245
|
+
// Find which panel's GRIP (left edge) is closest to this anchor
|
|
246
|
+
let closestPanelIdx = 0;
|
|
247
|
+
let closestDist = Infinity;
|
|
248
|
+
|
|
249
|
+
for (let i = 0; i < movingPanels.length; i++) {
|
|
250
|
+
const rect = movingPanels[i].element.getBoundingClientRect();
|
|
251
|
+
|
|
252
|
+
// Distance from anchor to the GRIP (left edge) of this panel
|
|
253
|
+
const gripX = rect.left;
|
|
254
|
+
const gripY = rect.top;
|
|
255
|
+
const dist = Math.sqrt(
|
|
256
|
+
Math.pow(gripX - anchorPos.x, 2) + Math.pow(gripY - anchorPos.y, 2)
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
if (dist < closestDist) {
|
|
260
|
+
closestDist = dist;
|
|
261
|
+
closestPanelIdx = i;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Calculate final positions with this panel's grip docking to anchor
|
|
266
|
+
const anchorX = anchorPos.x;
|
|
267
|
+
const anchorY = anchor.config.id.includes('bottom')
|
|
268
|
+
? anchorPos.y - movingPanels[closestPanelIdx].element.offsetHeight
|
|
269
|
+
: anchorPos.y;
|
|
270
|
+
|
|
271
|
+
// Calculate positions for all panels
|
|
272
|
+
const positions: Position[] = [];
|
|
273
|
+
|
|
274
|
+
// Find where leftmost panel would be (panels to left of docked one)
|
|
275
|
+
let leftX = anchorX;
|
|
276
|
+
for (let i = closestPanelIdx - 1; i >= 0; i--) {
|
|
277
|
+
leftX -= movingPanels[i].element.offsetWidth + this.config.panelGap;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Fill in all positions left to right
|
|
281
|
+
let currentX = leftX;
|
|
282
|
+
for (let i = 0; i < movingPanels.length; i++) {
|
|
283
|
+
positions.push({ x: currentX, y: anchorY });
|
|
284
|
+
currentX += movingPanels[i].element.offsetWidth + this.config.panelGap;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Check if all panels fit on screen
|
|
288
|
+
const leftmostX = positions[0].x;
|
|
289
|
+
const rightmostX =
|
|
290
|
+
positions[positions.length - 1].x +
|
|
291
|
+
movingPanels[movingPanels.length - 1].element.offsetWidth;
|
|
292
|
+
|
|
293
|
+
if (leftmostX < 0 || rightmostX > window.innerWidth) {
|
|
294
|
+
// Try to find a panel that WOULD fit
|
|
295
|
+
for (let tryIdx = 0; tryIdx < movingPanels.length; tryIdx++) {
|
|
296
|
+
const tryPositions: Position[] = [];
|
|
297
|
+
let tryLeftX = anchorX;
|
|
298
|
+
for (let i = tryIdx - 1; i >= 0; i--) {
|
|
299
|
+
tryLeftX -= movingPanels[i].element.offsetWidth + this.config.panelGap;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let tryCurrentX = tryLeftX;
|
|
303
|
+
for (let i = 0; i < movingPanels.length; i++) {
|
|
304
|
+
tryPositions.push({ x: tryCurrentX, y: anchorY });
|
|
305
|
+
tryCurrentX +=
|
|
306
|
+
movingPanels[i].element.offsetWidth + this.config.panelGap;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const tryLeftmostX = tryPositions[0].x;
|
|
310
|
+
const tryRightmostX =
|
|
311
|
+
tryPositions[tryPositions.length - 1].x +
|
|
312
|
+
movingPanels[movingPanels.length - 1].element.offsetWidth;
|
|
313
|
+
|
|
314
|
+
if (tryLeftmostX >= 0 && tryRightmostX <= window.innerWidth) {
|
|
315
|
+
if (closestDist < bestDist) {
|
|
316
|
+
bestDist = closestDist;
|
|
317
|
+
bestResult = {
|
|
318
|
+
anchor,
|
|
319
|
+
dockPanelIndex: tryIdx,
|
|
320
|
+
positions: tryPositions,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// This is a valid option
|
|
330
|
+
if (closestDist < bestDist) {
|
|
331
|
+
bestDist = closestDist;
|
|
332
|
+
bestResult = { anchor, dockPanelIndex: closestPanelIdx, positions };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return bestResult;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Show anchor indicators during drag
|
|
341
|
+
*/
|
|
342
|
+
showIndicators(activeAnchor: AnchorState | null): void {
|
|
343
|
+
for (const anchor of this.anchors) {
|
|
344
|
+
if (!anchor.indicator) continue;
|
|
345
|
+
|
|
346
|
+
if (activeAnchor === anchor) {
|
|
347
|
+
anchor.indicator.classList.add(
|
|
348
|
+
this.classes.anchorIndicatorVisible,
|
|
349
|
+
this.classes.anchorIndicatorActive
|
|
350
|
+
);
|
|
351
|
+
} else {
|
|
352
|
+
anchor.indicator.classList.add(this.classes.anchorIndicatorVisible);
|
|
353
|
+
anchor.indicator.classList.remove(this.classes.anchorIndicatorActive);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Hide all anchor indicators
|
|
360
|
+
*/
|
|
361
|
+
hideIndicators(): void {
|
|
362
|
+
for (const anchor of this.anchors) {
|
|
363
|
+
if (!anchor.indicator) continue;
|
|
364
|
+
anchor.indicator.classList.remove(
|
|
365
|
+
this.classes.anchorIndicatorVisible,
|
|
366
|
+
this.classes.anchorIndicatorActive
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Get all anchors
|
|
373
|
+
*/
|
|
374
|
+
getAnchors(): AnchorState[] {
|
|
375
|
+
return [...this.anchors];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Get anchor by ID
|
|
380
|
+
*/
|
|
381
|
+
getAnchor(id: string): AnchorState | undefined {
|
|
382
|
+
return this.anchors.find((a) => a.config.id === id);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Clean up
|
|
387
|
+
*/
|
|
388
|
+
destroy(): void {
|
|
389
|
+
window.removeEventListener('resize', this.updateIndicatorPositions.bind(this));
|
|
390
|
+
for (const anchor of this.anchors) {
|
|
391
|
+
anchor.indicator?.remove();
|
|
392
|
+
}
|
|
393
|
+
this.anchors = [];
|
|
394
|
+
}
|
|
395
|
+
}
|