@brftech/filex-core 0.4.2 → 0.5.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/dist/{ArchiveViewer-B_0IQExT.js → ArchiveViewer-DKW-HyXT.js} +2 -2
- package/dist/{ArchiveViewer-B_0IQExT.js.map → ArchiveViewer-DKW-HyXT.js.map} +1 -1
- package/dist/{CsvViewer-maMfUy5i.js → CsvViewer-D2HmP2yr.js} +2 -2
- package/dist/{CsvViewer-maMfUy5i.js.map → CsvViewer-D2HmP2yr.js.map} +1 -1
- package/dist/{DrawioViewer-DP_pXI00.js → DrawioViewer-DPNvOFCs.js} +2 -2
- package/dist/{DrawioViewer-DP_pXI00.js.map → DrawioViewer-DPNvOFCs.js.map} +1 -1
- package/dist/{EpubViewer-DSaqGnVx.js → EpubViewer-ByqjNuNL.js} +2 -2
- package/dist/{EpubViewer-DSaqGnVx.js.map → EpubViewer-ByqjNuNL.js.map} +1 -1
- package/dist/{IpynbViewer-BmdBanF1.js → IpynbViewer-2iONhbuJ.js} +2 -2
- package/dist/{IpynbViewer-BmdBanF1.js.map → IpynbViewer-2iONhbuJ.js.map} +1 -1
- package/dist/{MermaidViewer-Drlkl0Z5.js → MermaidViewer-CmiU7hIT.js} +2 -2
- package/dist/{MermaidViewer-Drlkl0Z5.js.map → MermaidViewer-CmiU7hIT.js.map} +1 -1
- package/dist/{PsdViewer-gftUJrsm.js → PsdViewer-Inhl2yN9.js} +2 -2
- package/dist/{PsdViewer-gftUJrsm.js.map → PsdViewer-Inhl2yN9.js.map} +1 -1
- package/dist/{TiffViewer-0Uv0-Lxh.js → TiffViewer-C2AFFEze.js} +2 -2
- package/dist/{TiffViewer-0Uv0-Lxh.js.map → TiffViewer-C2AFFEze.js.map} +1 -1
- package/dist/{Viewer3D-Gu3kUpxV.js → Viewer3D-YdW14q_n.js} +2 -2
- package/dist/{Viewer3D-Gu3kUpxV.js.map → Viewer3D-YdW14q_n.js.map} +1 -1
- package/dist/filex-core.js +47 -25
- package/dist/filex-core.umd.cjs +40 -40
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-eARevpz0.js +9952 -0
- package/dist/index-eARevpz0.js.map +1 -0
- package/dist/index.d.ts +363 -3
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +269 -1
- package/src/components/CommandPalette.vue +12 -0
- package/src/components/ContextMenu.vue +114 -8
- package/src/components/GridView.vue +33 -1
- package/src/components/ListView.vue +39 -6
- package/src/components/OnboardingTour.vue +332 -0
- package/src/components/OperationsCenter.vue +284 -0
- package/src/components/PendingOpsTray.vue +50 -66
- package/src/components/QuickLook.vue +142 -0
- package/src/components/ShortcutSettings.vue +270 -0
- package/src/components/ShortcutsHelp.vue +30 -9
- package/src/components/ThemeGallery.vue +253 -0
- package/src/components/Toolbar.vue +52 -6
- package/src/components/UploadProgress.vue +49 -57
- package/src/composables/useKeyboardShortcuts.ts +321 -137
- package/src/composables/useOperations.ts +291 -0
- package/src/index.ts +45 -0
- package/src/lib/dragGhost.ts +41 -0
- package/src/lib/themes.ts +505 -0
- package/src/locales/en.ts +92 -0
- package/src/locales/tr.ts +92 -0
- package/src/modals/Modal.vue +41 -5
- package/src/styles/base.css +672 -0
- package/dist/index-BLocdbmc.js +0 -7993
- package/dist/index-BLocdbmc.js.map +0 -1
|
@@ -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
|
-
|
|
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="
|
|
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,25 @@ 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 */"
|
|
332
364
|
draggable="true"
|
|
333
365
|
@click="onRowClick(n, $event)"
|
|
334
366
|
@dblclick="onRowDbl(n)"
|
|
335
367
|
@contextmenu="onRowCtx(n, $event)"
|
|
336
368
|
@dragstart="onItemDragStart(n, $event)"
|
|
337
369
|
@dragover="onItemDragOver(n, $event)"
|
|
370
|
+
@dragleave="onItemDragLeave(n) /* wiring:c4 */"
|
|
338
371
|
@drop="onItemDrop(n, $event)"
|
|
339
372
|
@touchstart.passive="onTouchStart(n, $event)"
|
|
340
373
|
@touchend="cancelPress"
|
|
341
374
|
@touchmove="cancelPress"
|
|
342
375
|
>
|
|
343
|
-
<div class="fe-list__col fe-list__col--star" @click.stop>
|
|
376
|
+
<div class="fe-list__col fe-list__col--star" role="gridcell" @click.stop>
|
|
344
377
|
<StarButton
|
|
345
378
|
v-if="typeof n.id === 'number' && n.type === 'file'"
|
|
346
379
|
:starred="!!starredIds?.has(n.id)"
|
|
@@ -351,7 +384,7 @@ const segments = computed<Segment[]>(() => {
|
|
|
351
384
|
@change="(val: boolean) => emit('star-change', n, val)"
|
|
352
385
|
/>
|
|
353
386
|
</div>
|
|
354
|
-
<div class="fe-list__col fe-list__col--name">
|
|
387
|
+
<div class="fe-list__col fe-list__col--name" role="gridcell">
|
|
355
388
|
<span v-if="specialEmojiFor(n)" class="fe-list__icon" aria-hidden="true">{{ specialEmojiFor(n) }}</span>
|
|
356
389
|
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
|
|
357
390
|
<span v-else class="fe-list__icon fe-list__icon--svg" aria-hidden="true" v-html="fileIconSvg(n)"></span>
|
|
@@ -375,10 +408,10 @@ const segments = computed<Segment[]>(() => {
|
|
|
375
408
|
</span>
|
|
376
409
|
</div>
|
|
377
410
|
</div>
|
|
378
|
-
<div class="fe-list__col fe-list__col--size">
|
|
411
|
+
<div class="fe-list__col fe-list__col--size" role="gridcell">
|
|
379
412
|
{{ formatSize(n.size) }}
|
|
380
413
|
</div>
|
|
381
|
-
<div class="fe-list__col fe-list__col--mod">
|
|
414
|
+
<div class="fe-list__col fe-list__col--mod" role="gridcell">
|
|
382
415
|
{{ displayDate(n.last_modified) }}
|
|
383
416
|
</div>
|
|
384
417
|
</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>
|