js-kompo-utils 1.0.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/README.md +1 -0
- package/app.js +22 -0
- package/components/VlApexChart.vue +89 -0
- package/components/VlCKEditorTemplate.vue +52 -0
- package/components/VlCKEditorWithVars.vue +80 -0
- package/components/VlCollapsible.vue +91 -0
- package/components/VlCopyBox.vue +61 -0
- package/components/VlCreditCardInput.vue +104 -0
- package/components/VlDateMonthDay.vue +327 -0
- package/components/VlDateTextInput.vue +151 -0
- package/components/VlInternationalPhoneInput.vue +902 -0
- package/components/VlPartialDate.vue +283 -0
- package/components/VlPasswordInput.vue +358 -0
- package/components/VlQrCodeScanner.vue +91 -0
- package/components/VlSwipeableTab.vue +35 -0
- package/components/VlSwipeableTabs.vue +325 -0
- package/components/VlText.vue +72 -0
- package/components/VlTutorialStepBuilder.vue +671 -0
- package/components/VlValidatedInput.vue +262 -0
- package/components/data/countries.js +253 -0
- package/components/getAllDefaultComponents.js +12 -0
- package/docs/architecture.md +71 -0
- package/docs/tutorials.md +247 -0
- package/docs/usage.md +128 -0
- package/functions/index.js +7 -0
- package/functions/inputs.js +64 -0
- package/functions/loading.js +20 -0
- package/kompo-ckeditor-1.0.17/index.js +5 -0
- package/kompo-ckeditor-1.0.17/komponents/CKEditor.vue +36 -0
- package/kompo-ckeditor-1.0.17/komponents/CKEditorExtended.vue +66 -0
- package/kompo-ckeditor-1.0.17/komponents/EditableCKEditor.vue +42 -0
- package/kompo-ckeditor-1.0.17/komponents/TranslatableEditor.vue +28 -0
- package/kompo-ckeditor-1.0.17/mixins/CKEditor.js +291 -0
- package/kompo-ckeditor-1.0.17/mixins/CKEditorUtilities.js +52 -0
- package/kompo-ckeditor-1.0.17/mixins/CkUploadAdapter.js +95 -0
- package/kompo-ckeditor-1.0.17/mixins/translations/en.js +1 -0
- package/kompo-ckeditor-1.0.17/mixins/translations/fr.js +1 -0
- package/kompo-ckeditor-1.0.17/plugins/JsonBlockCommand.js +100 -0
- package/kompo-ckeditor-1.0.17/plugins/JsonBlockEditing.js +225 -0
- package/kompo-ckeditor-1.0.17/plugins/JsonBlockUi.js +21 -0
- package/kompo-ckeditor-1.0.17/plugins/MentionPlugin.js +118 -0
- package/orchestrator/KompoJsBuilder.js +333 -0
- package/package.json +25 -0
- package/raw_animations/count-up-animation.js +220 -0
- package/raw_animations/magic-poof.js +210 -0
- package/tutorials/TutorialConfig.js +34 -0
- package/tutorials/TutorialEngine.js +448 -0
- package/tutorials/index.js +2 -0
- package/tutorials/modules/AutoNext.js +86 -0
- package/tutorials/modules/Avatar.js +94 -0
- package/tutorials/modules/Bubble.js +617 -0
- package/tutorials/modules/Cursor.js +315 -0
- package/tutorials/modules/Highlight.js +282 -0
- package/tutorials/modules/HoverForcer.js +250 -0
- package/tutorials/modules/Mobile.js +148 -0
- package/tutorials/modules/Options.js +98 -0
- package/tutorials/modules/Overlay.js +142 -0
- package/tutorials/modules/Scroll.js +37 -0
- package/tutorials/modules/Typewriter.js +132 -0
- package/tutorials/step-builder/step-builder-bridge.js +436 -0
- package/tutorials/step-builder/utils/bezier-editor.js +379 -0
- package/tutorials/step-builder/utils/code-generator.js +127 -0
- package/tutorials/step-builder/utils/element-picker.js +125 -0
- package/tutorials/tutorial.scss +816 -0
- package/tutorials/utils/selectors.js +225 -0
- package/tutorials/utils/stability.js +76 -0
- package/tutorials/utils/svg.js +46 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { gsap } from 'gsap';
|
|
2
|
+
import { MotionPathPlugin } from 'gsap/MotionPathPlugin';
|
|
3
|
+
import { resolveElementCenter, resolveRef } from '../utils/selectors.js';
|
|
4
|
+
import { denormalizeSvgPath } from '../utils/svg.js';
|
|
5
|
+
import { Z } from './Overlay.js';
|
|
6
|
+
|
|
7
|
+
gsap.registerPlugin(MotionPathPlugin);
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CURSOR_SIZE = 'clamp(32px, 8vw, 48px)';
|
|
10
|
+
const DEFAULT_SVG_PATH = 'M 0,0 C 0.3,0.1 0.7,0.9 1,1';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Cursor module: animated cursor element using GSAP MotionPathPlugin.
|
|
14
|
+
*/
|
|
15
|
+
export class Cursor {
|
|
16
|
+
constructor(engine) {
|
|
17
|
+
this.engine = engine;
|
|
18
|
+
this.el = null;
|
|
19
|
+
this.timeline = null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a cursor DOM element (desktop image or mobile dot).
|
|
24
|
+
* @param {Object} cfg - Cursor configuration
|
|
25
|
+
* @param {boolean} [isMobile=false]
|
|
26
|
+
* @returns {HTMLElement}
|
|
27
|
+
*/
|
|
28
|
+
create(cfg = {}, isMobile = false) {
|
|
29
|
+
let cursorEl;
|
|
30
|
+
|
|
31
|
+
if (isMobile) {
|
|
32
|
+
cursorEl = document.createElement('div');
|
|
33
|
+
cursorEl.className = 'tutorial-cursor tutorial-cursor--mobile';
|
|
34
|
+
} else {
|
|
35
|
+
cursorEl = document.createElement('img');
|
|
36
|
+
cursorEl.src = cfg.image || '/images/tutorial-cursor.svg';
|
|
37
|
+
cursorEl.className = 'tutorial-cursor';
|
|
38
|
+
cursorEl.style.width = DEFAULT_CURSOR_SIZE;
|
|
39
|
+
cursorEl.style.height = DEFAULT_CURSOR_SIZE;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
document.body.appendChild(cursorEl);
|
|
43
|
+
return cursorEl;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Launch a cursor animation, choosing waypoints or simple based on step config.
|
|
48
|
+
* @param {Object} step
|
|
49
|
+
* @param {HTMLElement} overlay
|
|
50
|
+
* @param {Function} [onComplete]
|
|
51
|
+
* @returns {{ el, timeline, destroy } | null}
|
|
52
|
+
*/
|
|
53
|
+
launch(step, overlay, onComplete) {
|
|
54
|
+
if (!step.cursor) {
|
|
55
|
+
onComplete?.();
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return step.cursor.waypoints
|
|
59
|
+
? this.animateWaypoints(step, overlay, onComplete)
|
|
60
|
+
: this.animate(step, overlay, onComplete);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Single from->to cursor animation with optional click pulse at end.
|
|
65
|
+
* @param {Object} step
|
|
66
|
+
* @param {HTMLElement} overlay
|
|
67
|
+
* @param {Function} [onComplete]
|
|
68
|
+
* @returns {{ el, timeline, destroy } | null}
|
|
69
|
+
*/
|
|
70
|
+
animate(step, overlay, onComplete) {
|
|
71
|
+
const cfg = step.cursor;
|
|
72
|
+
if (!cfg) {
|
|
73
|
+
onComplete?.();
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const fromPos = resolveElementCenter(cfg.from, cfg.fromAnchor, step);
|
|
78
|
+
const toPos = resolveElementCenter(cfg.to, cfg.toAnchor, step);
|
|
79
|
+
if (!fromPos || !toPos) {
|
|
80
|
+
console.warn('Cursor.animate: could not resolve from/to selectors', cfg.from, cfg.to);
|
|
81
|
+
onComplete?.();
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const isMobile = this.engine.isMobile?.() ?? false;
|
|
86
|
+
const cursorEl = this.create(cfg, isMobile);
|
|
87
|
+
this.el = cursorEl;
|
|
88
|
+
|
|
89
|
+
const svgPathNorm = cfg.svgPath || DEFAULT_SVG_PATH;
|
|
90
|
+
const realPath = denormalizeSvgPath(svgPathNorm, fromPos, toPos);
|
|
91
|
+
const devMode = this.engine?.config?.get('devMode');
|
|
92
|
+
|
|
93
|
+
const timeline = gsap.timeline({
|
|
94
|
+
onComplete: () => {
|
|
95
|
+
if (cfg.click) {
|
|
96
|
+
const cursorRect = cursorEl.getBoundingClientRect();
|
|
97
|
+
const pulseX = cursorRect.left + cursorRect.width * 0.15;
|
|
98
|
+
const pulseY = cursorRect.top + cursorRect.height * 0.1;
|
|
99
|
+
this.createClickPulse(pulseX, pulseY);
|
|
100
|
+
|
|
101
|
+
gsap.to(cursorEl, {
|
|
102
|
+
scale: 0.7,
|
|
103
|
+
duration: 0.1,
|
|
104
|
+
yoyo: true,
|
|
105
|
+
repeat: 1,
|
|
106
|
+
onComplete: () => {
|
|
107
|
+
if (!devMode) {
|
|
108
|
+
const targetEl = document.querySelector(resolveRef(cfg.to, step));
|
|
109
|
+
if (targetEl && typeof targetEl.click === 'function') {
|
|
110
|
+
targetEl.click();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (cfg.loop) {
|
|
114
|
+
gsap.set(cursorEl, { x: fromPos.x, y: fromPos.y, scale: 1 });
|
|
115
|
+
timeline.restart();
|
|
116
|
+
} else {
|
|
117
|
+
onComplete?.();
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
} else {
|
|
122
|
+
if (cfg.loop) {
|
|
123
|
+
gsap.set(cursorEl, { x: fromPos.x, y: fromPos.y });
|
|
124
|
+
timeline.restart();
|
|
125
|
+
} else {
|
|
126
|
+
onComplete?.();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
this.timeline = timeline;
|
|
133
|
+
|
|
134
|
+
gsap.set(cursorEl, { x: fromPos.x, y: fromPos.y });
|
|
135
|
+
timeline.to(cursorEl, { opacity: 1, duration: 0.3 });
|
|
136
|
+
timeline.to(cursorEl, {
|
|
137
|
+
duration: cfg.duration || 1.5,
|
|
138
|
+
ease: cfg.ease || 'power2.inOut',
|
|
139
|
+
motionPath: { path: realPath },
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (cfg.delay) {
|
|
143
|
+
timeline.delay(cfg.delay);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
el: cursorEl,
|
|
148
|
+
timeline,
|
|
149
|
+
destroy: () => {
|
|
150
|
+
timeline.kill();
|
|
151
|
+
cursorEl.remove();
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Multi-point cursor animation with click/hover actions at each waypoint.
|
|
158
|
+
* @param {Object} step
|
|
159
|
+
* @param {HTMLElement} overlay
|
|
160
|
+
* @param {Function} [onComplete]
|
|
161
|
+
* @returns {{ el, timeline, destroy } | null}
|
|
162
|
+
*/
|
|
163
|
+
animateWaypoints(step, overlay, onComplete) {
|
|
164
|
+
const cfg = step.cursor;
|
|
165
|
+
if (!cfg || !cfg.waypoints || !cfg.waypoints.length) {
|
|
166
|
+
return this.animate(step, overlay, onComplete);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const isMobile = this.engine.isMobile?.() ?? false;
|
|
170
|
+
const cursorEl = this.create(cfg, isMobile);
|
|
171
|
+
this.el = cursorEl;
|
|
172
|
+
|
|
173
|
+
const fromPos = resolveElementCenter(cfg.from, null, step);
|
|
174
|
+
if (!fromPos) {
|
|
175
|
+
cursorEl.remove();
|
|
176
|
+
this.el = null;
|
|
177
|
+
onComplete?.();
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const devMode = this.engine?.config?.get('devMode');
|
|
182
|
+
|
|
183
|
+
const timeline = gsap.timeline({
|
|
184
|
+
delay: cfg.delay || 0,
|
|
185
|
+
onComplete: () => {
|
|
186
|
+
if (cfg.loop) {
|
|
187
|
+
gsap.set(cursorEl, { x: fromPos.x, y: fromPos.y });
|
|
188
|
+
timeline.restart();
|
|
189
|
+
} else {
|
|
190
|
+
onComplete?.();
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
this.timeline = timeline;
|
|
196
|
+
|
|
197
|
+
gsap.set(cursorEl, { x: fromPos.x, y: fromPos.y });
|
|
198
|
+
timeline.to(cursorEl, { opacity: 1, duration: 0.3 });
|
|
199
|
+
|
|
200
|
+
let currentFrom = fromPos;
|
|
201
|
+
|
|
202
|
+
cfg.waypoints.forEach((wp) => {
|
|
203
|
+
const wpPos = resolveElementCenter(wp.target, null, step);
|
|
204
|
+
if (!wpPos) return;
|
|
205
|
+
|
|
206
|
+
const wpPath = wp.svgPath || DEFAULT_SVG_PATH;
|
|
207
|
+
const realPath = denormalizeSvgPath(wpPath, currentFrom, wpPos);
|
|
208
|
+
const segDuration = (cfg.duration || 1.5) / cfg.waypoints.length;
|
|
209
|
+
|
|
210
|
+
timeline.to(cursorEl, {
|
|
211
|
+
duration: segDuration,
|
|
212
|
+
ease: cfg.ease || 'power2.inOut',
|
|
213
|
+
motionPath: { path: realPath },
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const action = wp.action || 'path';
|
|
217
|
+
|
|
218
|
+
if (action === 'click') {
|
|
219
|
+
timeline.call(() => {
|
|
220
|
+
const cr = cursorEl.getBoundingClientRect();
|
|
221
|
+
this.createClickPulse(
|
|
222
|
+
cr.left + cr.width * 0.15,
|
|
223
|
+
cr.top + cr.height * 0.1
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
timeline.to(cursorEl, {
|
|
227
|
+
scale: 0.7,
|
|
228
|
+
duration: 0.1,
|
|
229
|
+
yoyo: true,
|
|
230
|
+
repeat: 1,
|
|
231
|
+
});
|
|
232
|
+
((target) => {
|
|
233
|
+
timeline.call(() => {
|
|
234
|
+
if (!devMode) {
|
|
235
|
+
const el = document.querySelector(target);
|
|
236
|
+
if (el) el.click();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
})(wp.target);
|
|
240
|
+
} else if (action === 'hover') {
|
|
241
|
+
((target) => {
|
|
242
|
+
timeline.call(() => {
|
|
243
|
+
const el = document.querySelector(target);
|
|
244
|
+
if (el) {
|
|
245
|
+
this.engine.forceHover?.(el);
|
|
246
|
+
el.classList.add('tutorial-force-hover');
|
|
247
|
+
el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
|
248
|
+
el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
timeline.to(cursorEl, { duration: 0.5 });
|
|
252
|
+
})(wp.target);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (wp.pause && wp.pause > 0) {
|
|
256
|
+
timeline.to(cursorEl, { duration: wp.pause });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
currentFrom = wpPos;
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
el: cursorEl,
|
|
264
|
+
timeline,
|
|
265
|
+
destroy: () => {
|
|
266
|
+
timeline.kill();
|
|
267
|
+
cursorEl.remove();
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Create a click-pulse visual effect at the given coordinates.
|
|
274
|
+
* @param {number} x
|
|
275
|
+
* @param {number} y
|
|
276
|
+
*/
|
|
277
|
+
createClickPulse(x, y) {
|
|
278
|
+
this._createRing(x, y, 0.25, 3, 0.7, 0);
|
|
279
|
+
this._createRing(x, y, 0.15, 2, 0.5, 0.15);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* @private
|
|
284
|
+
*/
|
|
285
|
+
_createRing(x, y, bgOpacity, scale, duration, delay) {
|
|
286
|
+
const ring = document.createElement('div');
|
|
287
|
+
ring.className = 'tutorial-pulse-ring';
|
|
288
|
+
ring.style.left = x + 'px';
|
|
289
|
+
ring.style.top = y + 'px';
|
|
290
|
+
ring.style.backgroundColor = `rgba(255, 68, 68, ${bgOpacity})`;
|
|
291
|
+
ring.style.setProperty('--pulse-scale', String(scale));
|
|
292
|
+
ring.style.setProperty('--pulse-duration', duration + 's');
|
|
293
|
+
ring.style.setProperty('--pulse-delay', delay + 's');
|
|
294
|
+
|
|
295
|
+
document.body.appendChild(ring);
|
|
296
|
+
|
|
297
|
+
ring.addEventListener('animationend', () => {
|
|
298
|
+
ring.remove();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
setTimeout(() => {
|
|
302
|
+
if (ring.parentNode) ring.remove();
|
|
303
|
+
}, (duration + delay) * 1000 + 100);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Kill the GSAP timeline and remove the cursor element.
|
|
308
|
+
*/
|
|
309
|
+
destroy() {
|
|
310
|
+
this.timeline?.kill();
|
|
311
|
+
this.el?.remove();
|
|
312
|
+
this.el = null;
|
|
313
|
+
this.timeline = null;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG overlay with cutout windows around highlighted elements.
|
|
3
|
+
*
|
|
4
|
+
* Creates an SVG covering the viewport with a semi-transparent dark layer,
|
|
5
|
+
* using the even-odd fill rule to cut out rounded rectangular windows
|
|
6
|
+
* around specified element groups.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createSvgEl } from '../utils/svg.js';
|
|
10
|
+
import { waitForStableHighlight } from '../utils/stability.js';
|
|
11
|
+
import { Z } from './Overlay.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build SVG path data for highlight cutouts from groups config.
|
|
15
|
+
* @param {Array} groups
|
|
16
|
+
* @param {number} padding
|
|
17
|
+
* @param {number} radius
|
|
18
|
+
* @returns {{ pathData: string, rects: Array }}
|
|
19
|
+
*/
|
|
20
|
+
function buildHighlightPathData(groups, padding, radius) {
|
|
21
|
+
const W = window.innerWidth;
|
|
22
|
+
const H = window.innerHeight;
|
|
23
|
+
|
|
24
|
+
let d = `M 0,0 L ${W},0 L ${W},${H} L 0,${H} Z `;
|
|
25
|
+
const cutoutRects = [];
|
|
26
|
+
|
|
27
|
+
for (const group of groups) {
|
|
28
|
+
const selectors = group.elements || group;
|
|
29
|
+
if (!selectors.length) continue;
|
|
30
|
+
|
|
31
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
32
|
+
|
|
33
|
+
for (const sel of selectors) {
|
|
34
|
+
const el = document.querySelector(sel);
|
|
35
|
+
if (!el) continue;
|
|
36
|
+
const rect = el.getBoundingClientRect();
|
|
37
|
+
if (rect.left < minX) minX = rect.left;
|
|
38
|
+
if (rect.top < minY) minY = rect.top;
|
|
39
|
+
if (rect.right > maxX) maxX = rect.right;
|
|
40
|
+
if (rect.bottom > maxY) maxY = rect.bottom;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (minX === Infinity) continue;
|
|
44
|
+
|
|
45
|
+
const x = minX - padding;
|
|
46
|
+
const y = minY - padding;
|
|
47
|
+
const w = (maxX - minX) + padding * 2;
|
|
48
|
+
const h = (maxY - minY) + padding * 2;
|
|
49
|
+
const r = Math.min(radius, w / 2, h / 2);
|
|
50
|
+
|
|
51
|
+
cutoutRects.push({ x, y, w, h, r });
|
|
52
|
+
|
|
53
|
+
d += `M ${x + r},${y}`
|
|
54
|
+
+ ` L ${x + w - r},${y}`
|
|
55
|
+
+ ` A ${r},${r} 0 0 1 ${x + w},${y + r}`
|
|
56
|
+
+ ` L ${x + w},${y + h - r}`
|
|
57
|
+
+ ` A ${r},${r} 0 0 1 ${x + w - r},${y + h}`
|
|
58
|
+
+ ` L ${x + r},${y + h}`
|
|
59
|
+
+ ` A ${r},${r} 0 0 1 ${x},${y + h - r}`
|
|
60
|
+
+ ` L ${x},${y + r}`
|
|
61
|
+
+ ` A ${r},${r} 0 0 1 ${x + r},${y} Z `;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { pathData: d, rects: cutoutRects };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Create an SVG <rect> border element for a highlight cutout.
|
|
69
|
+
* @param {{ x, y, w, h, r }} cr
|
|
70
|
+
* @returns {SVGRectElement}
|
|
71
|
+
*/
|
|
72
|
+
function createBorderRect(cr) {
|
|
73
|
+
return createSvgEl('rect', {
|
|
74
|
+
x: cr.x,
|
|
75
|
+
y: cr.y,
|
|
76
|
+
width: cr.w,
|
|
77
|
+
height: cr.h,
|
|
78
|
+
rx: cr.r,
|
|
79
|
+
fill: 'none',
|
|
80
|
+
stroke: '#ffd700',
|
|
81
|
+
'stroke-width': '2',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Debounced scroll/resize listener using requestAnimationFrame.
|
|
87
|
+
* @param {Function} callback
|
|
88
|
+
* @returns {Function} cleanup
|
|
89
|
+
*/
|
|
90
|
+
function onScrollResize(callback) {
|
|
91
|
+
let rafId = null;
|
|
92
|
+
|
|
93
|
+
function handler() {
|
|
94
|
+
if (!rafId) {
|
|
95
|
+
rafId = requestAnimationFrame(() => {
|
|
96
|
+
rafId = null;
|
|
97
|
+
callback();
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
window.addEventListener('scroll', handler, true);
|
|
103
|
+
window.addEventListener('resize', handler);
|
|
104
|
+
|
|
105
|
+
return function cleanup() {
|
|
106
|
+
window.removeEventListener('scroll', handler, true);
|
|
107
|
+
window.removeEventListener('resize', handler);
|
|
108
|
+
if (rafId) {
|
|
109
|
+
cancelAnimationFrame(rafId);
|
|
110
|
+
rafId = null;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class Highlight {
|
|
116
|
+
constructor(engine) {
|
|
117
|
+
this.engine = engine;
|
|
118
|
+
this.svgEl = null;
|
|
119
|
+
this.pathEl = null;
|
|
120
|
+
this.cleanupFn = null;
|
|
121
|
+
this._config = null;
|
|
122
|
+
this._generation = 0;
|
|
123
|
+
this._cancelStability = null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Auto-scroll to the first highlighted element if off-screen.
|
|
128
|
+
* @param {object} step
|
|
129
|
+
* @param {boolean} isMobile
|
|
130
|
+
* @returns {{ highlightBottom: boolean }}
|
|
131
|
+
*/
|
|
132
|
+
scrollToElements(step, isMobile) {
|
|
133
|
+
let highlightBottom = false;
|
|
134
|
+
const hlConfig = step.highlight;
|
|
135
|
+
if (!hlConfig) return { highlightBottom };
|
|
136
|
+
|
|
137
|
+
let firstSel = null;
|
|
138
|
+
if (hlConfig.selector) {
|
|
139
|
+
firstSel = hlConfig.selector;
|
|
140
|
+
} else if (hlConfig.groups && hlConfig.groups.length) {
|
|
141
|
+
const g = hlConfig.groups[0];
|
|
142
|
+
const elems = g.elements || g;
|
|
143
|
+
if (elems.length) firstSel = elems[0];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (firstSel) {
|
|
147
|
+
const hlFirstEl = document.querySelector(firstSel);
|
|
148
|
+
if (hlFirstEl) {
|
|
149
|
+
let hlRect = hlFirstEl.getBoundingClientRect();
|
|
150
|
+
if (hlRect.top < 0 || hlRect.bottom > window.innerHeight) {
|
|
151
|
+
document.body.style.overflow = '';
|
|
152
|
+
hlFirstEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
153
|
+
document.body.style.overflow = 'hidden';
|
|
154
|
+
}
|
|
155
|
+
hlRect = hlFirstEl.getBoundingClientRect();
|
|
156
|
+
if (isMobile) {
|
|
157
|
+
highlightBottom = (hlRect.top + hlRect.height / 2) > window.innerHeight / 2;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { highlightBottom };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Create the highlight overlay with cutout windows.
|
|
167
|
+
* @param {Object} config
|
|
168
|
+
* @returns {Highlight} this
|
|
169
|
+
*/
|
|
170
|
+
create(config) {
|
|
171
|
+
if (!config) return this;
|
|
172
|
+
|
|
173
|
+
this.destroy();
|
|
174
|
+
|
|
175
|
+
if (config.selector && !config.groups) {
|
|
176
|
+
config = {
|
|
177
|
+
groups: [{ elements: [config.selector] }],
|
|
178
|
+
padding: config.padding || 8,
|
|
179
|
+
borderRadius: config.borderRadius || 8,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!config.groups || !config.groups.length) return this;
|
|
184
|
+
|
|
185
|
+
this._config = config;
|
|
186
|
+
this._generation++;
|
|
187
|
+
const gen = this._generation;
|
|
188
|
+
const padding = config.padding || 8;
|
|
189
|
+
const radius = config.borderRadius || 8;
|
|
190
|
+
const blockOutside = config.blockOutside || false;
|
|
191
|
+
|
|
192
|
+
this._cancelStability = waitForStableHighlight(config, () => {
|
|
193
|
+
if (gen !== this._generation) return;
|
|
194
|
+
this._build(padding, radius, blockOutside);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
return this;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** @private */
|
|
201
|
+
_build(padding, radius, blockOutside) {
|
|
202
|
+
const config = this._config;
|
|
203
|
+
if (!config) return;
|
|
204
|
+
|
|
205
|
+
const W = window.innerWidth;
|
|
206
|
+
const H = window.innerHeight;
|
|
207
|
+
this.svgEl = createSvgEl('svg', {
|
|
208
|
+
width: W,
|
|
209
|
+
height: H,
|
|
210
|
+
viewBox: `0 0 ${W} ${H}`,
|
|
211
|
+
});
|
|
212
|
+
this.svgEl.classList.add('tutorial-highlight-svg');
|
|
213
|
+
Object.assign(this.svgEl.style, {
|
|
214
|
+
position: 'fixed',
|
|
215
|
+
top: '0',
|
|
216
|
+
left: '0',
|
|
217
|
+
width: '100vw',
|
|
218
|
+
height: '100vh',
|
|
219
|
+
zIndex: String(Z.HIGHLIGHT),
|
|
220
|
+
pointerEvents: 'none',
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const result = buildHighlightPathData(config.groups, padding, radius);
|
|
224
|
+
|
|
225
|
+
this.pathEl = createSvgEl('path', {
|
|
226
|
+
d: result.pathData,
|
|
227
|
+
fill: 'rgba(0,0,0,0.55)',
|
|
228
|
+
'fill-rule': 'evenodd',
|
|
229
|
+
'pointer-events': blockOutside ? 'fill' : 'none',
|
|
230
|
+
});
|
|
231
|
+
this.svgEl.appendChild(this.pathEl);
|
|
232
|
+
|
|
233
|
+
for (const cr of result.rects) {
|
|
234
|
+
this.svgEl.appendChild(createBorderRect(cr));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
document.body.appendChild(this.svgEl);
|
|
238
|
+
|
|
239
|
+
// Highlight SVG is now in DOM — overlay can safely go transparent
|
|
240
|
+
this.engine.overlay.setMode('transparent');
|
|
241
|
+
|
|
242
|
+
const cleanupScrollResize = onScrollResize(() => {
|
|
243
|
+
this._refresh(padding, radius);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
this.cleanupFn = cleanupScrollResize;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** @private */
|
|
250
|
+
_refresh(padding, radius) {
|
|
251
|
+
if (!this.svgEl || !this.pathEl || !this._config) return;
|
|
252
|
+
|
|
253
|
+
const W = window.innerWidth;
|
|
254
|
+
const H = window.innerHeight;
|
|
255
|
+
this.svgEl.setAttribute('width', W);
|
|
256
|
+
this.svgEl.setAttribute('height', H);
|
|
257
|
+
this.svgEl.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
|
258
|
+
|
|
259
|
+
const result = buildHighlightPathData(this._config.groups, padding, radius);
|
|
260
|
+
this.pathEl.setAttribute('d', result.pathData);
|
|
261
|
+
|
|
262
|
+
const oldBorders = this.svgEl.querySelectorAll('rect');
|
|
263
|
+
for (const border of oldBorders) {
|
|
264
|
+
border.remove();
|
|
265
|
+
}
|
|
266
|
+
for (const cr of result.rects) {
|
|
267
|
+
this.svgEl.appendChild(createBorderRect(cr));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
destroy() {
|
|
272
|
+
this._generation++;
|
|
273
|
+
this._cancelStability?.();
|
|
274
|
+
this._cancelStability = null;
|
|
275
|
+
this.cleanupFn?.();
|
|
276
|
+
this.svgEl?.remove();
|
|
277
|
+
this.svgEl = null;
|
|
278
|
+
this.pathEl = null;
|
|
279
|
+
this.cleanupFn = null;
|
|
280
|
+
this._config = null;
|
|
281
|
+
}
|
|
282
|
+
}
|