@duffcloudservices/cms 0.1.6 → 0.2.0
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/dist/editor/editorBridge.js +25 -3
- package/dist/editor/editorBridge.js.map +1 -1
- package/dist/index.d.ts +65 -2
- package/dist/index.js +30 -3
- package/dist/index.js.map +1 -1
- package/dist/plugins/index.d.ts +156 -1
- package/dist/plugins/index.js +295 -8
- package/dist/plugins/index.js.map +1 -1
- package/package.json +82 -77
- package/src/components/PreviewRibbon.vue +205 -193
- package/src/components/ResponsiveImage.vue +55 -0
|
@@ -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
|
-
|
|
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>
|
|
@@ -125,6 +132,13 @@ const STI = 4
|
|
|
125
132
|
/** The preview domain where the ribbon should be displayed */
|
|
126
133
|
const PREVIEW_DOMAIN = 'preview.duffcloudservices.com'
|
|
127
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Query string parameter that suppresses the preview ribbon.
|
|
137
|
+
* Used by GitHub Actions snapshot capture so screenshots don't include the ribbon overlay.
|
|
138
|
+
* Example: https://preview.duffcloudservices.com/kept/?dcs-hide-ribbon
|
|
139
|
+
*/
|
|
140
|
+
const HIDE_RIBBON_PARAM = 'dcs-hide-ribbon'
|
|
141
|
+
|
|
128
142
|
/** localStorage key for persisting which corner the ribbon is docked to */
|
|
129
143
|
const STORAGE_KEY = 'dcs-preview-ribbon-side'
|
|
130
144
|
|
|
@@ -149,6 +163,10 @@ function shouldShow(): boolean {
|
|
|
149
163
|
return false
|
|
150
164
|
}
|
|
151
165
|
|
|
166
|
+
// Allow suppression via query parameter (for automated screenshot capture)
|
|
167
|
+
const params = new URLSearchParams(globalThis.location.search)
|
|
168
|
+
if (params.has(HIDE_RIBBON_PARAM)) return false
|
|
169
|
+
|
|
152
170
|
return true
|
|
153
171
|
}
|
|
154
172
|
|
|
@@ -169,24 +187,6 @@ function saveSide(s: 'left' | 'right') {
|
|
|
169
187
|
try { localStorage.setItem(STORAGE_KEY, s) } catch { /* noop */ }
|
|
170
188
|
}
|
|
171
189
|
|
|
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
190
|
/* ------------------------------------------------------------------ */
|
|
191
191
|
/* Version display */
|
|
192
192
|
/* ------------------------------------------------------------------ */
|
|
@@ -212,10 +212,6 @@ onMounted(async () => {
|
|
|
212
212
|
if (!isVisible.value) return
|
|
213
213
|
|
|
214
214
|
side.value = loadSide()
|
|
215
|
-
updateViewport()
|
|
216
|
-
posX.value = getRestX()
|
|
217
|
-
|
|
218
|
-
window.addEventListener('resize', onResize)
|
|
219
215
|
|
|
220
216
|
// Resolve version
|
|
221
217
|
if (resolvedVersion.value) return
|
|
@@ -253,239 +249,232 @@ onMounted(async () => {
|
|
|
253
249
|
})
|
|
254
250
|
|
|
255
251
|
/* ------------------------------------------------------------------ */
|
|
256
|
-
/*
|
|
252
|
+
/* Elastic stretch state (control-point displacement) */
|
|
257
253
|
/* ------------------------------------------------------------------ */
|
|
258
|
-
const
|
|
259
|
-
const
|
|
254
|
+
const stretchX = ref(0)
|
|
255
|
+
const stretchY = ref(0)
|
|
260
256
|
const isDragging = ref(false)
|
|
261
|
-
const isFlipping = ref(false)
|
|
262
257
|
|
|
263
|
-
/**
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
258
|
+
/**
|
|
259
|
+
* Absolute SVG-space control point for the band center-line.
|
|
260
|
+
* Left side default: (CP0, CP0) = (90, 90)
|
|
261
|
+
* Right side default: (SVG_SIZE - CP0, CP0) = (170, 90)
|
|
262
|
+
* stretchX/Y displace the control point in screen-mapped SVG units.
|
|
263
|
+
*/
|
|
264
|
+
const cx = computed(() =>
|
|
265
|
+
side.value === 'left'
|
|
266
|
+
? CP0 + stretchX.value
|
|
267
|
+
: SVG_SIZE - CP0 + stretchX.value,
|
|
268
|
+
)
|
|
269
|
+
const cy = computed(() => CP0 + stretchY.value)
|
|
268
270
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
271
|
+
/**
|
|
272
|
+
* Perpendicular sign: for left-side ribbon the outward perpendicular
|
|
273
|
+
* direction is (+1, +1); for right-side it is (-1, +1).
|
|
274
|
+
* This determines how CPO is applied to get outer/inner band edges.
|
|
275
|
+
*/
|
|
276
|
+
const ps = computed(() => (side.value === 'left' ? 1 : -1))
|
|
272
277
|
|
|
273
278
|
/* ------------------------------------------------------------------ */
|
|
274
|
-
/* SVG path computation (side-aware,
|
|
279
|
+
/* SVG path computation (side-aware, with elastic control-point) */
|
|
275
280
|
/* ------------------------------------------------------------------ */
|
|
276
281
|
|
|
277
282
|
/**
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
* Right side: (SVG_SIZE, y0) → (SVG_SIZE-x1, 0) via (SVG_SIZE-cx, cy)
|
|
283
|
+
* Text center-line. On the right side the path direction is reversed
|
|
284
|
+
* so that textPath renders left-to-right (readable).
|
|
281
285
|
*/
|
|
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
286
|
const textPath = computed(() => {
|
|
291
287
|
if (side.value === 'left') {
|
|
292
|
-
return `M 0,${EP} Q ${
|
|
288
|
+
return `M 0,${EP} Q ${cx.value},${cy.value} ${EP},0`
|
|
293
289
|
}
|
|
294
|
-
|
|
295
|
-
return `M ${SVG_SIZE - EP},0 Q ${SVG_SIZE - CP0},${CP0} ${SVG_SIZE},${EP}`
|
|
290
|
+
return `M ${SVG_SIZE - EP},0 Q ${cx.value},${cy.value} ${SVG_SIZE},${EP}`
|
|
296
291
|
})
|
|
297
292
|
|
|
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
293
|
const bandPath = computed(() => {
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
const
|
|
324
|
-
const iCy = CP0 - CPO
|
|
294
|
+
const p = ps.value
|
|
295
|
+
// Outer edge control point (further from corner)
|
|
296
|
+
const ocx = cx.value + p * CPO
|
|
297
|
+
const ocy = cy.value + CPO
|
|
298
|
+
// Inner edge control point (closer to corner)
|
|
299
|
+
const icx = cx.value - p * CPO
|
|
300
|
+
const icy = cy.value - CPO
|
|
325
301
|
|
|
326
302
|
if (side.value === 'left') {
|
|
327
303
|
return [
|
|
328
|
-
`M 0,${
|
|
329
|
-
`L ${
|
|
330
|
-
`Q ${
|
|
304
|
+
`M 0,${EP + HW} Q ${ocx},${ocy} ${EP + HW},0`,
|
|
305
|
+
`L ${EP - HW},0`,
|
|
306
|
+
`Q ${icx},${icy} 0,${EP - HW}`,
|
|
331
307
|
`Z`,
|
|
332
308
|
].join(' ')
|
|
333
309
|
}
|
|
334
310
|
|
|
335
311
|
return [
|
|
336
|
-
`M ${SVG_SIZE},${
|
|
337
|
-
`L ${SVG_SIZE -
|
|
338
|
-
`Q ${
|
|
312
|
+
`M ${SVG_SIZE},${EP + HW} Q ${ocx},${ocy} ${SVG_SIZE - EP - HW},0`,
|
|
313
|
+
`L ${SVG_SIZE - EP + HW},0`,
|
|
314
|
+
`Q ${icx},${icy} ${SVG_SIZE},${EP - HW}`,
|
|
339
315
|
`Z`,
|
|
340
316
|
].join(' ')
|
|
341
317
|
})
|
|
342
318
|
|
|
319
|
+
const outerStitchPath = computed(() => {
|
|
320
|
+
const p = ps.value
|
|
321
|
+
const scx = cx.value + p * (CPO - 3)
|
|
322
|
+
const scy = cy.value + (CPO - 3)
|
|
323
|
+
const d = EP + HW - STI
|
|
324
|
+
|
|
325
|
+
if (side.value === 'left') {
|
|
326
|
+
return `M 0,${d} Q ${scx},${scy} ${d},0`
|
|
327
|
+
}
|
|
328
|
+
return `M ${SVG_SIZE},${d} Q ${scx},${scy} ${SVG_SIZE - d},0`
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
const innerStitchPath = computed(() => {
|
|
332
|
+
const p = ps.value
|
|
333
|
+
const scx = cx.value - p * (CPO - 3)
|
|
334
|
+
const scy = cy.value - (CPO - 3)
|
|
335
|
+
const d = EP - HW + STI
|
|
336
|
+
|
|
337
|
+
if (side.value === 'left') {
|
|
338
|
+
return `M 0,${d} Q ${scx},${scy} ${d},0`
|
|
339
|
+
}
|
|
340
|
+
return `M ${SVG_SIZE},${d} Q ${scx},${scy} ${SVG_SIZE - d},0`
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
/* ------------------------------------------------------------------ */
|
|
344
|
+
/* Touch overlay clip-path */
|
|
345
|
+
/* ------------------------------------------------------------------ */
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Clip-path polygon that creates an invisible hit area over the ribbon
|
|
349
|
+
* band, wider than the visible band for comfortable touch targeting.
|
|
350
|
+
* This is on an HTML <div>, so touch-action: none is guaranteed to work
|
|
351
|
+
* on Android Chrome (unlike SVG <path> elements).
|
|
352
|
+
*/
|
|
353
|
+
const touchClipStyle = computed(() => {
|
|
354
|
+
if (side.value === 'left') {
|
|
355
|
+
// Quadrilateral covering the band diagonal: bottom-left → top-right
|
|
356
|
+
return { clipPath: 'polygon(0% 100%, 100% 0%, 65% 0%, 0% 65%)' }
|
|
357
|
+
}
|
|
358
|
+
// Right side: mirrored
|
|
359
|
+
return { clipPath: 'polygon(100% 100%, 0% 0%, 35% 0%, 100% 65%)' }
|
|
360
|
+
})
|
|
361
|
+
|
|
343
362
|
/* ------------------------------------------------------------------ */
|
|
344
|
-
/* Drag:
|
|
363
|
+
/* Drag: elastic control-point stretch + flip on midpoint crossing */
|
|
345
364
|
/* ------------------------------------------------------------------ */
|
|
346
|
-
const
|
|
347
|
-
let dragStart: { x: number; y: number
|
|
348
|
-
let capturedTarget: Element | null = null
|
|
349
|
-
let lastPointerId: number | null = null
|
|
365
|
+
const touchOverlayRef = ref<HTMLDivElement | null>(null)
|
|
366
|
+
let dragStart: { x: number; y: number } | null = null
|
|
350
367
|
let springRaf = 0
|
|
351
|
-
let flipTimeout: ReturnType<typeof setTimeout> | null = null
|
|
352
368
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
369
|
+
/**
|
|
370
|
+
* Get the rendered pixel size of the SVG element.
|
|
371
|
+
* Used to convert screen-space drag deltas to SVG-space units.
|
|
372
|
+
*/
|
|
373
|
+
function getSvgPixelSize(): number {
|
|
374
|
+
return window.innerWidth <= 640 ? 200 : 260
|
|
375
|
+
}
|
|
356
376
|
|
|
377
|
+
function onPointerDown(e: PointerEvent) {
|
|
357
378
|
// Cancel any in-progress spring animation
|
|
358
379
|
if (springRaf) {
|
|
359
380
|
cancelAnimationFrame(springRaf)
|
|
360
381
|
springRaf = 0
|
|
361
382
|
}
|
|
362
383
|
|
|
363
|
-
// Capture the pointer on the
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
|
|
384
|
+
// Capture the pointer on the HTML overlay div.
|
|
385
|
+
// Unlike SVG <path> elements, HTML elements reliably support
|
|
386
|
+
// setPointerCapture on Android Chrome, ensuring all subsequent
|
|
387
|
+
// pointer events are delivered even when the finger moves outside.
|
|
388
|
+
const target = e.currentTarget as HTMLElement
|
|
367
389
|
target.setPointerCapture(e.pointerId)
|
|
368
|
-
capturedTarget = target
|
|
369
|
-
lastPointerId = e.pointerId
|
|
370
390
|
|
|
371
391
|
isDragging.value = true
|
|
372
|
-
dragStart = { x: e.clientX, y: e.clientY
|
|
392
|
+
dragStart = { x: e.clientX, y: e.clientY }
|
|
393
|
+
|
|
373
394
|
globalThis.addEventListener('pointermove', onPointerMove)
|
|
374
395
|
globalThis.addEventListener('pointerup', onPointerUp)
|
|
375
|
-
globalThis.addEventListener('pointercancel',
|
|
396
|
+
globalThis.addEventListener('pointercancel', onPointerUp)
|
|
376
397
|
}
|
|
377
398
|
|
|
378
399
|
function onPointerMove(e: PointerEvent) {
|
|
379
400
|
if (!dragStart) return
|
|
380
401
|
|
|
381
|
-
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
//
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
if (side.value === '
|
|
391
|
-
triggerFlip('
|
|
392
|
-
} else if (side.value === 'right' && ribbonCenterX < midpoint) {
|
|
393
|
-
triggerFlip('left')
|
|
402
|
+
// Convert screen-space drag delta to SVG-space units
|
|
403
|
+
const scale = SVG_SIZE / getSvgPixelSize()
|
|
404
|
+
stretchX.value = (e.clientX - dragStart.x) * scale
|
|
405
|
+
stretchY.value = (e.clientY - dragStart.y) * scale
|
|
406
|
+
|
|
407
|
+
// Flip when the pointer crosses the viewport midpoint
|
|
408
|
+
const mid = window.innerWidth / 2
|
|
409
|
+
if (side.value === 'left' && e.clientX > mid) {
|
|
410
|
+
triggerFlip('right', e)
|
|
411
|
+
} else if (side.value === 'right' && e.clientX < mid) {
|
|
412
|
+
triggerFlip('left', e)
|
|
394
413
|
}
|
|
395
414
|
}
|
|
396
415
|
|
|
397
|
-
function onPointerUp(e
|
|
398
|
-
|
|
416
|
+
function onPointerUp(e: PointerEvent) {
|
|
417
|
+
if (touchOverlayRef.value) {
|
|
418
|
+
try { touchOverlayRef.value.releasePointerCapture(e.pointerId) } catch { /* already released */ }
|
|
419
|
+
}
|
|
399
420
|
isDragging.value = false
|
|
400
421
|
dragStart = null
|
|
401
422
|
removeListeners()
|
|
402
|
-
|
|
403
|
-
// Spring back to rest position
|
|
404
423
|
animateSnapBack()
|
|
405
424
|
}
|
|
406
425
|
|
|
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
426
|
function removeListeners() {
|
|
421
427
|
globalThis.removeEventListener('pointermove', onPointerMove)
|
|
422
428
|
globalThis.removeEventListener('pointerup', onPointerUp)
|
|
423
|
-
globalThis.removeEventListener('pointercancel',
|
|
429
|
+
globalThis.removeEventListener('pointercancel', onPointerUp)
|
|
424
430
|
}
|
|
425
431
|
|
|
426
432
|
/**
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
433
|
+
* Snap the ribbon to the opposite corner when the pointer crosses
|
|
434
|
+
* the viewport midpoint. Resets the elastic stretch, switches side,
|
|
435
|
+
* and persists the user's preference.
|
|
430
436
|
*/
|
|
431
|
-
function triggerFlip(newSide: 'left' | 'right') {
|
|
437
|
+
function triggerFlip(newSide: 'left' | 'right', e: PointerEvent) {
|
|
438
|
+
// Reset stretch to default shape
|
|
439
|
+
stretchX.value = 0
|
|
440
|
+
stretchY.value = 0
|
|
441
|
+
|
|
432
442
|
// End drag
|
|
433
443
|
isDragging.value = false
|
|
434
444
|
dragStart = null
|
|
435
445
|
removeListeners()
|
|
436
446
|
|
|
437
447
|
// Release pointer capture
|
|
438
|
-
if (
|
|
439
|
-
try {
|
|
448
|
+
if (touchOverlayRef.value) {
|
|
449
|
+
try { touchOverlayRef.value.releasePointerCapture(e.pointerId) } catch { /* noop */ }
|
|
440
450
|
}
|
|
441
|
-
capturedTarget = null
|
|
442
|
-
lastPointerId = null
|
|
443
451
|
|
|
444
|
-
//
|
|
452
|
+
// Switch to the new side
|
|
445
453
|
side.value = newSide
|
|
446
454
|
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
455
|
}
|
|
462
456
|
|
|
463
|
-
/** Spring-physics animation to snap the
|
|
457
|
+
/** Spring-physics animation to snap the control point back to default */
|
|
464
458
|
function animateSnapBack() {
|
|
465
|
-
const targetX = getRestX()
|
|
466
|
-
const targetY = 0
|
|
467
459
|
const stiffness = 0.12
|
|
468
460
|
const damping = 0.72
|
|
469
461
|
let vx = 0
|
|
470
462
|
let vy = 0
|
|
471
463
|
|
|
472
464
|
function step() {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
vy = (vy - stiffness * dyToTarget) * damping
|
|
478
|
-
posX.value += vx
|
|
479
|
-
posY.value += vy
|
|
465
|
+
vx = (vx - stiffness * stretchX.value) * damping
|
|
466
|
+
vy = (vy - stiffness * stretchY.value) * damping
|
|
467
|
+
stretchX.value += vx
|
|
468
|
+
stretchY.value += vy
|
|
480
469
|
|
|
481
470
|
if (
|
|
482
|
-
Math.abs(
|
|
483
|
-
Math.abs(
|
|
471
|
+
Math.abs(stretchX.value) < 0.3 &&
|
|
472
|
+
Math.abs(stretchY.value) < 0.3 &&
|
|
484
473
|
Math.abs(vx) < 0.3 &&
|
|
485
474
|
Math.abs(vy) < 0.3
|
|
486
475
|
) {
|
|
487
|
-
|
|
488
|
-
|
|
476
|
+
stretchX.value = 0
|
|
477
|
+
stretchY.value = 0
|
|
489
478
|
springRaf = 0
|
|
490
479
|
return
|
|
491
480
|
}
|
|
@@ -501,31 +490,31 @@ function animateSnapBack() {
|
|
|
501
490
|
/* ------------------------------------------------------------------ */
|
|
502
491
|
onBeforeUnmount(() => {
|
|
503
492
|
if (springRaf) cancelAnimationFrame(springRaf)
|
|
504
|
-
|
|
505
|
-
globalThis.removeEventListener('pointermove', onPointerMove)
|
|
506
|
-
globalThis.removeEventListener('pointerup', onPointerUp)
|
|
507
|
-
globalThis.removeEventListener('pointercancel', onPointerCancel)
|
|
508
|
-
window.removeEventListener('resize', onResize)
|
|
493
|
+
removeListeners()
|
|
509
494
|
})
|
|
510
495
|
</script>
|
|
511
496
|
|
|
512
497
|
<style scoped>
|
|
513
498
|
/* ------------------------------------------------------------------ */
|
|
514
|
-
/* Container — fixed
|
|
499
|
+
/* Container — fixed in corner, non-blocking */
|
|
515
500
|
/* ------------------------------------------------------------------ */
|
|
516
501
|
.dcs-ribbon-container {
|
|
517
502
|
position: fixed;
|
|
518
503
|
top: 0;
|
|
519
|
-
left: 0;
|
|
520
504
|
z-index: 2147483647;
|
|
521
505
|
pointer-events: none;
|
|
522
506
|
user-select: none;
|
|
523
507
|
-webkit-user-select: none;
|
|
524
508
|
}
|
|
525
509
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
510
|
+
.dcs-ribbon-container--left {
|
|
511
|
+
left: 0;
|
|
512
|
+
right: auto;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
.dcs-ribbon-container--right {
|
|
516
|
+
right: 0;
|
|
517
|
+
left: auto;
|
|
529
518
|
}
|
|
530
519
|
|
|
531
520
|
/* ------------------------------------------------------------------ */
|
|
@@ -540,16 +529,10 @@ onBeforeUnmount(() => {
|
|
|
540
529
|
}
|
|
541
530
|
|
|
542
531
|
/* ------------------------------------------------------------------ */
|
|
543
|
-
/* Band (
|
|
532
|
+
/* Band (visual only — touch handled by overlay div) */
|
|
544
533
|
/* ------------------------------------------------------------------ */
|
|
545
534
|
.dcs-ribbon-band {
|
|
546
|
-
pointer-events:
|
|
547
|
-
cursor: grab;
|
|
548
|
-
touch-action: none;
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
.dcs-ribbon-band--dragging {
|
|
552
|
-
cursor: grabbing;
|
|
535
|
+
pointer-events: none;
|
|
553
536
|
}
|
|
554
537
|
|
|
555
538
|
/* ------------------------------------------------------------------ */
|
|
@@ -584,12 +567,41 @@ onBeforeUnmount(() => {
|
|
|
584
567
|
display: none;
|
|
585
568
|
}
|
|
586
569
|
|
|
570
|
+
/* ------------------------------------------------------------------ */
|
|
571
|
+
/* Touch overlay — transparent HTML div for reliable mobile touch */
|
|
572
|
+
/* */
|
|
573
|
+
/* Android Chrome ignores touch-action on SVG <path> elements because */
|
|
574
|
+
/* the compositor only walks HTML elements. This HTML <div> with */
|
|
575
|
+
/* clip-path creates a shaped, invisible hit area where touch-action */
|
|
576
|
+
/* is guaranteed to work. */
|
|
577
|
+
/* ------------------------------------------------------------------ */
|
|
578
|
+
.dcs-ribbon-touch {
|
|
579
|
+
position: absolute;
|
|
580
|
+
top: 0;
|
|
581
|
+
left: 0;
|
|
582
|
+
width: 260px;
|
|
583
|
+
height: 260px;
|
|
584
|
+
touch-action: none;
|
|
585
|
+
pointer-events: auto;
|
|
586
|
+
cursor: grab;
|
|
587
|
+
-webkit-tap-highlight-color: transparent;
|
|
588
|
+
/* clip-path set via :style binding for left/right side */
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
.dcs-ribbon-touch--dragging {
|
|
592
|
+
cursor: grabbing;
|
|
593
|
+
}
|
|
594
|
+
|
|
587
595
|
/* Compact label for narrow screens */
|
|
588
596
|
@media (max-width: 640px) {
|
|
589
597
|
.dcs-ribbon-svg {
|
|
590
598
|
width: 200px;
|
|
591
599
|
height: 200px;
|
|
592
600
|
}
|
|
601
|
+
.dcs-ribbon-touch {
|
|
602
|
+
width: 200px;
|
|
603
|
+
height: 200px;
|
|
604
|
+
}
|
|
593
605
|
.dcs-ribbon-text--full {
|
|
594
606
|
display: none;
|
|
595
607
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Convenience component that renders a responsive `<picture>` element for
|
|
4
|
+
* DCS CDN-hosted images. For non-CDN images it falls back to a plain `<img>`.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```vue
|
|
8
|
+
* <ResponsiveImage
|
|
9
|
+
* src="https://files.duffcloudservices.com/kept/assets/hero/abc-123.jpg"
|
|
10
|
+
* alt="Hero banner"
|
|
11
|
+
* context="hero"
|
|
12
|
+
* class="w-full h-full object-cover"
|
|
13
|
+
* />
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
import { useResponsiveImage } from '../composables/useResponsiveImage'
|
|
17
|
+
import type { ImageContext } from '@duffcloudservices/cms-core'
|
|
18
|
+
|
|
19
|
+
const props = defineProps<{
|
|
20
|
+
/** Image source URL (original CDN URL or local path). */
|
|
21
|
+
src: string
|
|
22
|
+
/** Alt text for accessibility. */
|
|
23
|
+
alt: string
|
|
24
|
+
/** Sizing context — determines which variants to include. */
|
|
25
|
+
context?: ImageContext
|
|
26
|
+
/** CSS class(es) applied to the `<img>` element. */
|
|
27
|
+
class?: string
|
|
28
|
+
/** Optional `sizes` attribute override. */
|
|
29
|
+
sizes?: string
|
|
30
|
+
/** Skip responsive variants and use the original URL only. */
|
|
31
|
+
original?: boolean
|
|
32
|
+
}>()
|
|
33
|
+
|
|
34
|
+
const image = useResponsiveImage({
|
|
35
|
+
src: () => props.src,
|
|
36
|
+
alt: () => props.alt,
|
|
37
|
+
context: () => props.context ?? 'content',
|
|
38
|
+
sizes: () => props.sizes,
|
|
39
|
+
original: () => props.original,
|
|
40
|
+
})
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<template>
|
|
44
|
+
<picture v-if="image.hasVariants && !original">
|
|
45
|
+
<source
|
|
46
|
+
v-for="source in image.sources"
|
|
47
|
+
:key="source.type"
|
|
48
|
+
:srcset="source.srcset"
|
|
49
|
+
:type="source.type"
|
|
50
|
+
:sizes="source.sizes"
|
|
51
|
+
/>
|
|
52
|
+
<img v-bind="image.imgProps" :class="props.class" />
|
|
53
|
+
</picture>
|
|
54
|
+
<img v-else v-bind="image.imgProps" :class="props.class" />
|
|
55
|
+
</template>
|