@duffcloudservices/cms 0.1.4 → 0.1.6
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 +77 -76
- package/src/components/PreviewRibbon.vue +249 -68
package/package.json
CHANGED
|
@@ -1,76 +1,77 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@duffcloudservices/cms",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Vue 3 composables and Vite plugins for DCS CMS integration",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"exports": {
|
|
7
|
-
".": {
|
|
8
|
-
"types": "./dist/index.d.ts",
|
|
9
|
-
"import": "./dist/index.js"
|
|
10
|
-
},
|
|
11
|
-
"./plugins": {
|
|
12
|
-
"types": "./dist/plugins/index.d.ts",
|
|
13
|
-
"import": "./dist/plugins/index.js"
|
|
14
|
-
},
|
|
15
|
-
"./editor": {
|
|
16
|
-
"types": "./dist/editor/editorBridge.d.ts",
|
|
17
|
-
"import": "./dist/editor/editorBridge.js"
|
|
18
|
-
},
|
|
19
|
-
"./components": {
|
|
20
|
-
"import": "./src/components/PreviewRibbon.vue"
|
|
21
|
-
}
|
|
22
|
-
},
|
|
23
|
-
"main": "./dist/index.js",
|
|
24
|
-
"types": "./dist/index.d.ts",
|
|
25
|
-
"files": [
|
|
26
|
-
"dist",
|
|
27
|
-
"src/components"
|
|
28
|
-
],
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"vue",
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
"
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
"
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
"
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@duffcloudservices/cms",
|
|
3
|
+
"version": "0.1.6",
|
|
4
|
+
"description": "Vue 3 composables and Vite plugins for DCS CMS integration",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./plugins": {
|
|
12
|
+
"types": "./dist/plugins/index.d.ts",
|
|
13
|
+
"import": "./dist/plugins/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./editor": {
|
|
16
|
+
"types": "./dist/editor/editorBridge.d.ts",
|
|
17
|
+
"import": "./dist/editor/editorBridge.js"
|
|
18
|
+
},
|
|
19
|
+
"./components": {
|
|
20
|
+
"import": "./src/components/PreviewRibbon.vue"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src/components"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsup",
|
|
31
|
+
"dev": "tsup --watch",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:watch": "vitest",
|
|
34
|
+
"type-check": "tsc --noEmit",
|
|
35
|
+
"lint": "eslint src --ext .ts",
|
|
36
|
+
"prepublishOnly": "pnpm run build"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"vue": "^3.4.0",
|
|
40
|
+
"@unhead/vue": "^1.9.0"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"js-yaml": "^4.1.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/js-yaml": "^4.0.9",
|
|
47
|
+
"@types/node": "^20.11.0",
|
|
48
|
+
"@vue/test-utils": "^2.4.0",
|
|
49
|
+
"tsup": "^8.0.0",
|
|
50
|
+
"typescript": "~5.6.3",
|
|
51
|
+
"vite": "^6.3.5",
|
|
52
|
+
"vitest": "^3.2.3",
|
|
53
|
+
"vue": "^3.5.16",
|
|
54
|
+
"@unhead/vue": "^2.0.5"
|
|
55
|
+
},
|
|
56
|
+
"keywords": [
|
|
57
|
+
"vue",
|
|
58
|
+
"vitepress",
|
|
59
|
+
"cms",
|
|
60
|
+
"dcs",
|
|
61
|
+
"composables",
|
|
62
|
+
"duff-cloud-services"
|
|
63
|
+
],
|
|
64
|
+
"author": "Duff Cloud Services",
|
|
65
|
+
"license": "MIT",
|
|
66
|
+
"repository": {
|
|
67
|
+
"type": "git",
|
|
68
|
+
"url": "https://github.com/duffn/dcs"
|
|
69
|
+
},
|
|
70
|
+
"homepage": "https://portal.duffcloudservices.com",
|
|
71
|
+
"bugs": {
|
|
72
|
+
"url": "https://github.com/duffn/dcs/issues"
|
|
73
|
+
},
|
|
74
|
+
"engines": {
|
|
75
|
+
"node": ">=18.0.0"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<Teleport to="body">
|
|
3
|
-
<div
|
|
3
|
+
<div
|
|
4
|
+
v-if="isVisible"
|
|
5
|
+
class="dcs-ribbon-container"
|
|
6
|
+
:class="{ 'dcs-ribbon-container--flipping': isFlipping }"
|
|
7
|
+
:style="containerStyle"
|
|
8
|
+
>
|
|
4
9
|
<svg
|
|
5
10
|
ref="ribbonSvgRef"
|
|
6
11
|
:viewBox="`0 0 ${SVG_SIZE} ${SVG_SIZE}`"
|
|
7
12
|
class="dcs-ribbon-svg"
|
|
8
13
|
>
|
|
9
14
|
<defs>
|
|
10
|
-
<path id="dcs-ribbon-text-path" :d="
|
|
11
|
-
<linearGradient
|
|
15
|
+
<path id="dcs-ribbon-text-path" :d="textPath" />
|
|
16
|
+
<linearGradient
|
|
17
|
+
id="dcs-ribbon-grad"
|
|
18
|
+
:x1="side === 'left' ? '0%' : '100%'"
|
|
19
|
+
y1="100%"
|
|
20
|
+
:x2="side === 'left' ? '100%' : '0%'"
|
|
21
|
+
y2="0%"
|
|
22
|
+
>
|
|
12
23
|
<stop offset="0%" stop-color="#1a1a2e" />
|
|
13
24
|
<stop offset="40%" stop-color="#16213e" />
|
|
14
25
|
<stop offset="100%" stop-color="#0f3460" />
|
|
@@ -19,7 +30,11 @@
|
|
|
19
30
|
</defs>
|
|
20
31
|
|
|
21
32
|
<!-- Drop shadow (offset copy of band) -->
|
|
22
|
-
<path
|
|
33
|
+
<path
|
|
34
|
+
:d="bandPath"
|
|
35
|
+
fill="rgba(0,0,0,0.2)"
|
|
36
|
+
:transform="side === 'left' ? 'translate(2,3)' : 'translate(-2,3)'"
|
|
37
|
+
/>
|
|
23
38
|
|
|
24
39
|
<!-- Main ribbon band -->
|
|
25
40
|
<path
|
|
@@ -110,6 +125,9 @@ const STI = 4
|
|
|
110
125
|
/** The preview domain where the ribbon should be displayed */
|
|
111
126
|
const PREVIEW_DOMAIN = 'preview.duffcloudservices.com'
|
|
112
127
|
|
|
128
|
+
/** localStorage key for persisting which corner the ribbon is docked to */
|
|
129
|
+
const STORAGE_KEY = 'dcs-preview-ribbon-side'
|
|
130
|
+
|
|
113
131
|
/* ------------------------------------------------------------------ */
|
|
114
132
|
/* Visibility gate */
|
|
115
133
|
/* ------------------------------------------------------------------ */
|
|
@@ -134,9 +152,40 @@ function shouldShow(): boolean {
|
|
|
134
152
|
return true
|
|
135
153
|
}
|
|
136
154
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
155
|
+
/* ------------------------------------------------------------------ */
|
|
156
|
+
/* Side state (which corner the ribbon is docked to, persisted) */
|
|
157
|
+
/* ------------------------------------------------------------------ */
|
|
158
|
+
const side = ref<'left' | 'right'>('left')
|
|
159
|
+
|
|
160
|
+
function loadSide(): 'left' | 'right' {
|
|
161
|
+
try {
|
|
162
|
+
const s = localStorage.getItem(STORAGE_KEY)
|
|
163
|
+
if (s === 'left' || s === 'right') return s
|
|
164
|
+
} catch { /* noop */ }
|
|
165
|
+
return 'left'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function saveSide(s: 'left' | 'right') {
|
|
169
|
+
try { localStorage.setItem(STORAGE_KEY, s) } catch { /* noop */ }
|
|
170
|
+
}
|
|
171
|
+
|
|
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
|
+
}
|
|
140
189
|
|
|
141
190
|
/* ------------------------------------------------------------------ */
|
|
142
191
|
/* Version display */
|
|
@@ -147,11 +196,30 @@ const displayVersion = computed(() =>
|
|
|
147
196
|
resolvedVersion.value ? ` v${resolvedVersion.value}` : '',
|
|
148
197
|
)
|
|
149
198
|
|
|
150
|
-
//
|
|
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
|
+
/* ------------------------------------------------------------------ */
|
|
151
210
|
onMounted(async () => {
|
|
211
|
+
isVisible.value = shouldShow()
|
|
212
|
+
if (!isVisible.value) return
|
|
213
|
+
|
|
214
|
+
side.value = loadSide()
|
|
215
|
+
updateViewport()
|
|
216
|
+
posX.value = getRestX()
|
|
217
|
+
|
|
218
|
+
window.addEventListener('resize', onResize)
|
|
219
|
+
|
|
220
|
+
// Resolve version
|
|
152
221
|
if (resolvedVersion.value) return
|
|
153
222
|
|
|
154
|
-
// Check Vite env first
|
|
155
223
|
try {
|
|
156
224
|
const envVersion = (import.meta.env as Record<string, string | undefined>)
|
|
157
225
|
.VITE_SITE_VERSION
|
|
@@ -163,8 +231,6 @@ onMounted(async () => {
|
|
|
163
231
|
/* noop */
|
|
164
232
|
}
|
|
165
233
|
|
|
166
|
-
// Fallback: fetch from API (reuse useSiteVersion logic inline to avoid
|
|
167
|
-
// coupling lifecycle hooks – the ribbon may mount before Pinia is ready)
|
|
168
234
|
try {
|
|
169
235
|
const slug =
|
|
170
236
|
(import.meta.env as Record<string, string | undefined>).VITE_SITE_SLUG ??
|
|
@@ -186,50 +252,63 @@ onMounted(async () => {
|
|
|
186
252
|
}
|
|
187
253
|
})
|
|
188
254
|
|
|
189
|
-
// Sync prop changes
|
|
190
|
-
watch(
|
|
191
|
-
() => props.version,
|
|
192
|
-
(v) => {
|
|
193
|
-
if (v !== null && v !== undefined) resolvedVersion.value = v
|
|
194
|
-
},
|
|
195
|
-
)
|
|
196
|
-
|
|
197
255
|
/* ------------------------------------------------------------------ */
|
|
198
|
-
/*
|
|
256
|
+
/* Position state */
|
|
199
257
|
/* ------------------------------------------------------------------ */
|
|
200
|
-
const
|
|
201
|
-
const
|
|
258
|
+
const posX = ref(0)
|
|
259
|
+
const posY = ref(0)
|
|
202
260
|
const isDragging = ref(false)
|
|
261
|
+
const isFlipping = ref(false)
|
|
203
262
|
|
|
204
|
-
/**
|
|
205
|
-
|
|
206
|
-
|
|
263
|
+
/** Rest X position for the current side */
|
|
264
|
+
function getRestX(): number {
|
|
265
|
+
if (side.value === 'left') return 0
|
|
266
|
+
return viewportWidth.value - svgSizePx.value
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const containerStyle = computed(() => ({
|
|
270
|
+
transform: `translate(${posX.value}px, ${posY.value}px)`,
|
|
271
|
+
}))
|
|
207
272
|
|
|
208
273
|
/* ------------------------------------------------------------------ */
|
|
209
|
-
/* SVG path computation
|
|
274
|
+
/* SVG path computation (side-aware, X-mirrored for right) */
|
|
210
275
|
/* ------------------------------------------------------------------ */
|
|
211
276
|
|
|
212
|
-
/**
|
|
213
|
-
|
|
214
|
-
|
|
277
|
+
/**
|
|
278
|
+
* Quadratic bezier curve for stitch / helper paths.
|
|
279
|
+
* Left side: (0, y0) → (x1, 0) via (cx, cy)
|
|
280
|
+
* Right side: (SVG_SIZE, y0) → (SVG_SIZE-x1, 0) via (SVG_SIZE-cx, cy)
|
|
281
|
+
*/
|
|
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`
|
|
215
287
|
}
|
|
216
288
|
|
|
217
|
-
|
|
289
|
+
/** Text center-line path. Reversed on right side so text reads naturally. */
|
|
290
|
+
const textPath = computed(() => {
|
|
291
|
+
if (side.value === 'left') {
|
|
292
|
+
return `M 0,${EP} Q ${CP0},${CP0} ${EP},0`
|
|
293
|
+
}
|
|
294
|
+
// Right side: path goes from top-edge → right-edge for left-to-right text
|
|
295
|
+
return `M ${SVG_SIZE - EP},0 Q ${SVG_SIZE - CP0},${CP0} ${SVG_SIZE},${EP}`
|
|
296
|
+
})
|
|
218
297
|
|
|
219
298
|
const outerStitchPath = computed(() =>
|
|
220
|
-
|
|
299
|
+
mirrorQ(
|
|
221
300
|
EP + HW - STI,
|
|
222
|
-
|
|
223
|
-
|
|
301
|
+
CP0 + CPO - 3,
|
|
302
|
+
CP0 + CPO - 3,
|
|
224
303
|
EP + HW - STI,
|
|
225
304
|
),
|
|
226
305
|
)
|
|
227
306
|
|
|
228
307
|
const innerStitchPath = computed(() =>
|
|
229
|
-
|
|
308
|
+
mirrorQ(
|
|
230
309
|
EP - HW + STI,
|
|
231
|
-
|
|
232
|
-
|
|
310
|
+
CP0 - CPO + 3,
|
|
311
|
+
CP0 - CPO + 3,
|
|
233
312
|
EP - HW + STI,
|
|
234
313
|
),
|
|
235
314
|
)
|
|
@@ -237,34 +316,43 @@ const innerStitchPath = computed(() =>
|
|
|
237
316
|
const bandPath = computed(() => {
|
|
238
317
|
const oY = EP + HW
|
|
239
318
|
const oX = EP + HW
|
|
240
|
-
const oCx =
|
|
241
|
-
const oCy =
|
|
319
|
+
const oCx = CP0 + CPO
|
|
320
|
+
const oCy = CP0 + CPO
|
|
242
321
|
const iY = EP - HW
|
|
243
322
|
const iX = EP - HW
|
|
244
|
-
const iCx =
|
|
245
|
-
const iCy =
|
|
323
|
+
const iCx = CP0 - CPO
|
|
324
|
+
const iCy = CP0 - CPO
|
|
325
|
+
|
|
326
|
+
if (side.value === 'left') {
|
|
327
|
+
return [
|
|
328
|
+
`M 0,${oY} Q ${oCx},${oCy} ${oX},0`,
|
|
329
|
+
`L ${iX},0`,
|
|
330
|
+
`Q ${iCx},${iCy} 0,${iY}`,
|
|
331
|
+
`Z`,
|
|
332
|
+
].join(' ')
|
|
333
|
+
}
|
|
246
334
|
|
|
247
335
|
return [
|
|
248
|
-
|
|
249
|
-
`
|
|
250
|
-
|
|
251
|
-
`L ${iX},0`,
|
|
252
|
-
// Inner curve (top-edge → left-edge, reversed direction)
|
|
253
|
-
`Q ${iCx},${iCy} 0,${iY}`,
|
|
336
|
+
`M ${SVG_SIZE},${oY} Q ${SVG_SIZE - oCx},${oCy} ${SVG_SIZE - oX},0`,
|
|
337
|
+
`L ${SVG_SIZE - iX},0`,
|
|
338
|
+
`Q ${SVG_SIZE - iCx},${iCy} ${SVG_SIZE},${iY}`,
|
|
254
339
|
`Z`,
|
|
255
340
|
].join(' ')
|
|
256
341
|
})
|
|
257
342
|
|
|
258
343
|
/* ------------------------------------------------------------------ */
|
|
259
|
-
/* Drag:
|
|
344
|
+
/* Drag: translate ribbon + flip on midpoint crossing */
|
|
260
345
|
/* ------------------------------------------------------------------ */
|
|
261
346
|
const ribbonSvgRef = ref<SVGSVGElement | null>(null)
|
|
262
|
-
let
|
|
347
|
+
let dragStart: { x: number; y: number; posX: number; posY: number } | null = null
|
|
348
|
+
let capturedTarget: Element | null = null
|
|
349
|
+
let lastPointerId: number | null = null
|
|
263
350
|
let springRaf = 0
|
|
351
|
+
let flipTimeout: ReturnType<typeof setTimeout> | null = null
|
|
264
352
|
|
|
265
353
|
function onRibbonPointerDown(e: PointerEvent) {
|
|
266
|
-
|
|
267
|
-
if (
|
|
354
|
+
// Don't allow interaction during flip animation
|
|
355
|
+
if (isFlipping.value) return
|
|
268
356
|
|
|
269
357
|
// Cancel any in-progress spring animation
|
|
270
358
|
if (springRaf) {
|
|
@@ -272,48 +360,132 @@ function onRibbonPointerDown(e: PointerEvent) {
|
|
|
272
360
|
springRaf = 0
|
|
273
361
|
}
|
|
274
362
|
|
|
275
|
-
|
|
363
|
+
// Capture the pointer on the element that received the event (the band path).
|
|
364
|
+
// This ensures all subsequent pointer events are delivered to this element,
|
|
365
|
+
// preventing the browser from cancelling the pointer sequence for scrolling.
|
|
366
|
+
const target = e.currentTarget as Element
|
|
367
|
+
target.setPointerCapture(e.pointerId)
|
|
368
|
+
capturedTarget = target
|
|
369
|
+
lastPointerId = e.pointerId
|
|
370
|
+
|
|
276
371
|
isDragging.value = true
|
|
277
|
-
|
|
372
|
+
dragStart = { x: e.clientX, y: e.clientY, posX: posX.value, posY: posY.value }
|
|
278
373
|
globalThis.addEventListener('pointermove', onPointerMove)
|
|
279
374
|
globalThis.addEventListener('pointerup', onPointerUp)
|
|
375
|
+
globalThis.addEventListener('pointercancel', onPointerCancel)
|
|
280
376
|
}
|
|
281
377
|
|
|
282
378
|
function onPointerMove(e: PointerEvent) {
|
|
283
|
-
if (!
|
|
284
|
-
|
|
285
|
-
|
|
379
|
+
if (!dragStart) return
|
|
380
|
+
|
|
381
|
+
const dx = e.clientX - dragStart.x
|
|
382
|
+
const dy = e.clientY - dragStart.y
|
|
383
|
+
posX.value = dragStart.posX + dx
|
|
384
|
+
posY.value = dragStart.posY + dy
|
|
385
|
+
|
|
386
|
+
// Check if ribbon center has crossed viewport midpoint → trigger flip
|
|
387
|
+
const ribbonCenterX = posX.value + svgSizePx.value / 2
|
|
388
|
+
const midpoint = viewportWidth.value / 2
|
|
389
|
+
|
|
390
|
+
if (side.value === 'left' && ribbonCenterX > midpoint) {
|
|
391
|
+
triggerFlip('right')
|
|
392
|
+
} else if (side.value === 'right' && ribbonCenterX < midpoint) {
|
|
393
|
+
triggerFlip('left')
|
|
394
|
+
}
|
|
286
395
|
}
|
|
287
396
|
|
|
288
|
-
function onPointerUp() {
|
|
397
|
+
function onPointerUp(e?: PointerEvent) {
|
|
398
|
+
releasePointer(e)
|
|
289
399
|
isDragging.value = false
|
|
290
|
-
|
|
400
|
+
dragStart = null
|
|
401
|
+
removeListeners()
|
|
402
|
+
|
|
403
|
+
// Spring back to rest position
|
|
404
|
+
animateSnapBack()
|
|
405
|
+
}
|
|
406
|
+
|
|
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
|
+
function removeListeners() {
|
|
291
421
|
globalThis.removeEventListener('pointermove', onPointerMove)
|
|
292
422
|
globalThis.removeEventListener('pointerup', onPointerUp)
|
|
293
|
-
|
|
423
|
+
globalThis.removeEventListener('pointercancel', onPointerCancel)
|
|
294
424
|
}
|
|
295
425
|
|
|
296
|
-
/**
|
|
426
|
+
/**
|
|
427
|
+
* Animate the ribbon to the opposite corner.
|
|
428
|
+
* Ends the drag, toggles SVG paths, and uses a CSS transition
|
|
429
|
+
* to smoothly slide the container to its new rest position.
|
|
430
|
+
*/
|
|
431
|
+
function triggerFlip(newSide: 'left' | 'right') {
|
|
432
|
+
// End drag
|
|
433
|
+
isDragging.value = false
|
|
434
|
+
dragStart = null
|
|
435
|
+
removeListeners()
|
|
436
|
+
|
|
437
|
+
// Release pointer capture
|
|
438
|
+
if (capturedTarget && lastPointerId !== null) {
|
|
439
|
+
try { capturedTarget.releasePointerCapture(lastPointerId) } catch { /* noop */ }
|
|
440
|
+
}
|
|
441
|
+
capturedTarget = null
|
|
442
|
+
lastPointerId = null
|
|
443
|
+
|
|
444
|
+
// Toggle side (paths flip instantly, gradient direction updates)
|
|
445
|
+
side.value = newSide
|
|
446
|
+
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
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Spring-physics animation to snap the ribbon back to its rest position */
|
|
297
464
|
function animateSnapBack() {
|
|
465
|
+
const targetX = getRestX()
|
|
466
|
+
const targetY = 0
|
|
298
467
|
const stiffness = 0.12
|
|
299
468
|
const damping = 0.72
|
|
300
469
|
let vx = 0
|
|
301
470
|
let vy = 0
|
|
302
471
|
|
|
303
472
|
function step() {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
473
|
+
const dxToTarget = posX.value - targetX
|
|
474
|
+
const dyToTarget = posY.value - targetY
|
|
475
|
+
|
|
476
|
+
vx = (vx - stiffness * dxToTarget) * damping
|
|
477
|
+
vy = (vy - stiffness * dyToTarget) * damping
|
|
478
|
+
posX.value += vx
|
|
479
|
+
posY.value += vy
|
|
308
480
|
|
|
309
481
|
if (
|
|
310
|
-
Math.abs(
|
|
311
|
-
Math.abs(
|
|
482
|
+
Math.abs(dxToTarget) < 0.3 &&
|
|
483
|
+
Math.abs(dyToTarget) < 0.3 &&
|
|
312
484
|
Math.abs(vx) < 0.3 &&
|
|
313
485
|
Math.abs(vy) < 0.3
|
|
314
486
|
) {
|
|
315
|
-
|
|
316
|
-
|
|
487
|
+
posX.value = targetX
|
|
488
|
+
posY.value = targetY
|
|
317
489
|
springRaf = 0
|
|
318
490
|
return
|
|
319
491
|
}
|
|
@@ -329,14 +501,17 @@ function animateSnapBack() {
|
|
|
329
501
|
/* ------------------------------------------------------------------ */
|
|
330
502
|
onBeforeUnmount(() => {
|
|
331
503
|
if (springRaf) cancelAnimationFrame(springRaf)
|
|
504
|
+
if (flipTimeout) clearTimeout(flipTimeout)
|
|
332
505
|
globalThis.removeEventListener('pointermove', onPointerMove)
|
|
333
506
|
globalThis.removeEventListener('pointerup', onPointerUp)
|
|
507
|
+
globalThis.removeEventListener('pointercancel', onPointerCancel)
|
|
508
|
+
window.removeEventListener('resize', onResize)
|
|
334
509
|
})
|
|
335
510
|
</script>
|
|
336
511
|
|
|
337
512
|
<style scoped>
|
|
338
513
|
/* ------------------------------------------------------------------ */
|
|
339
|
-
/* Container — fixed top-left, non-blocking
|
|
514
|
+
/* Container — fixed top-left, non-blocking, positioned via transform */
|
|
340
515
|
/* ------------------------------------------------------------------ */
|
|
341
516
|
.dcs-ribbon-container {
|
|
342
517
|
position: fixed;
|
|
@@ -348,6 +523,11 @@ onBeforeUnmount(() => {
|
|
|
348
523
|
-webkit-user-select: none;
|
|
349
524
|
}
|
|
350
525
|
|
|
526
|
+
/* Smooth slide transition when snapping to the opposite corner */
|
|
527
|
+
.dcs-ribbon-container--flipping {
|
|
528
|
+
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
|
529
|
+
}
|
|
530
|
+
|
|
351
531
|
/* ------------------------------------------------------------------ */
|
|
352
532
|
/* SVG canvas */
|
|
353
533
|
/* ------------------------------------------------------------------ */
|
|
@@ -365,6 +545,7 @@ onBeforeUnmount(() => {
|
|
|
365
545
|
.dcs-ribbon-band {
|
|
366
546
|
pointer-events: auto;
|
|
367
547
|
cursor: grab;
|
|
548
|
+
touch-action: none;
|
|
368
549
|
}
|
|
369
550
|
|
|
370
551
|
.dcs-ribbon-band--dragging {
|