@brftech/filex-core 0.4.2 → 0.6.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 (58) hide show
  1. package/dist/{ArchiveViewer-B_0IQExT.js → ArchiveViewer-EGgNqLPE.js} +2 -2
  2. package/dist/{ArchiveViewer-B_0IQExT.js.map → ArchiveViewer-EGgNqLPE.js.map} +1 -1
  3. package/dist/{CsvViewer-maMfUy5i.js → CsvViewer-BvriJ42M.js} +2 -2
  4. package/dist/{CsvViewer-maMfUy5i.js.map → CsvViewer-BvriJ42M.js.map} +1 -1
  5. package/dist/{DrawioViewer-DP_pXI00.js → DrawioViewer-B_bSPsWX.js} +2 -2
  6. package/dist/{DrawioViewer-DP_pXI00.js.map → DrawioViewer-B_bSPsWX.js.map} +1 -1
  7. package/dist/{EpubViewer-DSaqGnVx.js → EpubViewer-hl68h4gG.js} +2 -2
  8. package/dist/{EpubViewer-DSaqGnVx.js.map → EpubViewer-hl68h4gG.js.map} +1 -1
  9. package/dist/{IpynbViewer-BmdBanF1.js → IpynbViewer-lhwOjXK7.js} +2 -2
  10. package/dist/{IpynbViewer-BmdBanF1.js.map → IpynbViewer-lhwOjXK7.js.map} +1 -1
  11. package/dist/{MermaidViewer-Drlkl0Z5.js → MermaidViewer-H2Z6AYXg.js} +2 -2
  12. package/dist/{MermaidViewer-Drlkl0Z5.js.map → MermaidViewer-H2Z6AYXg.js.map} +1 -1
  13. package/dist/{PsdViewer-gftUJrsm.js → PsdViewer-Bsk-11yH.js} +2 -2
  14. package/dist/{PsdViewer-gftUJrsm.js.map → PsdViewer-Bsk-11yH.js.map} +1 -1
  15. package/dist/{TiffViewer-0Uv0-Lxh.js → TiffViewer-D-j9vycG.js} +2 -2
  16. package/dist/{TiffViewer-0Uv0-Lxh.js.map → TiffViewer-D-j9vycG.js.map} +1 -1
  17. package/dist/{Viewer3D-Gu3kUpxV.js → Viewer3D-Bcr70rTe.js} +2 -2
  18. package/dist/{Viewer3D-Gu3kUpxV.js.map → Viewer3D-Bcr70rTe.js.map} +1 -1
  19. package/dist/filex-core.js +50 -25
  20. package/dist/filex-core.umd.cjs +59 -58
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/index-C_ZSnk_I.js +11109 -0
  23. package/dist/index-C_ZSnk_I.js.map +1 -0
  24. package/dist/index.d.ts +597 -4
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +636 -15
  28. package/src/components/CommandPalette.vue +24 -1
  29. package/src/components/ContextMenu.vue +114 -8
  30. package/src/components/GalleryView.vue +214 -0
  31. package/src/components/GridView.vue +34 -1
  32. package/src/components/InspectorPanel.vue +170 -0
  33. package/src/components/ListView.vue +40 -6
  34. package/src/components/OnboardingTour.vue +332 -0
  35. package/src/components/OperationsCenter.vue +284 -0
  36. package/src/components/PendingOpsTray.vue +50 -66
  37. package/src/components/QuickLook.vue +142 -0
  38. package/src/components/SecondaryPane.vue +411 -0
  39. package/src/components/ShortcutSettings.vue +270 -0
  40. package/src/components/ShortcutsHelp.vue +30 -9
  41. package/src/components/TabBar.vue +174 -0
  42. package/src/components/ThemeGallery.vue +253 -0
  43. package/src/components/Toolbar.vue +75 -11
  44. package/src/components/UploadProgress.vue +49 -57
  45. package/src/composables/useFileApi.ts +53 -0
  46. package/src/composables/useKeyboardShortcuts.ts +340 -137
  47. package/src/composables/useOperations.ts +291 -0
  48. package/src/composables/useTabs.ts +222 -0
  49. package/src/index.ts +51 -0
  50. package/src/lib/dragGhost.ts +41 -0
  51. package/src/lib/themes.ts +505 -0
  52. package/src/locales/en.ts +125 -0
  53. package/src/locales/tr.ts +125 -0
  54. package/src/modals/Modal.vue +41 -5
  55. package/src/styles/base.css +1210 -0
  56. package/src/types/FileNode.ts +1 -1
  57. package/dist/index-BLocdbmc.js +0 -7993
  58. package/dist/index-BLocdbmc.js.map +0 -1
