@duffcloudservices/cms 0.3.12 → 0.3.14
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/README.md +332 -309
- package/dist/editor/editorBridge.js +127 -50
- package/dist/editor/editorBridge.js.map +1 -1
- package/dist/index.js +59 -13
- package/dist/index.js.map +1 -1
- package/dist/plugins/index.js.map +1 -1
- package/package.json +90 -90
- package/src/components/DcsReviewShowcase.vue +321 -326
- package/src/components/PreviewRibbon.vue +612 -612
- package/src/components/ResponsiveImage.vue +55 -55
- package/src/composables/index.ts +10 -10
- package/src/composables/useMediaCarousel.ts +158 -158
- package/src/composables/useReleaseNotes.ts +153 -153
- package/src/composables/useResponsiveImage.ts +85 -85
- package/src/composables/useReviewContent.ts +150 -92
- package/src/composables/useSEO.ts +387 -387
- package/src/composables/useSiteVersion.ts +123 -123
- package/src/composables/useTextContent.ts +297 -297
|
@@ -1,613 +1,613 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<Teleport to="body">
|
|
3
|
-
<div v-if="isVisible" :class="['dcs-ribbon-container', `dcs-ribbon-container--${side}`]">
|
|
4
|
-
<svg
|
|
5
|
-
:viewBox="`0 0 ${SVG_SIZE} ${SVG_SIZE}`"
|
|
6
|
-
class="dcs-ribbon-svg"
|
|
7
|
-
>
|
|
8
|
-
<defs>
|
|
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
|
-
>
|
|
17
|
-
<stop offset="0%" stop-color="#1a1a2e" />
|
|
18
|
-
<stop offset="40%" stop-color="#16213e" />
|
|
19
|
-
<stop offset="100%" stop-color="#0f3460" />
|
|
20
|
-
</linearGradient>
|
|
21
|
-
<filter id="dcs-ribbon-ts">
|
|
22
|
-
<feDropShadow dx="0" dy="1" stdDeviation="0.8" flood-color="rgba(0,0,0,0.5)" />
|
|
23
|
-
</filter>
|
|
24
|
-
</defs>
|
|
25
|
-
|
|
26
|
-
<!-- Drop shadow (offset copy of band) -->
|
|
27
|
-
<path
|
|
28
|
-
:d="bandPath"
|
|
29
|
-
fill="rgba(0,0,0,0.2)"
|
|
30
|
-
:transform="side === 'left' ? 'translate(2,3)' : 'translate(-2,3)'"
|
|
31
|
-
/>
|
|
32
|
-
|
|
33
|
-
<!-- Main ribbon band -->
|
|
34
|
-
<path
|
|
35
|
-
:d="bandPath"
|
|
36
|
-
fill="url(#dcs-ribbon-grad)"
|
|
37
|
-
stroke="rgba(255,255,255,0.08)"
|
|
38
|
-
stroke-width="1"
|
|
39
|
-
class="dcs-ribbon-band"
|
|
40
|
-
/>
|
|
41
|
-
|
|
42
|
-
<!-- Decorative stitched borders -->
|
|
43
|
-
<path :d="outerStitchPath" class="dcs-ribbon-stitch" />
|
|
44
|
-
<path :d="innerStitchPath" class="dcs-ribbon-stitch" />
|
|
45
|
-
|
|
46
|
-
<!-- Text: full (wide screens) -->
|
|
47
|
-
<text
|
|
48
|
-
class="dcs-ribbon-text dcs-ribbon-text--full"
|
|
49
|
-
dominant-baseline="central"
|
|
50
|
-
filter="url(#dcs-ribbon-ts)"
|
|
51
|
-
>
|
|
52
|
-
<textPath href="#dcs-ribbon-text-path" startOffset="50%" text-anchor="middle">
|
|
53
|
-
Duff Cloud Services Preview Site{{ displayVersion }}
|
|
54
|
-
</textPath>
|
|
55
|
-
</text>
|
|
56
|
-
|
|
57
|
-
<!-- Text: compact (narrow screens) -->
|
|
58
|
-
<text
|
|
59
|
-
class="dcs-ribbon-text dcs-ribbon-text--compact"
|
|
60
|
-
dominant-baseline="central"
|
|
61
|
-
filter="url(#dcs-ribbon-ts)"
|
|
62
|
-
>
|
|
63
|
-
<textPath href="#dcs-ribbon-text-path" startOffset="50%" text-anchor="middle">
|
|
64
|
-
DCS Preview{{ displayVersion }}
|
|
65
|
-
</textPath>
|
|
66
|
-
</text>
|
|
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
|
-
/>
|
|
82
|
-
</div>
|
|
83
|
-
</Teleport>
|
|
84
|
-
</template>
|
|
85
|
-
|
|
86
|
-
<script setup lang="ts">
|
|
87
|
-
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
|
88
|
-
|
|
89
|
-
/* ------------------------------------------------------------------ */
|
|
90
|
-
/* Props */
|
|
91
|
-
/* ------------------------------------------------------------------ */
|
|
92
|
-
const props = withDefaults(
|
|
93
|
-
defineProps<{
|
|
94
|
-
/** Override the auto-detected version string (e.g. "1.2.3"). */
|
|
95
|
-
version?: string | null
|
|
96
|
-
}>(),
|
|
97
|
-
{
|
|
98
|
-
version: null,
|
|
99
|
-
},
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
/* ------------------------------------------------------------------ */
|
|
103
|
-
/* Geometry constants */
|
|
104
|
-
/* ------------------------------------------------------------------ */
|
|
105
|
-
|
|
106
|
-
/** SVG viewBox size (square, covers the ribbon corner area) */
|
|
107
|
-
const SVG_SIZE = 260
|
|
108
|
-
|
|
109
|
-
/** Where the ribbon center-line meets each viewport edge */
|
|
110
|
-
const EP = 220
|
|
111
|
-
|
|
112
|
-
/** Half the ribbon band width (perpendicular to curve) */
|
|
113
|
-
const HW = 20
|
|
114
|
-
|
|
115
|
-
/** Default bezier control-point position (x = y → symmetric 45° curve) */
|
|
116
|
-
const CP0 = 90
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Perpendicular offset for band edges relative to center control point.
|
|
120
|
-
* HW / √2 because the perpendicular to a 45° diagonal has (1/√2, 1/√2)
|
|
121
|
-
* components.
|
|
122
|
-
*/
|
|
123
|
-
const CPO = Math.round(HW / Math.SQRT2) // ≈ 14
|
|
124
|
-
|
|
125
|
-
/** Stitch-line inset from band edges (px in SVG space) */
|
|
126
|
-
const STI = 4
|
|
127
|
-
|
|
128
|
-
/* ------------------------------------------------------------------ */
|
|
129
|
-
/* Constants */
|
|
130
|
-
/* ------------------------------------------------------------------ */
|
|
131
|
-
|
|
132
|
-
/** The preview domain where the ribbon should be displayed */
|
|
133
|
-
const PREVIEW_DOMAIN = 'preview.duffcloudservices.com'
|
|
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
|
-
|
|
142
|
-
/** localStorage key for persisting which corner the ribbon is docked to */
|
|
143
|
-
const STORAGE_KEY = 'dcs-preview-ribbon-side'
|
|
144
|
-
|
|
145
|
-
/* ------------------------------------------------------------------ */
|
|
146
|
-
/* Visibility gate */
|
|
147
|
-
/* ------------------------------------------------------------------ */
|
|
148
|
-
const isVisible = ref(false)
|
|
149
|
-
|
|
150
|
-
function shouldShow(): boolean {
|
|
151
|
-
if (globalThis.window === undefined) return false
|
|
152
|
-
|
|
153
|
-
const host = globalThis.location.hostname
|
|
154
|
-
|
|
155
|
-
// Only show on the preview domain
|
|
156
|
-
if (host !== PREVIEW_DOMAIN) return false
|
|
157
|
-
|
|
158
|
-
// Never show in the visual page editor (iframe inside portal)
|
|
159
|
-
try {
|
|
160
|
-
if (globalThis.self !== globalThis.top) return false
|
|
161
|
-
} catch {
|
|
162
|
-
// cross-origin – inside an iframe → editor context
|
|
163
|
-
return false
|
|
164
|
-
}
|
|
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
|
-
|
|
170
|
-
return true
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/* ------------------------------------------------------------------ */
|
|
174
|
-
/* Side state (which corner the ribbon is docked to, persisted) */
|
|
175
|
-
/* ------------------------------------------------------------------ */
|
|
176
|
-
const side = ref<'left' | 'right'>('left')
|
|
177
|
-
|
|
178
|
-
function loadSide(): 'left' | 'right' {
|
|
179
|
-
try {
|
|
180
|
-
const s = localStorage.getItem(STORAGE_KEY)
|
|
181
|
-
if (s === 'left' || s === 'right') return s
|
|
182
|
-
} catch { /* noop */ }
|
|
183
|
-
return 'left'
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function saveSide(s: 'left' | 'right') {
|
|
187
|
-
try { localStorage.setItem(STORAGE_KEY, s) } catch { /* noop */ }
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/* ------------------------------------------------------------------ */
|
|
191
|
-
/* Version display */
|
|
192
|
-
/* ------------------------------------------------------------------ */
|
|
193
|
-
const resolvedVersion = ref<string | null>(props.version ?? null)
|
|
194
|
-
|
|
195
|
-
const displayVersion = computed(() =>
|
|
196
|
-
resolvedVersion.value ? ` v${resolvedVersion.value}` : '',
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
// Sync prop changes
|
|
200
|
-
watch(
|
|
201
|
-
() => props.version,
|
|
202
|
-
(v) => {
|
|
203
|
-
if (v !== null && v !== undefined) resolvedVersion.value = v
|
|
204
|
-
},
|
|
205
|
-
)
|
|
206
|
-
|
|
207
|
-
/* ------------------------------------------------------------------ */
|
|
208
|
-
/* Lifecycle */
|
|
209
|
-
/* ------------------------------------------------------------------ */
|
|
210
|
-
onMounted(async () => {
|
|
211
|
-
isVisible.value = shouldShow()
|
|
212
|
-
if (!isVisible.value) return
|
|
213
|
-
|
|
214
|
-
side.value = loadSide()
|
|
215
|
-
|
|
216
|
-
// Resolve version
|
|
217
|
-
if (resolvedVersion.value) return
|
|
218
|
-
|
|
219
|
-
try {
|
|
220
|
-
const envVersion = (import.meta.env as Record<string, string | undefined>)
|
|
221
|
-
.VITE_SITE_VERSION
|
|
222
|
-
if (envVersion) {
|
|
223
|
-
resolvedVersion.value = envVersion
|
|
224
|
-
return
|
|
225
|
-
}
|
|
226
|
-
} catch {
|
|
227
|
-
/* noop */
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
try {
|
|
231
|
-
const slug =
|
|
232
|
-
(import.meta.env as Record<string, string | undefined>).VITE_SITE_SLUG ??
|
|
233
|
-
''
|
|
234
|
-
if (!slug) return
|
|
235
|
-
const base =
|
|
236
|
-
(import.meta.env as Record<string, string | undefined>)
|
|
237
|
-
.VITE_API_BASE_URL ?? 'https://portal.duffcloudservices.com'
|
|
238
|
-
const res = await fetch(
|
|
239
|
-
`${base}/api/v1/sites/${slug}/release-notes/latest`,
|
|
240
|
-
{ headers: { Accept: 'application/json' } },
|
|
241
|
-
)
|
|
242
|
-
if (res.ok) {
|
|
243
|
-
const data = await res.json()
|
|
244
|
-
if (data.version) resolvedVersion.value = data.version
|
|
245
|
-
}
|
|
246
|
-
} catch {
|
|
247
|
-
/* non-critical */
|
|
248
|
-
}
|
|
249
|
-
})
|
|
250
|
-
|
|
251
|
-
/* ------------------------------------------------------------------ */
|
|
252
|
-
/* Elastic stretch state (control-point displacement) */
|
|
253
|
-
/* ------------------------------------------------------------------ */
|
|
254
|
-
const stretchX = ref(0)
|
|
255
|
-
const stretchY = ref(0)
|
|
256
|
-
const isDragging = ref(false)
|
|
257
|
-
|
|
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)
|
|
270
|
-
|
|
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))
|
|
277
|
-
|
|
278
|
-
/* ------------------------------------------------------------------ */
|
|
279
|
-
/* SVG path computation (side-aware, with elastic control-point) */
|
|
280
|
-
/* ------------------------------------------------------------------ */
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Text center-line. On the right side the path direction is reversed
|
|
284
|
-
* so that textPath renders left-to-right (readable).
|
|
285
|
-
*/
|
|
286
|
-
const textPath = computed(() => {
|
|
287
|
-
if (side.value === 'left') {
|
|
288
|
-
return `M 0,${EP} Q ${cx.value},${cy.value} ${EP},0`
|
|
289
|
-
}
|
|
290
|
-
return `M ${SVG_SIZE - EP},0 Q ${cx.value},${cy.value} ${SVG_SIZE},${EP}`
|
|
291
|
-
})
|
|
292
|
-
|
|
293
|
-
const bandPath = computed(() => {
|
|
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
|
|
301
|
-
|
|
302
|
-
if (side.value === 'left') {
|
|
303
|
-
return [
|
|
304
|
-
`M 0,${EP + HW} Q ${ocx},${ocy} ${EP + HW},0`,
|
|
305
|
-
`L ${EP - HW},0`,
|
|
306
|
-
`Q ${icx},${icy} 0,${EP - HW}`,
|
|
307
|
-
`Z`,
|
|
308
|
-
].join(' ')
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
return [
|
|
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}`,
|
|
315
|
-
`Z`,
|
|
316
|
-
].join(' ')
|
|
317
|
-
})
|
|
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
|
-
|
|
362
|
-
/* ------------------------------------------------------------------ */
|
|
363
|
-
/* Drag: elastic control-point stretch + flip on midpoint crossing */
|
|
364
|
-
/* ------------------------------------------------------------------ */
|
|
365
|
-
const touchOverlayRef = ref<HTMLDivElement | null>(null)
|
|
366
|
-
let dragStart: { x: number; y: number } | null = null
|
|
367
|
-
let springRaf = 0
|
|
368
|
-
|
|
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
|
-
}
|
|
376
|
-
|
|
377
|
-
function onPointerDown(e: PointerEvent) {
|
|
378
|
-
// Cancel any in-progress spring animation
|
|
379
|
-
if (springRaf) {
|
|
380
|
-
cancelAnimationFrame(springRaf)
|
|
381
|
-
springRaf = 0
|
|
382
|
-
}
|
|
383
|
-
|
|
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
|
|
389
|
-
target.setPointerCapture(e.pointerId)
|
|
390
|
-
|
|
391
|
-
isDragging.value = true
|
|
392
|
-
dragStart = { x: e.clientX, y: e.clientY }
|
|
393
|
-
|
|
394
|
-
globalThis.addEventListener('pointermove', onPointerMove)
|
|
395
|
-
globalThis.addEventListener('pointerup', onPointerUp)
|
|
396
|
-
globalThis.addEventListener('pointercancel', onPointerUp)
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
function onPointerMove(e: PointerEvent) {
|
|
400
|
-
if (!dragStart) return
|
|
401
|
-
|
|
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)
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
function onPointerUp(e: PointerEvent) {
|
|
417
|
-
if (touchOverlayRef.value) {
|
|
418
|
-
try { touchOverlayRef.value.releasePointerCapture(e.pointerId) } catch { /* already released */ }
|
|
419
|
-
}
|
|
420
|
-
isDragging.value = false
|
|
421
|
-
dragStart = null
|
|
422
|
-
removeListeners()
|
|
423
|
-
animateSnapBack()
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
function removeListeners() {
|
|
427
|
-
globalThis.removeEventListener('pointermove', onPointerMove)
|
|
428
|
-
globalThis.removeEventListener('pointerup', onPointerUp)
|
|
429
|
-
globalThis.removeEventListener('pointercancel', onPointerUp)
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/**
|
|
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.
|
|
436
|
-
*/
|
|
437
|
-
function triggerFlip(newSide: 'left' | 'right', e: PointerEvent) {
|
|
438
|
-
// Reset stretch to default shape
|
|
439
|
-
stretchX.value = 0
|
|
440
|
-
stretchY.value = 0
|
|
441
|
-
|
|
442
|
-
// End drag
|
|
443
|
-
isDragging.value = false
|
|
444
|
-
dragStart = null
|
|
445
|
-
removeListeners()
|
|
446
|
-
|
|
447
|
-
// Release pointer capture
|
|
448
|
-
if (touchOverlayRef.value) {
|
|
449
|
-
try { touchOverlayRef.value.releasePointerCapture(e.pointerId) } catch { /* noop */ }
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
// Switch to the new side
|
|
453
|
-
side.value = newSide
|
|
454
|
-
saveSide(newSide)
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/** Spring-physics animation to snap the control point back to default */
|
|
458
|
-
function animateSnapBack() {
|
|
459
|
-
const stiffness = 0.12
|
|
460
|
-
const damping = 0.72
|
|
461
|
-
let vx = 0
|
|
462
|
-
let vy = 0
|
|
463
|
-
|
|
464
|
-
function step() {
|
|
465
|
-
vx = (vx - stiffness * stretchX.value) * damping
|
|
466
|
-
vy = (vy - stiffness * stretchY.value) * damping
|
|
467
|
-
stretchX.value += vx
|
|
468
|
-
stretchY.value += vy
|
|
469
|
-
|
|
470
|
-
if (
|
|
471
|
-
Math.abs(stretchX.value) < 0.3 &&
|
|
472
|
-
Math.abs(stretchY.value) < 0.3 &&
|
|
473
|
-
Math.abs(vx) < 0.3 &&
|
|
474
|
-
Math.abs(vy) < 0.3
|
|
475
|
-
) {
|
|
476
|
-
stretchX.value = 0
|
|
477
|
-
stretchY.value = 0
|
|
478
|
-
springRaf = 0
|
|
479
|
-
return
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
springRaf = requestAnimationFrame(step)
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
springRaf = requestAnimationFrame(step)
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/* ------------------------------------------------------------------ */
|
|
489
|
-
/* Cleanup */
|
|
490
|
-
/* ------------------------------------------------------------------ */
|
|
491
|
-
onBeforeUnmount(() => {
|
|
492
|
-
if (springRaf) cancelAnimationFrame(springRaf)
|
|
493
|
-
removeListeners()
|
|
494
|
-
})
|
|
495
|
-
</script>
|
|
496
|
-
|
|
497
|
-
<style scoped>
|
|
498
|
-
/* ------------------------------------------------------------------ */
|
|
499
|
-
/* Container — fixed in corner, non-blocking */
|
|
500
|
-
/* ------------------------------------------------------------------ */
|
|
501
|
-
.dcs-ribbon-container {
|
|
502
|
-
position: fixed;
|
|
503
|
-
top: 0;
|
|
504
|
-
z-index: 2147483647;
|
|
505
|
-
pointer-events: none;
|
|
506
|
-
user-select: none;
|
|
507
|
-
-webkit-user-select: none;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
.dcs-ribbon-container--left {
|
|
511
|
-
left: 0;
|
|
512
|
-
right: auto;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
.dcs-ribbon-container--right {
|
|
516
|
-
right: 0;
|
|
517
|
-
left: auto;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
/* ------------------------------------------------------------------ */
|
|
521
|
-
/* SVG canvas */
|
|
522
|
-
/* ------------------------------------------------------------------ */
|
|
523
|
-
.dcs-ribbon-svg {
|
|
524
|
-
display: block;
|
|
525
|
-
width: 260px;
|
|
526
|
-
height: 260px;
|
|
527
|
-
overflow: visible;
|
|
528
|
-
pointer-events: none;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
/* ------------------------------------------------------------------ */
|
|
532
|
-
/* Band (visual only — touch handled by overlay div) */
|
|
533
|
-
/* ------------------------------------------------------------------ */
|
|
534
|
-
.dcs-ribbon-band {
|
|
535
|
-
pointer-events: none;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
/* ------------------------------------------------------------------ */
|
|
539
|
-
/* Stitched borders */
|
|
540
|
-
/* ------------------------------------------------------------------ */
|
|
541
|
-
.dcs-ribbon-stitch {
|
|
542
|
-
fill: none;
|
|
543
|
-
stroke: rgba(255, 255, 255, 0.15);
|
|
544
|
-
stroke-width: 1;
|
|
545
|
-
stroke-dasharray: 4 3;
|
|
546
|
-
pointer-events: none;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
/* ------------------------------------------------------------------ */
|
|
550
|
-
/* Text */
|
|
551
|
-
/* ------------------------------------------------------------------ */
|
|
552
|
-
.dcs-ribbon-text {
|
|
553
|
-
fill: #e0e7ff;
|
|
554
|
-
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
|
555
|
-
font-weight: 600;
|
|
556
|
-
font-size: 12px;
|
|
557
|
-
letter-spacing: 0.06em;
|
|
558
|
-
text-transform: uppercase;
|
|
559
|
-
pointer-events: none;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/* Full label for wide screens */
|
|
563
|
-
.dcs-ribbon-text--full {
|
|
564
|
-
display: block;
|
|
565
|
-
}
|
|
566
|
-
.dcs-ribbon-text--compact {
|
|
567
|
-
display: none;
|
|
568
|
-
}
|
|
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
|
-
|
|
595
|
-
/* Compact label for narrow screens */
|
|
596
|
-
@media (max-width: 640px) {
|
|
597
|
-
.dcs-ribbon-svg {
|
|
598
|
-
width: 200px;
|
|
599
|
-
height: 200px;
|
|
600
|
-
}
|
|
601
|
-
.dcs-ribbon-touch {
|
|
602
|
-
width: 200px;
|
|
603
|
-
height: 200px;
|
|
604
|
-
}
|
|
605
|
-
.dcs-ribbon-text--full {
|
|
606
|
-
display: none;
|
|
607
|
-
}
|
|
608
|
-
.dcs-ribbon-text--compact {
|
|
609
|
-
display: block;
|
|
610
|
-
font-size: 11px;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
1
|
+
<template>
|
|
2
|
+
<Teleport to="body">
|
|
3
|
+
<div v-if="isVisible" :class="['dcs-ribbon-container', `dcs-ribbon-container--${side}`]">
|
|
4
|
+
<svg
|
|
5
|
+
:viewBox="`0 0 ${SVG_SIZE} ${SVG_SIZE}`"
|
|
6
|
+
class="dcs-ribbon-svg"
|
|
7
|
+
>
|
|
8
|
+
<defs>
|
|
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
|
+
>
|
|
17
|
+
<stop offset="0%" stop-color="#1a1a2e" />
|
|
18
|
+
<stop offset="40%" stop-color="#16213e" />
|
|
19
|
+
<stop offset="100%" stop-color="#0f3460" />
|
|
20
|
+
</linearGradient>
|
|
21
|
+
<filter id="dcs-ribbon-ts">
|
|
22
|
+
<feDropShadow dx="0" dy="1" stdDeviation="0.8" flood-color="rgba(0,0,0,0.5)" />
|
|
23
|
+
</filter>
|
|
24
|
+
</defs>
|
|
25
|
+
|
|
26
|
+
<!-- Drop shadow (offset copy of band) -->
|
|
27
|
+
<path
|
|
28
|
+
:d="bandPath"
|
|
29
|
+
fill="rgba(0,0,0,0.2)"
|
|
30
|
+
:transform="side === 'left' ? 'translate(2,3)' : 'translate(-2,3)'"
|
|
31
|
+
/>
|
|
32
|
+
|
|
33
|
+
<!-- Main ribbon band -->
|
|
34
|
+
<path
|
|
35
|
+
:d="bandPath"
|
|
36
|
+
fill="url(#dcs-ribbon-grad)"
|
|
37
|
+
stroke="rgba(255,255,255,0.08)"
|
|
38
|
+
stroke-width="1"
|
|
39
|
+
class="dcs-ribbon-band"
|
|
40
|
+
/>
|
|
41
|
+
|
|
42
|
+
<!-- Decorative stitched borders -->
|
|
43
|
+
<path :d="outerStitchPath" class="dcs-ribbon-stitch" />
|
|
44
|
+
<path :d="innerStitchPath" class="dcs-ribbon-stitch" />
|
|
45
|
+
|
|
46
|
+
<!-- Text: full (wide screens) -->
|
|
47
|
+
<text
|
|
48
|
+
class="dcs-ribbon-text dcs-ribbon-text--full"
|
|
49
|
+
dominant-baseline="central"
|
|
50
|
+
filter="url(#dcs-ribbon-ts)"
|
|
51
|
+
>
|
|
52
|
+
<textPath href="#dcs-ribbon-text-path" startOffset="50%" text-anchor="middle">
|
|
53
|
+
Duff Cloud Services Preview Site{{ displayVersion }}
|
|
54
|
+
</textPath>
|
|
55
|
+
</text>
|
|
56
|
+
|
|
57
|
+
<!-- Text: compact (narrow screens) -->
|
|
58
|
+
<text
|
|
59
|
+
class="dcs-ribbon-text dcs-ribbon-text--compact"
|
|
60
|
+
dominant-baseline="central"
|
|
61
|
+
filter="url(#dcs-ribbon-ts)"
|
|
62
|
+
>
|
|
63
|
+
<textPath href="#dcs-ribbon-text-path" startOffset="50%" text-anchor="middle">
|
|
64
|
+
DCS Preview{{ displayVersion }}
|
|
65
|
+
</textPath>
|
|
66
|
+
</text>
|
|
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
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
</Teleport>
|
|
84
|
+
</template>
|
|
85
|
+
|
|
86
|
+
<script setup lang="ts">
|
|
87
|
+
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
|
88
|
+
|
|
89
|
+
/* ------------------------------------------------------------------ */
|
|
90
|
+
/* Props */
|
|
91
|
+
/* ------------------------------------------------------------------ */
|
|
92
|
+
const props = withDefaults(
|
|
93
|
+
defineProps<{
|
|
94
|
+
/** Override the auto-detected version string (e.g. "1.2.3"). */
|
|
95
|
+
version?: string | null
|
|
96
|
+
}>(),
|
|
97
|
+
{
|
|
98
|
+
version: null,
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
/* ------------------------------------------------------------------ */
|
|
103
|
+
/* Geometry constants */
|
|
104
|
+
/* ------------------------------------------------------------------ */
|
|
105
|
+
|
|
106
|
+
/** SVG viewBox size (square, covers the ribbon corner area) */
|
|
107
|
+
const SVG_SIZE = 260
|
|
108
|
+
|
|
109
|
+
/** Where the ribbon center-line meets each viewport edge */
|
|
110
|
+
const EP = 220
|
|
111
|
+
|
|
112
|
+
/** Half the ribbon band width (perpendicular to curve) */
|
|
113
|
+
const HW = 20
|
|
114
|
+
|
|
115
|
+
/** Default bezier control-point position (x = y → symmetric 45° curve) */
|
|
116
|
+
const CP0 = 90
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Perpendicular offset for band edges relative to center control point.
|
|
120
|
+
* HW / √2 because the perpendicular to a 45° diagonal has (1/√2, 1/√2)
|
|
121
|
+
* components.
|
|
122
|
+
*/
|
|
123
|
+
const CPO = Math.round(HW / Math.SQRT2) // ≈ 14
|
|
124
|
+
|
|
125
|
+
/** Stitch-line inset from band edges (px in SVG space) */
|
|
126
|
+
const STI = 4
|
|
127
|
+
|
|
128
|
+
/* ------------------------------------------------------------------ */
|
|
129
|
+
/* Constants */
|
|
130
|
+
/* ------------------------------------------------------------------ */
|
|
131
|
+
|
|
132
|
+
/** The preview domain where the ribbon should be displayed */
|
|
133
|
+
const PREVIEW_DOMAIN = 'preview.duffcloudservices.com'
|
|
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
|
+
|
|
142
|
+
/** localStorage key for persisting which corner the ribbon is docked to */
|
|
143
|
+
const STORAGE_KEY = 'dcs-preview-ribbon-side'
|
|
144
|
+
|
|
145
|
+
/* ------------------------------------------------------------------ */
|
|
146
|
+
/* Visibility gate */
|
|
147
|
+
/* ------------------------------------------------------------------ */
|
|
148
|
+
const isVisible = ref(false)
|
|
149
|
+
|
|
150
|
+
function shouldShow(): boolean {
|
|
151
|
+
if (globalThis.window === undefined) return false
|
|
152
|
+
|
|
153
|
+
const host = globalThis.location.hostname
|
|
154
|
+
|
|
155
|
+
// Only show on the preview domain
|
|
156
|
+
if (host !== PREVIEW_DOMAIN) return false
|
|
157
|
+
|
|
158
|
+
// Never show in the visual page editor (iframe inside portal)
|
|
159
|
+
try {
|
|
160
|
+
if (globalThis.self !== globalThis.top) return false
|
|
161
|
+
} catch {
|
|
162
|
+
// cross-origin – inside an iframe → editor context
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
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
|
+
|
|
170
|
+
return true
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* ------------------------------------------------------------------ */
|
|
174
|
+
/* Side state (which corner the ribbon is docked to, persisted) */
|
|
175
|
+
/* ------------------------------------------------------------------ */
|
|
176
|
+
const side = ref<'left' | 'right'>('left')
|
|
177
|
+
|
|
178
|
+
function loadSide(): 'left' | 'right' {
|
|
179
|
+
try {
|
|
180
|
+
const s = localStorage.getItem(STORAGE_KEY)
|
|
181
|
+
if (s === 'left' || s === 'right') return s
|
|
182
|
+
} catch { /* noop */ }
|
|
183
|
+
return 'left'
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function saveSide(s: 'left' | 'right') {
|
|
187
|
+
try { localStorage.setItem(STORAGE_KEY, s) } catch { /* noop */ }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* ------------------------------------------------------------------ */
|
|
191
|
+
/* Version display */
|
|
192
|
+
/* ------------------------------------------------------------------ */
|
|
193
|
+
const resolvedVersion = ref<string | null>(props.version ?? null)
|
|
194
|
+
|
|
195
|
+
const displayVersion = computed(() =>
|
|
196
|
+
resolvedVersion.value ? ` v${resolvedVersion.value}` : '',
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
// Sync prop changes
|
|
200
|
+
watch(
|
|
201
|
+
() => props.version,
|
|
202
|
+
(v) => {
|
|
203
|
+
if (v !== null && v !== undefined) resolvedVersion.value = v
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
/* ------------------------------------------------------------------ */
|
|
208
|
+
/* Lifecycle */
|
|
209
|
+
/* ------------------------------------------------------------------ */
|
|
210
|
+
onMounted(async () => {
|
|
211
|
+
isVisible.value = shouldShow()
|
|
212
|
+
if (!isVisible.value) return
|
|
213
|
+
|
|
214
|
+
side.value = loadSide()
|
|
215
|
+
|
|
216
|
+
// Resolve version
|
|
217
|
+
if (resolvedVersion.value) return
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const envVersion = (import.meta.env as Record<string, string | undefined>)
|
|
221
|
+
.VITE_SITE_VERSION
|
|
222
|
+
if (envVersion) {
|
|
223
|
+
resolvedVersion.value = envVersion
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
/* noop */
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const slug =
|
|
232
|
+
(import.meta.env as Record<string, string | undefined>).VITE_SITE_SLUG ??
|
|
233
|
+
''
|
|
234
|
+
if (!slug) return
|
|
235
|
+
const base =
|
|
236
|
+
(import.meta.env as Record<string, string | undefined>)
|
|
237
|
+
.VITE_API_BASE_URL ?? 'https://portal.duffcloudservices.com'
|
|
238
|
+
const res = await fetch(
|
|
239
|
+
`${base}/api/v1/sites/${slug}/release-notes/latest`,
|
|
240
|
+
{ headers: { Accept: 'application/json' } },
|
|
241
|
+
)
|
|
242
|
+
if (res.ok) {
|
|
243
|
+
const data = await res.json()
|
|
244
|
+
if (data.version) resolvedVersion.value = data.version
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
/* non-critical */
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
/* ------------------------------------------------------------------ */
|
|
252
|
+
/* Elastic stretch state (control-point displacement) */
|
|
253
|
+
/* ------------------------------------------------------------------ */
|
|
254
|
+
const stretchX = ref(0)
|
|
255
|
+
const stretchY = ref(0)
|
|
256
|
+
const isDragging = ref(false)
|
|
257
|
+
|
|
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)
|
|
270
|
+
|
|
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))
|
|
277
|
+
|
|
278
|
+
/* ------------------------------------------------------------------ */
|
|
279
|
+
/* SVG path computation (side-aware, with elastic control-point) */
|
|
280
|
+
/* ------------------------------------------------------------------ */
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Text center-line. On the right side the path direction is reversed
|
|
284
|
+
* so that textPath renders left-to-right (readable).
|
|
285
|
+
*/
|
|
286
|
+
const textPath = computed(() => {
|
|
287
|
+
if (side.value === 'left') {
|
|
288
|
+
return `M 0,${EP} Q ${cx.value},${cy.value} ${EP},0`
|
|
289
|
+
}
|
|
290
|
+
return `M ${SVG_SIZE - EP},0 Q ${cx.value},${cy.value} ${SVG_SIZE},${EP}`
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
const bandPath = computed(() => {
|
|
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
|
|
301
|
+
|
|
302
|
+
if (side.value === 'left') {
|
|
303
|
+
return [
|
|
304
|
+
`M 0,${EP + HW} Q ${ocx},${ocy} ${EP + HW},0`,
|
|
305
|
+
`L ${EP - HW},0`,
|
|
306
|
+
`Q ${icx},${icy} 0,${EP - HW}`,
|
|
307
|
+
`Z`,
|
|
308
|
+
].join(' ')
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return [
|
|
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}`,
|
|
315
|
+
`Z`,
|
|
316
|
+
].join(' ')
|
|
317
|
+
})
|
|
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
|
+
|
|
362
|
+
/* ------------------------------------------------------------------ */
|
|
363
|
+
/* Drag: elastic control-point stretch + flip on midpoint crossing */
|
|
364
|
+
/* ------------------------------------------------------------------ */
|
|
365
|
+
const touchOverlayRef = ref<HTMLDivElement | null>(null)
|
|
366
|
+
let dragStart: { x: number; y: number } | null = null
|
|
367
|
+
let springRaf = 0
|
|
368
|
+
|
|
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
|
+
}
|
|
376
|
+
|
|
377
|
+
function onPointerDown(e: PointerEvent) {
|
|
378
|
+
// Cancel any in-progress spring animation
|
|
379
|
+
if (springRaf) {
|
|
380
|
+
cancelAnimationFrame(springRaf)
|
|
381
|
+
springRaf = 0
|
|
382
|
+
}
|
|
383
|
+
|
|
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
|
|
389
|
+
target.setPointerCapture(e.pointerId)
|
|
390
|
+
|
|
391
|
+
isDragging.value = true
|
|
392
|
+
dragStart = { x: e.clientX, y: e.clientY }
|
|
393
|
+
|
|
394
|
+
globalThis.addEventListener('pointermove', onPointerMove)
|
|
395
|
+
globalThis.addEventListener('pointerup', onPointerUp)
|
|
396
|
+
globalThis.addEventListener('pointercancel', onPointerUp)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function onPointerMove(e: PointerEvent) {
|
|
400
|
+
if (!dragStart) return
|
|
401
|
+
|
|
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)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function onPointerUp(e: PointerEvent) {
|
|
417
|
+
if (touchOverlayRef.value) {
|
|
418
|
+
try { touchOverlayRef.value.releasePointerCapture(e.pointerId) } catch { /* already released */ }
|
|
419
|
+
}
|
|
420
|
+
isDragging.value = false
|
|
421
|
+
dragStart = null
|
|
422
|
+
removeListeners()
|
|
423
|
+
animateSnapBack()
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function removeListeners() {
|
|
427
|
+
globalThis.removeEventListener('pointermove', onPointerMove)
|
|
428
|
+
globalThis.removeEventListener('pointerup', onPointerUp)
|
|
429
|
+
globalThis.removeEventListener('pointercancel', onPointerUp)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
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.
|
|
436
|
+
*/
|
|
437
|
+
function triggerFlip(newSide: 'left' | 'right', e: PointerEvent) {
|
|
438
|
+
// Reset stretch to default shape
|
|
439
|
+
stretchX.value = 0
|
|
440
|
+
stretchY.value = 0
|
|
441
|
+
|
|
442
|
+
// End drag
|
|
443
|
+
isDragging.value = false
|
|
444
|
+
dragStart = null
|
|
445
|
+
removeListeners()
|
|
446
|
+
|
|
447
|
+
// Release pointer capture
|
|
448
|
+
if (touchOverlayRef.value) {
|
|
449
|
+
try { touchOverlayRef.value.releasePointerCapture(e.pointerId) } catch { /* noop */ }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Switch to the new side
|
|
453
|
+
side.value = newSide
|
|
454
|
+
saveSide(newSide)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Spring-physics animation to snap the control point back to default */
|
|
458
|
+
function animateSnapBack() {
|
|
459
|
+
const stiffness = 0.12
|
|
460
|
+
const damping = 0.72
|
|
461
|
+
let vx = 0
|
|
462
|
+
let vy = 0
|
|
463
|
+
|
|
464
|
+
function step() {
|
|
465
|
+
vx = (vx - stiffness * stretchX.value) * damping
|
|
466
|
+
vy = (vy - stiffness * stretchY.value) * damping
|
|
467
|
+
stretchX.value += vx
|
|
468
|
+
stretchY.value += vy
|
|
469
|
+
|
|
470
|
+
if (
|
|
471
|
+
Math.abs(stretchX.value) < 0.3 &&
|
|
472
|
+
Math.abs(stretchY.value) < 0.3 &&
|
|
473
|
+
Math.abs(vx) < 0.3 &&
|
|
474
|
+
Math.abs(vy) < 0.3
|
|
475
|
+
) {
|
|
476
|
+
stretchX.value = 0
|
|
477
|
+
stretchY.value = 0
|
|
478
|
+
springRaf = 0
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
springRaf = requestAnimationFrame(step)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
springRaf = requestAnimationFrame(step)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/* ------------------------------------------------------------------ */
|
|
489
|
+
/* Cleanup */
|
|
490
|
+
/* ------------------------------------------------------------------ */
|
|
491
|
+
onBeforeUnmount(() => {
|
|
492
|
+
if (springRaf) cancelAnimationFrame(springRaf)
|
|
493
|
+
removeListeners()
|
|
494
|
+
})
|
|
495
|
+
</script>
|
|
496
|
+
|
|
497
|
+
<style scoped>
|
|
498
|
+
/* ------------------------------------------------------------------ */
|
|
499
|
+
/* Container — fixed in corner, non-blocking */
|
|
500
|
+
/* ------------------------------------------------------------------ */
|
|
501
|
+
.dcs-ribbon-container {
|
|
502
|
+
position: fixed;
|
|
503
|
+
top: 0;
|
|
504
|
+
z-index: 2147483647;
|
|
505
|
+
pointer-events: none;
|
|
506
|
+
user-select: none;
|
|
507
|
+
-webkit-user-select: none;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
.dcs-ribbon-container--left {
|
|
511
|
+
left: 0;
|
|
512
|
+
right: auto;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
.dcs-ribbon-container--right {
|
|
516
|
+
right: 0;
|
|
517
|
+
left: auto;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/* ------------------------------------------------------------------ */
|
|
521
|
+
/* SVG canvas */
|
|
522
|
+
/* ------------------------------------------------------------------ */
|
|
523
|
+
.dcs-ribbon-svg {
|
|
524
|
+
display: block;
|
|
525
|
+
width: 260px;
|
|
526
|
+
height: 260px;
|
|
527
|
+
overflow: visible;
|
|
528
|
+
pointer-events: none;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/* ------------------------------------------------------------------ */
|
|
532
|
+
/* Band (visual only — touch handled by overlay div) */
|
|
533
|
+
/* ------------------------------------------------------------------ */
|
|
534
|
+
.dcs-ribbon-band {
|
|
535
|
+
pointer-events: none;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/* ------------------------------------------------------------------ */
|
|
539
|
+
/* Stitched borders */
|
|
540
|
+
/* ------------------------------------------------------------------ */
|
|
541
|
+
.dcs-ribbon-stitch {
|
|
542
|
+
fill: none;
|
|
543
|
+
stroke: rgba(255, 255, 255, 0.15);
|
|
544
|
+
stroke-width: 1;
|
|
545
|
+
stroke-dasharray: 4 3;
|
|
546
|
+
pointer-events: none;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/* ------------------------------------------------------------------ */
|
|
550
|
+
/* Text */
|
|
551
|
+
/* ------------------------------------------------------------------ */
|
|
552
|
+
.dcs-ribbon-text {
|
|
553
|
+
fill: #e0e7ff;
|
|
554
|
+
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
|
555
|
+
font-weight: 600;
|
|
556
|
+
font-size: 12px;
|
|
557
|
+
letter-spacing: 0.06em;
|
|
558
|
+
text-transform: uppercase;
|
|
559
|
+
pointer-events: none;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/* Full label for wide screens */
|
|
563
|
+
.dcs-ribbon-text--full {
|
|
564
|
+
display: block;
|
|
565
|
+
}
|
|
566
|
+
.dcs-ribbon-text--compact {
|
|
567
|
+
display: none;
|
|
568
|
+
}
|
|
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
|
+
|
|
595
|
+
/* Compact label for narrow screens */
|
|
596
|
+
@media (max-width: 640px) {
|
|
597
|
+
.dcs-ribbon-svg {
|
|
598
|
+
width: 200px;
|
|
599
|
+
height: 200px;
|
|
600
|
+
}
|
|
601
|
+
.dcs-ribbon-touch {
|
|
602
|
+
width: 200px;
|
|
603
|
+
height: 200px;
|
|
604
|
+
}
|
|
605
|
+
.dcs-ribbon-text--full {
|
|
606
|
+
display: none;
|
|
607
|
+
}
|
|
608
|
+
.dcs-ribbon-text--compact {
|
|
609
|
+
display: block;
|
|
610
|
+
font-size: 11px;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
613
|
</style>
|