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.
Files changed (67) hide show
  1. package/README.md +1 -0
  2. package/app.js +22 -0
  3. package/components/VlApexChart.vue +89 -0
  4. package/components/VlCKEditorTemplate.vue +52 -0
  5. package/components/VlCKEditorWithVars.vue +80 -0
  6. package/components/VlCollapsible.vue +91 -0
  7. package/components/VlCopyBox.vue +61 -0
  8. package/components/VlCreditCardInput.vue +104 -0
  9. package/components/VlDateMonthDay.vue +327 -0
  10. package/components/VlDateTextInput.vue +151 -0
  11. package/components/VlInternationalPhoneInput.vue +902 -0
  12. package/components/VlPartialDate.vue +283 -0
  13. package/components/VlPasswordInput.vue +358 -0
  14. package/components/VlQrCodeScanner.vue +91 -0
  15. package/components/VlSwipeableTab.vue +35 -0
  16. package/components/VlSwipeableTabs.vue +325 -0
  17. package/components/VlText.vue +72 -0
  18. package/components/VlTutorialStepBuilder.vue +671 -0
  19. package/components/VlValidatedInput.vue +262 -0
  20. package/components/data/countries.js +253 -0
  21. package/components/getAllDefaultComponents.js +12 -0
  22. package/docs/architecture.md +71 -0
  23. package/docs/tutorials.md +247 -0
  24. package/docs/usage.md +128 -0
  25. package/functions/index.js +7 -0
  26. package/functions/inputs.js +64 -0
  27. package/functions/loading.js +20 -0
  28. package/kompo-ckeditor-1.0.17/index.js +5 -0
  29. package/kompo-ckeditor-1.0.17/komponents/CKEditor.vue +36 -0
  30. package/kompo-ckeditor-1.0.17/komponents/CKEditorExtended.vue +66 -0
  31. package/kompo-ckeditor-1.0.17/komponents/EditableCKEditor.vue +42 -0
  32. package/kompo-ckeditor-1.0.17/komponents/TranslatableEditor.vue +28 -0
  33. package/kompo-ckeditor-1.0.17/mixins/CKEditor.js +291 -0
  34. package/kompo-ckeditor-1.0.17/mixins/CKEditorUtilities.js +52 -0
  35. package/kompo-ckeditor-1.0.17/mixins/CkUploadAdapter.js +95 -0
  36. package/kompo-ckeditor-1.0.17/mixins/translations/en.js +1 -0
  37. package/kompo-ckeditor-1.0.17/mixins/translations/fr.js +1 -0
  38. package/kompo-ckeditor-1.0.17/plugins/JsonBlockCommand.js +100 -0
  39. package/kompo-ckeditor-1.0.17/plugins/JsonBlockEditing.js +225 -0
  40. package/kompo-ckeditor-1.0.17/plugins/JsonBlockUi.js +21 -0
  41. package/kompo-ckeditor-1.0.17/plugins/MentionPlugin.js +118 -0
  42. package/orchestrator/KompoJsBuilder.js +333 -0
  43. package/package.json +25 -0
  44. package/raw_animations/count-up-animation.js +220 -0
  45. package/raw_animations/magic-poof.js +210 -0
  46. package/tutorials/TutorialConfig.js +34 -0
  47. package/tutorials/TutorialEngine.js +448 -0
  48. package/tutorials/index.js +2 -0
  49. package/tutorials/modules/AutoNext.js +86 -0
  50. package/tutorials/modules/Avatar.js +94 -0
  51. package/tutorials/modules/Bubble.js +617 -0
  52. package/tutorials/modules/Cursor.js +315 -0
  53. package/tutorials/modules/Highlight.js +282 -0
  54. package/tutorials/modules/HoverForcer.js +250 -0
  55. package/tutorials/modules/Mobile.js +148 -0
  56. package/tutorials/modules/Options.js +98 -0
  57. package/tutorials/modules/Overlay.js +142 -0
  58. package/tutorials/modules/Scroll.js +37 -0
  59. package/tutorials/modules/Typewriter.js +132 -0
  60. package/tutorials/step-builder/step-builder-bridge.js +436 -0
  61. package/tutorials/step-builder/utils/bezier-editor.js +379 -0
  62. package/tutorials/step-builder/utils/code-generator.js +127 -0
  63. package/tutorials/step-builder/utils/element-picker.js +125 -0
  64. package/tutorials/tutorial.scss +816 -0
  65. package/tutorials/utils/selectors.js +225 -0
  66. package/tutorials/utils/stability.js +76 -0
  67. package/tutorials/utils/svg.js +46 -0
