@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duffcloudservices/cms",
3
- "version": "0.1.5",
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,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="centerPath" />
11
- <linearGradient id="dcs-ribbon-grad" x1="0%" y1="100%" x2="100%" y2="0%">
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 :d="bandPath" fill="rgba(0,0,0,0.2)" transform="translate(2,3)" />
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
- :class="['dcs-ribbon-band', { 'dcs-ribbon-band--dragging': isDragging }]"
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
- onMounted(() => {
138
- isVisible.value = shouldShow()
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
- // Try to read the version from VITE_SITE_VERSION env var at build time
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
- /** Effective control-point position (default + stretch offset) */
205
- const cpX = computed(() => CP0 + stretchX.value)
206
- const cpY = computed(() => CP0 + stretchY.value)
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
- /** Quadratic bezier from left-edge (0, y0) to top-edge (x1, 0) */
213
- function q(y0: number, cx: number, cy: number, x1: number): string {
214
- return `M 0,${y0} Q ${cx},${cy} ${x1},0`
215
- }
216
-
217
- const centerPath = computed(() => q(EP, cpX.value, cpY.value, EP))
218
-
219
- const outerStitchPath = computed(() =>
220
- q(
221
- EP + HW - STI,
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 oY = EP + HW
239
- const oX = EP + HW
240
- const oCx = cpX.value + CPO
241
- const oCy = cpY.value + CPO
242
- const iY = EP - HW
243
- const iX = EP - HW
244
- const iCx = cpX.value - CPO
245
- const iCy = cpY.value - 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
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
- // Outer curve (left-edge top-edge)
249
- `M 0,${oY} Q ${oCx},${oCy} ${oX},0`,
250
- // Cap at top-edge → inner curve start
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
- /* Drag: elastic ribbon stretch */
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
- const ribbonSvgRef = ref<SVGSVGElement | null>(null)
262
- let dragState: { startX: number; startY: number } | null = null
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
- function onRibbonPointerDown(e: PointerEvent) {
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 element that received the event (the band path).
274
- // This ensures all subsequent pointer events are delivered to this element,
275
- // preventing the browser from cancelling the pointer sequence for scrolling.
276
- 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
277
378
  target.setPointerCapture(e.pointerId)
278
- capturedTarget = target
279
379
 
280
380
  isDragging.value = true
281
- dragState = { startX: e.clientX, startY: e.clientY }
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', onPointerCancel)
385
+ globalThis.addEventListener('pointercancel', onPointerUp)
285
386
  }
286
387
 
287
388
  function onPointerMove(e: PointerEvent) {
288
- if (!dragState) return
289
- stretchX.value = e.clientX - dragState.startX
290
- stretchY.value = e.clientY - dragState.startY
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?: PointerEvent) {
294
- if (capturedTarget && e) {
295
- try { capturedTarget.releasePointerCapture(e.pointerId) } catch { /* already released */ }
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
- dragState = null
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', onPointerCancel)
303
- animateSnapBack()
418
+ globalThis.removeEventListener('pointercancel', onPointerUp)
304
419
  }
305
420
 
306
- /** Handle browser cancelling the pointer (e.g. when native scroll takes over) */
307
- function onPointerCancel(e: PointerEvent) {
308
- onPointerUp(e)
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
- globalThis.removeEventListener('pointermove', onPointerMove)
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 top-left, non-blocking */
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 (interactive) */
521
+ /* Band (visual only — touch handled by overlay div) */
380
522
  /* ------------------------------------------------------------------ */
381
523
  .dcs-ribbon-band {
382
- pointer-events: auto;
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
  }