@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.
- package/dist/{ArchiveViewer-B_0IQExT.js → ArchiveViewer-EGgNqLPE.js} +2 -2
- package/dist/{ArchiveViewer-B_0IQExT.js.map → ArchiveViewer-EGgNqLPE.js.map} +1 -1
- package/dist/{CsvViewer-maMfUy5i.js → CsvViewer-BvriJ42M.js} +2 -2
- package/dist/{CsvViewer-maMfUy5i.js.map → CsvViewer-BvriJ42M.js.map} +1 -1
- package/dist/{DrawioViewer-DP_pXI00.js → DrawioViewer-B_bSPsWX.js} +2 -2
- package/dist/{DrawioViewer-DP_pXI00.js.map → DrawioViewer-B_bSPsWX.js.map} +1 -1
- package/dist/{EpubViewer-DSaqGnVx.js → EpubViewer-hl68h4gG.js} +2 -2
- package/dist/{EpubViewer-DSaqGnVx.js.map → EpubViewer-hl68h4gG.js.map} +1 -1
- package/dist/{IpynbViewer-BmdBanF1.js → IpynbViewer-lhwOjXK7.js} +2 -2
- package/dist/{IpynbViewer-BmdBanF1.js.map → IpynbViewer-lhwOjXK7.js.map} +1 -1
- package/dist/{MermaidViewer-Drlkl0Z5.js → MermaidViewer-H2Z6AYXg.js} +2 -2
- package/dist/{MermaidViewer-Drlkl0Z5.js.map → MermaidViewer-H2Z6AYXg.js.map} +1 -1
- package/dist/{PsdViewer-gftUJrsm.js → PsdViewer-Bsk-11yH.js} +2 -2
- package/dist/{PsdViewer-gftUJrsm.js.map → PsdViewer-Bsk-11yH.js.map} +1 -1
- package/dist/{TiffViewer-0Uv0-Lxh.js → TiffViewer-D-j9vycG.js} +2 -2
- package/dist/{TiffViewer-0Uv0-Lxh.js.map → TiffViewer-D-j9vycG.js.map} +1 -1
- package/dist/{Viewer3D-Gu3kUpxV.js → Viewer3D-Bcr70rTe.js} +2 -2
- package/dist/{Viewer3D-Gu3kUpxV.js.map → Viewer3D-Bcr70rTe.js.map} +1 -1
- package/dist/filex-core.js +50 -25
- package/dist/filex-core.umd.cjs +59 -58
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-C_ZSnk_I.js +11109 -0
- package/dist/index-C_ZSnk_I.js.map +1 -0
- package/dist/index.d.ts +597 -4
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +636 -15
- package/src/components/CommandPalette.vue +24 -1
- package/src/components/ContextMenu.vue +114 -8
- package/src/components/GalleryView.vue +214 -0
- package/src/components/GridView.vue +34 -1
- package/src/components/InspectorPanel.vue +170 -0
- package/src/components/ListView.vue +40 -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/SecondaryPane.vue +411 -0
- package/src/components/ShortcutSettings.vue +270 -0
- package/src/components/ShortcutsHelp.vue +30 -9
- package/src/components/TabBar.vue +174 -0
- package/src/components/ThemeGallery.vue +253 -0
- package/src/components/Toolbar.vue +75 -11
- package/src/components/UploadProgress.vue +49 -57
- package/src/composables/useFileApi.ts +53 -0
- package/src/composables/useKeyboardShortcuts.ts +340 -137
- package/src/composables/useOperations.ts +291 -0
- package/src/composables/useTabs.ts +222 -0
- package/src/index.ts +51 -0
- package/src/lib/dragGhost.ts +41 -0
- package/src/lib/themes.ts +505 -0
- package/src/locales/en.ts +125 -0
- package/src/locales/tr.ts +125 -0
- package/src/modals/Modal.vue +41 -5
- package/src/styles/base.css +1210 -0
- package/src/types/FileNode.ts +1 -1
- package/dist/index-BLocdbmc.js +0 -7993
- 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
|
-
|
|
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,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>
|