@duffcloudservices/cms 0.1.6 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duffcloudservices/cms",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Vue 3 composables and Vite plugins for DCS CMS integration",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,13 +1,7 @@
1
1
  <template>
2
2
  <Teleport to="body">
3
- <div
4
- v-if="isVisible"
5
- class="dcs-ribbon-container"
6
- :class="{ 'dcs-ribbon-container--flipping': isFlipping }"
7
- :style="containerStyle"
8
- >
3
+ <div v-if="isVisible" :class="['dcs-ribbon-container', `dcs-ribbon-container--${side}`]">
9
4
  <svg
10
- ref="ribbonSvgRef"
11
5
  :viewBox="`0 0 ${SVG_SIZE} ${SVG_SIZE}`"
12
6
  class="dcs-ribbon-svg"
13
7
  >
@@ -42,8 +36,7 @@
42
36
  fill="url(#dcs-ribbon-grad)"
43
37
  stroke="rgba(255,255,255,0.08)"
44
38
  stroke-width="1"
45
- :class="['dcs-ribbon-band', { 'dcs-ribbon-band--dragging': isDragging }]"
46
- @pointerdown.stop.prevent="onRibbonPointerDown"
39
+ class="dcs-ribbon-band"
47
40
  />
48
41
 
49
42
  <!-- Decorative stitched borders -->
@@ -72,6 +65,20 @@
72
65
  </textPath>
73
66
  </text>
74
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
+ />
75
82
  </div>
76
83
  </Teleport>
77
84
  </template>
@@ -169,24 +176,6 @@ function saveSide(s: 'left' | 'right') {
169
176
  try { localStorage.setItem(STORAGE_KEY, s) } catch { /* noop */ }
170
177
  }
171
178
 
172
- /* ------------------------------------------------------------------ */
173
- /* Viewport tracking */
174
- /* ------------------------------------------------------------------ */
175
- const viewportWidth = ref(1920)
176
- const svgSizePx = ref(260)
177
-
178
- function updateViewport() {
179
- viewportWidth.value = window.innerWidth
180
- svgSizePx.value = window.innerWidth <= 640 ? 200 : 260
181
- }
182
-
183
- function onResize() {
184
- updateViewport()
185
- if (!isDragging.value && !isFlipping.value) {
186
- posX.value = getRestX()
187
- }
188
- }
189
-
190
179
  /* ------------------------------------------------------------------ */
191
180
  /* Version display */
192
181
  /* ------------------------------------------------------------------ */
@@ -212,10 +201,6 @@ onMounted(async () => {
212
201
  if (!isVisible.value) return
213
202
 
214
203
  side.value = loadSide()
215
- updateViewport()
216
- posX.value = getRestX()
217
-
218
- window.addEventListener('resize', onResize)
219
204
 
220
205
  // Resolve version
221
206
  if (resolvedVersion.value) return
@@ -253,239 +238,232 @@ onMounted(async () => {
253
238
  })
254
239
 
255
240
  /* ------------------------------------------------------------------ */
256
- /* Position state */
241
+ /* Elastic stretch state (control-point displacement) */
257
242
  /* ------------------------------------------------------------------ */
258
- const posX = ref(0)
259
- const posY = ref(0)
243
+ const stretchX = ref(0)
244
+ const stretchY = ref(0)
260
245
  const isDragging = ref(false)
261
- const isFlipping = ref(false)
262
246
 
263
- /** Rest X position for the current side */
264
- function getRestX(): number {
265
- if (side.value === 'left') return 0
266
- return viewportWidth.value - svgSizePx.value
267
- }
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)
268
259
 
269
- const containerStyle = computed(() => ({
270
- transform: `translate(${posX.value}px, ${posY.value}px)`,
271
- }))
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))
272
266
 
273
267
  /* ------------------------------------------------------------------ */
274
- /* SVG path computation (side-aware, X-mirrored for right) */
268
+ /* SVG path computation (side-aware, with elastic control-point) */
275
269
  /* ------------------------------------------------------------------ */
276
270
 
277
271
  /**
278
- * Quadratic bezier curve for stitch / helper paths.
279
- * Left side: (0, y0) (x1, 0) via (cx, cy)
280
- * Right side: (SVG_SIZE, y0) → (SVG_SIZE-x1, 0) via (SVG_SIZE-cx, cy)
272
+ * Text center-line. On the right side the path direction is reversed
273
+ * so that textPath renders left-to-right (readable).
281
274
  */