@@ -0,0 +1,448 @@
1
+ import { TutorialConfig } from './TutorialConfig.js';
2
+ import { Overlay } from './modules/Overlay.js';
3
+ import { Avatar } from './modules/Avatar.js';
4
+ import { Bubble } from './modules/Bubble.js';
5
+ import { Typewriter } from './modules/Typewriter.js';
6
+ import { Cursor } from './modules/Cursor.js';
7
+ import { Highlight } from './modules/Highlight.js';
8
+ import { HoverForcer } from './modules/HoverForcer.js';
9
+ import { Scroll } from './modules/Scroll.js';
10
+ import { Options } from './modules/Options.js';
11
+ import { AutoNext } from './modules/AutoNext.js';
12
+ import { Mobile } from './modules/Mobile.js';
13
+
14
+
15
+ const STEP_TRANSITION_MS = 300;
16
+
17
+ /**
18
+ * Main tutorial orchestrator. Delegates DOM building, positioning, and layout
19
+ * to dedicated modules.
20
+ */
21
+ export class TutorialEngine {
22
+ static cache = new Map();
23
+
24
+ constructor(config = {}) {
25
+ this.config = new TutorialConfig(config);
26
+ this.overlay = new Overlay(this);
27
+ this.avatar = new Avatar(this);
28
+ this.bubble = new Bubble(this);
29
+ this.typewriter = new Typewriter(this);
30
+ this.cursor = new Cursor(this);
31
+ this.highlight = new Highlight(this);
32
+ this.hoverForcer = new HoverForcer();
33
+ this.scroll = new Scroll();
34
+ this.options = new Options();
35
+ this.autoNext = new AutoNext();
36
+ this.mobile = new Mobile();
37
+
38
+ this.steps = [];
39
+ this.currentStep = 0;
40
+ this._lastDirection = 1;
41
+ this._isAutoAdvancing = false;
42
+ this._onReadyCallbacks = [];
43
+ this._readyContext = null;
44
+ this._orientationHandler = null;
45
+ this._resizeHandler = null;
46
+ this._running = false;
47
+ this._startAborted = false;
48
+ this._startGeneration = 0;
49
+ this._hasActiveHighlight = false;
50
+ }
51
+
52
+ static async preload(tutorialName) {
53
+ if (TutorialEngine.cache.has(tutorialName)) {
54
+ return TutorialEngine.cache.get(tutorialName);
55
+ }
56
+ const response = await fetch(`/api/tutorials/${tutorialName}`);
57
+ const data = await response.json();
58
+ TutorialEngine.cache.set(tutorialName, data);
59
+ return data;
60
+ }
61
+
62
+
63
+ // ─── Interface methods for modules ─────────────────────────────────
64
+
65
+ getContainer() { return this.avatar.container; }
66
+ getCurrentStep() { return this.steps[this.currentStep]; }
67
+ hasActiveHighlight() { return this._hasActiveHighlight; }
68
+ isMobile() { return this.mobile.isMobile(); }
69
+ forceHover(el) { this.hoverForcer.forceHover(el); }
70
+
71
+ previewStep(index) {
72
+ this._showStep(index);
73
+ }
74
+
75
+ // ─── Public API ────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * Start the tutorial.
79
+ *
80
+ * @param {string|Array} tutorialNameOrSteps - Tutorial name (fetched from API) or step array
81
+ * @param {number|object} [startStepOrOptions=0] - Start step index, or config options when passing step array
82
+ */
83
+ async start(tutorialNameOrSteps, startStepOrOptions = 0) {
84
+ // Cancel any in-flight start and clean up existing tutorial
85
+ this._startAborted = true;
86
+ if (this._running) {
87
+ this._close();
88
+ }
89
+ this._running = true;
90
+ this._startAborted = false;
91
+ this._startGeneration++;
92
+ const gen = this._startGeneration;
93
+
94
+ if (Array.isArray(tutorialNameOrSteps)) {
95
+ this.steps = tutorialNameOrSteps;
96
+ if (typeof startStepOrOptions === 'object') {
97
+ this.config = new TutorialConfig(startStepOrOptions);
98
+ }
99
+ } else {
100
+ let data = TutorialEngine.cache.get(tutorialNameOrSteps);
101
+ if (!data) {
102
+ const response = await fetch(`/api/tutorials/${tutorialNameOrSteps}`);
103
+ if (this._startAborted) return;
104
+ data = await response.json();
105
+ if (this._startAborted) return;
106
+ TutorialEngine.cache.set(tutorialNameOrSteps, data);
107
+ }
108
+ this.steps = data.steps;
109
+ if (data.options) {
110
+ this.config = new TutorialConfig(data.options);
111
+ }
112
+ }
113
+
114
+ let startFrom = typeof startStepOrOptions === 'number' ? startStepOrOptions : 0;
115
+ startFrom = startFrom || this.config.get('fromStep') || 0;
116
+
117
+ const avatarSrc = await this.avatar.preloadImage(this.config.get('avatar'));
118
+ if (this._startAborted) return;
119
+
120
+ this._buildDOM(avatarSrc);
121
+
122
+ requestAnimationFrame(() => {
123
+ if (gen !== this._startGeneration) return;
124
+ this._showStep(startFrom);
125
+ });
126
+
127
+ this._readyContext = {
128
+ overlay: this.overlay.el,
129
+ container: this.avatar.container,
130
+ steps: this.steps,
131
+ showStep: (i) => this._showStep(i),
132
+ activeCursor: () => this.cursor,
133
+ activeHighlight: () => this.highlight,
134
+ actionBtn: this.bubble.actionBtn,
135
+ bubble: this.bubble.getElement(),
136
+ textEl: this.bubble.getTextElement(),
137
+ optionsRow: this.bubble.getOptionsContainer(),
138
+ currentStep: () => this.currentStep,
139
+ opts: this.config.options,
140
+ };
141
+ for (const cb of this._onReadyCallbacks) { cb(this._readyContext); }
142
+ }
143
+
144
+ onReady(cb) {
145
+ this._onReadyCallbacks.push(cb);
146
+ if (this._readyContext) cb(this._readyContext);
147
+ }
148
+
149
+ // ─── Internal: DOM Construction ────────────────────────────────────
150
+
151
+ _buildDOM(avatarSrc) {
152
+ const opts = this.config.options;
153
+
154
+ this.overlay.create();
155
+ this.avatar.create(opts, avatarSrc);
156
+ this.bubble.create(opts);
157
+
158
+ // All tutorial elements are direct children of body with fixed positioning.
159
+ // This ensures floating-ui viewport coordinates map 1:1 to element position.
160
+ if (this.avatar.container) {
161
+ document.body.appendChild(this.avatar.container);
162
+ }
163
+ document.body.appendChild(this.bubble.getElement());
164
+
165
+ this._wireButtons();
166
+
167
+ this._orientationHandler = () => {
168
+ setTimeout(() => {
169
+ const step = this.steps[this.currentStep];
170
+ if (step) this._positionForStep(step);
171
+ }, 300);
172
+ };
173
+ this._resizeHandler = () => {
174
+ setTimeout(() => {
175
+ const step = this.steps[this.currentStep];
176
+ if (step) this._positionForStep(step);
177
+ }, 100);
178
+ };
179
+ window.addEventListener('orientationchange', this._orientationHandler);
180
+ window.addEventListener('resize', this._resizeHandler);
181
+ }
182
+
183
+ _wireButtons() {
184
+ if (this.bubble.actionBtn) {
185
+ this.bubble.actionBtn.addEventListener('click', () => { this._advance(); });
186
+ }
187
+
188
+ if (this.bubble.backBtn) {
189
+ this.bubble.backBtn.addEventListener('click', () => {
190
+ const step = this.steps[this.currentStep];
191
+ const target = (typeof step.showBack === 'number') ? step.showBack : this.currentStep - 1;
192
+ if (target >= 0) {
193
+ this._lastDirection = -1;
194
+ this.autoNext.clear();
195
+ this._cleanupAnimations();
196
+ this.bubble.hide();
197
+ setTimeout(() => { this._showStep(target); }, STEP_TRANSITION_MS);
198
+ }
199
+ });
200
+ }
201
+
202
+ if (this.bubble.pauseBtn) {
203
+ this.bubble.pauseBtn.addEventListener('click', () => {
204
+ if (this.autoNext.paused) {
205
+ this.autoNext.resume(() => {
206
+ this._isAutoAdvancing = true;
207
+ if (this.bubble.actionBtn) this.bubble.actionBtn.click();
208
+ });
209
+ this.bubble.pauseBtn.textContent = '\u275A\u275A';
210
+ this.bubble.pauseBtn.setAttribute('aria-label', 'Pause');
211
+ } else {
212
+ this.autoNext.pause();
213
+ this.bubble.pauseBtn.textContent = '\u25B6';
214
+ this.bubble.pauseBtn.setAttribute('aria-label', 'Resume');
215
+ }
216
+ });
217
+ }
218
+ }
219
+
220
+ // ─── Internal: Step Orchestration ──────────────────────────────────
221
+
222
+ async _showStep(index) {
223
+ let step = this.steps[index];
224
+ if (!step) return;
225
+ this.currentStep = index;
226
+
227
+ const isLast = index === this.steps.length - 1;
228
+ const devMode = this.config.get('devMode');
229
+
230
+ if (step.showIf) {
231
+ const sel = typeof step.showIf === 'string' ? step.showIf : step.showIf.selector;
232
+ if (sel && !document.querySelector(sel)) {
233
+ const nextIdx = index + this._lastDirection;
234
+ if (nextIdx >= 0 && nextIdx < this.steps.length) this._showStep(nextIdx);
235
+ return;
236
+ }
237
+ }
238
+
239
+ document.dispatchEvent(new CustomEvent('tutorial-step-change', { detail: { index } }));
240
+
241
+ this.autoNext.clear();
242
+ if (this.bubble.pauseBtn) this.bubble.pauseBtn.style.display = 'none';
243
+ this._cleanupAnimations();
244
+
245
+ if (step.scrollTo) this.scroll.scrollTo(step.scrollTo);
246
+ if (step.scrollInside) this.scroll.scrollInside(step.scrollInside);
247
+
248
+ if (step.silentClick && !this._isAutoAdvancing) {
249
+ if (!devMode) {
250
+ const silentEl = document.querySelector(step.silentClick);
251
+ if (silentEl && typeof silentEl.click === 'function') silentEl.click();
252
+ }
253
+ if (!isLast) this._showStep(this.currentStep + 1);
254
+ return;
255
+ }
256
+ this._isAutoAdvancing = false;
257
+
258
+ // When highlights are active, overlay stays dimmed until SVG is built.
259
+ // Highlight._build() will switch overlay to transparent once the SVG is in DOM.
260
+ this._hasActiveHighlight = !!step.highlight;
261
+ if (!this._hasActiveHighlight) {
262
+ this.overlay.setMode(step.overlay !== false ? 'dimmed' : 'transparent');
263
+ }
264
+ if (this.overlay.el) {
265
+ const blockClicks = (step.overlay !== false && !this._hasActiveHighlight) || step.clearOverlay;
266
+ this.overlay.el.style.pointerEvents = blockClicks ? 'auto' : 'none';
267
+ }
268
+ if (this.avatar.container) this.avatar.container.style.pointerEvents = 'auto';
269
+
270
+ this.bubble.setChatMode(!!step.chatMode, {
271
+ img: this.avatar.img,
272
+ videoEl: this.avatar.videoEl,
273
+ });
274
+
275
+ this.bubble.clearStepContent();
276
+ this.bubble.configureButtons({
277
+ isLast,
278
+ nextLabel: this.config.get('nextLabel'),
279
+ doneLabel: this.config.get('doneLabel'),
280
+ hasOptions: !!step.options,
281
+ showBack: step.showBack,
282
+ });
283
+
284
+ step = this.mobile.resolveTabsForStep(step);
285
+
286
+ let highlightBottom = false;
287
+ if (step.highlight) {
288
+ const result = this.highlight.scrollToElements(step, this.isMobile());
289
+ highlightBottom = result.highlightBottom;
290
+ }
291
+
292
+ try {
293
+ await this._positionForStep(step, highlightBottom);
294
+ } catch (e) {
295
+ console.warn('Tutorial: positioning failed', e);
296
+ }
297
+ this.bubble.show();
298
+
299
+ this.overlay.resetPeek();
300
+ if (this.avatar.container) this.avatar.container.style.display = 'flex';
301
+ this.overlay.showPeekButton(this.isMobile());
302
+
303
+ if (step.hover) {
304
+ const hoverSelectors = Array.isArray(step.hover) ? step.hover : [step.hover];
305
+ for (const sel of hoverSelectors) {
306
+ const el = document.querySelector(sel);
307
+ if (el) {
308
+ const target = this.hoverForcer.findHoverableParent(el) || el;
309
+ this.hoverForcer.forceHover(target);
310
+ target.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
311
+ target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
312
+ }
313
+ }
314
+ }
315
+
316
+ if (step.highlight) {
317
+ this.highlight.create(step.highlight);
318
+ }
319
+
320
+ setTimeout(() => {
321
+ const twSpeed = devMode ? 0 : this.config.get('typewriteSpeed');
322
+ this.typewriter.type(this.bubble.getTextElement(), step.html || '', twSpeed, () => {
323
+ this.bubble.revealButtons();
324
+ this._handlePostTyping(step, isLast);
325
+ });
326
+ }, STEP_TRANSITION_MS);
327
+ }
328
+
329
+ _handlePostTyping(step, isLast) {
330
+ const devMode = this.config.get('devMode');
331
+
332
+ if (step.options && step.options.length) {
333
+ this.options.render(step.options, this.bubble.getOptionsContainer(), {
334
+ goToStep: (i) => this._showStep(i),
335
+ close: () => this._close(),
336
+ redirect: (url) => { window.location.href = url; },
337
+ onTransition: () => this.bubble.hide(),
338
+ advanceClick: () => this._advance(),
339
+ });
340
+ }
341
+
342
+ if (step.cursor && !step.afterAnimation) {
343
+ const mobileTabCb = step._mobileTabToSelect
344
+ ? () => this.mobile.selectMobileTab(step._mobileTabToSelect)
345
+ : null;
346
+ this.cursor.launch(step, this.overlay.el, mobileTabCb);
347
+ }
348
+
349
+ if (step.autoNext && !isLast) {
350
+ const seconds = (typeof step.autoNext === 'number') ? step.autoNext : 3;
351
+ if (this.bubble.pauseBtn) {
352
+ this.bubble.pauseBtn.style.display = '';
353
+ this.bubble.pauseBtn.textContent = '\u275A\u275A';
354
+ }
355
+
356
+ this.autoNext.start(seconds, this.bubble.getElement(), () => {
357
+ this._isAutoAdvancing = true;
358
+ if (this.bubble.actionBtn) this.bubble.actionBtn.click();
359
+ });
360
+
361
+ if (devMode) {
362
+ this.autoNext.pause();
363
+ if (this.bubble.pauseBtn) {
364
+ this.bubble.pauseBtn.textContent = '\u25B6';
365
+ this.bubble.pauseBtn.setAttribute('aria-label', 'Resume');
366
+ }
367
+ }
368
+ }
369
+
370
+ if (step.afterAnimation && !isLast) {
371
+ if (this.bubble.actionBtn) this.bubble.actionBtn.style.display = 'none';
372
+ const advanceFn = () => {
373
+ this._isAutoAdvancing = true;
374
+ setTimeout(() => { if (this.bubble.actionBtn) this.bubble.actionBtn.click(); }, STEP_TRANSITION_MS);
375
+ };
376
+ if (step.cursor) {
377
+ this.cursor.launch(step, this.overlay.el, advanceFn);
378
+ } else {
379
+ advanceFn();
380
+ }
381
+ }
382
+ }
383
+
384
+ async _positionForStep(step, highlightBottom = false) {
385
+ await this.bubble.positionForStep(step, {
386
+ overlay: this.overlay.el,
387
+ container: this.avatar.container,
388
+ img: this.avatar.img,
389
+ imgWrapper: this.avatar.imgWrapper,
390
+ videoEl: this.avatar.videoEl,
391
+ isMobile: this.isMobile(),
392
+ config: this.config.options,
393
+ }, highlightBottom);
394
+ }
395
+
396
+ _advance() {
397
+ this._lastDirection = 1;
398
+ this.autoNext.clear();
399
+ if (this.bubble.pauseBtn) this.bubble.pauseBtn.style.display = 'none';
400
+ this._cleanupAnimations();
401
+ const step = this.steps[this.currentStep];
402
+
403
+ if (step && step.redirect) {
404
+ window.location.href = step.redirect;
405
+ return;
406
+ }
407
+
408
+ if (this.currentStep < this.steps.length - 1) {
409
+ this.bubble.hide();
410
+ setTimeout(() => { this._showStep(this.currentStep + 1); }, STEP_TRANSITION_MS);
411
+ } else {
412
+ this._close();
413
+ }
414
+ }
415
+
416
+ _close() {
417
+ this._running = false;
418
+ this._readyContext = null;
419
+ this.autoNext.clear();
420
+ this._cleanupAnimations();
421
+
422
+ if (this._orientationHandler) {
423
+ window.removeEventListener('orientationchange', this._orientationHandler);
424
+ this._orientationHandler = null;
425
+ }
426
+ if (this._resizeHandler) {
427
+ window.removeEventListener('resize', this._resizeHandler);
428
+ this._resizeHandler = null;
429
+ }
430
+
431
+ this.typewriter.destroy();
432
+ this.bubble.destroy();
433
+ this.overlay.destroy();
434
+ this.avatar.destroy();
435
+ }
436
+
437
+ _cleanupAnimations() {
438
+ // If highlight was active, overlay was transparent — restore dimmed during transition
439
+ if (this._hasActiveHighlight) {
440
+ this._hasActiveHighlight = false;
441
+ this.overlay.setMode('dimmed');
442
+ }
443
+ this.cursor.destroy();
444
+ this.highlight.destroy();
445
+ this.hoverForcer.clearAll();
446
+ this.options.destroy();
447
+ }
448
+ }
@@ -0,0 +1,2 @@
1
+ export { TutorialEngine } from './TutorialEngine.js';
2
+ export { TutorialConfig } from './TutorialConfig.js';
@@ -0,0 +1,86 @@
1
+ import { gsap } from 'gsap';
2
+
3
+ /**
4
+ * Auto-advance timer with a visual progress bar.
5
+ */
6
+ export class AutoNext {
7
+ constructor() {
8
+ this.timer = null;
9
+ this.tween = null;
10
+ this.bar = null;
11
+ this.paused = false;
12
+ this.remainingMs = 0;
13
+ this.startedAt = 0;
14
+ }
15
+
16
+ /**
17
+ * Start the auto-advance timer and progress bar.
18
+ * @param {number} seconds
19
+ * @param {HTMLElement} bubble - The bubble element (bar is appended here)
20
+ * @param {Function} onComplete
21
+ */
22
+ start(seconds, bubble, onComplete) {
23
+ this.clear();
24
+
25
+ const delayMs = seconds * 1000;
26
+ this.paused = false;
27
+ this.remainingMs = delayMs;
28
+ this.startedAt = Date.now();
29
+
30
+ this.bar = document.createElement('div');
31
+ this.bar.className = 'tutorial-auto-next-bar';
32
+ bubble.appendChild(this.bar);
33
+
34
+ this.tween = gsap.to(this.bar, {
35
+ width: '100%',
36
+ duration: seconds,
37
+ ease: 'none',
38
+ });
39
+
40
+ this.timer = setTimeout(() => {
41
+ onComplete();
42
+ }, delayMs);
43
+ }
44
+
45
+ pause() {
46
+ if (!this.timer || this.paused) return;
47
+ this.paused = true;
48
+ this.remainingMs = Math.max(0, this.remainingMs - (Date.now() - this.startedAt));
49
+ clearTimeout(this.timer);
50
+ this.timer = -1;
51
+ if (this.tween) this.tween.pause();
52
+ }
53
+
54
+ /**
55
+ * @param {Function} onComplete
56
+ */
57
+ resume(onComplete) {
58
+ if (!this.paused) return;
59
+ this.paused = false;
60
+ this.startedAt = Date.now();
61
+ if (this.tween) this.tween.resume();
62
+ this.timer = setTimeout(() => {
63
+ onComplete();
64
+ }, this.remainingMs);
65
+ }
66
+
67
+ clear() {
68
+ if (this.timer && this.timer !== -1) {
69
+ clearTimeout(this.timer);
70
+ }
71
+ this.timer = null;
72
+
73
+ if (this.tween) {
74
+ this.tween.kill();
75
+ this.tween = null;
76
+ }
77
+
78
+ if (this.bar) {
79
+ this.bar.remove();
80
+ this.bar = null;
81
+ }
82
+
83
+ this.paused = false;
84
+ this.remainingMs = 0;
85
+ }
86
+ }
@@ -0,0 +1,94 @@
1
+ import { Z } from './Overlay.js';
2
+
3
+ /**
4
+ * Avatar module: creates and manages the tutorial avatar DOM elements.
5
+ * When no avatar image is configured, renders a bubble-only layout (no avatar).
6
+ */
7
+ export class Avatar {
8
+ constructor(engine) {
9
+ this.engine = engine;
10
+ this.container = null;
11
+ this.imgWrapper = null;
12
+ this.img = null;
13
+ this.videoEl = null;
14
+ this.hasAvatar = false;
15
+ }
16
+
17
+ /**
18
+ * Preload the avatar image. Returns null if no src provided.
19
+ */
20
+ preloadImage(src) {
21
+ if (!src) return Promise.resolve(null);
22
+
23
+ return new Promise((resolve) => {
24
+ const preload = new Image();
25
+ preload.src = src;
26
+ preload.onload = () => resolve(preload.src);
27
+ preload.onerror = () => {
28
+ console.warn('Avatar: image failed to load, skipping avatar', src);
29
+ resolve(null);
30
+ };
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Build the avatar DOM structure.
36
+ * If no avatar image, creates container only (bubble-only mode).
37
+ */
38
+ create(config, avatarSrc) {
39
+ if (this.container) this.destroy();
40
+
41
+ this.hasAvatar = !!avatarSrc;
42
+
43
+ this.container = document.createElement('div');
44
+ this.container.id = 'tutorial-container';
45
+ this.container.className = 'tutorial-container';
46
+
47
+ if (!this.hasAvatar) {
48
+ this.container.classList.add('tutorial-container--no-avatar');
49
+ return;
50
+ }
51
+
52
+ this.imgWrapper = document.createElement('div');
53
+ this.imgWrapper.className = 'tutorial-img-wrapper';
54
+
55
+ this.img = document.createElement('img');
56
+ this.img.src = avatarSrc;
57
+ this.img.alt = 'Tutorial avatar';
58
+ this.img.className = 'tutorial-avatar';
59
+ this.img.style.height = config.avatarHeight;
60
+ this.img.style.maxHeight = config.avatarMaxHeight;
61
+
62
+ this.videoEl = null;
63
+ if (config.avatarVideo) {
64
+ this.img.style.display = 'none';
65
+ this.videoEl = document.createElement('video');
66
+ this.videoEl.src = config.avatarVideo;
67
+ this.videoEl.autoplay = true;
68
+ this.videoEl.muted = true;
69
+ this.videoEl.playsInline = true;
70
+ this.videoEl.className = 'tutorial-avatar-video';
71
+ this.videoEl.style.height = config.avatarHeight;
72
+ this.videoEl.style.maxHeight = config.avatarMaxHeight;
73
+ this.videoEl.addEventListener('ended', () => {
74
+ this.videoEl.style.display = 'none';
75
+ this.img.style.display = 'block';
76
+ });
77
+ }
78
+
79
+ if (this.videoEl) this.imgWrapper.appendChild(this.videoEl);
80
+ this.imgWrapper.appendChild(this.img);
81
+ this.container.appendChild(this.imgWrapper);
82
+ }
83
+
84
+ destroy() {
85
+ if (this.container) {
86
+ this.container.remove();
87
+ this.container = null;
88
+ }
89
+ this.imgWrapper = null;
90
+ this.img = null;
91
+ this.videoEl = null;
92
+ this.hasAvatar = false;
93
+ }
94
+ }