@@ -244,6 +244,114 @@ watch(
244
244
  () => void refresh(),
245
245
  { immediate: true },
246
246
  );
247
+
248
+ /* === calisma:d3 — Yorumlar (node comments) ===
249
+ * Flat chronological thread per node (files AND folders — every backend
250
+ * row carries an id). Loaded through its own watcher + seq guard so the
251
+ * existing refresh() stays untouched. Add/delete are NOT optimistic: the
252
+ * list re-fetches after each successful API round-trip. The section hides
253
+ * silently when the backend gates (401/403) or lacks (404) the endpoint.
254
+ */
255
+ import type { NodeComment } from '../composables/useFileApi';
256
+
257
+ const commentsState = ref<SectionState>('hidden');
258
+ const comments = ref<NodeComment[]>([]);
259
+ const commentDraft = ref('');
260
+ const commentBusy = ref(false);
261
+ const commentNodeId = computed<number | null>(() =>
262
+ typeof single.value?.id === 'number' ? (single.value.id as number) : null,
263
+ );
264
+ let commentSeq = 0;
265
+
266
+ async function loadComments(): Promise<void> {
267
+ const seq = ++commentSeq;
268
+ const id = commentNodeId.value;
269
+ if (id == null) {
270
+ commentsState.value = 'hidden';
271
+ comments.value = [];
272
+ return;
273
+ }
274
+ commentsState.value = comments.value.length > 0 ? 'ok' : 'loading';
275
+ try {
276
+ const list = await props.api.listComments(id);
277
+ if (seq !== commentSeq) return;
278
+ comments.value = list;
279
+ commentsState.value = 'ok';
280
+ } catch (err) {
281
+ if (seq !== commentSeq) return;
282
+ comments.value = [];
283
+ const status = (err as { status?: number }).status;
284
+ commentsState.value =
285
+ status === 401 || status === 403 || status === 404 ? 'hidden' : 'error';
286
+ }
287
+ }
288
+
289
+ async function sendComment(): Promise<void> {
290
+ const id = commentNodeId.value;
291
+ const body = commentDraft.value.trim();
292
+ if (id == null || body === '' || commentBusy.value) return;
293
+ commentBusy.value = true;
294
+ try {
295
+ await props.api.addComment(id, body);
296
+ commentDraft.value = '';
297
+ await loadComments();
298
+ } catch (err) {
299
+ emit('toast', (err as Error).message);
300
+ } finally {
301
+ commentBusy.value = false;
302
+ }
303
+ }
304
+
305
+ async function removeComment(c: NodeComment): Promise<void> {
306
+ if (commentBusy.value) return;
307
+ commentBusy.value = true;
308
+ try {
309
+ await props.api.deleteComment(c.id);
310
+ await loadComments();
311
+ } catch (err) {
312
+ emit('toast', (err as Error).message);
313
+ } finally {
314
+ commentBusy.value = false;
315
+ }
316
+ }
317
+
318
+ /** Relative "3 minutes ago" stamp via Intl (locale-aware, no i18n keys). */
319
+ function relativeTime(iso: string): string {
320
+ const ms = Date.parse(iso);
321
+ if (Number.isNaN(ms)) return iso;
322
+ const diffS = Math.round((ms - Date.now()) / 1000);
323
+ const abs = Math.abs(diffS);
324
+ const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
325
+ ['year', 31536000],
326
+ ['month', 2592000],
327
+ ['day', 86400],
328
+ ['hour', 3600],
329
+ ['minute', 60],
330
+ ];
331
+ try {
332
+ const rtf = new Intl.RelativeTimeFormat(
333
+ props.locale === 'en' ? 'en' : 'tr',
334
+ { numeric: 'auto' },
335
+ );
336
+ for (const [unit, secs] of units) {
337
+ if (abs >= secs) return rtf.format(Math.trunc(diffS / secs), unit);
338
+ }
339
+ return rtf.format(diffS, 'second');
340
+ } catch {
341
+ return formatDateStr(iso);
342
+ }
343
+ }
344
+
345
+ watch(
346
+ () => commentNodeId.value,
347
+ () => {
348
+ comments.value = [];
349
+ commentDraft.value = '';
350
+ void loadComments();
351
+ },
352
+ { immediate: true },
353
+ );
354
+ /* === /calisma:d3 === */
247
355
  </script>
