@brftech/filex-core 0.13.4 → 0.15.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-CxdOyBhs.js → ArchiveViewer-B9JSFdX6.js} +2 -2
- package/dist/ArchiveViewer-B9JSFdX6.js.map +1 -0
- package/dist/{CsvViewer-JG3nzTgf.js → CsvViewer-DxujFEM3.js} +2 -2
- package/dist/CsvViewer-DxujFEM3.js.map +1 -0
- package/dist/{DrawioViewer-BEYLdtPG.js → DrawioViewer-DCvWnX2t.js} +3 -3
- package/dist/DrawioViewer-DCvWnX2t.js.map +1 -0
- package/dist/{EpubViewer-BwUDmJm4.js → EpubViewer-BPmlImG7.js} +2 -2
- package/dist/EpubViewer-BPmlImG7.js.map +1 -0
- package/dist/{IpynbViewer-CuHQoAHU.js → IpynbViewer-CDlQmuCV.js} +2 -2
- package/dist/IpynbViewer-CDlQmuCV.js.map +1 -0
- package/dist/{MermaidViewer-DLmijDXo.js → MermaidViewer-sfZWdR-d.js} +19 -19
- package/dist/MermaidViewer-sfZWdR-d.js.map +1 -0
- package/dist/{PsdViewer-D-FM4eNi.js → PsdViewer-DwuySZsb.js} +2 -2
- package/dist/PsdViewer-DwuySZsb.js.map +1 -0
- package/dist/{TiffViewer-DOw4z_dO.js → TiffViewer-BDQ3d3CQ.js} +8 -8
- package/dist/TiffViewer-BDQ3d3CQ.js.map +1 -0
- package/dist/common-xdTjul80.js +12982 -0
- package/dist/common-xdTjul80.js.map +1 -0
- package/dist/filex-core.js +4950 -4822
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +45 -41
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index.d.ts +34 -2
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +94 -26
- package/src/components/QuickLook.vue +1 -1
- package/src/components/ThemeGallery.vue +64 -1
- package/src/composables/useFileApi.ts +29 -9
- package/src/composables/useMonacoLoader.ts +1 -1
- package/src/lib/themes.ts +67 -0
- package/src/locales/en.ts +5 -1
- package/src/locales/tr.ts +5 -1
- package/src/modals/PreviewModal.vue +33 -6
- package/src/styles/base.css +125 -0
- package/src/types/ExplorerConfig.ts +13 -0
- package/src/viewers/ArchiveViewer.vue +2 -2
- package/src/viewers/CsvViewer.vue +2 -2
- package/src/viewers/DrawioViewer.vue +3 -3
- package/src/viewers/EpubViewer.vue +2 -2
- package/src/viewers/IpynbViewer.vue +2 -2
- package/src/viewers/MermaidViewer.vue +2 -2
- package/src/viewers/PdfViewer.vue +3 -3
- package/src/viewers/PsdViewer.vue +2 -2
- package/src/viewers/TiffViewer.vue +2 -2
- package/dist/ArchiveViewer-CxdOyBhs.js.map +0 -1
- package/dist/CsvViewer-JG3nzTgf.js.map +0 -1
- package/dist/DrawioViewer-BEYLdtPG.js.map +0 -1
- package/dist/EpubViewer-BwUDmJm4.js.map +0 -1
- package/dist/IpynbViewer-CuHQoAHU.js.map +0 -1
- package/dist/MermaidViewer-DLmijDXo.js.map +0 -1
- package/dist/PsdViewer-D-FM4eNi.js.map +0 -1
- package/dist/TiffViewer-DOw4z_dO.js.map +0 -1
package/src/FileExplorer.vue
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* the difference.
|
|
16
16
|
*/
|
|
17
17
|
import { computed, customRef, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
18
|
-
import type { ExplorerConfig } from './types/ExplorerConfig';
|
|
18
|
+
import type { ExplorerConfig, ThemeMode } from './types/ExplorerConfig';
|
|
19
19
|
import type {
|
|
20
20
|
FileNode,
|
|
21
21
|
ShareInfo,
|
|
@@ -53,7 +53,13 @@ import ShortcutsHelp from './components/ShortcutsHelp.vue';
|
|
|
53
53
|
/* /cila:c wiring */
|
|
54
54
|
/* wiring:c1 — tema galerisi */
|
|
55
55
|
import ThemeGallery from './components/ThemeGallery.vue';
|
|
56
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
useThemeState,
|
|
58
|
+
useThemeModeState,
|
|
59
|
+
applyThemeToEl,
|
|
60
|
+
syncThemeStyle,
|
|
61
|
+
type ThemeModePref,
|
|
62
|
+
} from './lib/themes';
|
|
57
63
|
/* /wiring:c1 */
|
|
58
64
|
/* wiring:c2 — shortcut settings modal + Space quick-look overlay */
|
|
59
65
|
import ShortcutSettings from './components/ShortcutSettings.vue';
|
|
@@ -1372,9 +1378,10 @@ function openNodeInNewTab(n: FileNode) {
|
|
|
1372
1378
|
// mode so OnlyOffice / Monaco mount with write permissions.
|
|
1373
1379
|
// Read-only inspection lives on the "Önizle" entry + the dbl-click
|
|
1374
1380
|
// in-page modal.
|
|
1375
|
-
const
|
|
1381
|
+
const abs = absolutePageUrl(base);
|
|
1382
|
+
const sep = abs.includes('?') ? '&' : '?';
|
|
1376
1383
|
const url =
|
|
1377
|
-
`${
|
|
1384
|
+
`${abs}${sep}path=${encodeURIComponent(n.path)}` +
|
|
1378
1385
|
`&type=${encodeURIComponent(ext)}` +
|
|
1379
1386
|
`&mode=edit`;
|
|
1380
1387
|
window.open(url, '_blank', 'noopener');
|
|
@@ -2287,14 +2294,50 @@ async function onCopyPath(adapterPath: string) {
|
|
|
2287
2294
|
}
|
|
2288
2295
|
}
|
|
2289
2296
|
|
|
2290
|
-
//
|
|
2291
|
-
//
|
|
2292
|
-
//
|
|
2293
|
-
//
|
|
2297
|
+
// Auth-headers builder handed to PreviewModal, QuickLook and every viewer.
|
|
2298
|
+
//
|
|
2299
|
+
// ⚠⚠ ASYNC on purpose. This used to call `authHeadersSync`, which drops the
|
|
2300
|
+
// bearer entirely when the embedder supplies a token FUNCTION rather than a
|
|
2301
|
+
// string — the shape the desktop app uses so the credential is fetched per
|
|
2302
|
+
// call instead of sitting in the page. The result was an Authorization-less
|
|
2303
|
+
// request and a 401 on the OnlyOffice config endpoint, the starred list, the
|
|
2304
|
+
// recently-opened POST and every viewer that fetches its own bytes. Consumers
|
|
2305
|
+
// must `await` this; the guard test in web/tests/api/authHeaders.test.ts fails
|
|
2306
|
+
// the build if one forgets.
|
|
2294
2307
|
function buildAuthHeaders(extra: Record<string, string> = {}) {
|
|
2295
|
-
return api.
|
|
2308
|
+
return api.authHeaders({ ...extra });
|
|
2296
2309
|
}
|
|
2297
2310
|
|
|
2311
|
+
/** The standalone viewer/editor route the backend serves next to the API. */
|
|
2312
|
+
const STANDALONE_ROUTE = '/files/edit';
|
|
2313
|
+
|
|
2314
|
+
/**
|
|
2315
|
+
* Makes a page route absolute against the API host.
|
|
2316
|
+
*
|
|
2317
|
+
* ⚠ A root-relative route like `/files/edit` is resolved by the browser
|
|
2318
|
+
* against the PAGE, not the API — which is right when the explorer is embedded
|
|
2319
|
+
* in the same app that serves the route, and wrong for every embed served from
|
|
2320
|
+
* a different origin. In the desktop app the page origin is `app://filex`, so
|
|
2321
|
+
* "Open in new tab" asked the OS to open `app://filex/files/edit?…`: no handler
|
|
2322
|
+
* for that scheme exists, so the click did nothing at all, silently. Measured
|
|
2323
|
+
* 2026-08-10.
|
|
2324
|
+
*/
|
|
2325
|
+
function absolutePageUrl(base: string): string {
|
|
2326
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(base)) return base; // already absolute
|
|
2327
|
+
const api = props.config.apiBase;
|
|
2328
|
+
if (!api) return base; // same-origin embed — the browser resolves it correctly
|
|
2329
|
+
try {
|
|
2330
|
+
return new URL(base, api.endsWith('/') ? api : `${api}/`).toString();
|
|
2331
|
+
} catch {
|
|
2332
|
+
return base;
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
/** Where the modal's "Open in new tab" / "Edit" buttons should land. */
|
|
2337
|
+
const effectiveViewerBaseUrl = computed(() =>
|
|
2338
|
+
absolutePageUrl(props.config.viewerBaseUrl || STANDALONE_ROUTE),
|
|
2339
|
+
);
|
|
2340
|
+
|
|
2298
2341
|
/* === wiring:c1 — tema galerisi ===
|
|
2299
2342
|
* Selected theme (shared module state, localStorage `filex.theme`) is applied
|
|
2300
2343
|
* as inline `--fe-*` variables on the explorer root, resolved to the active
|
|
@@ -2304,8 +2347,20 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
|
|
|
2304
2347
|
* WHICH variant of the theme paints. */
|
|
2305
2348
|
const showThemeGallery = ref(false);
|
|
2306
2349
|
const { themeId: activeThemeId, setTheme: setActiveTheme } = useThemeState();
|
|
2307
|
-
//
|
|
2308
|
-
//
|
|
2350
|
+
// The light/dark MODE the person looking at it picked in the gallery. 'host'
|
|
2351
|
+
// (the default) defers to whatever the embedder passed, so no existing embed
|
|
2352
|
+
// changes appearance until someone actually chooses.
|
|
2353
|
+
//
|
|
2354
|
+
// ⚠ Everything that used to read `config.theme` reads `themeMode` instead — a
|
|
2355
|
+
// choice that only reached the token resolution would leave the root class, the
|
|
2356
|
+
// modals and the teleported menus painting the OLD mode, which is exactly the
|
|
2357
|
+
// half-dark UI that bug looks like.
|
|
2358
|
+
const { themeMode: themeModePref, setThemeMode: setThemeModePref } = useThemeModeState();
|
|
2359
|
+
const themeMode = computed<ThemeMode>(() =>
|
|
2360
|
+
themeModePref.value === 'host' ? props.config.theme || 'auto' : themeModePref.value,
|
|
2361
|
+
);
|
|
2362
|
+
// Resolved mode: an explicit choice wins, otherwise the OS preference — the
|
|
2363
|
+
// same logic variables.css encodes in CSS. The inline root variables beat every
|
|
2309
2364
|
// stylesheet rule, so they must track this resolution at runtime.
|
|
2310
2365
|
const themeMq =
|
|
2311
2366
|
typeof window !== 'undefined' && window.matchMedia
|
|
@@ -2318,7 +2373,7 @@ function onThemeMqChange(e: MediaQueryListEvent) {
|
|
|
2318
2373
|
onMounted(() => themeMq?.addEventListener?.('change', onThemeMqChange));
|
|
2319
2374
|
onBeforeUnmount(() => themeMq?.removeEventListener?.('change', onThemeMqChange));
|
|
2320
2375
|
const themeResolvedDark = computed(
|
|
2321
|
-
() =>
|
|
2376
|
+
() => themeMode.value === 'dark' || (themeMode.value !== 'light' && themeOsDark.value),
|
|
2322
2377
|
);
|
|
2323
2378
|
watch(
|
|
2324
2379
|
[activeThemeId, themeResolvedDark, rootEl],
|
|
@@ -2527,7 +2582,6 @@ const tabsApi = useTabs({ storageKey: tabsStorageKey() });
|
|
|
2527
2582
|
const tabsRestored = tabsApi.restore();
|
|
2528
2583
|
const tabsActiveId = tabsApi.activeId;
|
|
2529
2584
|
|
|
2530
|
-
const tabsVisible = computed(() => tabsApi.hasMultiple.value);
|
|
2531
2585
|
const activeSplit = computed(() => tabsApi.activeTab.value?.split ?? null);
|
|
2532
2586
|
|
|
2533
2587
|
// Sekme adı OTOMATİK = güncel klasör adı (kök = depo adı / kök etiketi).
|
|
@@ -2541,6 +2595,17 @@ const tabItems = computed(() =>
|
|
|
2541
2595
|
tabsApi.tabs.value.map((tb) => ({ id: tb.id, label: tabLabel(tb.path), split: !!tb.split })),
|
|
2542
2596
|
);
|
|
2543
2597
|
|
|
2598
|
+
// A second tab always shows the strip; `tabStrip: 'always'` keeps it there for
|
|
2599
|
+
// the first one too (see ExplorerConfig).
|
|
2600
|
+
// ⚠ Gated on there being a tab at all, not on the flag alone: tabs are seeded
|
|
2601
|
+
// in onMounted, so an 'always' host would otherwise paint one frame of an empty
|
|
2602
|
+
// strip — a lone `+` floating above the toolbar.
|
|
2603
|
+
const tabsVisible = computed(
|
|
2604
|
+
() =>
|
|
2605
|
+
tabsApi.hasMultiple.value ||
|
|
2606
|
+
(props.config.tabStrip === 'always' && tabItems.value.length > 0),
|
|
2607
|
+
);
|
|
2608
|
+
|
|
2544
2609
|
// Aktif tab kullanıcıyı izler: gezinme + görünüm değişimi snapshot'a yazılır.
|
|
2545
2610
|
watch(currentPath, (p) => tabsApi.syncActive({ path: p }));
|
|
2546
2611
|
watch(viewMode, (v) => tabsApi.syncActive({ viewMode: v }));
|
|
@@ -2994,8 +3059,8 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
2994
3059
|
ref="rootEl"
|
|
2995
3060
|
class="fe"
|
|
2996
3061
|
:class="{
|
|
2997
|
-
'fe--theme-light':
|
|
2998
|
-
'fe--theme-dark':
|
|
3062
|
+
'fe--theme-light': themeMode === 'light',
|
|
3063
|
+
'fe--theme-dark': themeMode === 'dark',
|
|
2999
3064
|
'fe--is-dragover': dragOver,
|
|
3000
3065
|
'fe--density-compact': density === 'compact' /* cila:a density */,
|
|
3001
3066
|
'fe--narrow': isNarrow /* bag:b4 */,
|
|
@@ -3036,7 +3101,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3036
3101
|
:can-write="canWriteHere"
|
|
3037
3102
|
:locale="locale"
|
|
3038
3103
|
:narrow="isNarrow /* bag:b4 */"
|
|
3039
|
-
:theme="
|
|
3104
|
+
:theme="themeMode /* bag:b4 */"
|
|
3040
3105
|
:inspector-open="showInspector /* koru:k1 */"
|
|
3041
3106
|
@toggle-inspector="toggleInspector /* koru:k1 */"
|
|
3042
3107
|
@open-theme="showThemeGallery = true /* wiring:c1 */"
|
|
@@ -3474,7 +3539,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3474
3539
|
<ContextMenu
|
|
3475
3540
|
ref="ctxRef"
|
|
3476
3541
|
:locale="locale"
|
|
3477
|
-
:theme="
|
|
3542
|
+
:theme="themeMode"
|
|
3478
3543
|
:sheet="isCoarse /* bag:b4 */"
|
|
3479
3544
|
:actions="contextActions"
|
|
3480
3545
|
@select="onContextAction"
|
|
@@ -3523,7 +3588,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3523
3588
|
:open="showPreview"
|
|
3524
3589
|
:locale="locale"
|
|
3525
3590
|
:file="previewTarget"
|
|
3526
|
-
:theme="
|
|
3591
|
+
:theme="themeMode"
|
|
3527
3592
|
:preview-url="(p) => e2ePreviewSrc(p) /* wiring:e2 — çözülmüş blob > ham URL */"
|
|
3528
3593
|
:download-url="(p) => (e2eUnlocked ? e2ePreviewSrc(p) : api.downloadUrl(p)) /* wiring:e2 */"
|
|
3529
3594
|
:only-office-base="e2eActive ? null : effectiveOnlyOfficeBase /* wiring:e2 — OO ciphertext açamaz */"
|
|
@@ -3536,7 +3601,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3536
3601
|
:drawio-url="effectiveDrawioUrl"
|
|
3537
3602
|
:pdf-worker-url="props.config.pdfWorkerUrl || null"
|
|
3538
3603
|
:pdf-save-url="props.config.pdfSaveUrl || null"
|
|
3539
|
-
:viewer-base-url="
|
|
3604
|
+
:viewer-base-url="effectiveViewerBaseUrl"
|
|
3540
3605
|
@close="showPreview = false"
|
|
3541
3606
|
/>
|
|
3542
3607
|
<ConvertModal
|
|
@@ -3568,8 +3633,8 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3568
3633
|
v-if="showRecents"
|
|
3569
3634
|
class="fe fe-modal__backdrop fe-recents__backdrop"
|
|
3570
3635
|
:class="{
|
|
3571
|
-
'fe--theme-light':
|
|
3572
|
-
'fe--theme-dark':
|
|
3636
|
+
'fe--theme-light': themeMode === 'light',
|
|
3637
|
+
'fe--theme-dark': themeMode === 'dark',
|
|
3573
3638
|
}"
|
|
3574
3639
|
@click="showRecents = false"
|
|
3575
3640
|
>
|
|
@@ -3656,11 +3721,14 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3656
3721
|
<ThemeGallery
|
|
3657
3722
|
:open="showThemeGallery"
|
|
3658
3723
|
:locale="locale"
|
|
3659
|
-
:theme="
|
|
3724
|
+
:theme="themeMode"
|
|
3660
3725
|
:dark="themeResolvedDark"
|
|
3661
3726
|
:current="activeThemeId"
|
|
3727
|
+
:mode="themeModePref"
|
|
3728
|
+
:host-mode="config.theme || 'auto'"
|
|
3662
3729
|
@close="showThemeGallery = false"
|
|
3663
3730
|
@select="setActiveTheme"
|
|
3731
|
+
@mode="(m: ThemeModePref) => setThemeModePref(m)"
|
|
3664
3732
|
/>
|
|
3665
3733
|
<!-- /wiring:c1 -->
|
|
3666
3734
|
|
|
@@ -3694,14 +3762,14 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3694
3762
|
<ShortcutSettings
|
|
3695
3763
|
:open="showShortcutSettings"
|
|
3696
3764
|
:locale="locale"
|
|
3697
|
-
:theme="
|
|
3765
|
+
:theme="themeMode"
|
|
3698
3766
|
@close="showShortcutSettings = false"
|
|
3699
3767
|
/>
|
|
3700
3768
|
<QuickLook
|
|
3701
3769
|
:open="quickLookOpen"
|
|
3702
3770
|
:locale="locale"
|
|
3703
3771
|
:file="quickLookTarget"
|
|
3704
|
-
:theme="
|
|
3772
|
+
:theme="themeMode"
|
|
3705
3773
|
:preview-url="(p: string) => e2ePreviewSrc(p) /* wiring:e2 */"
|
|
3706
3774
|
:download-url="(p: string) => (e2eUnlocked ? e2ePreviewSrc(p) : api.downloadUrl(p)) /* wiring:e2 */"
|
|
3707
3775
|
:only-office-base="e2eActive ? null : effectiveOnlyOfficeBase /* wiring:e2 */"
|
|
@@ -3710,7 +3778,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3710
3778
|
:auth-credentials="api.credentialsMode()"
|
|
3711
3779
|
:drawio-url="effectiveDrawioUrl"
|
|
3712
3780
|
:pdf-worker-url="props.config.pdfWorkerUrl || null"
|
|
3713
|
-
:viewer-base-url="
|
|
3781
|
+
:viewer-base-url="effectiveViewerBaseUrl"
|
|
3714
3782
|
@close="quickLookOpen = false"
|
|
3715
3783
|
@nav="quickLookNav"
|
|
3716
3784
|
@open-full="quickLookOpenFull"
|
|
@@ -3721,7 +3789,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3721
3789
|
:open="showTour"
|
|
3722
3790
|
:locale="locale"
|
|
3723
3791
|
:root="rootEl"
|
|
3724
|
-
:theme="
|
|
3792
|
+
:theme="themeMode"
|
|
3725
3793
|
@close="onTourClose"
|
|
3726
3794
|
/>
|
|
3727
3795
|
<!-- /wiring:c4 -->
|
|
@@ -32,7 +32,7 @@ const props = defineProps<{
|
|
|
32
32
|
downloadUrl: (path: string) => string;
|
|
33
33
|
onlyOfficeBase?: string | null;
|
|
34
34
|
onlyOfficeConfigEndpoint?: string | null;
|
|
35
|
-
authHeaders?: () => Record<string, string
|
|
35
|
+
authHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
36
36
|
authCredentials?: RequestCredentials;
|
|
37
37
|
drawioUrl?: string | null;
|
|
38
38
|
pdfWorkerUrl?: string | null;
|
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
* Presentational only — selection state + persistence live in
|
|
12
12
|
* lib/themes.ts (shared across instances); the parent forwards `select`.
|
|
13
13
|
*/
|
|
14
|
+
import { computed } from 'vue';
|
|
14
15
|
import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
|
|
15
16
|
import { useLocale } from '../composables/useLocale';
|
|
16
|
-
import { THEMES, DEFAULT_THEME_ID, type ThemeDef } from '../lib/themes';
|
|
17
|
+
import { THEMES, DEFAULT_THEME_ID, type ThemeDef, type ThemeModePref } from '../lib/themes';
|
|
17
18
|
import Modal from '../modals/Modal.vue';
|
|
18
19
|
|
|
19
20
|
const props = defineProps<{
|
|
@@ -25,15 +26,37 @@ const props = defineProps<{
|
|
|
25
26
|
dark: boolean;
|
|
26
27
|
/** Currently selected theme id. */
|
|
27
28
|
current: string;
|
|
29
|
+
/** The user's own light/dark preference; 'host' = never chosen. */
|
|
30
|
+
mode: ThemeModePref;
|
|
31
|
+
/** What the embedder asked for — what 'host' resolves to. */
|
|
32
|
+
hostMode: ThemeMode;
|
|
28
33
|
}>();
|
|
29
34
|
|
|
30
35
|
const emit = defineEmits<{
|
|
31
36
|
(e: 'close'): void;
|
|
32
37
|
(e: 'select', id: string): void;
|
|
38
|
+
(e: 'mode', mode: ThemeModePref): void;
|
|
33
39
|
}>();
|
|
34
40
|
|
|
35
41
|
const { t } = useLocale(() => props.locale);
|
|
36
42
|
|
|
43
|
+
/* ---- light / dark / auto -------------------------------------------
|
|
44
|
+
* A theme says which palette; this says whether it paints its light or its
|
|
45
|
+
* dark variant. Two separate questions, one place to answer both — the theme
|
|
46
|
+
* button is where people come looking for "night mode".
|
|
47
|
+
*
|
|
48
|
+
* Only three choices are ever offered. 'host' is an internal default, not an
|
|
49
|
+
* option: when it is still in force the control highlights whichever of the
|
|
50
|
+
* three the embedder asked for, so the strip always shows the truth, and the
|
|
51
|
+
* first click pins an explicit choice. */
|
|
52
|
+
const MODES: { id: Exclude<ThemeModePref, 'host'>; labelKey: string }[] = [
|
|
53
|
+
{ id: 'light', labelKey: 'theme.mode.light' },
|
|
54
|
+
{ id: 'dark', labelKey: 'theme.mode.dark' },
|
|
55
|
+
{ id: 'auto', labelKey: 'theme.mode.auto' },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const shownMode = computed(() => (props.mode === 'host' ? props.hostMode : props.mode));
|
|
59
|
+
|
|
37
60
|
/** Inline variables for one card — a self-contained mini `--fe-*` scope,
|
|
38
61
|
* so the preview markup can use the exact same tokens the real UI uses. */
|
|
39
62
|
function cardVars(th: ThemeDef): Record<string, string> {
|
|
@@ -45,6 +68,46 @@ const DEFAULT_ID = DEFAULT_THEME_ID;
|
|
|
45
68
|
|
|
46
69
|
<template>
|
|
47
70
|
<Modal :open="open" :title="t('theme.title')" size="lg" :theme="theme" @close="emit('close')">
|
|
71
|
+
<div class="fe-thememode" role="group" :aria-label="t('theme.mode.label')">
|
|
72
|
+
<span class="fe-thememode__label">{{ t('theme.mode.label') }}</span>
|
|
73
|
+
<div class="fe-thememode__seg">
|
|
74
|
+
<button
|
|
75
|
+
v-for="m in MODES"
|
|
76
|
+
:key="m.id"
|
|
77
|
+
type="button"
|
|
78
|
+
class="fe-thememode__opt"
|
|
79
|
+
:class="{ 'is-active': shownMode === m.id }"
|
|
80
|
+
:aria-pressed="shownMode === m.id"
|
|
81
|
+
@click="emit('mode', m.id)"
|
|
82
|
+
>
|
|
83
|
+
<svg
|
|
84
|
+
class="fe-ficon"
|
|
85
|
+
viewBox="0 0 24 24"
|
|
86
|
+
fill="none"
|
|
87
|
+
stroke="currentColor"
|
|
88
|
+
stroke-width="1.8"
|
|
89
|
+
stroke-linecap="round"
|
|
90
|
+
stroke-linejoin="round"
|
|
91
|
+
aria-hidden="true"
|
|
92
|
+
focusable="false"
|
|
93
|
+
>
|
|
94
|
+
<template v-if="m.id === 'light'">
|
|
95
|
+
<circle cx="12" cy="12" r="4" />
|
|
96
|
+
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
|
97
|
+
</template>
|
|
98
|
+
<template v-else-if="m.id === 'dark'">
|
|
99
|
+
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
|
100
|
+
</template>
|
|
101
|
+
<template v-else>
|
|
102
|
+
<!-- Half-filled disc: the mode that is neither, decided elsewhere. -->
|
|
103
|
+
<circle cx="12" cy="12" r="9" />
|
|
104
|
+
<path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none" />
|
|
105
|
+
</template>
|
|
106
|
+
</svg>
|
|
107
|
+
<span>{{ t(m.labelKey) }}</span>
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
48
111
|
<p class="fe-themes__hint">{{ t('theme.hint') }}</p>
|
|
49
112
|
<div class="fe-themes" role="listbox" :aria-label="t('theme.title')">
|
|
50
113
|
<button
|
|
@@ -259,11 +259,27 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
259
259
|
const endpoints = resolveEndpoints(config);
|
|
260
260
|
const authConf = normalizeAuth(config.auth);
|
|
261
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Last bearer value a function-token actually produced.
|
|
264
|
+
*
|
|
265
|
+
* ⚠ Exists for `authHeadersSync` only. A function token is resolved
|
|
266
|
+
* asynchronously (the desktop app fetches it from the main process per
|
|
267
|
+
* call), and a synchronous caller cannot wait for that — before this cache
|
|
268
|
+
* it simply emitted NO Authorization header, so the request went out
|
|
269
|
+
* anonymous and came back 401. Remembering the last value turns "no
|
|
270
|
+
* credential at all" into "the credential we last held", which is the
|
|
271
|
+
* difference between a dead feature and a stale-token retry.
|
|
272
|
+
*/
|
|
273
|
+
let lastBearer: string | null = null;
|
|
274
|
+
|
|
262
275
|
async function authHeaders(extra: Record<string, string> = {}): Promise<Record<string, string>> {
|
|
263
276
|
const h: Record<string, string> = { Accept: 'application/json', ...extra };
|
|
264
277
|
if (authConf.kind === 'bearer') {
|
|
265
278
|
const token = await resolveToken(authConf.token);
|
|
266
|
-
if (token)
|
|
279
|
+
if (token) {
|
|
280
|
+
h.Authorization = `Bearer ${token}`;
|
|
281
|
+
lastBearer = token;
|
|
282
|
+
}
|
|
267
283
|
} else if (authConf.kind === 'csrf') {
|
|
268
284
|
h['X-CSRF-TOKEN'] = authConf.csrf;
|
|
269
285
|
h['X-Requested-With'] = 'XMLHttpRequest';
|
|
@@ -275,17 +291,21 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
275
291
|
}
|
|
276
292
|
|
|
277
293
|
/**
|
|
278
|
-
* Sync auth-header builder for callers that
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
294
|
+
* Sync auth-header builder for the few callers that genuinely cannot await
|
|
295
|
+
* (XMLHttpRequest's `setRequestHeader` loop).
|
|
296
|
+
*
|
|
297
|
+
* ⚠ Prefer `authHeaders()`. A function token can only be *resolved*
|
|
298
|
+
* asynchronously, so this returns the last value one produced — which is
|
|
299
|
+
* nothing at all until the first async call has run. Measured on 2026-08-10
|
|
300
|
+
* in the desktop app: the OnlyOffice config POST, the starred list and the
|
|
301
|
+
* recently-opened POST all went out with no Authorization header and came
|
|
302
|
+
* back 401, because every one of them reached the API through this function.
|
|
284
303
|
*/
|
|
285
304
|
function authHeadersSync(extra: Record<string, string> = {}): Record<string, string> {
|
|
286
305
|
const h: Record<string, string> = { Accept: 'application/json', ...extra };
|
|
287
|
-
if (authConf.kind === 'bearer'
|
|
288
|
-
|
|
306
|
+
if (authConf.kind === 'bearer') {
|
|
307
|
+
const token = typeof authConf.token === 'string' ? authConf.token : lastBearer;
|
|
308
|
+
if (token) h.Authorization = `Bearer ${token}`;
|
|
289
309
|
} else if (authConf.kind === 'csrf') {
|
|
290
310
|
h['X-CSRF-TOKEN'] = authConf.csrf;
|
|
291
311
|
h['X-Requested-With'] = 'XMLHttpRequest';
|
|
@@ -45,7 +45,7 @@ export function preloadEditor(): void {
|
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
47
|
if (!highlightPromise) {
|
|
48
|
-
highlightPromise = import(/* @vite-ignore */ 'highlight.js')
|
|
48
|
+
highlightPromise = import(/* @vite-ignore */ 'highlight.js/lib/common')
|
|
49
49
|
.then((mod) => {
|
|
50
50
|
highlightCached = (mod as { default?: unknown }).default ?? mod;
|
|
51
51
|
return highlightCached;
|
package/src/lib/themes.ts
CHANGED
|
@@ -403,6 +403,73 @@ export function useThemeState(): { themeId: Ref<string>; setTheme: (id: string)
|
|
|
403
403
|
return { themeId, setTheme };
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
/* ------------------------------------------------------------------ */
|
|
407
|
+
/* Light / dark MODE preference */
|
|
408
|
+
/* ------------------------------------------------------------------ */
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The user's own light/dark choice — a different question from WHICH theme
|
|
412
|
+
* paints (that is `themeId` above).
|
|
413
|
+
*
|
|
414
|
+
* `'host'` is the default and means "whatever the embedder passed in
|
|
415
|
+
* `config.theme`". That is what keeps every existing embed unchanged: work and
|
|
416
|
+
* the fishapp hand the explorer the mode their own page is in, and an explorer
|
|
417
|
+
* that silently overrode it would sit as a dark rectangle inside a light page.
|
|
418
|
+
* The other three are an explicit choice by the person looking at it and
|
|
419
|
+
* outrank the host: `'auto'` follows the operating system, `'light'`/`'dark'`
|
|
420
|
+
* are fixed.
|
|
421
|
+
*/
|
|
422
|
+
export type ThemeModePref = 'host' | 'auto' | 'light' | 'dark';
|
|
423
|
+
|
|
424
|
+
export const THEME_MODE_LS_KEY = 'filex.thememode';
|
|
425
|
+
|
|
426
|
+
const MODE_VALUES: ThemeModePref[] = ['host', 'auto', 'light', 'dark'];
|
|
427
|
+
|
|
428
|
+
function readStoredThemeMode(): ThemeModePref {
|
|
429
|
+
try {
|
|
430
|
+
const v = localStorage.getItem(THEME_MODE_LS_KEY) as ThemeModePref | null;
|
|
431
|
+
return v && MODE_VALUES.includes(v) ? v : 'host';
|
|
432
|
+
} catch {
|
|
433
|
+
return 'host';
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const themeMode: Ref<ThemeModePref> = ref(
|
|
438
|
+
typeof window === 'undefined' ? 'host' : readStoredThemeMode(),
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
export function setThemeMode(mode: ThemeModePref): void {
|
|
442
|
+
const valid = MODE_VALUES.includes(mode) ? mode : 'host';
|
|
443
|
+
themeMode.value = valid;
|
|
444
|
+
try {
|
|
445
|
+
if (valid === 'host') localStorage.removeItem(THEME_MODE_LS_KEY);
|
|
446
|
+
else localStorage.setItem(THEME_MODE_LS_KEY, valid);
|
|
447
|
+
} catch {
|
|
448
|
+
/* quota / private mode */
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Reactive handle for components: `{ themeMode, setThemeMode }`. */
|
|
453
|
+
export function useThemeModeState(): {
|
|
454
|
+
themeMode: Ref<ThemeModePref>;
|
|
455
|
+
setThemeMode: (mode: ThemeModePref) => void;
|
|
456
|
+
} {
|
|
457
|
+
return { themeMode, setThemeMode };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Cross-tab sync, same contract as the theme id above.
|
|
461
|
+
if (typeof window !== 'undefined') {
|
|
462
|
+
try {
|
|
463
|
+
window.addEventListener('storage', (e) => {
|
|
464
|
+
if (e.key !== THEME_MODE_LS_KEY) return;
|
|
465
|
+
const v = e.newValue as ThemeModePref | null;
|
|
466
|
+
themeMode.value = v && MODE_VALUES.includes(v) ? v : 'host';
|
|
467
|
+
});
|
|
468
|
+
} catch {
|
|
469
|
+
/* non-browser env */
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
406
473
|
// Cross-tab sync — another tab changing the preference updates this one live.
|
|
407
474
|
if (typeof window !== 'undefined') {
|
|
408
475
|
try {
|
package/src/locales/en.ts
CHANGED
|
@@ -248,7 +248,11 @@ export const en: Record<string, string> = {
|
|
|
248
248
|
/* wiring:c1 — theme gallery */
|
|
249
249
|
'theme.menu': 'Theme',
|
|
250
250
|
'theme.title': 'Theme Gallery',
|
|
251
|
-
'theme.hint': '
|
|
251
|
+
'theme.hint': 'A theme only changes the colour palette — day or night is the choice above.',
|
|
252
|
+
'theme.mode.label': 'Appearance',
|
|
253
|
+
'theme.mode.light': 'Day',
|
|
254
|
+
'theme.mode.dark': 'Night',
|
|
255
|
+
'theme.mode.auto': 'Automatic',
|
|
252
256
|
'theme.reset': 'Reset to default',
|
|
253
257
|
'theme.close': 'Close',
|
|
254
258
|
'theme.selected': 'Selected',
|
package/src/locales/tr.ts
CHANGED
|
@@ -248,7 +248,11 @@ export const tr: Record<string, string> = {
|
|
|
248
248
|
/* wiring:c1 — tema galerisi */
|
|
249
249
|
'theme.menu': 'Tema',
|
|
250
250
|
'theme.title': 'Tema Galerisi',
|
|
251
|
-
'theme.hint': 'Tema yalnız renk paletini değiştirir;
|
|
251
|
+
'theme.hint': 'Tema yalnız renk paletini değiştirir; gündüz mü gece mi olduğu yukarıdaki seçim.',
|
|
252
|
+
'theme.mode.label': 'Görünüm',
|
|
253
|
+
'theme.mode.light': 'Gündüz',
|
|
254
|
+
'theme.mode.dark': 'Gece',
|
|
255
|
+
'theme.mode.auto': 'Otomatik',
|
|
252
256
|
'theme.reset': 'Varsayılana dön',
|
|
253
257
|
'theme.close': 'Kapat',
|
|
254
258
|
'theme.selected': 'Seçili',
|
|
@@ -38,7 +38,7 @@ const props = defineProps<{
|
|
|
38
38
|
onlyOfficeConfigEndpoint?: string | null;
|
|
39
39
|
saveTextEndpoint?: string | null;
|
|
40
40
|
openMode?: 'edit' | 'view';
|
|
41
|
-
authHeaders?: () => Record<string, string
|
|
41
|
+
authHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
42
42
|
authCredentials?: RequestCredentials;
|
|
43
43
|
/** New rich-viewer config (forwarded by FileExplorer from ExplorerConfig). */
|
|
44
44
|
drawioUrl?: string | null;
|
|
@@ -351,7 +351,7 @@ async function fetchText(url: string): Promise<void> {
|
|
|
351
351
|
tooLarge.value = false;
|
|
352
352
|
try {
|
|
353
353
|
const headers: Record<string, string> = {};
|
|
354
|
-
if (props.authHeaders) Object.assign(headers, props.authHeaders());
|
|
354
|
+
if (props.authHeaders) Object.assign(headers, await props.authHeaders());
|
|
355
355
|
const res = await fetch(url, {
|
|
356
356
|
credentials: props.authCredentials || 'same-origin',
|
|
357
357
|
headers,
|
|
@@ -390,14 +390,25 @@ async function fetchText(url: string): Promise<void> {
|
|
|
390
390
|
|
|
391
391
|
const markdownHtml = ref<string>('');
|
|
392
392
|
const markdownEl = ref<HTMLDivElement | null>(null);
|
|
393
|
+
/**
|
|
394
|
+
* The renderer could not be loaded at all.
|
|
395
|
+
*
|
|
396
|
+
* ⚠ Tracked separately from "nothing to render". In view mode a missing
|
|
397
|
+
* renderer falls through to the raw source, which is honest; in the split
|
|
398
|
+
* editor the preview pane simply stayed BLANK — a white half-window with no
|
|
399
|
+
* explanation, measured in the desktop app on 2026-08-10.
|
|
400
|
+
*/
|
|
401
|
+
const markdownUnavailable = ref(false);
|
|
393
402
|
|
|
394
403
|
async function renderMarkdown(text: string): Promise<void> {
|
|
395
404
|
try {
|
|
396
405
|
const mod = (await import(/* @vite-ignore */ 'markdown-it').catch(() => null)) as any;
|
|
397
406
|
if (!mod) {
|
|
398
407
|
markdownHtml.value = '';
|
|
408
|
+
markdownUnavailable.value = true;
|
|
399
409
|
return;
|
|
400
410
|
}
|
|
411
|
+
markdownUnavailable.value = false;
|
|
401
412
|
const Md = (mod as { default: any }).default ?? (mod as any);
|
|
402
413
|
// html: true so inline HTML in README.md / docs renders (the
|
|
403
414
|
// GitHub / GitLab contract — operators expect `<img>`, tables,
|
|
@@ -552,6 +563,8 @@ const monacoEl = ref<HTMLDivElement | null>(null);
|
|
|
552
563
|
let monacoEditor: any = null;
|
|
553
564
|
let codeAutosaveTimer: ReturnType<typeof setTimeout> | undefined;
|
|
554
565
|
const monacoReady = ref(false);
|
|
566
|
+
/** Monaco resolved to nothing — the peer is not in this build at all. */
|
|
567
|
+
const monacoUnavailable = ref(false);
|
|
555
568
|
const saving = ref(false);
|
|
556
569
|
const saveOk = ref(false);
|
|
557
570
|
const saveError = ref<string | null>(null);
|
|
@@ -596,6 +609,12 @@ function monacoLanguageFor(extension: string): string {
|
|
|
596
609
|
|
|
597
610
|
async function tryMountMonaco(text: string, extension: string): Promise<boolean> {
|
|
598
611
|
const monaco = (await ensureMonaco()) as any;
|
|
612
|
+
// ⚠ "Not installed" is not "still loading". Monaco is an optional peer that
|
|
613
|
+
// most embeds do not ship, and the toolbar label was gated only on "not ready
|
|
614
|
+
// yet" — so a bundle without it showed "Editor loading…" forever, over a
|
|
615
|
+
// perfectly good read-only view. Measured in the desktop app once syntax
|
|
616
|
+
// highlighting started working and that label finally had something to sit on.
|
|
617
|
+
if (!monaco) monacoUnavailable.value = true;
|
|
599
618
|
if (!monaco || !monacoEl.value) return false;
|
|
600
619
|
try {
|
|
601
620
|
disposeMonaco();
|
|
@@ -653,7 +672,7 @@ async function saveCode(): Promise<void> {
|
|
|
653
672
|
try {
|
|
654
673
|
const text = monacoEditor ? monacoEditor.getValue() : '';
|
|
655
674
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
656
|
-
if (props.authHeaders) Object.assign(headers, props.authHeaders());
|
|
675
|
+
if (props.authHeaders) Object.assign(headers, await props.authHeaders());
|
|
657
676
|
const res = await fetch(props.saveTextEndpoint, {
|
|
658
677
|
method: 'POST',
|
|
659
678
|
headers,
|
|
@@ -718,7 +737,10 @@ async function runOrchestration(open: boolean, url: string, k: string): Promise<
|
|
|
718
737
|
// kick off the Monaco load and swap it in once ready.
|
|
719
738
|
if (k === 'code') await highlightCode(rawText.value, lang);
|
|
720
739
|
ensureMonaco().then(async (m) => {
|
|
721
|
-
|
|
740
|
+
// ⚠ Record the "never coming" case here too. This branch returns early
|
|
741
|
+
// without ever reaching tryMountMonaco, so a build with no Monaco left
|
|
742
|
+
// the toolbar saying "Editor loading…" indefinitely.
|
|
743
|
+
if (!m) { monacoUnavailable.value = true; return; }
|
|
722
744
|
if (!props.open) return;
|
|
723
745
|
// Wait one tick so the placeholder DOM exists before swapping.
|
|
724
746
|
await new Promise<void>((r) => setTimeout(r, 0));
|
|
@@ -770,7 +792,7 @@ async function mountOnlyOfficeEditor(): Promise<void> {
|
|
|
770
792
|
|
|
771
793
|
try {
|
|
772
794
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
773
|
-
if (props.authHeaders) Object.assign(headers, props.authHeaders());
|
|
795
|
+
if (props.authHeaders) Object.assign(headers, await props.authHeaders());
|
|
774
796
|
const res = await fetch(props.onlyOfficeConfigEndpoint, {
|
|
775
797
|
method: 'POST',
|
|
776
798
|
headers,
|
|
@@ -932,6 +954,11 @@ function loadOnlyOfficeScript(base: string): Promise<void> {
|
|
|
932
954
|
placeholder="# Markdown buraya…"
|
|
933
955
|
/>
|
|
934
956
|
<div
|
|
957
|
+
v-if="markdownUnavailable"
|
|
958
|
+
class="fe-preview__md-split-output fe-preview__md"
|
|
959
|
+
>{{ t('viewer.peer_not_installed') }}</div>
|
|
960
|
+
<div
|
|
961
|
+
v-else
|
|
935
962
|
ref="markdownEl"
|
|
936
963
|
class="fe-preview__md-split-output fe-preview__md"
|
|
937
964
|
v-html="markdownHtml"
|
|
@@ -978,7 +1005,7 @@ function loadOnlyOfficeScript(base: string): Promise<void> {
|
|
|
978
1005
|
<div class="fe-preview__code-toolbar">
|
|
979
1006
|
<span class="fe-preview__code-lang">{{ codeLanguage }}</span>
|
|
980
1007
|
<span v-if="!monacoReady && codeHtml" class="fe-preview__code-status">
|
|
981
|
-
{{ saveTextEndpoint ? t('viewer.editor_loading') : t('viewer.read_only') }}
|
|
1008
|
+
{{ saveTextEndpoint && !monacoUnavailable ? t('viewer.editor_loading') : t('viewer.read_only') }}
|
|
982
1009
|
</span>
|
|
983
1010
|
<span v-if="saveOk" class="fe-preview__code-status fe-preview__code-status--ok">✓ {{ t('viewer.saved') }}</span>
|
|
984
1011
|
<span v-if="saveError" class="fe-preview__code-status fe-preview__code-status--err" :title="saveError">✗ {{ t('viewer.save_error') }}</span>
|