@duffcloudservices/cms 0.1.5 → 0.1.7
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/package.json +1 -1
- package/src/components/PreviewRibbon.vue +267 -102
package/package.json
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<Teleport to="body">
|
|
3
|
-
<div v-if="isVisible" class="dcs-ribbon-container">
|
|
3
|
+
<div v-if="isVisible" :class="['dcs-ribbon-container', `dcs-ribbon-container--${side}`]">
|
|
4
4
|
<svg
|
|
5
|
-
ref="ribbonSvgRef"
|
|
6
5
|
:viewBox="`0 0 ${SVG_SIZE} ${SVG_SIZE}`"
|
|
7
6
|
class="dcs-ribbon-svg"
|
|
8
7
|
>
|
|
9
8
|
<defs>
|
|
10
|
-
<path id="dcs-ribbon-text-path" :d="
|
|
11
|
-
<linearGradient
|
|
9
|
+
<path id="dcs-ribbon-text-path" :d="textPath" />
|
|
10
|
+
<linearGradient
|
|
11
|
+
id="dcs-ribbon-grad"
|
|
12
|
+
:x1="side === 'left' ? '0%' : '100%'"
|
|
13
|
+
y1="100%"
|
|
14
|
+
:x2="side === 'left' ? '100%' : '0%'"
|
|
15
|
+
y2="0%"
|
|
16
|
+
>
|
|
12
17
|
<stop offset="0%" stop-color="#1a1a2e" />
|
|
13
18
|
<stop offset="40%" stop-color="#16213e" />
|
|
14
19
|
<stop offset="100%" stop-color="#0f3460" />
|
|
@@ -19,7 +24,11 @@
|
|
|
19
24
|
</defs>
|
|
20
25
|
|
|
21
26
|
<!-- Drop shadow (offset copy of band) -->
|
|
22
|
-
<path
|
|
27
|
+
<path
|
|
28
|
+
:d="bandPath"
|
|
29
|
+
fill="rgba(0,0,0,0.2)"
|
|
30
|
+
:transform="side === 'left' ? 'translate(2,3)' : 'translate(-2,3)'"
|
|
31
|
+
/>
|
|
23
32
|
|
|
24
33
|
<!-- Main ribbon band -->
|
|
25
34
|
<path
|
|
@@ -27,8 +36,7 @@
|
|
|
27
36
|
fill="url(#dcs-ribbon-grad)"
|
|
28
37
|
stroke="rgba(255,255,255,0.08)"
|
|
29
38
|
stroke-width="1"
|
|
30
|
-
|
|
31
|
-
@pointerdown.stop.prevent="onRibbonPointerDown"
|
|
39
|
+
class="dcs-ribbon-band"
|
|
32
40
|
/>
|
|
33
41
|
|
|
34
42
|
<!-- Decorative stitched borders -->
|
|
@@ -57,6 +65,20 @@
|
|
|
57
65
|
</textPath>
|
|
58
66
|
</text>
|
|
59
67
|
</svg>
|
|
68
|
+
|
|
69
|
+
<!--
|
|
70
|
+
Touch overlay: transparent HTML <div> sitting on top of the SVG.
|
|
71
|
+
Android Chrome ignores touch-action: none on SVG <path> elements
|
|
72
|
+
because the compositor only checks HTML elements in the hit chain.
|
|
73
|
+
This HTML div with clip-path creates a reliable touch-action: none
|
|
74
|
+
hit area over the ribbon band that works on all mobile browsers.
|
|
75
|
+
-->
|
|
76
|
+
<div
|
|
77
|
+
ref="touchOverlayRef"
|
|
78
|
+
:class="['dcs-ribbon-touch', { 'dcs-ribbon-touch--dragging': isDragging }]"
|
|
79
|
+
:style="touchClipStyle"
|
|
80
|
+
@pointerdown.stop.prevent="onPointerDown"
|
|
81
|
+
/>
|
|
60
82
|
</div>
|
|
61
83
|
</Teleport>
|
|
62
84
|
</template>
|
|
@@ -110,6 +132,9 @@ const STI = 4
|
|
|
110
132
|
/** The preview domain where the ribbon should be displayed */
|
|
111
133
|
const PREVIEW_DOMAIN = 'preview.duffcloudservices.com'
|
|
112
134
|
|
|
135
|
+
/** localStorage key for persisting which corner the ribbon is docked to */
|
|
136
|
+
const STORAGE_KEY = 'dcs-preview-ribbon-side'
|
|
137
|
+
|
|
113
138
|
/* ------------------------------------------------------------------ */
|
|
114
139
|
/* Visibility gate */
|
|
115
140
|
/* ------------------------------------------------------------------ */
|
|
@@ -134,9 +159,22 @@ function shouldShow(): boolean {
|
|
|
134
159
|
return true
|
|
135
160
|
}
|
|
136
161
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
162
|
+
/* ------------------------------------------------------------------ */
|
|
163
|
+
/* Side state (which corner the ribbon is docked to, persisted) */
|
|
164
|
+
/* ------------------------------------------------------------------ */
|
|
165
|
+
const side = ref<'left' | 'right'>('left')
|
|
166
|
+
|
|
167
|
+
function loadSide(): 'left' | 'right' {
|
|
168
|
+
try {
|
|
169
|
+
const s = localStorage.getItem(STORAGE_KEY)
|
|
170
|
+
if (s === 'left' || s === 'right') return s
|
|
171
|
+
} catch { /* noop */ }
|
|
172
|
+
return 'left'
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function saveSide(s: 'left' | 'right') {
|
|
176
|
+
try { localStorage.setItem(STORAGE_KEY, s) } catch { /* noop */ }
|
|
177
|
+
}
|
|
140
178
|
|
|
141
179
|
/* ------------------------------------------------------------------ */
|
|
142
180
|
/* Version display */
|
|
@@ -147,11 +185,26 @@ const displayVersion = computed(() =>
|
|
|
147
185
|
resolvedVersion.value ? ` v${resolvedVersion.value}` : '',
|
|
148
186
|
)
|
|
149
187
|
|
|
150
|
-
//
|
|
188
|
+
// Sync prop changes
|
|
189
|
+
watch(
|
|
190
|
+
() => props.version,
|
|
191
|
+
(v) => {
|
|
192
|
+
if (v !== null && v !== undefined) resolvedVersion.value = v
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
/* ------------------------------------------------------------------ */
|
|
197
|
+
/* Lifecycle */
|
|
198
|
+
/* ------------------------------------------------------------------ */
|
|
151
199
|
onMounted(async () => {
|
|
200
|
+
isVisible.value = shouldShow()
|
|
201
|
+
if (!isVisible.value) return
|
|
202
|
+
|
|
203
|
+
side.value = loadSide()
|
|
204
|
+
|
|
205
|
+
// Resolve version
|
|
152
206
|
if (resolvedVersion.value) return
|
|
153
207
|
|
|
154
|
-
// Check Vite env first
|
|
155
208
|
try {
|
|
156
209
|
const envVersion = (import.meta.env as Record<string, string | undefined>)
|
|
157
210
|
.VITE_SITE_VERSION
|
|
@@ -163,8 +216,6 @@ onMounted(async () => {
|
|
|
163
216
|
/* noop */
|
|
164
217
|
}
|
|
165
218
|
|
|
166
|
-
// Fallback: fetch from API (reuse useSiteVersion logic inline to avoid
|
|
167
|
-
// coupling lifecycle hooks – the ribbon may mount before Pinia is ready)
|
|
168
219
|
try {
|
|
169
220
|
const slug =
|
|
170
221
|
(import.meta.env as Record<string, string | undefined>).VITE_SITE_SLUG ??
|
|
@@ -186,14 +237,6 @@ onMounted(async () => {
|
|
|
186
237
|
}
|
|
187
238
|
})
|
|
188
239
|
|
|
189
|
-
// Sync prop changes
|
|
190
|
-
watch(
|
|
191
|
-
() => props.version,
|
|
192
|
-
(v) => {
|
|
193
|
-
if (v !== null && v !== undefined) resolvedVersion.value = v
|
|
194
|
-
},
|
|
195
|
-
)
|
|
196
|
-
|
|
197
240
|
/* ------------------------------------------------------------------ */
|
|
198
241
|
/* Elastic stretch state (control-point displacement) */
|
|
199
242
|
/* ------------------------------------------------------------------ */
|
|
@@ -201,111 +244,203 @@ const stretchX = ref(0)
|
|
|
201
244
|
const stretchY = ref(0)
|
|
202
245
|
const isDragging = ref(false)
|
|
203
246
|
|
|
204
|
-
/**
|
|
205
|
-
|
|
206
|
-
|
|
247
|
+
/**
|
|
248
|
+
* Absolute SVG-space control point for the band center-line.
|
|
249
|
+
* Left side default: (CP0, CP0) = (90, 90)
|
|
250
|
+
* Right side default: (SVG_SIZE - CP0, CP0) = (170, 90)
|
|
251
|
+
* stretchX/Y displace the control point in screen-mapped SVG units.
|
|
252
|
+
*/
|
|
253
|
+
const cx = computed(() =>
|
|
254
|
+
side.value === 'left'
|
|
255
|
+
? CP0 + stretchX.value
|
|
256
|
+
: SVG_SIZE - CP0 + stretchX.value,
|
|
257
|
+
)
|
|
258
|
+
const cy = computed(() => CP0 + stretchY.value)
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Perpendicular sign: for left-side ribbon the outward perpendicular
|
|
262
|
+
* direction is (+1, +1); for right-side it is (-1, +1).
|
|
263
|
+
* This determines how CPO is applied to get outer/inner band edges.
|
|
264
|
+
*/
|
|
265
|
+
const ps = computed(() => (side.value === 'left' ? 1 : -1))
|
|
207
266
|
|
|
208
267
|
/* ------------------------------------------------------------------ */
|
|
209
|
-
/* SVG path computation
|
|
268
|
+
/* SVG path computation (side-aware, with elastic control-point) */
|
|
210
269
|
/* ------------------------------------------------------------------ */
|
|
211
270
|
|
|
212
|
-
/**
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
cpX.value + CPO - 3,
|
|
223
|
-
cpY.value + CPO - 3,
|
|
224
|
-
EP + HW - STI,
|
|
225
|
-
),
|
|
226
|
-
)
|
|
227
|
-
|
|
228
|
-
const innerStitchPath = computed(() =>
|
|
229
|
-
q(
|
|
230
|
-
EP - HW + STI,
|
|
231
|
-
cpX.value - CPO + 3,
|
|
232
|
-
cpY.value - CPO + 3,
|
|
233
|
-
EP - HW + STI,
|
|
234
|
-
),
|
|
235
|
-
)
|
|
271
|
+
/**
|
|
272
|
+
* Text center-line. On the right side the path direction is reversed
|
|
273
|
+
* so that textPath renders left-to-right (readable).
|
|
274
|
+
*/
|
|
275
|
+
const textPath = computed(() => {
|
|
276
|
+
if (side.value === 'left') {
|
|
277
|
+
return `M 0,${EP} Q ${cx.value},${cy.value} ${EP},0`
|
|
278
|
+
}
|
|
279
|
+
return `M ${SVG_SIZE - EP},0 Q ${cx.value},${cy.value} ${SVG_SIZE},${EP}`
|
|
280
|
+
})
|
|
236
281
|
|
|
237
282
|
const bandPath = computed(() => {
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
const
|
|
245
|
-
|
|
283
|
+
const p = ps.value
|
|
284
|
+
// Outer edge control point (further from corner)
|
|
285
|
+
const ocx = cx.value + p * CPO
|
|
286
|
+
const ocy = cy.value + CPO
|
|
287
|
+
// Inner edge control point (closer to corner)
|
|
288
|
+
const icx = cx.value - p * CPO
|
|
289
|
+
const icy = cy.value - CPO
|
|
290
|
+
|
|
291
|
+
if (side.value === 'left') {
|
|
292
|
+
return [
|
|
293
|
+
`M 0,${EP + HW} Q ${ocx},${ocy} ${EP + HW},0`,
|
|
294
|
+
`L ${EP - HW},0`,
|
|
295
|
+
`Q ${icx},${icy} 0,${EP - HW}`,
|
|
296
|
+
`Z`,
|
|
297
|
+
].join(' ')
|
|
298
|
+
}
|
|
246
299
|
|
|
247
300
|
return [
|
|
248
|
-
|
|
249
|
-
`
|
|
250
|
-
|
|
251
|
-
`L ${iX},0`,
|
|
252
|
-
// Inner curve (top-edge → left-edge, reversed direction)
|
|
253
|
-
`Q ${iCx},${iCy} 0,${iY}`,
|
|
301
|
+
`M ${SVG_SIZE},${EP + HW} Q ${ocx},${ocy} ${SVG_SIZE - EP - HW},0`,
|
|
302
|
+
`L ${SVG_SIZE - EP + HW},0`,
|
|
303
|
+
`Q ${icx},${icy} ${SVG_SIZE},${EP - HW}`,
|
|
254
304
|
`Z`,
|
|
255
305
|
].join(' ')
|
|
256
306
|
})
|
|
257
307
|
|
|
308
|
+
const outerStitchPath = computed(() => {
|
|
309
|
+
const p = ps.value
|
|
310
|
+
const scx = cx.value + p * (CPO - 3)
|
|
311
|
+
const scy = cy.value + (CPO - 3)
|
|
312
|
+
const d = EP + HW - STI
|
|
313
|
+
|
|
314
|
+
if (side.value === 'left') {
|
|
315
|
+
return `M 0,${d} Q ${scx},${scy} ${d},0`
|
|
316
|
+
}
|
|
317
|
+
return `M ${SVG_SIZE},${d} Q ${scx},${scy} ${SVG_SIZE - d},0`
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
const innerStitchPath = computed(() => {
|
|
321
|
+
const p = ps.value
|
|
322
|
+
const scx = cx.value - p * (CPO - 3)
|
|
323
|
+
const scy = cy.value - (CPO - 3)
|
|
324
|
+
const d = EP - HW + STI
|
|
325
|
+
|
|
326
|
+
if (side.value === 'left') {
|
|
327
|
+
return `M 0,${d} Q ${scx},${scy} ${d},0`
|
|
328
|
+
}
|
|
329
|
+
return `M ${SVG_SIZE},${d} Q ${scx},${scy} ${SVG_SIZE - d},0`
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
/* ------------------------------------------------------------------ */
|
|
333
|
+
/* Touch overlay clip-path */
|
|
258
334
|
/* ------------------------------------------------------------------ */
|
|
259
|
-
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Clip-path polygon that creates an invisible hit area over the ribbon
|
|
338
|
+
* band, wider than the visible band for comfortable touch targeting.
|
|
339
|
+
* This is on an HTML <div>, so touch-action: none is guaranteed to work
|
|
340
|
+
* on Android Chrome (unlike SVG <path> elements).
|
|
341
|
+
*/
|
|
342
|
+
const touchClipStyle = computed(() => {
|
|
343
|
+
if (side.value === 'left') {
|
|
344
|
+
// Quadrilateral covering the band diagonal: bottom-left → top-right
|
|
345
|
+
return { clipPath: 'polygon(0% 100%, 100% 0%, 65% 0%, 0% 65%)' }
|
|
346
|
+
}
|
|
347
|
+
// Right side: mirrored
|
|
348
|
+
return { clipPath: 'polygon(100% 100%, 0% 0%, 35% 0%, 100% 65%)' }
|
|
349
|
+
})
|
|
350
|
+
|
|
260
351
|
/* ------------------------------------------------------------------ */
|
|
261
|
-
|
|
262
|
-
|
|
352
|
+
/* Drag: elastic control-point stretch + flip on midpoint crossing */
|
|
353
|
+
/* ------------------------------------------------------------------ */
|
|
354
|
+
const touchOverlayRef = ref<HTMLDivElement | null>(null)
|
|
355
|
+
let dragStart: { x: number; y: number } | null = null
|
|
263
356
|
let springRaf = 0
|
|
264
|
-
let capturedTarget: Element | null = null
|
|
265
357
|
|
|
266
|
-
|
|
358
|
+
/**
|
|
359
|
+
* Get the rendered pixel size of the SVG element.
|
|
360
|
+
* Used to convert screen-space drag deltas to SVG-space units.
|
|
361
|
+
*/
|
|
362
|
+
function getSvgPixelSize(): number {
|
|
363
|
+
return window.innerWidth <= 640 ? 200 : 260
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function onPointerDown(e: PointerEvent) {
|
|
267
367
|
// Cancel any in-progress spring animation
|
|
268
368
|
if (springRaf) {
|
|
269
369
|
cancelAnimationFrame(springRaf)
|
|
270
370
|
springRaf = 0
|
|
271
371
|
}
|
|
272
372
|
|
|
273
|
-
// Capture the pointer on the
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
|
|
373
|
+
// Capture the pointer on the HTML overlay div.
|
|
374
|
+
// Unlike SVG <path> elements, HTML elements reliably support
|
|
375
|
+
// setPointerCapture on Android Chrome, ensuring all subsequent
|
|
376
|
+
// pointer events are delivered even when the finger moves outside.
|
|
377
|
+
const target = e.currentTarget as HTMLElement
|
|
277
378
|
target.setPointerCapture(e.pointerId)
|
|
278
|
-
capturedTarget = target
|
|
279
379
|
|
|
280
380
|
isDragging.value = true
|
|
281
|
-
|
|
381
|
+
dragStart = { x: e.clientX, y: e.clientY }
|
|
382
|
+
|
|
282
383
|
globalThis.addEventListener('pointermove', onPointerMove)
|
|
283
384
|
globalThis.addEventListener('pointerup', onPointerUp)
|
|
284
|
-
globalThis.addEventListener('pointercancel',
|
|
385
|
+
globalThis.addEventListener('pointercancel', onPointerUp)
|
|
285
386
|
}
|
|
286
387
|
|
|
287
388
|
function onPointerMove(e: PointerEvent) {
|
|
288
|
-
if (!
|
|
289
|
-
|
|
290
|
-
|
|
389
|
+
if (!dragStart) return
|
|
390
|
+
|
|
391
|
+
// Convert screen-space drag delta to SVG-space units
|
|
392
|
+
const scale = SVG_SIZE / getSvgPixelSize()
|
|
393
|
+
stretchX.value = (e.clientX - dragStart.x) * scale
|
|
394
|
+
stretchY.value = (e.clientY - dragStart.y) * scale
|
|
395
|
+
|
|
396
|
+
// Flip when the pointer crosses the viewport midpoint
|
|
397
|
+
const mid = window.innerWidth / 2
|
|
398
|
+
if (side.value === 'left' && e.clientX > mid) {
|
|
399
|
+
triggerFlip('right', e)
|
|
400
|
+
} else if (side.value === 'right' && e.clientX < mid) {
|
|
401
|
+
triggerFlip('left', e)
|
|
402
|
+
}
|
|
291
403
|
}
|
|
292
404
|
|
|
293
|
-
function onPointerUp(e
|
|
294
|
-
if (
|
|
295
|
-
try {
|
|
405
|
+
function onPointerUp(e: PointerEvent) {
|
|
406
|
+
if (touchOverlayRef.value) {
|
|
407
|
+
try { touchOverlayRef.value.releasePointerCapture(e.pointerId) } catch { /* already released */ }
|
|
296
408
|
}
|
|
297
|
-
capturedTarget = null
|
|
298
409
|
isDragging.value = false
|
|
299
|
-
|
|
410
|
+
dragStart = null
|
|
411
|
+
removeListeners()
|
|
412
|
+
animateSnapBack()
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function removeListeners() {
|
|
300
416
|
globalThis.removeEventListener('pointermove', onPointerMove)
|
|
301
417
|
globalThis.removeEventListener('pointerup', onPointerUp)
|
|
302
|
-
globalThis.removeEventListener('pointercancel',
|
|
303
|
-
animateSnapBack()
|
|
418
|
+
globalThis.removeEventListener('pointercancel', onPointerUp)
|
|
304
419
|
}
|
|
305
420
|
|
|
306
|
-
/**
|
|
307
|
-
|
|
308
|
-
|
|
421
|
+
/**
|
|
422
|
+
* Snap the ribbon to the opposite corner when the pointer crosses
|
|
423
|
+
* the viewport midpoint. Resets the elastic stretch, switches side,
|
|
424
|
+
* and persists the user's preference.
|
|
425
|
+
*/
|
|
426
|
+
function triggerFlip(newSide: 'left' | 'right', e: PointerEvent) {
|
|
427
|
+
// Reset stretch to default shape
|
|
428
|
+
stretchX.value = 0
|
|
429
|
+
stretchY.value = 0
|
|
430
|
+
|
|
431
|
+
// End drag
|
|
432
|
+
isDragging.value = false
|
|
433
|
+
dragStart = null
|
|
434
|
+
removeListeners()
|
|
435
|
+
|
|
436
|
+
// Release pointer capture
|
|
437
|
+
if (touchOverlayRef.value) {
|
|
438
|
+
try { touchOverlayRef.value.releasePointerCapture(e.pointerId) } catch { /* noop */ }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Switch to the new side
|
|
442
|
+
side.value = newSide
|
|
443
|
+
saveSide(newSide)
|
|
309
444
|
}
|
|
310
445
|
|
|
311
446
|
/** Spring-physics animation to snap the control point back to default */
|
|
@@ -344,26 +479,33 @@ function animateSnapBack() {
|
|
|
344
479
|
/* ------------------------------------------------------------------ */
|
|
345
480
|
onBeforeUnmount(() => {
|
|
346
481
|
if (springRaf) cancelAnimationFrame(springRaf)
|
|
347
|
-
|
|
348
|
-
globalThis.removeEventListener('pointerup', onPointerUp)
|
|
349
|
-
globalThis.removeEventListener('pointercancel', onPointerCancel)
|
|
482
|
+
removeListeners()
|
|
350
483
|
})
|
|
351
484
|
</script>
|
|
352
485
|
|
|
353
486
|
<style scoped>
|
|
354
487
|
/* ------------------------------------------------------------------ */
|
|
355
|
-
/* Container — fixed
|
|
488
|
+
/* Container — fixed in corner, non-blocking */
|
|
356
489
|
/* ------------------------------------------------------------------ */
|
|
357
490
|
.dcs-ribbon-container {
|
|
358
491
|
position: fixed;
|
|
359
492
|
top: 0;
|
|
360
|
-
left: 0;
|
|
361
493
|
z-index: 2147483647;
|
|
362
494
|
pointer-events: none;
|
|
363
495
|
user-select: none;
|
|
364
496
|
-webkit-user-select: none;
|
|
365
497
|
}
|
|
366
498
|
|
|
499
|
+
.dcs-ribbon-container--left {
|
|
500
|
+
left: 0;
|
|
501
|
+
right: auto;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
.dcs-ribbon-container--right {
|
|
505
|
+
right: 0;
|
|
506
|
+
left: auto;
|
|
507
|
+
}
|
|
508
|
+
|
|
367
509
|
/* ------------------------------------------------------------------ */
|
|
368
510
|
/* SVG canvas */
|
|
369
511
|
/* ------------------------------------------------------------------ */
|
|
@@ -376,16 +518,10 @@ onBeforeUnmount(() => {
|
|
|
376
518
|
}
|
|
377
519
|
|
|
378
520
|
/* ------------------------------------------------------------------ */
|
|
379
|
-
/* Band (
|
|
521
|
+
/* Band (visual only — touch handled by overlay div) */
|
|
380
522
|
/* ------------------------------------------------------------------ */
|
|
381
523
|
.dcs-ribbon-band {
|
|
382
|
-
pointer-events:
|
|
383
|
-
cursor: grab;
|
|
384
|
-
touch-action: none;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
.dcs-ribbon-band--dragging {
|
|
388
|
-
cursor: grabbing;
|
|
524
|
+
pointer-events: none;
|
|
389
525
|
}
|
|
390
526
|
|
|
391
527
|
/* ------------------------------------------------------------------ */
|
|
@@ -420,12 +556,41 @@ onBeforeUnmount(() => {
|
|
|
420
556
|
display: none;
|
|
421
557
|
}
|
|
422
558
|
|
|
559
|
+
/* ------------------------------------------------------------------ */
|
|
560
|
+
/* Touch overlay — transparent HTML div for reliable mobile touch */
|
|
561
|
+
/* */
|
|
562
|
+
/* Android Chrome ignores touch-action on SVG <path> elements because */
|
|
563
|
+
/* the compositor only walks HTML elements. This HTML <div> with */
|
|
564
|
+
/* clip-path creates a shaped, invisible hit area where touch-action */
|
|
565
|
+
/* is guaranteed to work. */
|
|
566
|
+
/* ------------------------------------------------------------------ */
|
|
567
|
+
.dcs-ribbon-touch {
|
|
568
|
+
position: absolute;
|
|
569
|
+
top: 0;
|
|
570
|
+
left: 0;
|
|
571
|
+
width: 260px;
|
|
572
|
+
height: 260px;
|
|
573
|
+
touch-action: none;
|
|
574
|
+
pointer-events: auto;
|
|
575
|
+
cursor: grab;
|
|
576
|
+
-webkit-tap-highlight-color: transparent;
|
|
577
|
+
/* clip-path set via :style binding for left/right side */
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
.dcs-ribbon-touch--dragging {
|
|
581
|
+
cursor: grabbing;
|
|
582
|
+
}
|
|
583
|
+
|
|
423
584
|
/* Compact label for narrow screens */
|
|
424
585
|
@media (max-width: 640px) {
|
|
425
586
|
.dcs-ribbon-svg {
|
|
426
587
|
width: 200px;
|
|
427
588
|
height: 200px;
|
|
428
589
|
}
|
|
590
|
+
.dcs-ribbon-touch {
|
|
591
|
+
width: 200px;
|
|
592
|
+
height: 200px;
|
|
593
|
+
}
|
|
429
594
|
.dcs-ribbon-text--full {
|
|
430
595
|
display: none;
|
|
431
596
|
}
|