248
356
 
249
357
  <template>
@@ -450,6 +558,68 @@ watch(
450
558
  </li>
451
559
  </ul>
452
560
  </section>
561
+
562
+ <!-- ══ calisma:d3 — Yorumlar ══ -->
563
+ <section
564
+ v-if="commentsState === 'ok' || commentsState === 'error'"
565
+ class="fe-inspector__section"
566
+ >
567
+ <h3 class="fe-inspector__heading">
568
+ {{ t('inspector.section.comments') }}
569
+ <span
570
+ v-if="comments.length > 0"
571
+ class="fe-inspector__countbadge"
572
+ >{{ comments.length }}</span>
573
+ </h3>
574
+
575
+ <p v-if="commentsState === 'error'" class="fe-inspector__empty">
576
+ {{ t('inspector.error') }}
577
+ </p>
578
+ <template v-else>
579
+ <p v-if="comments.length === 0" class="fe-inspector__empty">
580
+ {{ t('inspector.comments.empty') }}
581
+ </p>
582
+ <ul v-else class="fe-inspector__comments">
583
+ <li v-for="c in comments" :key="c.id" class="fe-inspector__comment">
584
+ <div class="fe-inspector__comment-top">
585
+ <span class="fe-inspector__comment-author" :title="c.author_name">
586
+ {{ c.author_name || '—' }}
587
+ </span>
588
+ <span class="fe-inspector__comment-time" :title="formatDateStr(c.created_at)">
589
+ {{ relativeTime(c.created_at) }}
590
+ </span>
591
+ <button
592
+ v-if="c.can_delete"
593
+ type="button"
594
+ class="fe-inspector__comment-del"
595
+ :disabled="commentBusy"
596
+ :title="t('inspector.comments.delete')"
597
+ :aria-label="t('inspector.comments.delete')"
598
+ @click="removeComment(c)"
599
+ >×</button>
600
+ </div>
601
+ <p class="fe-inspector__comment-body">{{ c.body }}</p>
602
+ </li>
603
+ </ul>
604
+ <form class="fe-inspector__comment-form" @submit.prevent="sendComment">
605
+ <input
606
+ v-model="commentDraft"
607
+ type="text"
608
+ class="fe-inspector__comment-input"
609
+ maxlength="5000"
610
+ :placeholder="t('inspector.comments.placeholder')"
611
+ :aria-label="t('inspector.comments.placeholder')"
612
+ :disabled="commentBusy"
613
+ />
614
+ <button
615
+ type="submit"
616
+ class="fe-btn fe-btn--primary fe-btn--sm"
617
+ :disabled="commentBusy || commentDraft.trim() === ''"
618
+ >{{ t('inspector.comments.send') }}</button>
619
+ </form>
620
+ </template>
621
+ </section>
622
+ <!-- ══ /calisma:d3 ══ -->
453
623
  </div>
454
624
  </aside>
455
625
  </template>
@@ -12,6 +12,7 @@ import type { LocaleCode } from '../types/ExplorerConfig';
12
12
  import { useLocale } from '../composables/useLocale';
13
13
  import { fileIconSvg } from '../lib/fileIcons';
14
14
  import { matchedInContent, snippetSegments } from '../lib/snippet'; /* bul:s3 */
15
+ import { applyDragGhost } from '../lib/dragGhost'; /* wiring:c4 */
15
16
  import StarButton from './StarButton.vue';
16
17
 
17
18
  const props = defineProps<{
@@ -71,18 +72,38 @@ function onRowCtx(n: FileNode, ev: MouseEvent) {
71
72
  }
72
73
 
73
74
  function onItemDragStart(n: FileNode, ev: DragEvent) {
75
+ /* wiring:c4 — custom ghost (name + multi-select count badge). Visual
76
+ * only; the payload/selection logic stays in FileExplorer. */
77
+ applyDragGhost(
78
+ ev,
79
+ nodeDisplayName(n),
80
+ props.selected.has(n.path) ? props.selected.size : 1,
81
+ );
74
82
  emit('item-drag-start', n, ev);
75
83
  }
76
84
 
85
+ /* wiring:c4 — droptarget highlight: the folder row currently hovered by an
86
+ * internal drag gets `.is-droptarget`. Purely visual — the accept/deny
87
+ * decision below is untouched (files never preventDefault, so the browser
88
+ * shows the native no-drop cursor on invalid targets). */
89
+ const dropTargetPath = ref<string | null>(null);
90
+
77
91
  function onItemDragOver(n: FileNode, ev: DragEvent) {
78
92
  if (n.type !== 'dir') return;
79
93
  if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
80
94
  ev.preventDefault();
81
95
  ev.stopPropagation();
82
96
  if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move';
97
+ dropTargetPath.value = n.path; /* wiring:c4 */
98
+ }
99
+
100
+ /* wiring:c4 */
101
+ function onItemDragLeave(n: FileNode) {
102
+ if (dropTargetPath.value === n.path) dropTargetPath.value = null;
83
103
  }
84
104
 
85
105
  function onItemDrop(n: FileNode, ev: DragEvent) {
106
+ dropTargetPath.value = null; /* wiring:c4 */
86
107
  if (n.type !== 'dir') return;
87
108
  if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
88
109
  ev.preventDefault();
@@ -292,9 +313,17 @@ const segments = computed<Segment[]>(() => {
292
313
  </script>
293
314
 
294
315
  <template>
295
- <div class="fe-list" :class="{ 'is-loading': loading, 'has-star-col': !!apiBase || apiBase === '' }">
316
+ <!-- wiring:c4 grid semantics: container role + localized labels; rows
317
+ carry aria-selected below. Structure/layout untouched. -->
318
+ <div
319
+ class="fe-list"
320
+ :class="{ 'is-loading': loading, 'has-star-col': !!apiBase || apiBase === '' }"
321
+ role="grid"
322
+ :aria-label="t('list.aria')"
323
+ :aria-busy="loading ? 'true' : undefined"
324
+ >
296
325
  <div class="fe-list__head" role="row">
297
- <div class="fe-list__col fe-list__col--star" role="columnheader" aria-label="Star"></div>
326
+ <div class="fe-list__col fe-list__col--star" role="columnheader" :aria-label="t('col.star') /* wiring:c4 */"></div>
298
327
  <div class="fe-list__col fe-list__col--name" role="columnheader" :aria-sort="ariaSort('name')">
299
328
  <button type="button" class="fe-list__sort" :title="t('col.sort')" @click="toggleSort('name')">
300
329
  {{ t('col.name') }}
@@ -326,21 +355,26 @@ const segments = computed<Segment[]>(() => {
326
355
  'is-dir': n.type === 'dir',
327
356
  'is-trash': n.trashed,
328
357
  'is-clipped': clipped?.has(n.path),
358
+ 'is-droptarget': dropTargetPath === n.path /* wiring:c4 */,
329
359
  }"
330
360
  role="row"
331
361
  tabindex="0"
362
+ :aria-selected="isSelected(n) ? 'true' : 'false'"
363
+ :aria-label="nodeDisplayName(n) /* wiring:c4 */"
364
+ :data-fe-path="n.path /* wiring:d1 — orta-tık yeni sekme delegasyonu */"
332
365
  draggable="true"
333
366
  @click="onRowClick(n, $event)"
334
367
  @dblclick="onRowDbl(n)"
335
368
  @contextmenu="onRowCtx(n, $event)"
336
369
  @dragstart="onItemDragStart(n, $event)"
337
370
  @dragover="onItemDragOver(n, $event)"
371
+ @dragleave="onItemDragLeave(n) /* wiring:c4 */"
338
372
  @drop="onItemDrop(n, $event)"
339
373
  @touchstart.passive="onTouchStart(n, $event)"
340
374
  @touchend="cancelPress"
341
375
  @touchmove="cancelPress"
342
376
  >
343
- <div class="fe-list__col fe-list__col--star" @click.stop>
377
+ <div class="fe-list__col fe-list__col--star" role="gridcell" @click.stop>
344
378
  <StarButton
345
379
  v-if="typeof n.id === 'number' && n.type === 'file'"
346
380
  :starred="!!starredIds?.has(n.id)"
@@ -351,7 +385,7 @@ const segments = computed<Segment[]>(() => {
351
385
  @change="(val: boolean) => emit('star-change', n, val)"
352
386
  />
353
387
  </div>
354
- <div class="fe-list__col fe-list__col--name">
388
+ <div class="fe-list__col fe-list__col--name" role="gridcell">
355
389
  <span v-if="specialEmojiFor(n)" class="fe-list__icon" aria-hidden="true">{{ specialEmojiFor(n) }}</span>
356
390
  <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
357
391
  <span v-else class="fe-list__icon fe-list__icon--svg" aria-hidden="true" v-html="fileIconSvg(n)"></span>
@@ -375,10 +409,10 @@ const segments = computed<Segment[]>(() => {
375
409
  </span>
376
410
  </div>
377
411
  </div>
378
- <div class="fe-list__col fe-list__col--size">
412
+ <div class="fe-list__col fe-list__col--size" role="gridcell">
379
413
  {{ formatSize(n.size) }}
380
414
  </div>
381
- <div class="fe-list__col fe-list__col--mod">
415
+ <div class="fe-list__col fe-list__col--mod" role="gridcell">
382
416
  {{ displayDate(n.last_modified) }}
383
417
  </div>
384
418
  </div>
@@ -0,0 +1,332 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * OnboardingTour — wiring:c4 first-use coach-mark tour.
4
+ *
5
+ * A dependency-free spotlight walkthrough: each step targets a live DOM
6
+ * element inside the explorer root (resolved lazily — steps whose target
7
+ * is missing are skipped, so confined embeds with hidden surfaces never
8
+ * show a dangling spotlight). Positioning is hand-rolled with
9
+ * getBoundingClientRect + fixed overlay; the spotlight is a rounded
10
+ * cutout drawn with a huge box-shadow.
11
+ *
12
+ * Teleported under <body> (same reason as ContextMenu: transformed embed
13
+ * ancestors break position:fixed) and re-uses the `.fe-ctx-backdrop--theme-*`
14
+ * classes so the CSS-variable theme cascade applies outside the `.fe` tree.
15
+ *
16
+ * Persistence (`filex.tourDone`) is the PARENT's job — the tour only
17
+ * emits `close`; FileExplorer decides when it counts as done.
18
+ */
19
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
20
+ import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
21
+ import { useLocale } from '../composables/useLocale';
22
+
23
+ const props = defineProps<{
24
+ open: boolean;
25
+ locale: LocaleCode;
26
+ /** Explorer root — targets are resolved inside it only. */
27
+ root: HTMLElement | null;
28
+ theme?: ThemeMode;
29
+ }>();
30
+
31
+ const emit = defineEmits<{
32
+ (e: 'close'): void;
33
+ }>();
34
+
35
+ const { t } = useLocale(() => props.locale);
36
+
37
+ // ------------------------------------------------------------------
38
+ // Step definitions. `target` returns the element to spotlight (null =
39
+ // centered card, e.g. the closing shortcuts step). Buttons without a
40
+ // stable class are found via their localized title attribute — the
41
+ // toolbar gives every icon-only button a title from the same catalogue.
42
+ // ------------------------------------------------------------------
43
+
44
+ function byTitle(root: HTMLElement, label: string): HTMLElement | null {
45
+ const nodes = root.querySelectorAll<HTMLElement>('button[title]');
46
+ for (const el of Array.from(nodes)) {
47
+ if (el.getAttribute('title') === label) return el;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ interface TourStep {
53
+ id: string;
54
+ titleKey: string;
55
+ descKey: string;
56
+ target: (root: HTMLElement) => HTMLElement | null;
57
+ }
58
+
59
+ const STEPS: TourStep[] = [
60
+ {
61
+ id: 'nav',
62
+ titleKey: 'tour.step.nav.title',
63
+ descKey: 'tour.step.nav.desc',
64
+ target: (r) => r.querySelector<HTMLElement>('.fe-breadcrumb'),
65
+ },
66
+ {
67
+ id: 'upload',
68
+ titleKey: 'tour.step.upload.title',
69
+ descKey: 'tour.step.upload.desc',
70
+ target: (r) =>
71
+ byTitle(r, t('toolbar.upload')) || r.querySelector<HTMLElement>('.fe-fab'),
72
+ },
73
+ {
74
+ id: 'search',
75
+ titleKey: 'tour.step.search.title',
76
+ descKey: 'tour.step.search.desc',
77
+ target: (r) =>
78
+ r.querySelector<HTMLElement>('.fe-search__input') ||
79
+ r.querySelector<HTMLElement>('.fe-toolbar__search-toggle'),
80
+ },
81
+ {
82
+ id: 'view',
83
+ titleKey: 'tour.step.view.title',
84
+ descKey: 'tour.step.view.desc',
85
+ target: (r) => r.querySelector<HTMLElement>('.fe-toolbar__view'),
86
+ },
87
+ {
88
+ id: 'share',
89
+ titleKey: 'tour.step.share.title',
90
+ descKey: 'tour.step.share.desc',
91
+ target: (r) => r.querySelector<HTMLElement>('.fe__body'),
92
+ },
93
+ {
94
+ id: 'help',
95
+ titleKey: 'tour.step.help.title',
96
+ descKey: 'tour.step.help.desc',
97
+ target: () => null,
98
+ },
99
+ ];
100
+
101
+ // Steps re-resolved on every open so surfaces that appeared/disappeared
102
+ // since mount (narrow-mode collapse, RBAC hiding upload…) are honored.
103
+ const activeSteps = ref<TourStep[]>([]);
104
+ const stepIdx = ref(0);
105
+
106
+ const step = computed<TourStep | null>(() => activeSteps.value[stepIdx.value] ?? null);
107
+ const total = computed(() => activeSteps.value.length);
108
+
109
+ function resolveSteps() {
110
+ const r = props.root;
111
+ activeSteps.value = STEPS.filter((s) => {
112
+ if (s.id === 'help') return true; // centered card — no DOM dependency
113
+ if (!r) return false;
114
+ const el = s.target(r);
115
+ // Element must exist AND be laid out (visible) — a display:none
116
+ // toolbar section in narrow mode yields a 0×0 rect.
117
+ if (!el) return false;
118
+ const rect = el.getBoundingClientRect();
119
+ return rect.width > 0 && rect.height > 0;
120
+ });
121
+ }
122
+
123
+ // ------------------------------------------------------------------
124
+ // Spotlight + card geometry
125
+ // ------------------------------------------------------------------
126
+
127
+ interface Rect {
128
+ top: number;
129
+ left: number;
130
+ width: number;
131
+ height: number;
132
+ }
133
+ const spot = ref<Rect | null>(null);
134
+ const cardStyle = ref<Record<string, string>>({});
135
+ const cardEl = ref<HTMLElement | null>(null);
136
+
137
+ const PAD = 6; // spotlight breathing room around the target
138
+ const GAP = 12; // gap between spotlight and the card
139
+
140
+ async function place() {
141
+ const s = step.value;
142
+ const r = props.root;
143
+ if (!s) return;
144
+ const el = r ? s.target(r) : null;
145
+ if (!el) {
146
+ spot.value = null; // centered card
147
+ cardStyle.value = {};
148
+ return;
149
+ }
150
+ const rect = el.getBoundingClientRect();
151
+ if (rect.width <= 0 && rect.height <= 0) {
152
+ spot.value = null;
153
+ cardStyle.value = {};
154
+ return;
155
+ }
156
+ spot.value = {
157
+ top: rect.top - PAD,
158
+ left: rect.left - PAD,
159
+ width: rect.width + PAD * 2,
160
+ height: rect.height + PAD * 2,
161
+ };
162
+ // Card: below the spotlight when there's room, above otherwise;
163
+ // clamped into the viewport horizontally.
164
+ await nextTick();
165
+ const vw = window.innerWidth;
166
+ const vh = window.innerHeight;
167
+ const cw = Math.min(340, vw - 24);
168
+ const ch = cardEl.value?.offsetHeight ?? 160;
169
+ let top = rect.bottom + PAD + GAP;
170
+ if (top + ch > vh - 12) top = Math.max(12, rect.top - PAD - GAP - ch);
171
+ let left = rect.left + rect.width / 2 - cw / 2;
172
+ left = Math.min(Math.max(12, left), vw - cw - 12);
173
+ cardStyle.value = {
174
+ top: `${Math.round(top)}px`,
175
+ left: `${Math.round(left)}px`,
176
+ width: `${cw}px`,
177
+ };
178
+ }
179
+
180
+ function onViewportChange() {
181
+ if (props.open) void place();
182
+ }
183
+
184
+ // ------------------------------------------------------------------
185
+ // Flow
186
+ // ------------------------------------------------------------------
187
+
188
+ function next() {
189
+ if (stepIdx.value >= total.value - 1) {
190
+ emit('close');
191
+ return;
192
+ }
193
+ stepIdx.value += 1;
194
+ }
195
+
196
+ function back() {
197
+ if (stepIdx.value > 0) stepIdx.value -= 1;
198
+ }
199
+
200
+ function skip() {
201
+ emit('close');
202
+ }
203
+
204
+ function onKey(e: KeyboardEvent) {
205
+ if (!props.open) return;
206
+ if (e.key === 'Escape') {
207
+ e.stopPropagation();
208
+ skip();
209
+ } else if (e.key === 'ArrowRight' || e.key === 'Enter') {
210
+ e.preventDefault();
211
+ next();
212
+ } else if (e.key === 'ArrowLeft') {
213
+ e.preventDefault();
214
+ back();
215
+ }
216
+ }
217
+
218
+ watch(
219
+ () => props.open,
220
+ async (v) => {
221
+ if (v) {
222
+ stepIdx.value = 0;
223
+ resolveSteps();
224
+ await nextTick();
225
+ await place();
226
+ cardEl.value?.focus();
227
+ }
228
+ },
229
+ );
230
+
231
+ watch(stepIdx, async () => {
232
+ await place();
233
+ cardEl.value?.focus();
234
+ });
235
+
236
+ onMounted(() => {
237
+ window.addEventListener('resize', onViewportChange);
238
+ window.addEventListener('scroll', onViewportChange, true);
239
+ });
240
+ onBeforeUnmount(() => {
241
+ window.removeEventListener('resize', onViewportChange);
242
+ window.removeEventListener('scroll', onViewportChange, true);
243
+ });
244
+
245
+ // Theme cascade outside `.fe` — same pattern as ContextMenu.
246
+ const prefersDark = ref(false);
247
+ let mq: MediaQueryList | undefined;
248
+ function syncPrefersDark(e?: MediaQueryListEvent | MediaQueryList) {
249
+ prefersDark.value = !!(e && 'matches' in e && e.matches);
250
+ }
251
+ onMounted(() => {
252
+ if (typeof window === 'undefined') return;
253
+ mq = window.matchMedia('(prefers-color-scheme: dark)');
254
+ syncPrefersDark(mq);
255
+ mq.addEventListener?.('change', syncPrefersDark);
256
+ });
257
+ onBeforeUnmount(() => {
258
+ mq?.removeEventListener?.('change', syncPrefersDark);
259
+ });
260
+ const themeClass = computed(() => `fe-ctx-backdrop--theme-${props.theme || 'auto'}`);
261
+ </script>
262
+
263
+ <template>
264
+ <Teleport to="body">
265
+ <transition name="fe-tour">
266
+ <div
267
+ v-if="open && step"
268
+ class="fe-tour"
269
+ :class="themeClass"
270
+ :data-prefers-dark="prefersDark ? '1' : '0'"
271
+ role="dialog"
272
+ aria-modal="true"
273
+ :aria-label="t('tour.aria')"
274
+ @keydown="onKey"
275
+ >
276
+ <!-- Spotlight: rounded cutout via huge box-shadow. Centered steps
277
+ (no target) use a full-dim overlay instead. -->
278
+ <div
279
+ v-if="spot"
280
+ class="fe-tour__spot"
281
+ :style="{
282
+ top: spot.top + 'px',
283
+ left: spot.left + 'px',
284
+ width: spot.width + 'px',
285
+ height: spot.height + 'px',
286
+ }"
287
+ aria-hidden="true"
288
+ />
289
+ <div v-else class="fe-tour__dim" aria-hidden="true" />
290
+
291
+ <div
292
+ ref="cardEl"
293
+ class="fe-tour__card"
294
+ :class="{ 'fe-tour__card--center': !spot }"
295
+ :style="spot ? cardStyle : {}"
296
+ tabindex="-1"
297
+ >
298
+ <p class="fe-tour__progress" aria-hidden="true">
299
+ {{ t('tour.progress', { n: stepIdx + 1, m: total }) }}
300
+ </p>
301
+ <h3 class="fe-tour__title">{{ t(step.titleKey) }}</h3>
302
+ <p class="fe-tour__desc">{{ t(step.descKey) }}</p>
303
+ <div class="fe-tour__dots" aria-hidden="true">
304
+ <span
305
+ v-for="(s, i) in activeSteps"
306
+ :key="s.id"
307
+ class="fe-tour__dot"
308
+ :class="{ 'is-active': i === stepIdx }"
309
+ />
310
+ </div>
311
+ <div class="fe-tour__actions">
312
+ <button type="button" class="fe-btn fe-tour__skip" @click="skip">
313
+ {{ t('tour.skip') }}
314
+ </button>
315
+ <span class="fe-tour__actions-spacer" />
316
+ <button
317
+ v-if="stepIdx > 0"
318
+ type="button"
319
+ class="fe-btn"
320
+ @click="back"
321
+ >
322
+ {{ t('tour.back') }}
323
+ </button>
324
+ <button type="button" class="fe-btn fe-btn--primary" @click="next">
325
+ {{ stepIdx >= total - 1 ? t('tour.done') : t('tour.next') }}
326
+ </button>
327
+ </div>
328
+ </div>
329
+ </div>
330
+ </transition>
331
+ </Teleport>
332
+ </template>