@cloudron/pankow 4.4.11 → 4.4.12

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.
@@ -1,7 +1,7 @@
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
5
 
6
6
  defineProps({
7
7
  showOpenAction: {
@@ -11,7 +11,9 @@ defineProps({
11
11
  });
12
12
 
13
13
  const sideBar = useTemplateRef('sideBar');
14
+ const backDrop = useTemplateRef('backDrop');
14
15
  const isVisible = ref(false);
16
+ const dragging = ref(false);
15
17
 
16
18
  function open() {
17
19
  isVisible.value = true;
@@ -21,10 +23,130 @@ function close() {
21
23
  isVisible.value = false;
22
24
  }
23
25
 
24
- onMounted(() => {
25
- onSwipe(sideBar.value, (direction) => {
26
- if (direction === 'left') close();
26
+ function width() {
27
+ return sideBar.value ? sideBar.value.offsetWidth : 0;
28
+ }
29
+
30
+ function clearPendingSettle() {
31
+ if (settleOnEnd && sideBar.value) sideBar.value.removeEventListener('transitionend', settleOnEnd);
32
+ settleOnEnd = null;
33
+ if (settleTimer) {
34
+ clearTimeout(settleTimer);
35
+ settleTimer = null;
36
+ }
37
+ }
38
+
39
+ function dragStart() {
40
+ clearPendingSettle();
41
+ dragging.value = true;
42
+ }
43
+
44
+ function dragMove({ deltaX }) {
45
+ const offset = Math.max(-width(), Math.min(0, deltaX));
46
+ sideBar.value.style.transform = `translateX(${offset}px)`;
47
+ if (backDrop.value) backDrop.value.style.opacity = String(1 + offset / width());
48
+ }
49
+
50
+ function settle(shouldClose) {
51
+ dragging.value = false;
52
+ isVisible.value = shouldClose ? false : true;
53
+
54
+ nextTick(() => {
55
+ const el = sideBar.value;
56
+ const bd = backDrop.value;
57
+ if (!el) return;
58
+
59
+ clearPendingSettle();
60
+
61
+ el.style.transform = shouldClose ? 'translateX(-100%)' : 'translateX(0)';
62
+ if (bd) bd.style.opacity = '';
63
+
64
+ function cleanup() {
65
+ if (settleTimer) {
66
+ clearTimeout(settleTimer);
67
+ settleTimer = null;
68
+ }
69
+ if (settleOnEnd) {
70
+ el.removeEventListener('transitionend', onEnd);
71
+ settleOnEnd = null;
72
+ }
73
+ el.style.transform = '';
74
+ }
75
+
76
+ function onEnd(event) {
77
+ if (event.propertyName === 'transform') cleanup();
78
+ }
79
+
80
+ settleOnEnd = onEnd;
81
+ el.addEventListener('transitionend', onEnd);
82
+ settleTimer = setTimeout(cleanup, 300);
27
83
  });
84
+ }
85
+
86
+ function suppressClick() {
87
+ suppressBackdropClick = true;
88
+ setTimeout(() => { suppressBackdropClick = false; }, 0);
89
+ }
90
+
91
+ function onBackdropClick() {
92
+ if (suppressBackdropClick) {
93
+ suppressBackdropClick = false;
94
+ return;
95
+ }
96
+ close();
97
+ }
98
+
99
+ function dragEnd({ deltaX, velocity }) {
100
+ suppressClick();
101
+ const w = width();
102
+ settle(deltaX < -w / 2 || velocity < -0.5);
103
+ }
104
+
105
+ function dragCancel() {
106
+ suppressClick();
107
+ settle(false);
108
+ }
109
+
110
+ let removePans = [];
111
+ let settleTimer = null;
112
+ let settleOnEnd = null;
113
+ let suppressBackdropClick = false;
114
+ const media = typeof window !== 'undefined' ? window.matchMedia('(max-width: 576px)') : null;
115
+
116
+ function setup() {
117
+ if (!media || !media.matches || !sideBar.value || removePans.length) return;
118
+ const handlers = {
119
+ onStart: dragStart,
120
+ onMove: dragMove,
121
+ onEnd: dragEnd,
122
+ onCancel: dragCancel
123
+ };
124
+ removePans.push(onPan(sideBar.value, handlers));
125
+ if (backDrop.value) removePans.push(onPan(backDrop.value, handlers));
126
+ }
127
+
128
+ function teardown() {
129
+ removePans.forEach((remove) => { remove(); });
130
+ removePans = [];
131
+ dragging.value = false;
132
+ clearPendingSettle();
133
+ if (sideBar.value) sideBar.value.style.transform = '';
134
+ if (backDrop.value) backDrop.value.style.opacity = '';
135
+ }
136
+
137
+ function onMediaChange() {
138
+ if (media && media.matches) setup();
139
+ else teardown();
140
+ }
141
+
142
+ onMounted(() => {
143
+ setup();
144
+ media && media.addEventListener('change', onMediaChange);
145
+ });
146
+
147
+ onUnmounted(() => {
148
+ media && media.removeEventListener('change', onMediaChange);
149
+ teardown();
28
150
  });
29
151
 
30
152
  defineExpose({ open, close });
@@ -32,15 +154,13 @@ defineExpose({ open, close });
32
154
  </script>
33
155
 
34
156
  <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>
157
+ <div class="pankow-sidebar-container" :class="{ 'pankow-sidebar-open': isVisible, 'pankow-sidebar-dragging': dragging }">
158
+ <div class="pankow-sidebar-backdrop" ref="backDrop" @click="onBackdropClick"></div>
159
+ <Transition name="pankow-scale">
160
+ <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>
161
+ <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
162
  </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>
163
+ <div class="pankow-sidebar" ref="sideBar">
44
164
  <div class="pankow-sidebar-inner">
45
165
  <slot></slot>
46
166
  </div>
@@ -60,6 +180,8 @@ defineExpose({ open, close });
60
180
  display: block;
61
181
  height: 100%;
62
182
  overflow: auto;
183
+ background-color: #0a2e4c;
184
+ color: white;
63
185
  }
64
186
 
65
187
  .pankow-sidebar-inner {
@@ -91,7 +213,7 @@ defineExpose({ open, close });
91
213
 
92
214
  .pankow-sidebar-backdrop {
93
215
  display: none;
94
- position: absolute;
216
+ position: fixed;
95
217
  top: 0;
96
218
  bottom: 0;
97
219
  left: 0;
@@ -115,19 +237,34 @@ defineExpose({ open, close });
115
237
  }
116
238
 
117
239
  .pankow-sidebar {
118
- position: absolute;
119
- width: calc(70%);
240
+ position: fixed;
241
+ top: 0;
120
242
  left: 0;
121
- transition: left 250ms ease-in-out;
243
+ bottom: 0;
244
+ width: calc(70%);
245
+ transform: translateX(-100%);
246
+ transition: transform 250ms ease-in-out;
122
247
  }
123
248
 
124
- .pankow-sidebar-open .pankow-sidebar-backdrop {
249
+ .pankow-sidebar-open .pankow-sidebar {
250
+ transform: translateX(0);
251
+ }
252
+
253
+ .pankow-sidebar-dragging .pankow-sidebar,
254
+ .pankow-sidebar-dragging .pankow-sidebar-backdrop {
255
+ transition: none;
256
+ }
257
+
258
+ .pankow-sidebar-backdrop {
125
259
  display: block;
260
+ opacity: 0;
261
+ pointer-events: none;
262
+ transition: opacity 250ms ease-in-out;
126
263
  }
127
264
 
128
- .pankow-sidebar-closed {
129
- position: fixed;
130
- left: -600px; /* depends on media query */
265
+ .pankow-sidebar-open .pankow-sidebar-backdrop {
266
+ opacity: 1;
267
+ pointer-events: auto;
131
268
  }
132
269
 
133
270
  .pankow-sidebar-open-action {
@@ -143,4 +280,4 @@ defineExpose({ open, close });
143
280
  }
144
281
  }
145
282
 
146
- </style>
283
+ </style>
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.12",
5
5
  "description": "Vue 3 UI component library for Cloudron apps",
6
6
  "main": "index.js",
7
7
  "types": "types/index.d.ts",
@@ -48,13 +48,13 @@
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",
57
+ "vue": "^3.5.42",
58
58
  "vue-router": "^5.2.0"
59
59
  },
60
60
  "allowScripts": {
@@ -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%;