282
- function mirrorQ(y0: number, cx: number, cy: number, x1: number): string {
283
- if (side.value === 'left') {
284
- return `M 0,${y0} Q ${cx},${cy} ${x1},0`
285
- }
286
- return `M ${SVG_SIZE},${y0} Q ${SVG_SIZE - cx},${cy} ${SVG_SIZE - x1},0`
287
- }
288
-
289
- /** Text center-line path. Reversed on right side so text reads naturally. */
290
275
  const textPath = computed(() => {
291
276
  if (side.value === 'left') {
292
- return `M 0,${EP} Q ${CP0},${CP0} ${EP},0`
277
+ return `M 0,${EP} Q ${cx.value},${cy.value} ${EP},0`
293
278
  }
294
- // Right side: path goes from top-edge right-edge for left-to-right text
295
- return `M ${SVG_SIZE - EP},0 Q ${SVG_SIZE - CP0},${CP0} ${SVG_SIZE},${EP}`
279
+ return `M ${SVG_SIZE - EP},0 Q ${cx.value},${cy.value} ${SVG_SIZE},${EP}`
296
280
  })
297
281
 
298
- const outerStitchPath = computed(() =>
299
- mirrorQ(
300
- EP + HW - STI,
301
- CP0 + CPO - 3,
302
- CP0 + CPO - 3,
303
- EP + HW - STI,
304
- ),
305
- )
306
-
307
- const innerStitchPath = computed(() =>
308
- mirrorQ(
309
- EP - HW + STI,
310
- CP0 - CPO + 3,
311
- CP0 - CPO + 3,
312
- EP - HW + STI,
313
- ),
314
- )
315
-
316
282
  const bandPath = computed(() => {
317
- const oY = EP + HW
318
- const oX = EP + HW
319
- const oCx = CP0 + CPO
320
- const oCy = CP0 + CPO
321
- const iY = EP - HW
322
- const iX = EP - HW
323
- const iCx = CP0 - CPO
324
- const iCy = CP0 - CPO
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
325
290
 
326
291
  if (side.value === 'left') {
327
292
  return [
328
- `M 0,${oY} Q ${oCx},${oCy} ${oX},0`,
329
- `L ${iX},0`,
330
- `Q ${iCx},${iCy} 0,${iY}`,
293
+ `M 0,${EP + HW} Q ${ocx},${ocy} ${EP + HW},0`,
294
+ `L ${EP - HW},0`,
295
+ `Q ${icx},${icy} 0,${EP - HW}`,
331
296
  `Z`,
332
297
  ].join(' ')
333
298
  }
334
299
 
335
300
  return [
336
- `M ${SVG_SIZE},${oY} Q ${SVG_SIZE - oCx},${oCy} ${SVG_SIZE - oX},0`,
337
- `L ${SVG_SIZE - iX},0`,
338
- `Q ${SVG_SIZE - iCx},${iCy} ${SVG_SIZE},${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}`,
339
304
  `Z`,
340
305
  ].join(' ')
341
306
  })
342
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
+
343
332
  /* ------------------------------------------------------------------ */
344
- /* Drag: translate ribbon + flip on midpoint crossing */
333
+ /* Touch overlay clip-path */
345
334
  /* ------------------------------------------------------------------ */
346
- const ribbonSvgRef = ref<SVGSVGElement | null>(null)
347
- let dragStart: { x: number; y: number; posX: number; posY: number } | null = null
348
- let capturedTarget: Element | null = null
349
- let lastPointerId: number | null = null
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
+
351
+ /* ------------------------------------------------------------------ */
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
350
356
  let springRaf = 0
351
- let flipTimeout: ReturnType<typeof setTimeout> | null = null
352
357
 
353
- function onRibbonPointerDown(e: PointerEvent) {
354
- // Don't allow interaction during flip animation
355
- if (isFlipping.value) return
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
+ }
356
365
 
366
+ function onPointerDown(e: PointerEvent) {
357
367
  // Cancel any in-progress spring animation
358
368
  if (springRaf) {
359
369
  cancelAnimationFrame(springRaf)
360
370
  springRaf = 0
361
371
  }
362
372
 
363
- // Capture the pointer on the element that received the event (the band path).
364
- // This ensures all subsequent pointer events are delivered to this element,
365
- // preventing the browser from cancelling the pointer sequence for scrolling.
366
- const target = e.currentTarget as Element
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
367
378
  target.setPointerCapture(e.pointerId)
368
- capturedTarget = target
369
- lastPointerId = e.pointerId
370
379
 
371
380
  isDragging.value = true
372
- dragStart = { x: e.clientX, y: e.clientY, posX: posX.value, posY: posY.value }
381
+ dragStart = { x: e.clientX, y: e.clientY }
382
+
373
383
  globalThis.addEventListener('pointermove', onPointerMove)
374
384
  globalThis.addEventListener('pointerup', onPointerUp)
375
- globalThis.addEventListener('pointercancel', onPointerCancel)
385
+ globalThis.addEventListener('pointercancel', onPointerUp)
376
386
  }
