@bagelink/vue 1.10.35 → 1.10.37
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/dist/components/form/inputs/SelectInput.vue.d.ts.map +1 -1
- package/dist/components/layout/Panel.vue.d.ts +68 -0
- package/dist/components/layout/Panel.vue.d.ts.map +1 -0
- package/dist/components/layout/Resizable.vue.d.ts +38 -0
- package/dist/components/layout/Resizable.vue.d.ts.map +1 -0
- package/dist/components/layout/index.d.ts.map +1 -1
- package/dist/composables/index.d.ts.map +1 -1
- package/dist/composables/useResizableLayout.d.ts +39 -0
- package/dist/composables/useResizableLayout.d.ts.map +1 -0
- package/dist/index.cjs +61 -61
- package/dist/index.mjs +6815 -6594
- package/dist/style.css +1 -1
- package/package.json +2 -2
- package/src/components/form/inputs/SelectInput.vue +1 -1
- package/src/components/layout/Panel.vue +278 -0
- package/src/components/layout/Resizable.vue +64 -0
- package/src/components/layout/index.ts +2 -0
- package/src/composables/index.ts +2 -0
- package/src/composables/useResizableLayout.ts +252 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bagelink/vue",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.10.
|
|
4
|
+
"version": "1.10.37",
|
|
5
5
|
"description": "Bagel core sdk packages",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Bagel Studio",
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
"signature_pad": "^5.0.9",
|
|
91
91
|
"vue-i18n": "^11.2.8",
|
|
92
92
|
"vue-toastification": "^2.0.0-rc.5",
|
|
93
|
-
"@bagelink/utils": "1.10.
|
|
93
|
+
"@bagelink/utils": "1.10.37"
|
|
94
94
|
},
|
|
95
95
|
"scripts": {
|
|
96
96
|
"dev": "tsx watch src/index.ts",
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import type { PanelConfig, PanelState } from '../../composables/useResizableLayout'
|
|
3
|
+
import { computed, inject, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
|
|
4
|
+
import { RESIZABLE_KEY, nextPanelId } from '../../composables/useResizableLayout'
|
|
5
|
+
|
|
6
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
7
|
+
defaultSize: 200,
|
|
8
|
+
min: 0,
|
|
9
|
+
max: Number.POSITIVE_INFINITY,
|
|
10
|
+
collapsible: false,
|
|
11
|
+
collapsed: undefined,
|
|
12
|
+
flex: false,
|
|
13
|
+
handle: true,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const emit = defineEmits<{
|
|
17
|
+
'update:collapsed': [value: boolean]
|
|
18
|
+
'update:size': [value: number]
|
|
19
|
+
}>()
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
id?: string
|
|
23
|
+
/** Initial size in pixels. @default 200 */
|
|
24
|
+
defaultSize?: number
|
|
25
|
+
/** Controlled size — use with v-model:size to drive from outside (e.g. device presets). */
|
|
26
|
+
size?: number
|
|
27
|
+
/** Minimum size in pixels. @default 0 */
|
|
28
|
+
min?: number
|
|
29
|
+
/** Maximum size in pixels. @default Infinity */
|
|
30
|
+
max?: number
|
|
31
|
+
/** Allows the panel to be collapsed. Exposes toggleCollapse via slot + v-model:collapsed. */
|
|
32
|
+
collapsible?: boolean
|
|
33
|
+
/** Controlled collapsed state — use with v-model:collapsed. */
|
|
34
|
+
collapsed?: boolean
|
|
35
|
+
/** Fill all remaining space in the layout. */
|
|
36
|
+
flex?: boolean
|
|
37
|
+
/** Collapse this panel when the layout switches to mobile. */
|
|
38
|
+
collapseOnMobile?: boolean
|
|
39
|
+
/** Hide this panel entirely when the layout switches to mobile. */
|
|
40
|
+
hideOnMobile?: boolean
|
|
41
|
+
/** Show a drag handle on the trailing edge of this panel. @default true */
|
|
42
|
+
handle?: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ctx = inject(RESIZABLE_KEY)!
|
|
46
|
+
if (!ctx) throw new Error('<Panel> must be used inside a <Resizable>')
|
|
47
|
+
|
|
48
|
+
// Stable ID for the lifetime of this instance
|
|
49
|
+
const panelId = props.id ?? nextPanelId()
|
|
50
|
+
|
|
51
|
+
const panelState = shallowRef<PanelState | null>(null)
|
|
52
|
+
const panelEl = ref<HTMLElement | null>(null)
|
|
53
|
+
|
|
54
|
+
// ── Collapsed (controlled / uncontrolled) ──────────────────────────────────
|
|
55
|
+
|
|
56
|
+
const collapsedInternal = computed({
|
|
57
|
+
get: () => props.collapsed ?? panelState.value?.collapsed.value ?? false,
|
|
58
|
+
set(val) {
|
|
59
|
+
if (panelState.value) panelState.value.collapsed.value = val
|
|
60
|
+
emit('update:collapsed', val)
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
watch(() => props.collapsed, (val) => {
|
|
65
|
+
if (val !== undefined && panelState.value) panelState.value.collapsed.value = val
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// ── Size (controlled / uncontrolled) ──────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
// Prop → internal (button presets, external control)
|
|
71
|
+
watch(() => props.size, (val) => {
|
|
72
|
+
if (val === undefined || !panelState.value) return
|
|
73
|
+
const clamped = Math.max(props.min, Math.min(props.max, val))
|
|
74
|
+
if (clamped !== panelState.value.size.value) panelState.value.size.value = clamped
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// Internal → prop (drag end syncs back up via v-model:size)
|
|
78
|
+
// Skip the initial mount transition (undefined → number) to avoid spurious emits
|
|
79
|
+
watch(
|
|
80
|
+
() => panelState.value?.size.value,
|
|
81
|
+
(newSize, oldSize) => {
|
|
82
|
+
if (newSize !== undefined && oldSize !== undefined) emit('update:size', newSize)
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
onMounted(() => {
|
|
89
|
+
const config: PanelConfig = {
|
|
90
|
+
id: panelId,
|
|
91
|
+
defaultSize: props.size ?? props.defaultSize,
|
|
92
|
+
min: props.min,
|
|
93
|
+
max: props.max,
|
|
94
|
+
flex: props.flex,
|
|
95
|
+
collapseOnMobile: props.collapseOnMobile ?? false,
|
|
96
|
+
hideOnMobile: props.hideOnMobile ?? false,
|
|
97
|
+
}
|
|
98
|
+
panelState.value = ctx.registerPanel(config)
|
|
99
|
+
panelState.value.el.value = panelEl.value
|
|
100
|
+
if (props.collapsed !== undefined) panelState.value.collapsed.value = props.collapsed
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
onUnmounted(() => { ctx.unregisterPanel(panelId) })
|
|
104
|
+
|
|
105
|
+
// ── Computed state ─────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
const size = computed(() => panelState.value?.size.value ?? props.defaultSize)
|
|
108
|
+
const isCollapsed = computed(() => collapsedInternal.value)
|
|
109
|
+
const isHorizontal = computed(() => ctx.direction.value === 'horizontal')
|
|
110
|
+
const isHidden = computed(() => ctx.isMobile.value && props.hideOnMobile)
|
|
111
|
+
|
|
112
|
+
// Default to false (show handle) before mount so handles appear on first render.
|
|
113
|
+
// After registration the correct last-panel is identified reactively.
|
|
114
|
+
const isLastPanel = computed(() => {
|
|
115
|
+
const ps = ctx.panels.value
|
|
116
|
+
const idx = ps.findIndex(p => p.id === panelId)
|
|
117
|
+
return idx >= 0 && idx === ps.length - 1
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const panelStyle = computed(() => {
|
|
121
|
+
if (isCollapsed.value) {
|
|
122
|
+
return isHorizontal.value
|
|
123
|
+
? { flex: '0 0 0px', width: '0px', minWidth: '0', overflow: 'hidden' }
|
|
124
|
+
: { flex: '0 0 0px', height: '0px', minHeight: '0', overflow: 'hidden' }
|
|
125
|
+
}
|
|
126
|
+
if (props.flex) {
|
|
127
|
+
return isHorizontal.value
|
|
128
|
+
? { flex: '1 1 0', minWidth: `${props.min}px`, maxWidth: props.max === Number.POSITIVE_INFINITY ? undefined : `${props.max}px`, overflow: 'hidden' }
|
|
129
|
+
: { flex: '1 1 0', minHeight: `${props.min}px`, maxHeight: props.max === Number.POSITIVE_INFINITY ? undefined : `${props.max}px`, overflow: 'hidden' }
|
|
130
|
+
}
|
|
131
|
+
return isHorizontal.value
|
|
132
|
+
? { flex: `0 0 ${size.value}px`, width: `${size.value}px`, overflow: 'hidden' }
|
|
133
|
+
: { flex: `0 0 ${size.value}px`, height: `${size.value}px`, overflow: 'hidden' }
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// ── Exposed / slot API ─────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
function toggleCollapse() {
|
|
139
|
+
collapsedInternal.value = !collapsedInternal.value
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
defineExpose({ toggleCollapse, size, isCollapsed })
|
|
143
|
+
</script>
|
|
144
|
+
|
|
145
|
+
<template>
|
|
146
|
+
<div
|
|
147
|
+
v-if="!isHidden" ref="panelEl" class="panel"
|
|
148
|
+
:class="[isHorizontal ? 'panel--h' : 'panel--v', isCollapsed && 'panel--collapsed']" :style="panelStyle"
|
|
149
|
+
>
|
|
150
|
+
<div class="panel__content">
|
|
151
|
+
<slot :collapsed="isCollapsed" :size="size" :toggle-collapse="toggleCollapse" />
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<div
|
|
155
|
+
v-if="props.handle && !isLastPanel && !isCollapsed" class="panel__handle"
|
|
156
|
+
:class="isHorizontal ? 'panel__handle--h' : 'panel__handle--v'"
|
|
157
|
+
@pointerdown="ctx.onHandlePointerDown(panelId, $event)"
|
|
158
|
+
>
|
|
159
|
+
<div class="panel__handle-bar" />
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</template>
|
|
163
|
+
|
|
164
|
+
<style scoped>
|
|
165
|
+
.panel {
|
|
166
|
+
position: relative;
|
|
167
|
+
display: flex;
|
|
168
|
+
overflow: hidden;
|
|
169
|
+
/* transition: flex-basis 0.2s ease, width 0.2s ease, height 0.2s ease; */
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* Disable transitions while dragging so the handle tracks the pointer exactly */
|
|
173
|
+
:global(.resizable[data-resizing]) .panel {
|
|
174
|
+
transition: none !important;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* Prevent iframes from absorbing pointer events and triggering expensive
|
|
178
|
+
cross-process reflows on every resize tick */
|
|
179
|
+
:global(.resizable[data-resizing]) iframe,
|
|
180
|
+
:global(.resizable[data-resizing]) video {
|
|
181
|
+
pointer-events: none !important;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.panel--h {
|
|
185
|
+
flex-direction: row;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.panel--v {
|
|
189
|
+
flex-direction: column;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.panel__content {
|
|
193
|
+
flex: 1 1 0;
|
|
194
|
+
overflow: hidden;
|
|
195
|
+
min-width: 0;
|
|
196
|
+
min-height: 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* ─── Handle ─────────────────────────────────────────────────────────────── */
|
|
200
|
+
|
|
201
|
+
.panel__handle {
|
|
202
|
+
flex-shrink: 0;
|
|
203
|
+
position: relative;
|
|
204
|
+
z-index: 10;
|
|
205
|
+
touch-action: none;
|
|
206
|
+
user-select: none;
|
|
207
|
+
display: flex;
|
|
208
|
+
align-items: center;
|
|
209
|
+
justify-content: center;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.panel__handle--h {
|
|
213
|
+
width: var(--resizable-handle-size, 9px);
|
|
214
|
+
cursor: ew-resize;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.panel__handle--v {
|
|
218
|
+
height: var(--resizable-handle-size, 9px);
|
|
219
|
+
cursor: ns-resize;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* Persistent 1px divider */
|
|
223
|
+
.panel__handle::before {
|
|
224
|
+
content: '';
|
|
225
|
+
position: absolute;
|
|
226
|
+
border-radius: 9999px;
|
|
227
|
+
background: var(--resizable-divider-color, color-mix(in srgb, currentColor 14%, transparent));
|
|
228
|
+
transition: background 0.15s;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.panel__handle--h::before {
|
|
232
|
+
top: 0;
|
|
233
|
+
bottom: 0;
|
|
234
|
+
left: 50%;
|
|
235
|
+
transform: translateX(-50%);
|
|
236
|
+
width: 1px;
|
|
237
|
+
height: 100%;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.panel__handle--v::before {
|
|
241
|
+
left: 0;
|
|
242
|
+
right: 0;
|
|
243
|
+
top: 50%;
|
|
244
|
+
transform: translateY(-50%);
|
|
245
|
+
height: 1px;
|
|
246
|
+
width: 100%;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* Grip pill — fades in on hover */
|
|
250
|
+
.panel__handle-bar {
|
|
251
|
+
position: relative;
|
|
252
|
+
z-index: 1;
|
|
253
|
+
border-radius: 9999px;
|
|
254
|
+
background: var(--resizable-grip-color, var(--color-primary, #3b82f6));
|
|
255
|
+
opacity: 0;
|
|
256
|
+
transition: opacity 0.15s, transform 0.15s;
|
|
257
|
+
transform: scale(0.8);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.panel__handle--h .panel__handle-bar {
|
|
261
|
+
width: 3px;
|
|
262
|
+
height: 24px;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.panel__handle--v .panel__handle-bar {
|
|
266
|
+
height: 3px;
|
|
267
|
+
width: 24px;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.panel__handle:hover::before {
|
|
271
|
+
background: var(--resizable-divider-active-color, var(--color-primary, #3b82f6));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.panel__handle:hover .panel__handle-bar {
|
|
275
|
+
opacity: 1;
|
|
276
|
+
transform: scale(1);
|
|
277
|
+
}
|
|
278
|
+
</style>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { computed, onMounted, onUnmounted, provide, ref } from 'vue'
|
|
3
|
+
import { RESIZABLE_KEY, useResizableLayoutProvider } from '../../composables/useResizableLayout'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
/** Lay panels side-by-side. Default: vertical (stacked). */
|
|
7
|
+
horizontal?: boolean
|
|
8
|
+
/**
|
|
9
|
+
* Stack panels vertically when the container is narrower than `breakpoint`.
|
|
10
|
+
* Only applies when `horizontal` is set. Default: true.
|
|
11
|
+
* Set `:breakpoint="0"` to disable responsive switching entirely.
|
|
12
|
+
*/
|
|
13
|
+
mobileVertical?: boolean
|
|
14
|
+
/** Width (px) below which the mobile layout activates. @default 768 */
|
|
15
|
+
breakpoint?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
19
|
+
horizontal: false,
|
|
20
|
+
mobileVertical: true,
|
|
21
|
+
breakpoint: 768,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const containerEl = ref<HTMLElement>()
|
|
25
|
+
const directionProp = computed(() => props.horizontal ? 'horizontal' as const : 'vertical' as const)
|
|
26
|
+
const mobileDirection = computed(() => props.mobileVertical || !props.horizontal ? 'vertical' as const : 'horizontal' as const,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const { context, observe, disconnect } = useResizableLayoutProvider({
|
|
30
|
+
directionProp,
|
|
31
|
+
mobileDirection: mobileDirection.value,
|
|
32
|
+
breakpoint: props.breakpoint,
|
|
33
|
+
containerEl,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
provide(RESIZABLE_KEY, context)
|
|
37
|
+
onMounted(observe)
|
|
38
|
+
onUnmounted(disconnect)
|
|
39
|
+
|
|
40
|
+
const isHorizontal = computed(() => context.direction.value === 'horizontal')
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<template>
|
|
44
|
+
<div ref="containerEl" class="resizable" :class="isHorizontal ? 'resizable--row' : 'resizable--col'">
|
|
45
|
+
<slot />
|
|
46
|
+
</div>
|
|
47
|
+
</template>
|
|
48
|
+
|
|
49
|
+
<style scoped>
|
|
50
|
+
.resizable {
|
|
51
|
+
display: flex;
|
|
52
|
+
width: 100%;
|
|
53
|
+
height: 100%;
|
|
54
|
+
overflow: hidden;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.resizable--row {
|
|
58
|
+
flex-direction: row;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.resizable--col {
|
|
62
|
+
flex-direction: column;
|
|
63
|
+
}
|
|
64
|
+
</style>
|
|
@@ -3,6 +3,8 @@ export { default as AppLayout } from './AppLayout.vue'
|
|
|
3
3
|
export { default as AppSidebar } from './AppSidebar.vue'
|
|
4
4
|
export { default as BottomMenu } from './BottomMenu.vue'
|
|
5
5
|
export { default as Layout } from './Layout.vue'
|
|
6
|
+
export { default as Panel } from './Panel.vue'
|
|
7
|
+
export { default as Resizable } from './Resizable.vue'
|
|
6
8
|
export { default as SidebarMenu } from './SidebarMenu.vue'
|
|
7
9
|
export { default as Skeleton } from './Skeleton.vue'
|
|
8
10
|
export { default as TabbedLayout } from './TabbedLayout.vue'
|
package/src/composables/index.ts
CHANGED
|
@@ -9,6 +9,8 @@ export { useDevice } from './useDevice'
|
|
|
9
9
|
export { useExcel } from './useExcel'
|
|
10
10
|
export { useLocalStore } from './useLocalStore'
|
|
11
11
|
export { usePolling } from './usePolling'
|
|
12
|
+
export { useResizableLayoutProvider } from './useResizableLayout'
|
|
13
|
+
export type { PanelConfig, PanelState, ResizableContext } from './useResizableLayout'
|
|
12
14
|
export { useResizeObserver } from './useResizeObserver'
|
|
13
15
|
export { useTheme } from './useTheme'
|
|
14
16
|
interface UseBglSchemaParamsT<T> {
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type { InjectionKey, Ref } from 'vue'
|
|
2
|
+
import { computed, ref, shallowRef, watch } from 'vue'
|
|
3
|
+
|
|
4
|
+
export interface PanelConfig {
|
|
5
|
+
id: string
|
|
6
|
+
defaultSize: number
|
|
7
|
+
min: number
|
|
8
|
+
max: number
|
|
9
|
+
flex: boolean
|
|
10
|
+
collapseOnMobile: boolean
|
|
11
|
+
hideOnMobile: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PanelState {
|
|
15
|
+
id: string
|
|
16
|
+
config: PanelConfig
|
|
17
|
+
size: Ref<number>
|
|
18
|
+
collapsed: Ref<boolean>
|
|
19
|
+
/** DOM reference set by Panel.vue for direct style mutation during drag. */
|
|
20
|
+
el: Ref<HTMLElement | null>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ResizableContext {
|
|
24
|
+
direction: Ref<'horizontal' | 'vertical'>
|
|
25
|
+
isMobile: Ref<boolean>
|
|
26
|
+
panels: Ref<PanelState[]>
|
|
27
|
+
registerPanel: (config: PanelConfig) => PanelState
|
|
28
|
+
unregisterPanel: (id: string) => void
|
|
29
|
+
onHandlePointerDown: (panelId: string, event: PointerEvent) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const RESIZABLE_KEY: InjectionKey<ResizableContext> = Symbol('Resizable')
|
|
33
|
+
|
|
34
|
+
let _panelUid = 0
|
|
35
|
+
export function nextPanelId(): string {
|
|
36
|
+
return `panel-${++_panelUid}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Internal drag state ───────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
interface DragState {
|
|
42
|
+
panelId: string
|
|
43
|
+
pointerId: number
|
|
44
|
+
isHorizontal: boolean
|
|
45
|
+
isRtl: boolean
|
|
46
|
+
startPos: number
|
|
47
|
+
startSizeA: number
|
|
48
|
+
startSizeB: number
|
|
49
|
+
currentSizeA: number
|
|
50
|
+
currentSizeB: number
|
|
51
|
+
indexA: number
|
|
52
|
+
indexB: number
|
|
53
|
+
isFlexA: boolean
|
|
54
|
+
isFlexB: boolean
|
|
55
|
+
elA: HTMLElement | null
|
|
56
|
+
elB: HTMLElement | null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Provider ──────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export function useResizableLayoutProvider(options: {
|
|
62
|
+
directionProp: Ref<'horizontal' | 'vertical'>
|
|
63
|
+
mobileDirection: 'horizontal' | 'vertical'
|
|
64
|
+
breakpoint: number
|
|
65
|
+
containerEl: Ref<HTMLElement | undefined>
|
|
66
|
+
}) {
|
|
67
|
+
const { containerEl, breakpoint, mobileDirection } = options
|
|
68
|
+
|
|
69
|
+
const isMobile = ref(false)
|
|
70
|
+
const panels = shallowRef<PanelState[]>([])
|
|
71
|
+
|
|
72
|
+
const direction = computed<'horizontal' | 'vertical'>(() => isMobile.value ? mobileDirection : options.directionProp.value,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// ── Breakpoint detection ──
|
|
76
|
+
let ro: ResizeObserver | null = null
|
|
77
|
+
|
|
78
|
+
function observe() {
|
|
79
|
+
if (!containerEl.value) return
|
|
80
|
+
ro?.disconnect()
|
|
81
|
+
ro = new ResizeObserver((entries) => {
|
|
82
|
+
const entry = entries[0]
|
|
83
|
+
if (entry) isMobile.value = entry.contentRect.width < breakpoint
|
|
84
|
+
})
|
|
85
|
+
ro.observe(containerEl.value)
|
|
86
|
+
isMobile.value = containerEl.value.clientWidth < breakpoint
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function disconnect() {
|
|
90
|
+
ro?.disconnect()
|
|
91
|
+
ro = null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// On mobile switch: apply collapsed state overrides
|
|
95
|
+
watch(isMobile, (mobile) => {
|
|
96
|
+
for (const panel of panels.value) {
|
|
97
|
+
const { config } = panel
|
|
98
|
+
panel.collapsed.value = mobile
|
|
99
|
+
? (config.collapseOnMobile || false)
|
|
100
|
+
: false
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// ── Panel registry ──
|
|
105
|
+
function registerPanel(config: PanelConfig): PanelState {
|
|
106
|
+
const state: PanelState = {
|
|
107
|
+
id: config.id,
|
|
108
|
+
config,
|
|
109
|
+
size: ref(config.defaultSize),
|
|
110
|
+
collapsed: ref(isMobile.value ? config.collapseOnMobile : false),
|
|
111
|
+
el: ref(null),
|
|
112
|
+
}
|
|
113
|
+
panels.value = [...panels.value, state]
|
|
114
|
+
return state
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function unregisterPanel(id: string) {
|
|
118
|
+
panels.value = panels.value.filter(p => p.id !== id)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getPanelIndex(id: string) {
|
|
122
|
+
return panels.value.findIndex(p => p.id === id)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Drag ──
|
|
126
|
+
// Cleanup function for the current drag's document-level listeners.
|
|
127
|
+
// Called at the start of every pointerdown to clear any previously stuck drag.
|
|
128
|
+
let dragCleanup: (() => void) | null = null
|
|
129
|
+
|
|
130
|
+
function flushResize(ds: DragState, currentPos: number) {
|
|
131
|
+
const rawDelta = currentPos - ds.startPos
|
|
132
|
+
const delta = ds.isRtl ? -rawDelta : rawDelta
|
|
133
|
+
|
|
134
|
+
const panelA = panels.value[ds.indexA]
|
|
135
|
+
const panelB = panels.value[ds.indexB]
|
|
136
|
+
const minA = panelA.config.min
|
|
137
|
+
const maxA = panelA.config.max
|
|
138
|
+
const minB = panelB.config.min
|
|
139
|
+
const maxB = panelB.config.max
|
|
140
|
+
|
|
141
|
+
let newA = ds.currentSizeA
|
|
142
|
+
let newB = ds.currentSizeB
|
|
143
|
+
|
|
144
|
+
if (ds.isFlexB) {
|
|
145
|
+
newA = Math.max(minA, Math.min(maxA, ds.startSizeA + delta))
|
|
146
|
+
}
|
|
147
|
+
else if (ds.isFlexA) {
|
|
148
|
+
newB = Math.max(minB, Math.min(maxB, ds.startSizeB - delta))
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
const proposed = Math.max(minA, Math.min(maxA, ds.startSizeA + delta))
|
|
152
|
+
const actualDelta = proposed - ds.startSizeA
|
|
153
|
+
newB = Math.max(minB, Math.min(maxB, ds.startSizeB - actualDelta))
|
|
154
|
+
newA = ds.startSizeA + (ds.startSizeB - newB)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
ds.currentSizeA = newA
|
|
158
|
+
ds.currentSizeB = newB
|
|
159
|
+
|
|
160
|
+
// Direct DOM mutation — bypasses Vue scheduler for zero-overhead drag
|
|
161
|
+
const dim = ds.isHorizontal ? 'width' : 'height'
|
|
162
|
+
if (ds.elA && !ds.isFlexA) {
|
|
163
|
+
ds.elA.style.flex = `0 0 ${newA}px`
|
|
164
|
+
ds.elA.style[dim] = `${newA}px`
|
|
165
|
+
}
|
|
166
|
+
if (ds.elB && !ds.isFlexB) {
|
|
167
|
+
ds.elB.style.flex = `0 0 ${newB}px`
|
|
168
|
+
ds.elB.style[dim] = `${newB}px`
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function onHandlePointerDown(panelId: string, event: PointerEvent) {
|
|
173
|
+
// Always clean up any previously stuck drag before starting a new one
|
|
174
|
+
dragCleanup?.()
|
|
175
|
+
|
|
176
|
+
const indexA = getPanelIndex(panelId)
|
|
177
|
+
if (indexA < 0 || indexA >= panels.value.length - 1) return
|
|
178
|
+
|
|
179
|
+
const panelA = panels.value[indexA]
|
|
180
|
+
const panelB = panels.value[indexA + 1]
|
|
181
|
+
if (panelA.collapsed.value || panelB.collapsed.value) return
|
|
182
|
+
|
|
183
|
+
event.preventDefault()
|
|
184
|
+
|
|
185
|
+
const isHorizontal = direction.value === 'horizontal'
|
|
186
|
+
const pos = isHorizontal ? event.clientX : event.clientY
|
|
187
|
+
const isRtl = isHorizontal
|
|
188
|
+
&& !!containerEl.value
|
|
189
|
+
&& getComputedStyle(containerEl.value).direction === 'rtl'
|
|
190
|
+
|
|
191
|
+
const ds: DragState = {
|
|
192
|
+
panelId,
|
|
193
|
+
pointerId: event.pointerId,
|
|
194
|
+
isHorizontal,
|
|
195
|
+
isRtl,
|
|
196
|
+
startPos: pos,
|
|
197
|
+
startSizeA: panelA.size.value,
|
|
198
|
+
startSizeB: panelB.size.value,
|
|
199
|
+
currentSizeA: panelA.size.value,
|
|
200
|
+
currentSizeB: panelB.size.value,
|
|
201
|
+
indexA,
|
|
202
|
+
indexB: indexA + 1,
|
|
203
|
+
isFlexA: panelA.config.flex,
|
|
204
|
+
isFlexB: panelB.config.flex,
|
|
205
|
+
elA: panelA.el.value,
|
|
206
|
+
elB: panelB.el.value,
|
|
207
|
+
}
|
|
208
|
+
containerEl.value?.setAttribute('data-resizing', '')
|
|
209
|
+
document.body.style.userSelect = 'none'
|
|
210
|
+
document.body.style.cursor = isHorizontal ? 'ew-resize' : 'ns-resize'
|
|
211
|
+
|
|
212
|
+
function onMove(e: PointerEvent) {
|
|
213
|
+
if (e.pointerId !== ds.pointerId) return
|
|
214
|
+
flushResize(ds, ds.isHorizontal ? e.clientX : e.clientY)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function onUp(e: PointerEvent) {
|
|
218
|
+
if (e.pointerId !== ds.pointerId) return
|
|
219
|
+
flushResize(ds, ds.isHorizontal ? e.clientX : e.clientY)
|
|
220
|
+
// Single Vue update at drag end — syncs DOM state back to reactive refs
|
|
221
|
+
panels.value[ds.indexA].size.value = ds.currentSizeA
|
|
222
|
+
panels.value[ds.indexB].size.value = ds.currentSizeB
|
|
223
|
+
cleanup()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function cleanup() {
|
|
227
|
+
dragCleanup = null
|
|
228
|
+
containerEl.value?.removeAttribute('data-resizing')
|
|
229
|
+
document.body.style.userSelect = ''
|
|
230
|
+
document.body.style.cursor = ''
|
|
231
|
+
document.removeEventListener('pointermove', onMove)
|
|
232
|
+
document.removeEventListener('pointerup', onUp)
|
|
233
|
+
document.removeEventListener('pointercancel', cleanup)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
document.addEventListener('pointermove', onMove)
|
|
237
|
+
document.addEventListener('pointerup', onUp)
|
|
238
|
+
document.addEventListener('pointercancel', cleanup)
|
|
239
|
+
dragCleanup = cleanup
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const context: ResizableContext = {
|
|
243
|
+
direction,
|
|
244
|
+
isMobile,
|
|
245
|
+
panels,
|
|
246
|
+
registerPanel,
|
|
247
|
+
unregisterPanel,
|
|
248
|
+
onHandlePointerDown,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { context, observe, disconnect }
|
|
252
|
+
}
|