@cloudron/pankow 4.4.11 → 4.4.13

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.
@@ -291,7 +291,7 @@ defineExpose({
291
291
  text-overflow: ellipsis;
292
292
  white-space: nowrap;
293
293
  margin-top: 4px;
294
- font-size: 0.85rem;
294
+ font-size: 1rem;
295
295
  padding: 0 2px;
296
296
  }
297
297
 
@@ -23,8 +23,7 @@ const props = defineProps({
23
23
  align-content: stretch;
24
24
  }
25
25
 
26
- /* spacing between adjacent FormGroups */
27
- .pankow-form-group + .pankow-form-group {
26
+ .pankow-form-group {
28
27
  margin: 6px 0;
29
28
  }
30
29
 
@@ -0,0 +1,118 @@
1
+ <script setup>
2
+
3
+ defineProps({
4
+ orientation: {
5
+ type: String,
6
+ default: 'horizontal'
7
+ },
8
+ opaque: {
9
+ type: Boolean,
10
+ default: false
11
+ },
12
+ dragging: {
13
+ type: Boolean,
14
+ default: false
15
+ },
16
+ contrast: {
17
+ type: String,
18
+ default: 'auto'
19
+ }
20
+ });
21
+
22
+ </script>
23
+
24
+ <template>
25
+ <div
26
+ class="pankow-resize-handle"
27
+ :class="{
28
+ 'pankow-resize-handle-vertical': orientation === 'vertical',
29
+ 'pankow-resize-handle-opaque': opaque,
30
+ 'pankow-resize-handle-dragging': dragging,
31
+ 'pankow-resize-handle-contrast-light': contrast === 'light',
32
+ 'pankow-resize-handle-contrast-dark': contrast === 'dark'
33
+ }"
34
+ >
35
+ <div class="pankow-resize-handle-grip"><span></span><span></span></div>
36
+ </div>
37
+ </template>
38
+
39
+ <style>
40
+
41
+ .pankow-resize-handle {
42
+ background: transparent;
43
+ position: relative;
44
+ flex-shrink: 0;
45
+ display: flex;
46
+ align-items: center;
47
+ justify-content: center;
48
+ }
49
+
50
+ .pankow-resize-handle:not(.pankow-resize-handle-vertical) {
51
+ width: 6px;
52
+ cursor: col-resize;
53
+ }
54
+
55
+ .pankow-resize-handle-vertical {
56
+ height: 6px;
57
+ cursor: row-resize;
58
+ }
59
+
60
+ .pankow-resize-handle-opaque {
61
+ background: var(--pankow-color-background);
62
+ }
63
+
64
+ .pankow-resize-handle:hover,
65
+ .pankow-resize-handle-dragging {
66
+ background: var(--pankow-input-border-color);
67
+ }
68
+
69
+ .pankow-resize-handle-grip {
70
+ position: absolute;
71
+ top: 50%;
72
+ left: 50%;
73
+ transform: translate(-50%, -50%);
74
+ display: flex;
75
+ gap: 2px;
76
+ opacity: 0;
77
+ transition: opacity 0.15s;
78
+ pointer-events: none;
79
+ }
80
+
81
+ .pankow-resize-handle:not(.pankow-resize-handle-vertical) .pankow-resize-handle-grip {
82
+ flex-direction: column;
83
+ }
84
+
85
+ .pankow-resize-handle-vertical .pankow-resize-handle-grip {
86
+ flex-direction: row;
87
+ }
88
+
89
+ .pankow-resize-handle:hover .pankow-resize-handle-grip,
90
+ .pankow-resize-handle-dragging .pankow-resize-handle-grip {
91
+ opacity: 0.5;
92
+ }
93
+
94
+ .pankow-resize-handle-grip span {
95
+ display: block;
96
+ border-radius: 1px;
97
+ background: currentColor;
98
+ }
99
+
100
+ .pankow-resize-handle-contrast-light .pankow-resize-handle-grip span {
101
+ background: var(--pankow-text-color-white);
102
+ }
103
+
104
+ .pankow-resize-handle-contrast-dark .pankow-resize-handle-grip span {
105
+ background: var(--pankow-color-dark);
106
+ }
107
+
108
+ .pankow-resize-handle:not(.pankow-resize-handle-vertical) .pankow-resize-handle-grip span {
109
+ width: 2px;
110
+ height: 16px;
111
+ }
112
+
113
+ .pankow-resize-handle-vertical .pankow-resize-handle-grip span {
114
+ width: 16px;
115
+ height: 2px;
116
+ }
117
+
118
+ </style>
@@ -1,17 +1,35 @@
1
1
  <script setup>
2
2
 
3
- import { ref, useTemplateRef, onMounted } from 'vue';
4
- import { onSwipe } from '../gestures.js';
3
+ import { ref, useTemplateRef, onMounted, onUnmounted, nextTick } from 'vue';
4
+ import { onPan } from '../gestures.js';
5
+ import ResizeHandle from './ResizeHandle.vue';
5
6
 
6
- defineProps({
7
+ const props = defineProps({
7
8
  showOpenAction: {
8
9
  type: Boolean,
9
10
  default: true
10
11
  },
12
+ resizable: {
13
+ type: Boolean,
14
+ default: false
15
+ },
16
+ storageKey: {
17
+ type: String,
18
+ default: 'pankow-sidebar-width'
19
+ },
11
20
  });
12
21
 
22
+ const container = useTemplateRef('container');
13
23
  const sideBar = useTemplateRef('sideBar');
24
+ const backDrop = useTemplateRef('backDrop');
14
25
  const isVisible = ref(false);
26
+ const dragging = ref(false);
27
+ const resizing = ref(false);
28
+
29
+ const SIDEBAR_MIN_WIDTH = 200;
30
+ const SIDEBAR_MAX_WIDTH = 600;
31
+ let resizeStartX = 0;
32
+ let resizeStartWidth = 0;
15
33
 
16
34
  function open() {
17
35
  isVisible.value = true;
@@ -21,10 +39,199 @@ function close() {
21
39
  isVisible.value = false;
22
40
  }
23
41
 
24
- onMounted(() => {
25
- onSwipe(sideBar.value, (direction) => {
26
- if (direction === 'left') close();
42
+ function width() {
43
+ return sideBar.value ? sideBar.value.offsetWidth : 0;
44
+ }
45
+
46
+ function clearPendingSettle() {
47
+ if (settleOnEnd && sideBar.value) sideBar.value.removeEventListener('transitionend', settleOnEnd);
48
+ settleOnEnd = null;
49
+ if (settleTimer) {
50
+ clearTimeout(settleTimer);
51
+ settleTimer = null;
52
+ }
53
+ }
54
+
55
+ function dragStart() {
56
+ clearPendingSettle();
57
+ dragging.value = true;
58
+ }
59
+
60
+ function dragMove({ deltaX }) {
61
+ const offset = Math.max(-width(), Math.min(0, deltaX));
62
+ sideBar.value.style.transform = `translateX(${offset}px)`;
63
+ if (backDrop.value) backDrop.value.style.opacity = String(1 + offset / width());
64
+ }
65
+
66
+ function settle(shouldClose) {
67
+ dragging.value = false;
68
+ isVisible.value = shouldClose ? false : true;
69
+
70
+ nextTick(() => {
71
+ const el = sideBar.value;
72
+ const bd = backDrop.value;
73
+ if (!el) return;
74
+
75
+ clearPendingSettle();
76
+
77
+ el.style.transform = shouldClose ? 'translateX(-100%)' : 'translateX(0)';
78
+ if (bd) bd.style.opacity = '';
79
+
80
+ function cleanup() {
81
+ if (settleTimer) {
82
+ clearTimeout(settleTimer);
83
+ settleTimer = null;
84
+ }
85
+ if (settleOnEnd) {
86
+ el.removeEventListener('transitionend', onEnd);
87
+ settleOnEnd = null;
88
+ }
89
+ el.style.transform = '';
90
+ }
91
+
92
+ function onEnd(event) {
93
+ if (event.propertyName === 'transform') cleanup();
94
+ }
95
+
96
+ settleOnEnd = onEnd;
97
+ el.addEventListener('transitionend', onEnd);
98
+ settleTimer = setTimeout(cleanup, 300);
27
99
  });
100
+ }
101
+
102
+ function suppressClick() {
103
+ suppressBackdropClick = true;
104
+ setTimeout(() => { suppressBackdropClick = false; }, 0);
105
+ }
106
+
107
+ function onBackdropClick() {
108
+ if (suppressBackdropClick) {
109
+ suppressBackdropClick = false;
110
+ return;
111
+ }
112
+ close();
113
+ }
114
+
115
+ function dragEnd({ deltaX, velocity }) {
116
+ suppressClick();
117
+ const w = width();
118
+ settle(deltaX < -w / 2 || velocity < -0.5);
119
+ }
120
+
121
+ function dragCancel() {
122
+ suppressClick();
123
+ settle(false);
124
+ }
125
+
126
+ function resizeStart(event) {
127
+ if (!props.resizable) return;
128
+ resizing.value = true;
129
+ resizeStartX = event.clientX ?? event.touches?.[0]?.clientX ?? 0;
130
+ resizeStartWidth = container.value ? container.value.offsetWidth : 0;
131
+ document.body.style.cursor = 'col-resize';
132
+ document.body.style.userSelect = 'none';
133
+ event.preventDefault();
134
+ }
135
+
136
+ function resizeMove(event) {
137
+ if (!resizing.value || !container.value) return;
138
+ const x = event.clientX ?? event.touches?.[0]?.clientX ?? 0;
139
+ const width = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, resizeStartWidth + (x - resizeStartX)));
140
+ applyWidth(width);
141
+ }
142
+
143
+ function resizeEnd() {
144
+ if (!resizing.value) return;
145
+ resizing.value = false;
146
+ document.body.style.cursor = '';
147
+ document.body.style.userSelect = '';
148
+ saveWidth();
149
+ }
150
+
151
+ function applyWidth(width) {
152
+ if (!container.value) return;
153
+ container.value.style.width = `${width}px`;
154
+ container.value.style.minWidth = `${width}px`;
155
+ }
156
+
157
+ function saveWidth() {
158
+ if (!props.resizable || !container.value || typeof window === 'undefined') return;
159
+ try {
160
+ window.localStorage.setItem(props.storageKey, String(container.value.offsetWidth));
161
+ } catch (e) { /* ignore */ }
162
+ }
163
+
164
+ function restoreWidth() {
165
+ if (!props.resizable || typeof window === 'undefined') return;
166
+ try {
167
+ const stored = window.localStorage.getItem(props.storageKey);
168
+ if (!stored) return;
169
+ const width = parseInt(stored, 10);
170
+ if (Number.isNaN(width)) return;
171
+ applyWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, width)));
172
+ } catch (e) { /* ignore */ }
173
+ }
174
+
175
+ function clearResizeWidth() {
176
+ if (container.value) {
177
+ container.value.style.width = '';
178
+ container.value.style.minWidth = '';
179
+ }
180
+ }
181
+
182
+ let removePans = [];
183
+ let settleTimer = null;
184
+ let settleOnEnd = null;
185
+ let suppressBackdropClick = false;
186
+ const media = typeof window !== 'undefined' ? window.matchMedia('(max-width: 576px)') : null;
187
+
188
+ function setup() {
189
+ if (!media || !media.matches || !sideBar.value || removePans.length) return;
190
+ const handlers = {
191
+ onStart: dragStart,
192
+ onMove: dragMove,
193
+ onEnd: dragEnd,
194
+ onCancel: dragCancel
195
+ };
196
+ removePans.push(onPan(sideBar.value, handlers));
197
+ if (backDrop.value) removePans.push(onPan(backDrop.value, handlers));
198
+ }
199
+
200
+ function teardown() {
201
+ removePans.forEach((remove) => { remove(); });
202
+ removePans = [];
203
+ dragging.value = false;
204
+ clearPendingSettle();
205
+ if (sideBar.value) sideBar.value.style.transform = '';
206
+ if (backDrop.value) backDrop.value.style.opacity = '';
207
+ }
208
+
209
+ function onMediaChange() {
210
+ if (media && media.matches) {
211
+ setup();
212
+ clearResizeWidth();
213
+ } else {
214
+ teardown();
215
+ }
216
+ }
217
+
218
+ onMounted(() => {
219
+ setup();
220
+ restoreWidth();
221
+ document.addEventListener('mousemove', resizeMove);
222
+ document.addEventListener('mouseup', resizeEnd);
223
+ document.addEventListener('touchmove', resizeMove, { passive: false });
224
+ document.addEventListener('touchend', resizeEnd);
225
+ media && media.addEventListener('change', onMediaChange);
226
+ });
227
+
228
+ onUnmounted(() => {
229
+ media && media.removeEventListener('change', onMediaChange);
230
+ document.removeEventListener('mousemove', resizeMove);
231
+ document.removeEventListener('mouseup', resizeEnd);
232
+ document.removeEventListener('touchmove', resizeMove);
233
+ document.removeEventListener('touchend', resizeEnd);
234
+ teardown();
28
235
  });
29
236
 
30
237
  defineExpose({ open, close });
@@ -32,19 +239,26 @@ defineExpose({ open, close });
32
239
  </script>
33
240
 
34
241
  <template>
35
- <div class="pankow-sidebar-container" :class="{ 'pankow-sidebar-open': isVisible }">
36
- <Transition name="pankow-fade">
37
- <div class="pankow-sidebar-backdrop" @click="close"></div>
242
+ <div class="pankow-sidebar-container" ref="container" :class="{ 'pankow-sidebar-open': isVisible, 'pankow-sidebar-dragging': dragging, 'pankow-sidebar-resizing': resizing }">
243
+ <div class="pankow-sidebar-backdrop" ref="backDrop" @click="onBackdropClick"></div>
244
+ <Transition name="pankow-scale">
245
+ <div class="pankow-sidebar-close-action" v-if="isVisible" tabindex="0" role="button" aria-label="Close sidebar" @click="close()" @keydown.enter="close()" @keydown.space.prevent="close()"><i class="fa-solid fa-xmark"></i></div>
246
+ <div class="pankow-sidebar-open-action" v-else-if="showOpenAction" tabindex="0" role="button" aria-label="Open sidebar" @click="open()" @keydown.enter="open()" @keydown.space.prevent="open()"><i class="fa-solid fa-bars"></i></div>
38
247
  </Transition>
39
- <div class="pankow-sidebar" ref="sideBar" :class="{ 'pankow-sidebar-closed': !isVisible }">
40
- <Transition name="pankow-scale">
41
- <div class="pankow-sidebar-close-action" v-if="isVisible" tabindex="0" role="button" aria-label="Close sidebar" @click="close()" @keydown.enter="close()" @keydown.space.prevent="close()"><i class="fa-solid fa-xmark"></i></div>
42
- <div class="pankow-sidebar-open-action" v-else-if="showOpenAction" tabindex="0" role="button" aria-label="Open sidebar" @click="open()" @keydown.enter="open()" @keydown.space.prevent="open()"><i class="fa-solid fa-bars"></i></div>
43
- </Transition>
248
+ <div class="pankow-sidebar" ref="sideBar">
44
249
  <div class="pankow-sidebar-inner">
45
250
  <slot></slot>
46
251
  </div>
47
252
  </div>
253
+ <ResizeHandle
254
+ v-if="resizable"
255
+ class="pankow-sidebar-resize-handle"
256
+ orientation="horizontal"
257
+ contrast="light"
258
+ :dragging="resizing"
259
+ @mousedown="resizeStart"
260
+ @touchstart.prevent="resizeStart"
261
+ />
48
262
  </div>
49
263
  </template>
50
264
 
@@ -53,13 +267,28 @@ defineExpose({ open, close });
53
267
  .pankow-sidebar-container {
54
268
  display: block;
55
269
  height: 100%;
56
- min-width: 220px;
270
+ min-width: 280px;
271
+ position: relative;
272
+ }
273
+
274
+ .pankow-sidebar-resize-handle {
275
+ position: absolute;
276
+ top: 0;
277
+ right: 0;
278
+ bottom: 0;
279
+ z-index: 1;
280
+ }
281
+
282
+ .pankow-sidebar-resizing {
283
+ user-select: none;
57
284
  }
58
285
 
59
286
  .pankow-sidebar {
60
287
  display: block;
61
288
  height: 100%;
62
289
  overflow: auto;
290
+ background-color: #0a2e4c;
291
+ color: white;
63
292
  }
64
293
 
65
294
  .pankow-sidebar-inner {
@@ -91,7 +320,7 @@ defineExpose({ open, close });
91
320
 
92
321
  .pankow-sidebar-backdrop {
93
322
  display: none;
94
- position: absolute;
323
+ position: fixed;
95
324
  top: 0;
96
325
  bottom: 0;
97
326
  left: 0;
@@ -115,19 +344,34 @@ defineExpose({ open, close });
115
344
  }
116
345
 
117
346
  .pankow-sidebar {
118
- position: absolute;
119
- width: calc(70%);
347
+ position: fixed;
348
+ top: 0;
120
349
  left: 0;
121
- transition: left 250ms ease-in-out;
350
+ bottom: 0;
351
+ width: calc(70%);
352
+ transform: translateX(-100%);
353
+ transition: transform 250ms ease-in-out;
122
354
  }
123
355
 
124
- .pankow-sidebar-open .pankow-sidebar-backdrop {
356
+ .pankow-sidebar-open .pankow-sidebar {
357
+ transform: translateX(0);
358
+ }
359
+
360
+ .pankow-sidebar-dragging .pankow-sidebar,
361
+ .pankow-sidebar-dragging .pankow-sidebar-backdrop {
362
+ transition: none;
363
+ }
364
+
365
+ .pankow-sidebar-backdrop {
125
366
  display: block;
367
+ opacity: 0;
368
+ pointer-events: none;
369
+ transition: opacity 250ms ease-in-out;
126
370
  }
127
371
 
128
- .pankow-sidebar-closed {
129
- position: fixed;
130
- left: -600px; /* depends on media query */
372
+ .pankow-sidebar-open .pankow-sidebar-backdrop {
373
+ opacity: 1;
374
+ pointer-events: auto;
131
375
  }
132
376
 
133
377
  .pankow-sidebar-open-action {
@@ -141,6 +385,10 @@ defineExpose({ open, close });
141
385
  .pankow-sidebar-close-action {
142
386
  display: block;
143
387
  }
388
+
389
+ .pankow-sidebar-resize-handle {
390
+ display: none;
391
+ }
144
392
  }
145
393
 
146
- </style>
394
+ </style>
@@ -1,5 +1,6 @@
1
1
  <script setup>
2
2
  import { ref, computed, watch, onMounted, onUnmounted, useTemplateRef } from 'vue';
3
+ import ResizeHandle from './ResizeHandle.vue';
3
4
 
4
5
  const props = defineProps({
5
6
  orientation: {
@@ -142,20 +143,19 @@ const firstStyle = computed(() => {
142
143
  class="pankow-split-layout"
143
144
  :class="{
144
145
  'pankow-split-layout-dragging': isDragging,
145
- 'pankow-split-layout-vertical': !isHorizontal,
146
- 'pankow-split-layout-divider-opaque': opaque
146
+ 'pankow-split-layout-vertical': !isHorizontal
147
147
  }"
148
148
  >
149
149
  <div class="pankow-split-layout-first" :style="firstStyle">
150
150
  <slot name="left" />
151
151
  </div>
152
- <div
153
- class="pankow-split-layout-divider"
152
+ <ResizeHandle
153
+ :orientation="orientation"
154
+ :opaque="opaque"
155
+ :dragging="isDragging"
154
156
  @mousedown="startDrag"
155
157
  @touchstart.prevent="startDrag"
156
- >
157
- <div class="pankow-split-layout-handle"><span></span><span></span></div>
158
- </div>
158
+ />
159
159
  <div class="pankow-split-layout-second">
160
160
  <slot name="right" />
161
161
  </div>
@@ -186,75 +186,6 @@ const firstStyle = computed(() => {
186
186
  min-width: 0;
187
187
  }
188
188
 
189
- .pankow-split-layout-divider {
190
- background: transparent;
191
- position: relative;
192
- flex-shrink: 0;
193
- display: flex;
194
- align-items: center;
195
- justify-content: center;
196
- }
197
-
198
- .pankow-split-layout:not(.pankow-split-layout-vertical) .pankow-split-layout-divider {
199
- width: 6px;
200
- cursor: col-resize;
201
- }
202
-
203
- .pankow-split-layout-vertical .pankow-split-layout-divider {
204
- height: 6px;
205
- cursor: row-resize;
206
- }
207
-
208
- .pankow-split-layout-divider-opaque .pankow-split-layout-divider {
209
- background: var(--pankow-color-background);
210
- }
211
-
212
- .pankow-split-layout-divider:hover,
213
- .pankow-split-layout-dragging .pankow-split-layout-divider {
214
- background: var(--pankow-input-border-color);
215
- }
216
-
217
- .pankow-split-layout-handle {
218
- position: absolute;
219
- top: 50%;
220
- left: 50%;
221
- transform: translate(-50%, -50%);
222
- display: flex;
223
- gap: 2px;
224
- opacity: 0;
225
- transition: opacity 0.15s;
226
- pointer-events: none;
227
- }
228
-
229
- .pankow-split-layout:not(.pankow-split-layout-vertical) .pankow-split-layout-handle {
230
- flex-direction: column;
231
- }
232
-
233
- .pankow-split-layout-vertical .pankow-split-layout-handle {
234
- flex-direction: row;
235
- }
236
-
237
- .pankow-split-layout-divider:hover .pankow-split-layout-handle,
238
- .pankow-split-layout-dragging .pankow-split-layout-handle {
239
- opacity: 0.5;
240
- }
241
-
242
- .pankow-split-layout-handle span {
243
- display: block;
244
- border-radius: 1px;
245
- background: var(--pankow-color-text);
246
- }
247
-
248
- .pankow-split-layout:not(.pankow-split-layout-vertical) .pankow-split-layout-handle span {
249
- width: 2px;
250
- height: 16px;
251
- }
252
-
253
- .pankow-split-layout-vertical .pankow-split-layout-handle span {
254
- width: 16px;
255
- height: 2px;
256
- }
257
-
258
189
  .pankow-split-layout-second {
259
190
  flex: 1;
260
191
  min-width: 0;
package/gestures.js CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
- * Swipe handler
2
+ * Gesture handlers
3
3
  *
4
4
  * Usage:
5
5
  * ```
6
- * import { onSwipe } from 'pankow/gestures';
6
+ * import { onSwipe, onPan } from 'pankow/gestures';
7
7
  * onSwipe(element, (direction) => {});
8
+ * onPan(element, { onStart, onMove, onEnd, onCancel });
8
9
  * ```
9
10
  */
10
11
 
@@ -55,10 +56,101 @@ function onSwipe(elem, callback, threshold = 50) {
55
56
  elem.addEventListener('touchcancel', touchEnd);
56
57
  }
57
58
 
59
+ /**
60
+ * Registers pointer event listeners on an element for interactive dragging (pan).
61
+ * The drag is only "claimed" once the pointer moves past a small threshold and
62
+ * horizontal movement dominates vertical movement, so vertical scrolling inside
63
+ * the element still works as expected.
64
+ *
65
+ * Callbacks:
66
+ * onStart({ x, y }) - drag claimed (horizontal intent confirmed)
67
+ * onMove({ deltaX, deltaY }) - pointer moved (called continuously while dragging)
68
+ * onEnd({ deltaX, velocity }) - pointer released; velocity in px/ms (negative = leftward)
69
+ * onCancel() - pointer cancelled
70
+ *
71
+ * @param {HTMLElement} elem
72
+ * @param {{ onStart?: Function, onMove?: Function, onEnd?: Function, onCancel?: Function }} handlers
73
+ * @param {number} [threshold=10] - Minimum pixel distance before the drag is claimed
74
+ */
75
+ function onPan(elem, handlers = {}, threshold = 10) {
76
+ let pointerId = null;
77
+ let dragging = false;
78
+ let startX = 0;
79
+ let startY = 0;
80
+ let lastX = 0;
81
+ let lastTime = 0;
82
+ let lastVelocity = 0;
83
+
84
+ function pointerDown(event) {
85
+ if (dragging || !event.isPrimary) return;
86
+ pointerId = event.pointerId;
87
+ startX = event.clientX;
88
+ startY = event.clientY;
89
+ lastX = startX;
90
+ lastTime = event.timeStamp;
91
+ lastVelocity = 0;
92
+ }
93
+
94
+ function pointerMove(event) {
95
+ if (pointerId === null || event.pointerId !== pointerId) return;
96
+
97
+ const deltaX = event.clientX - startX;
98
+ const deltaY = event.clientY - startY;
99
+
100
+ if (!dragging) {
101
+ const absX = Math.abs(deltaX);
102
+ const absY = Math.abs(deltaY);
103
+ if (absX < threshold && absY < threshold) return;
104
+ if (absY > absX) return; // vertical intent, keep it for scrolling
105
+ dragging = true;
106
+ try { elem.setPointerCapture(pointerId); } catch (e) { /* noop */ }
107
+ if (typeof handlers.onStart === 'function') handlers.onStart({ x: startX, y: startY });
108
+ }
109
+
110
+ const now = event.timeStamp;
111
+ const dt = now - lastTime;
112
+ if (dt > 0) {
113
+ lastVelocity = (event.clientX - lastX) / dt;
114
+ }
115
+ lastX = event.clientX;
116
+ lastTime = now;
117
+
118
+ if (typeof handlers.onMove === 'function') handlers.onMove({ deltaX, deltaY });
119
+ }
120
+
121
+ function end(event) {
122
+ if (pointerId === null || event.pointerId !== pointerId) return;
123
+ pointerId = null;
124
+
125
+ if (dragging) {
126
+ dragging = false;
127
+ if (event.type === 'pointercancel') {
128
+ if (typeof handlers.onCancel === 'function') handlers.onCancel();
129
+ } else if (typeof handlers.onEnd === 'function') {
130
+ handlers.onEnd({ deltaX: event.clientX - startX, velocity: lastVelocity });
131
+ }
132
+ }
133
+ }
134
+
135
+ elem.addEventListener('pointerdown', pointerDown);
136
+ elem.addEventListener('pointermove', pointerMove);
137
+ elem.addEventListener('pointerup', end);
138
+ elem.addEventListener('pointercancel', end);
139
+
140
+ return function remove() {
141
+ elem.removeEventListener('pointerdown', pointerDown);
142
+ elem.removeEventListener('pointermove', pointerMove);
143
+ elem.removeEventListener('pointerup', end);
144
+ elem.removeEventListener('pointercancel', end);
145
+ };
146
+ }
147
+
58
148
  export default {
59
- onSwipe
149
+ onSwipe,
150
+ onPan
60
151
  };
61
152
 
62
153
  export {
63
- onSwipe
154
+ onSwipe,
155
+ onPan
64
156
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cloudron/pankow",
3
3
  "private": false,
4
- "version": "4.4.11",
4
+ "version": "4.4.13",
5
5
  "description": "Vue 3 UI component library for Cloudron apps",
6
6
  "main": "index.js",
7
7
  "types": "types/index.d.ts",
@@ -39,7 +39,7 @@
39
39
  "dependencies": {
40
40
  "@fontsource/inter": "^5.3.0",
41
41
  "@fortawesome/fontawesome-free": "^7.3.1",
42
- "filesize": "^11.0.22",
42
+ "filesize": "^11.0.23",
43
43
  "monaco-editor": "^0.56.0",
44
44
  "online-3d-viewer": "^0.18.0"
45
45
  },
@@ -48,14 +48,14 @@
48
48
  },
49
49
  "devDependencies": {
50
50
  "@highlightjs/vue-plugin": "^2.1.0",
51
- "@unhead/vue": "^3.3.1",
51
+ "@unhead/vue": "^3.4.0",
52
52
  "@vitejs/plugin-vue": "^6.0.8",
53
- "highlight.js": "^11.11.1",
53
+ "highlight.js": "^11.12.0",
54
54
  "typescript": "^7.0.2",
55
- "vite": "^8.2.1",
55
+ "vite": "^8.2.2",
56
56
  "vite-ssg": "^28.3.0",
57
- "vue": "^3.5.41",
58
- "vue-router": "^5.2.0"
57
+ "vue": "^3.5.42",
58
+ "vue-router": "^5.3.1"
59
59
  },
60
60
  "allowScripts": {
61
61
  "core-js": false
@@ -1,10 +1,11 @@
1
1
  /**
2
- * Swipe handler
2
+ * Gesture handlers
3
3
  *
4
4
  * Usage:
5
5
  * ```
6
- * import { onSwipe } from 'pankow/gestures';
6
+ * import { onSwipe, onPan } from 'pankow/gestures';
7
7
  * onSwipe(element, (direction) => {});
8
+ * onPan(element, { onStart, onMove, onEnd, onCancel });
8
9
  * ```
9
10
  */
10
11
  /**
@@ -14,8 +15,31 @@
14
15
  * @param {number} [threshold=50] - Minimum pixel distance to trigger a swipe
15
16
  */
16
17
  declare function onSwipe(elem: HTMLElement, callback: Function, threshold?: number): void;
18
+ /**
19
+ * Registers pointer event listeners on an element for interactive dragging (pan).
20
+ * The drag is only "claimed" once the pointer moves past a small threshold and
21
+ * horizontal movement dominates vertical movement, so vertical scrolling inside
22
+ * the element still works as expected.
23
+ *
24
+ * Callbacks:
25
+ * onStart({ x, y }) - drag claimed (horizontal intent confirmed)
26
+ * onMove({ deltaX, deltaY }) - pointer moved (called continuously while dragging)
27
+ * onEnd({ deltaX, velocity }) - pointer released; velocity in px/ms (negative = leftward)
28
+ * onCancel() - pointer cancelled
29
+ *
30
+ * @param {HTMLElement} elem
31
+ * @param {{ onStart?: Function, onMove?: Function, onEnd?: Function, onCancel?: Function }} handlers
32
+ * @param {number} [threshold=10] - Minimum pixel distance before the drag is claimed
33
+ */
34
+ declare function onPan(elem: HTMLElement, handlers?: {
35
+ onStart?: Function;
36
+ onMove?: Function;
37
+ onEnd?: Function;
38
+ onCancel?: Function;
39
+ }, threshold?: number): () => void;
17
40
  declare const _default: {
18
41
  onSwipe: typeof onSwipe;
42
+ onPan: typeof onPan;
19
43
  };
20
44
  export default _default;
21
- export { onSwipe };
45
+ export { onSwipe, onPan };
@@ -2,10 +2,10 @@
2
2
  <template>
3
3
  <div ref="rootEl" @mousemove="onMouseMove" class="full-page" @keyup.esc.stop="onClose" @keyup.left.stop="onPrev" @keyup.right.stop="onNext" tabindex="1" :style="{ opacity: dismissDistance ? (200 - dismissDistance)/100 : 1 }">
4
4
  <div class="image-layer" ref="imageContainer" @scroll="onScroll($event)">
5
- <div class="image" v-for="(entry, index) in entries" :key="entry.id" v-bind:id="`image${index}`" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd" :style="{ marginTop: dismissDistance ? (dismissDistance + 'px') : 0 }">
6
- <img :src="entry.fullFileUrl" v-if="getFileTypeGroup(entry) === 'image'" draggable="false"/>
7
- <video v-if="getFileTypeGroup(entry) !== 'image'" :ref="addVideoRef" controls>
8
- <source :src="entry.fullFileUrl" />
5
+ <div class="image" v-for="(imageEntry, index) in entries" :key="imageEntry.id" :id="`image${index}`" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd" :style="{ marginTop: dismissDistance ? (dismissDistance + 'px') : 0 }">
6
+ <img :src="imageEntry.fullFileUrl" v-if="getFileTypeGroup(imageEntry) === 'image'" draggable="false" @load="onImageLoad(index)"/>
7
+ <video v-if="getFileTypeGroup(imageEntry) !== 'image'" :ref="addVideoRef" controls>
8
+ <source :src="imageEntry.fullFileUrl" />
9
9
  </video>
10
10
  </div>
11
11
  </div>
@@ -19,7 +19,7 @@
19
19
 
20
20
  <script setup>
21
21
 
22
- import { ref, computed, onBeforeUpdate, onMounted, useTemplateRef } from 'vue';
22
+ import { ref, computed, onBeforeUpdate, useTemplateRef } from 'vue';
23
23
  import { getFileTypeGroup } from '../utils.js';
24
24
  import Button from '../components/Button.vue';
25
25
 
@@ -129,6 +129,18 @@ function onTouchEnd() {
129
129
  dismissDistance.value = 0;
130
130
  }
131
131
 
132
+ function scrollToCurrent(behavior) {
133
+ const el = document.getElementById(`image${curIndex.value}`);
134
+ if (el && imageContainer.value) imageContainer.value.scrollTo({ left: el.offsetLeft, behavior });
135
+ }
136
+
137
+ function onImageLoad(index) {
138
+ if (lockScroll.value && index === curIndex.value) {
139
+ scrollToCurrent('auto');
140
+ lockScroll.value = false;
141
+ }
142
+ }
143
+
132
144
  function open(e, fileList, instant = true) {
133
145
  if (!e || e.isDirectory || !canHandle(e)) return;
134
146
 
@@ -139,8 +151,8 @@ function open(e, fileList, instant = true) {
139
151
  curIndex.value = fileList.findIndex((item) => item.fileName === e.fileName);
140
152
 
141
153
  setTimeout(() => {
142
- document.getElementById(`image${curIndex.value}`).scrollIntoView({ behavior: instant ? 'instant' : 'smooth' });
143
- rootEl.value.focus();
154
+ scrollToCurrent(instant ? 'auto' : 'smooth');
155
+ rootEl.value.focus({ preventScroll: true });
144
156
  }, 0);
145
157
 
146
158
  setTimeout(() => {
@@ -156,7 +168,7 @@ function onDownload() {
156
168
  function onClose() {
157
169
  emit('close');
158
170
 
159
- if (videoElements.value && videoElements.value.length) videoElements.value.forEach(e => e.pause());
171
+ if (videoElements.value && videoElements.value.length) videoElements.value.forEach((e) => { e.pause(); });
160
172
 
161
173
  setTimeout(() => {
162
174
  entry.value = {};
@@ -218,12 +230,19 @@ defineExpose({ canHandle, open });
218
230
 
219
231
  .image-layer {
220
232
  display: flex;
221
- position: fixed;
233
+ position: absolute;
234
+ top: 0;
235
+ left: 0;
222
236
  height: 100%;
223
237
  width: 100%;
224
238
  overflow-y: hidden;
225
239
  overflow-x: auto;
226
240
  scroll-snap-type: x mandatory;
241
+ scrollbar-width: none;
242
+ }
243
+
244
+ .image-layer::-webkit-scrollbar {
245
+ display: none;
227
246
  }
228
247
 
229
248
  .image {
@@ -237,6 +256,7 @@ defineExpose({ canHandle, open });
237
256
  }
238
257
 
239
258
  img, video {
259
+ display: block;
240
260
  object-fit: contain;
241
261
  width: 100%;
242
262
  height: 100%;