377
387
 
378
388
  function onPointerMove(e: PointerEvent) {
379
389
  if (!dragStart) return
380
390
 
381
- const dx = e.clientX - dragStart.x
382
- const dy = e.clientY - dragStart.y
383
- posX.value = dragStart.posX + dx
384
- posY.value = dragStart.posY + dy
385
-
386
- // Check if ribbon center has crossed viewport midpoint → trigger flip
387
- const ribbonCenterX = posX.value + svgSizePx.value / 2
388
- const midpoint = viewportWidth.value / 2
389
-
390
- if (side.value === 'left' && ribbonCenterX > midpoint) {
391
- triggerFlip('right')
392
- } else if (side.value === 'right' && ribbonCenterX < midpoint) {
393
- triggerFlip('left')
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)
394
402
  }
395
403
  }
396
404
 
397
- function onPointerUp(e?: PointerEvent) {
398
- releasePointer(e)
405
+ function onPointerUp(e: PointerEvent) {
406
+ if (touchOverlayRef.value) {
407
+ try { touchOverlayRef.value.releasePointerCapture(e.pointerId) } catch { /* already released */ }
408
+ }
399
409
  isDragging.value = false
400
410
  dragStart = null
401
411
  removeListeners()
402
-
403
- // Spring back to rest position
404
412
  animateSnapBack()
405
413
  }
406
414
 
407
- /** Handle browser cancelling the pointer (e.g. when native scroll takes over) */
408
- function onPointerCancel(e: PointerEvent) {
409
- onPointerUp(e)
410
- }
411
-
412
- function releasePointer(e?: PointerEvent) {
413
- if (capturedTarget && e) {
414
- try { capturedTarget.releasePointerCapture(e.pointerId) } catch { /* already released */ }
415
- }
416
- capturedTarget = null
417
- lastPointerId = null
418
- }
419
-
420
415
  function removeListeners() {
421
416
  globalThis.removeEventListener('pointermove', onPointerMove)
422
417
  globalThis.removeEventListener('pointerup', onPointerUp)
423
- globalThis.removeEventListener('pointercancel', onPointerCancel)
418
+ globalThis.removeEventListener('pointercancel', onPointerUp)
424
419
  }
425
420
 
426
421
  /**
427
- * Animate the ribbon to the opposite corner.
428
- * Ends the drag, toggles SVG paths, and uses a CSS transition
429
- * to smoothly slide the container to its new rest position.
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.
430
425
  */
431
- function triggerFlip(newSide: 'left' | 'right') {
426
+ function triggerFlip(newSide: 'left' | 'right', e: PointerEvent) {
427
+ // Reset stretch to default shape
428
+ stretchX.value = 0
429
+ stretchY.value = 0
430
+
432
431
  // End drag
433
432
  isDragging.value = false
434
433
  dragStart = null
435
434
  removeListeners()
436
435
 
437
436
  // Release pointer capture
438
- if (capturedTarget && lastPointerId !== null) {
439
- try { capturedTarget.releasePointerCapture(lastPointerId) } catch { /* noop */ }
437
+ if (touchOverlayRef.value) {
438
+ try { touchOverlayRef.value.releasePointerCapture(e.pointerId) } catch { /* noop */ }
440
439
  }
441
- capturedTarget = null
442
- lastPointerId = null
443
440
 
444
- // Toggle side (paths flip instantly, gradient direction updates)
441
+ // Switch to the new side
445
442
  side.value = newSide
446
443
  saveSide(newSide)
447
-
448
- // Enable CSS transition, then set target position.
449
- // The requestAnimationFrame ensures the browser has laid out the
450
- // current position before the transition target is applied.
451
- isFlipping.value = true
452
- requestAnimationFrame(() => {
453
- posX.value = getRestX()
454
- posY.value = 0
455
-
456
- flipTimeout = setTimeout(() => {
457
- isFlipping.value = false
458
- flipTimeout = null
459
- }, 550)
460
- })
461
444
  }
462
445
 
463
- /** Spring-physics animation to snap the ribbon back to its rest position */
446
+ /** Spring-physics animation to snap the control point back to default */
464
447
  function animateSnapBack() {
465
- const targetX = getRestX()
466
- const targetY = 0
467
448
  const stiffness = 0.12
468
449
  const damping = 0.72
469
450
  let vx = 0
470
451
  let vy = 0
471
452
 
472
453
  function step() {
473
- const dxToTarget = posX.value - targetX
474
- const dyToTarget = posY.value - targetY
475
-
476
- vx = (vx - stiffness * dxToTarget) * damping
477
- vy = (vy - stiffness * dyToTarget) * damping
478
- posX.value += vx
479
- posY.value += vy
454
+ vx = (vx - stiffness * stretchX.value) * damping
455
+ vy = (vy - stiffness * stretchY.value) * damping
456
+ stretchX.value += vx
457
+ stretchY.value += vy
480
458
 
481
459
  if (
482
- Math.abs(dxToTarget) < 0.3 &&
483
- Math.abs(dyToTarget) < 0.3 &&
460
+ Math.abs(stretchX.value) < 0.3 &&
461
+ Math.abs(stretchY.value) < 0.3 &&
484
462
  Math.abs(vx) < 0.3 &&
485
463
  Math.abs(vy) < 0.3
486
464
  ) {
487
- posX.value = targetX
488
- posY.value = targetY
465
+ stretchX.value = 0
466
+ stretchY.value = 0
489
467
  springRaf = 0
490
468
  return
491
469
  }
@@ -501,31 +479,31 @@ function animateSnapBack() {
501
479
  /* ------------------------------------------------------------------ */
502
480
  onBeforeUnmount(() => {
503
481
  if (springRaf) cancelAnimationFrame(springRaf)
504
- if (flipTimeout) clearTimeout(flipTimeout)
505
- globalThis.removeEventListener('pointermove', onPointerMove)
506
- globalThis.removeEventListener('pointerup', onPointerUp)
507
- globalThis.removeEventListener('pointercancel', onPointerCancel)
508
- window.removeEventListener('resize', onResize)
482
+ removeListeners()
509
483
  })
510
484
  </script>
511
485
 
512
486
  <style scoped>
513
487
  /* ------------------------------------------------------------------ */
514
- /* Container — fixed top-left, non-blocking, positioned via transform */
488
+ /* Container — fixed in corner, non-blocking */
515
489
  /* ------------------------------------------------------------------ */
516
490
  .dcs-ribbon-container {
517
491
  position: fixed;
518
492
  top: 0;
519
- left: 0;
520
493
  z-index: 2147483647;
521
494
  pointer-events: none;
522
495
  user-select: none;
523
496
  -webkit-user-select: none;
524
497
  }
525
498
 
526
- /* Smooth slide transition when snapping to the opposite corner */
527
- .dcs-ribbon-container--flipping {
528
- transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
499
+ .dcs-ribbon-container--left {
500
+ left: 0;
501
+ right: auto;
502
+ }
503
+
504
+ .dcs-ribbon-container--right {
505
+ right: 0;
506
+ left: auto;
529
507
  }
530
508
 
531
509
  /* ------------------------------------------------------------------ */
@@ -540,16 +518,10 @@ onBeforeUnmount(() => {
540
518
  }
541
519
 
542
520
  /* ------------------------------------------------------------------ */
543
- /* Band (interactive) */
521
+ /* Band (visual only — touch handled by overlay div) */
544
522
  /* ------------------------------------------------------------------ */
545
523
  .dcs-ribbon-band {
546
- pointer-events: auto;
547
- cursor: grab;
548
- touch-action: none;
549
- }
550
-
551
- .dcs-ribbon-band--dragging {
552
- cursor: grabbing;
524
+ pointer-events: none;
553
525
  }
554
526
 
555
527
  /* ------------------------------------------------------------------ */
@@ -584,12 +556,41 @@ onBeforeUnmount(() => {
584
556
  display: none;
585
557
  }
586
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
+
587
584
  /* Compact label for narrow screens */
588
585
  @media (max-width: 640px) {
589
586
  .dcs-ribbon-svg {
590
587
  width: 200px;
591
588
  height: 200px;
592
589
  }
590
+ .dcs-ribbon-touch {
591
+ width: 200px;
592
+ height: 200px;
593
+ }
593
594
  .dcs-ribbon-text--full {
594
595
  display: none;
595
596
  }