@brftech/filex-core 0.1.56
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/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/ArchiveViewer-CJdaw5x3.js +99 -0
- package/dist/ArchiveViewer-CJdaw5x3.js.map +1 -0
- package/dist/CsvViewer-QYx_tePQ.js +132 -0
- package/dist/CsvViewer-QYx_tePQ.js.map +1 -0
- package/dist/DrawioViewer-GWt0TyB7.js +141 -0
- package/dist/DrawioViewer-GWt0TyB7.js.map +1 -0
- package/dist/EpubViewer-Je0ywklQ.js +146 -0
- package/dist/EpubViewer-Je0ywklQ.js.map +1 -0
- package/dist/IpynbViewer-TsB4gqP-.js +175 -0
- package/dist/IpynbViewer-TsB4gqP-.js.map +1 -0
- package/dist/MermaidViewer-CXfeDS-7.js +128 -0
- package/dist/MermaidViewer-CXfeDS-7.js.map +1 -0
- package/dist/PsdViewer-VtPpxKVe.js +113 -0
- package/dist/PsdViewer-VtPpxKVe.js.map +1 -0
- package/dist/TiffViewer-OINn9bbb.js +133 -0
- package/dist/TiffViewer-OINn9bbb.js.map +1 -0
- package/dist/UTIF-Cjsrlm0l.js +3104 -0
- package/dist/UTIF-Cjsrlm0l.js.map +1 -0
- package/dist/Viewer3D-xvvyiWHd.js +61 -0
- package/dist/Viewer3D-xvvyiWHd.js.map +1 -0
- package/dist/_commonjsHelpers-DaMA6jEr.js +9 -0
- package/dist/_commonjsHelpers-DaMA6jEr.js.map +1 -0
- package/dist/filex-core.js +26 -0
- package/dist/filex-core.js.map +1 -0
- package/dist/filex-core.umd.cjs +285 -0
- package/dist/filex-core.umd.cjs.map +1 -0
- package/dist/index-CmSYzt6L.js +5404 -0
- package/dist/index-CmSYzt6L.js.map +1 -0
- package/dist/index-UFULWo35.js +10736 -0
- package/dist/index-UFULWo35.js.map +1 -0
- package/dist/index.d.ts +1089 -0
- package/dist/katex-yuB6V-q6.js +11616 -0
- package/dist/katex-yuB6V-q6.js.map +1 -0
- package/dist/papaparse.min-VB1HBwYX.js +441 -0
- package/dist/papaparse.min-VB1HBwYX.js.map +1 -0
- package/dist/style.css +1 -0
- package/dist/useViewerFetch-czqbd2Lj.js +25 -0
- package/dist/useViewerFetch-czqbd2Lj.js.map +1 -0
- package/package.json +113 -0
- package/src/FileExplorer.vue +1872 -0
- package/src/components/Breadcrumb.vue +282 -0
- package/src/components/ContextMenu.vue +152 -0
- package/src/components/GridView.vue +166 -0
- package/src/components/ListView.vue +212 -0
- package/src/components/PendingOpsTray.vue +91 -0
- package/src/components/RecentlyOpened.vue +155 -0
- package/src/components/StarButton.vue +101 -0
- package/src/components/TagPicker.vue +184 -0
- package/src/components/Toolbar.vue +220 -0
- package/src/components/UploadProgress.vue +78 -0
- package/src/composables/useFileApi.ts +692 -0
- package/src/composables/useKeyboardShortcuts.ts +144 -0
- package/src/composables/useLocale.ts +60 -0
- package/src/composables/useMonacoLoader.ts +98 -0
- package/src/composables/usePendingOps.ts +200 -0
- package/src/composables/useSelection.ts +81 -0
- package/src/composables/useUploadChunked.ts +250 -0
- package/src/composables/useViewerFetch.ts +58 -0
- package/src/index.ts +80 -0
- package/src/locales/en.ts +122 -0
- package/src/locales/index.ts +12 -0
- package/src/locales/tr.ts +122 -0
- package/src/modals/ConvertModal.vue +254 -0
- package/src/modals/DeleteConfirmModal.vue +32 -0
- package/src/modals/Modal.vue +104 -0
- package/src/modals/NewFolderModal.vue +64 -0
- package/src/modals/PermissionsModal.vue +815 -0
- package/src/modals/PreviewModal.vue +1070 -0
- package/src/modals/RenameModal.vue +58 -0
- package/src/modals/ShareModal.vue +135 -0
- package/src/styles/base.css +1224 -0
- package/src/styles/variables.css +90 -0
- package/src/types/ExplorerConfig.ts +282 -0
- package/src/types/FileNode.ts +136 -0
- package/src/types/index.ts +27 -0
- package/src/types/peers.d.ts +45 -0
- package/src/viewers/ArchiveViewer.vue +186 -0
- package/src/viewers/CsvViewer.vue +319 -0
- package/src/viewers/DrawioViewer.vue +243 -0
- package/src/viewers/EpubViewer.vue +307 -0
- package/src/viewers/IpynbViewer.vue +365 -0
- package/src/viewers/MermaidViewer.vue +259 -0
- package/src/viewers/PdfViewer.vue +562 -0
- package/src/viewers/PsdViewer.vue +264 -0
- package/src/viewers/TiffViewer.vue +273 -0
- package/src/viewers/Viewer3D.vue +120 -0
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* PreviewModal — inline file preview.
|
|
4
|
+
*
|
|
5
|
+
* Strategy by extension:
|
|
6
|
+
* image / video / audio / pdf → native browser elements
|
|
7
|
+
* md / markdown → markdown-it (lazy) → rendered HTML
|
|
8
|
+
* code (js/ts/php/py/json/...) → Monaco when ready, highlight.js
|
|
9
|
+
* fallback while it's still loading,
|
|
10
|
+
* plain `<pre>` when neither is
|
|
11
|
+
* installed
|
|
12
|
+
* office (docx/xlsx/pptx) → OnlyOffice iframe (config.onlyOfficeBase)
|
|
13
|
+
* plain text (txt/log/conf) → CodeMirror when saveText is set,
|
|
14
|
+
* `<pre>` otherwise
|
|
15
|
+
* anything else → "indir" fallback
|
|
16
|
+
*
|
|
17
|
+
* Monaco is dynamic-imported at FileExplorer onMounted; by the time the
|
|
18
|
+
* user clicks an editable code file the chunk is usually already cached.
|
|
19
|
+
* We probe `getMonaco()` synchronously when the modal opens — if it's
|
|
20
|
+
* not yet loaded we render the highlight.js read-only view immediately,
|
|
21
|
+
* then upgrade to Monaco once the import resolves.
|
|
22
|
+
*/
|
|
23
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
|
|
24
|
+
import type { Component } from 'vue';
|
|
25
|
+
import type { FileNode } from '../types/FileNode';
|
|
26
|
+
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
27
|
+
import Modal from './Modal.vue';
|
|
28
|
+
import { ensureMonaco, getMonaco, ensureHighlight } from '../composables/useMonacoLoader';
|
|
29
|
+
import { useLocale } from '../composables/useLocale';
|
|
30
|
+
|
|
31
|
+
const props = defineProps<{
|
|
32
|
+
open: boolean;
|
|
33
|
+
locale: LocaleCode;
|
|
34
|
+
file: FileNode | null;
|
|
35
|
+
previewUrl: (path: string) => string;
|
|
36
|
+
downloadUrl: (path: string) => string;
|
|
37
|
+
onlyOfficeBase?: string | null;
|
|
38
|
+
onlyOfficeConfigEndpoint?: string | null;
|
|
39
|
+
saveTextEndpoint?: string | null;
|
|
40
|
+
openMode?: 'edit' | 'view';
|
|
41
|
+
authHeaders?: () => Record<string, string>;
|
|
42
|
+
authCredentials?: RequestCredentials;
|
|
43
|
+
/** New rich-viewer config (forwarded by FileExplorer from ExplorerConfig). */
|
|
44
|
+
drawioUrl?: string | null;
|
|
45
|
+
pdfWorkerUrl?: string | null;
|
|
46
|
+
pdfSaveUrl?: string | null;
|
|
47
|
+
/** Standalone full-screen viewer route — `?path=…&storage=…&type=…`. */
|
|
48
|
+
viewerBaseUrl?: string | null;
|
|
49
|
+
/** Drop the dialog chrome (backdrop tint, header bar, footer actions)
|
|
50
|
+
* so the viewer fills the full viewport. Used by the standalone
|
|
51
|
+
* /files/edit route where the browser tab IS the container — a
|
|
52
|
+
* modal frame on top of the editor just steals real estate. */
|
|
53
|
+
chromeless?: boolean;
|
|
54
|
+
/** When the dynamic-viewer chunk fails to load, fall back to the
|
|
55
|
+
* legacy native renderer (e.g. native `<object>` for PDFs). */
|
|
56
|
+
/** Explicit theme. Forwarded to the underlying Modal (which tags
|
|
57
|
+
* its backdrop with `.fe--theme-{light,dark}` so the CSS variable
|
|
58
|
+
* override matches the host admin shell) and to Monaco (vs vs
|
|
59
|
+
* vs-dark). When unset Monaco falls back to `prefers-color-scheme`
|
|
60
|
+
* and the modal cascade follows whatever `.fe` parent it gets. */
|
|
61
|
+
theme?: 'light' | 'dark' | 'auto';
|
|
62
|
+
}>();
|
|
63
|
+
|
|
64
|
+
const emit = defineEmits<{
|
|
65
|
+
(e: 'close'): void;
|
|
66
|
+
}>();
|
|
67
|
+
|
|
68
|
+
const { t } = useLocale(() => props.locale);
|
|
69
|
+
|
|
70
|
+
function ext(f: FileNode | null): string {
|
|
71
|
+
return (f?.extension || '').toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const IMAGE = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'avif', 'svg', 'heic'];
|
|
75
|
+
const VIDEO = ['mp4', 'webm', 'mov', 'mkv', 'm4v', 'ogv'];
|
|
76
|
+
const AUDIO = ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac', 'opus'];
|
|
77
|
+
|
|
78
|
+
const CODE_LANGS: Record<string, string> = {
|
|
79
|
+
md: 'markdown', markdown: 'markdown',
|
|
80
|
+
txt: 'plaintext', log: 'plaintext',
|
|
81
|
+
js: 'javascript', mjs: 'javascript', cjs: 'javascript',
|
|
82
|
+
ts: 'typescript', tsx: 'typescript', jsx: 'javascript',
|
|
83
|
+
vue: 'xml', svelte: 'xml', html: 'xml', htm: 'xml', xml: 'xml', svg: 'xml',
|
|
84
|
+
css: 'css', scss: 'scss', sass: 'scss', less: 'less',
|
|
85
|
+
json: 'json', jsonc: 'json',
|
|
86
|
+
yml: 'yaml', yaml: 'yaml',
|
|
87
|
+
php: 'php', py: 'python', rb: 'ruby', go: 'go', rs: 'rust',
|
|
88
|
+
java: 'java', kt: 'kotlin', swift: 'swift', cpp: 'cpp', c: 'c',
|
|
89
|
+
h: 'cpp', hpp: 'cpp', cs: 'csharp', dart: 'dart',
|
|
90
|
+
sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash',
|
|
91
|
+
sql: 'sql',
|
|
92
|
+
toml: 'ini', ini: 'ini', conf: 'ini', env: 'bash', cfg: 'ini',
|
|
93
|
+
dockerfile: 'dockerfile',
|
|
94
|
+
graphql: 'graphql', gql: 'graphql',
|
|
95
|
+
diff: 'diff', patch: 'diff',
|
|
96
|
+
};
|
|
97
|
+
// CSV/TSV land in the rich viewer (`csv` kind below) instead of the
|
|
98
|
+
// legacy plain-text path so the user gets a proper table preview.
|
|
99
|
+
const TEXT_PLAIN = ['txt', 'log'];
|
|
100
|
+
const OFFICE = ['docx', 'doc', 'xlsx', 'xls', 'pptx', 'ppt', 'odt', 'ods', 'odp', 'rtf'];
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Lazy viewer map — extension → component loader. Each loader is a
|
|
104
|
+
* dynamic import so the viewer chunk only ships when the file type
|
|
105
|
+
* is actually opened. The PreviewModal mounts the resolved component
|
|
106
|
+
* via `<component :is="ViewerCmp" />` once the dynamic import settles.
|
|
107
|
+
*/
|
|
108
|
+
const VIEWER_MAP: Record<string, () => Promise<Component>> = {
|
|
109
|
+
glb: () => import('../viewers/Viewer3D.vue'),
|
|
110
|
+
gltf: () => import('../viewers/Viewer3D.vue'),
|
|
111
|
+
obj: () => import('../viewers/Viewer3D.vue'),
|
|
112
|
+
stl: () => import('../viewers/Viewer3D.vue'),
|
|
113
|
+
fbx: () => import('../viewers/Viewer3D.vue'),
|
|
114
|
+
'3ds': () => import('../viewers/Viewer3D.vue'),
|
|
115
|
+
|
|
116
|
+
epub: () => import('../viewers/EpubViewer.vue'),
|
|
117
|
+
|
|
118
|
+
mmd: () => import('../viewers/MermaidViewer.vue'),
|
|
119
|
+
mermaid: () => import('../viewers/MermaidViewer.vue'),
|
|
120
|
+
|
|
121
|
+
drawio: () => import('../viewers/DrawioViewer.vue'),
|
|
122
|
+
dio: () => import('../viewers/DrawioViewer.vue'),
|
|
123
|
+
|
|
124
|
+
tif: () => import('../viewers/TiffViewer.vue'),
|
|
125
|
+
tiff: () => import('../viewers/TiffViewer.vue'),
|
|
126
|
+
|
|
127
|
+
psd: () => import('../viewers/PsdViewer.vue'),
|
|
128
|
+
|
|
129
|
+
// PDF deliberately uses the native browser viewer (`kind === 'pdf'`
|
|
130
|
+
// branch in the template). PdfViewer.vue still exists for callers
|
|
131
|
+
// that want the rich custom UI but the SFC default keeps things
|
|
132
|
+
// minimal — the browser already paints search/zoom/page UI for free.
|
|
133
|
+
|
|
134
|
+
ipynb: () => import('../viewers/IpynbViewer.vue'),
|
|
135
|
+
|
|
136
|
+
csv: () => import('../viewers/CsvViewer.vue'),
|
|
137
|
+
tsv: () => import('../viewers/CsvViewer.vue'),
|
|
138
|
+
|
|
139
|
+
zip: () => import('../viewers/ArchiveViewer.vue'),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
type PreviewKind =
|
|
143
|
+
| 'image' | 'video' | 'audio' | 'pdf' | 'markdown' | 'code'
|
|
144
|
+
| 'office' | 'text' | 'viewer' | 'other';
|
|
145
|
+
|
|
146
|
+
const kind = computed<PreviewKind>(() => {
|
|
147
|
+
const e = ext(props.file);
|
|
148
|
+
if (!e) return 'other';
|
|
149
|
+
if (IMAGE.includes(e)) return 'image';
|
|
150
|
+
if (VIDEO.includes(e)) return 'video';
|
|
151
|
+
if (AUDIO.includes(e)) return 'audio';
|
|
152
|
+
// PDF always uses the native browser <object> renderer. The
|
|
153
|
+
// PdfViewer SFC is no longer wired into the default map — the
|
|
154
|
+
// browser-bundled toolbar is enough and removes a 600 KB pdfjs
|
|
155
|
+
// worker chunk + a custom toolbar layer for parity with the rest
|
|
156
|
+
// of the read-only viewers.
|
|
157
|
+
if (e === 'pdf') return 'pdf';
|
|
158
|
+
// Markdown stays in its own branch (gets the split view in edit mode);
|
|
159
|
+
// plain text → code editor in edit mode, raw <pre> in view mode.
|
|
160
|
+
const wantEdit = props.openMode !== 'view';
|
|
161
|
+
if (e === 'md' || e === 'markdown') return 'markdown';
|
|
162
|
+
if (e in CODE_LANGS) return 'code';
|
|
163
|
+
if (e in VIEWER_MAP) return 'viewer';
|
|
164
|
+
if (OFFICE.includes(e)) return 'office';
|
|
165
|
+
if (TEXT_PLAIN.includes(e)) return wantEdit ? 'code' : 'text';
|
|
166
|
+
return 'other';
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// --- Rich viewer plumbing ---
|
|
170
|
+
//
|
|
171
|
+
// `viewerCmp` holds the dynamically-imported component once the load
|
|
172
|
+
// resolves. Stored in a `shallowRef` because the component itself
|
|
173
|
+
// shouldn't be deep-watched.
|
|
174
|
+
const viewerCmp = shallowRef<Component | null>(null);
|
|
175
|
+
const viewerLoadError = ref<string | null>(null);
|
|
176
|
+
const pdfFallbackToNative = ref(false);
|
|
177
|
+
|
|
178
|
+
async function loadViewerFor(extension: string): Promise<void> {
|
|
179
|
+
viewerCmp.value = null;
|
|
180
|
+
viewerLoadError.value = null;
|
|
181
|
+
const loader = VIEWER_MAP[extension];
|
|
182
|
+
if (!loader) return;
|
|
183
|
+
try {
|
|
184
|
+
const mod = (await loader()) as { default?: Component };
|
|
185
|
+
viewerCmp.value = mod.default ?? (mod as unknown as Component);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
viewerLoadError.value = err instanceof Error ? err.message : 'viewer load failed';
|
|
188
|
+
if (extension === 'pdf') pdfFallbackToNative.value = true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function onPdfFallback(): void {
|
|
193
|
+
pdfFallbackToNative.value = true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Build the props bundle pushed into the active viewer component. */
|
|
197
|
+
const viewerProps = computed(() => {
|
|
198
|
+
const e = ext(props.file);
|
|
199
|
+
const base: Record<string, unknown> = {
|
|
200
|
+
url: src.value,
|
|
201
|
+
ext: e,
|
|
202
|
+
mime: props.file?.mime_type,
|
|
203
|
+
t,
|
|
204
|
+
authHeaders: props.authHeaders,
|
|
205
|
+
authCredentials: props.authCredentials,
|
|
206
|
+
};
|
|
207
|
+
if (props.file) {
|
|
208
|
+
base.filePath = stripAdapter(props.file.path);
|
|
209
|
+
// Archive viewer needs the adapter-qualified path because the
|
|
210
|
+
// /api/files/archive/list handler falls back to storages[0] when
|
|
211
|
+
// no adapter prefix is present — on multi-storage instances that
|
|
212
|
+
// 500s for every non-default storage (sample.zip on fm s3-test).
|
|
213
|
+
if (e === 'zip' || e === 'rar' || e === '7z' || e === 'tar' || e === 'gz' || e === 'tgz') {
|
|
214
|
+
base.filePath = props.file.path;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (e === 'drawio' || e === 'dio') {
|
|
218
|
+
base.drawioUrl = props.drawioUrl ?? undefined;
|
|
219
|
+
base.saveUrl = props.saveTextEndpoint ?? undefined;
|
|
220
|
+
base.readOnly = props.openMode === 'view';
|
|
221
|
+
}
|
|
222
|
+
if (e === 'pdf') {
|
|
223
|
+
base.pdfWorkerUrl = props.pdfWorkerUrl ?? undefined;
|
|
224
|
+
base.pdfSaveUrl = props.pdfSaveUrl ?? undefined;
|
|
225
|
+
}
|
|
226
|
+
return base;
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
function openInNewTab(): void {
|
|
230
|
+
buildAndOpenStandalone(props.openMode || 'edit');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function openEditInNewTab(): void {
|
|
234
|
+
buildAndOpenStandalone('edit');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function buildAndOpenStandalone(mode: 'view' | 'edit'): void {
|
|
238
|
+
if (!props.file) return;
|
|
239
|
+
const e = ext(props.file);
|
|
240
|
+
// Keep the adapter-qualified path intact so the editor route
|
|
241
|
+
// resolves the storage from the URL (stripping it falls back to
|
|
242
|
+
// storages[0] and 404s for any non-default adapter). Default base
|
|
243
|
+
// is the SFC's standalone /files/edit route; embedders override via
|
|
244
|
+
// viewerBaseUrl when they mount us elsewhere.
|
|
245
|
+
const base = props.viewerBaseUrl || '/files/edit';
|
|
246
|
+
const sep = base.includes('?') ? '&' : '?';
|
|
247
|
+
const url =
|
|
248
|
+
`${base}${sep}path=${encodeURIComponent(props.file.path)}` +
|
|
249
|
+
`&type=${encodeURIComponent(e)}` +
|
|
250
|
+
`&mode=${encodeURIComponent(mode)}`;
|
|
251
|
+
window.open(url, '_blank', 'noopener');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Extensions that have a meaningful "edit" surface. Read-only kinds
|
|
256
|
+
* (image/video/audio/3D/archive) don't surface a "Düzenle" button.
|
|
257
|
+
*/
|
|
258
|
+
const EDITABLE_EXTS = new Set([
|
|
259
|
+
// OnlyOffice — open the in-page office editor (or new tab) with
|
|
260
|
+
// edit permissions.
|
|
261
|
+
'docx', 'doc', 'xlsx', 'xls', 'pptx', 'ppt',
|
|
262
|
+
'odt', 'ods', 'odp', 'rtf',
|
|
263
|
+
// Drawio / mermaid round-trip via the new-tab route.
|
|
264
|
+
'drawio', 'dio', 'mmd', 'mermaid',
|
|
265
|
+
// Code / text / markdown — Monaco / split editor.
|
|
266
|
+
'md', 'markdown', 'txt', 'log',
|
|
267
|
+
'json', 'jsonc', 'yaml', 'yml', 'xml', 'svg', 'html', 'htm',
|
|
268
|
+
'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'vue', 'svelte',
|
|
269
|
+
'css', 'scss', 'sass', 'less',
|
|
270
|
+
'php', 'py', 'rb', 'go', 'rs', 'java', 'kt', 'swift',
|
|
271
|
+
'cpp', 'c', 'h', 'hpp', 'cs', 'dart',
|
|
272
|
+
'sh', 'bash', 'sql',
|
|
273
|
+
'toml', 'ini', 'conf', 'cfg', 'env',
|
|
274
|
+
'dockerfile', 'graphql', 'gql',
|
|
275
|
+
]);
|
|
276
|
+
|
|
277
|
+
const canEditKind = computed<boolean>(() =>
|
|
278
|
+
!!props.file && EDITABLE_EXTS.has(ext(props.file)),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
// Keep adapter prefix so backend resolves the right storage — stripping
|
|
282
|
+
// it defaults to storages[0] and 404s on any non-default adapter.
|
|
283
|
+
const src = computed(() => (props.file ? props.previewUrl(props.file.path) : ''));
|
|
284
|
+
const download = computed(() => (props.file ? props.downloadUrl(props.file.path) : ''));
|
|
285
|
+
|
|
286
|
+
function stripAdapter(p: string): string {
|
|
287
|
+
const idx = p.indexOf('://');
|
|
288
|
+
return idx === -1 ? p : p.slice(idx + 3);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const loading = ref(false);
|
|
292
|
+
const fetchError = ref<string | null>(null);
|
|
293
|
+
const rawText = ref<string>('');
|
|
294
|
+
const MAX_TEXT_BYTES = 1_000_000;
|
|
295
|
+
const tooLarge = ref(false);
|
|
296
|
+
|
|
297
|
+
// Markdown split-edit state — drives the side-by-side textarea+preview
|
|
298
|
+
// layout inside the `kind === 'markdown'` template when openMode='edit'.
|
|
299
|
+
const mdDirty = ref(false);
|
|
300
|
+
const mdSaving = ref(false);
|
|
301
|
+
let mdReRenderTimer: ReturnType<typeof setTimeout> | undefined;
|
|
302
|
+
let mdAutosaveTimer: ReturnType<typeof setTimeout> | undefined;
|
|
303
|
+
function onMdInput() {
|
|
304
|
+
mdDirty.value = true;
|
|
305
|
+
if (mdReRenderTimer) clearTimeout(mdReRenderTimer);
|
|
306
|
+
mdReRenderTimer = setTimeout(() => {
|
|
307
|
+
renderMarkdown(rawText.value);
|
|
308
|
+
}, 250);
|
|
309
|
+
// Autosave 1.5s after last keystroke — manual Kaydet button + Ctrl+S
|
|
310
|
+
// still work; saveMarkdown() guards against overlap.
|
|
311
|
+
if (mdAutosaveTimer) clearTimeout(mdAutosaveTimer);
|
|
312
|
+
mdAutosaveTimer = setTimeout(() => {
|
|
313
|
+
void saveMarkdown();
|
|
314
|
+
}, 1500);
|
|
315
|
+
}
|
|
316
|
+
async function saveMarkdown() {
|
|
317
|
+
if (!props.saveTextEndpoint || !props.file || mdSaving.value) return;
|
|
318
|
+
mdSaving.value = true;
|
|
319
|
+
fetchError.value = null;
|
|
320
|
+
try {
|
|
321
|
+
const headers = {
|
|
322
|
+
'Content-Type': 'application/json',
|
|
323
|
+
...(await (props.authHeaders ?? (() => ({})))()),
|
|
324
|
+
};
|
|
325
|
+
const res = await fetch(props.saveTextEndpoint, {
|
|
326
|
+
method: 'POST',
|
|
327
|
+
headers,
|
|
328
|
+
credentials: props.authCredentials || 'same-origin',
|
|
329
|
+
body: JSON.stringify({ path: stripAdapter(props.file.path), content: rawText.value }),
|
|
330
|
+
});
|
|
331
|
+
if (!res.ok) {
|
|
332
|
+
throw new Error(`save failed: ${res.status} ${await res.text()}`);
|
|
333
|
+
}
|
|
334
|
+
mdDirty.value = false;
|
|
335
|
+
} catch (err) {
|
|
336
|
+
fetchError.value = err instanceof Error ? err.message : String(err);
|
|
337
|
+
} finally {
|
|
338
|
+
mdSaving.value = false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function fetchText(url: string): Promise<void> {
|
|
343
|
+
loading.value = true;
|
|
344
|
+
fetchError.value = null;
|
|
345
|
+
rawText.value = '';
|
|
346
|
+
tooLarge.value = false;
|
|
347
|
+
try {
|
|
348
|
+
const headers: Record<string, string> = {};
|
|
349
|
+
if (props.authHeaders) Object.assign(headers, props.authHeaders());
|
|
350
|
+
const res = await fetch(url, {
|
|
351
|
+
credentials: props.authCredentials || 'include',
|
|
352
|
+
headers,
|
|
353
|
+
});
|
|
354
|
+
if (!res.ok) {
|
|
355
|
+
throw new Error(`${res.status} ${res.statusText}`);
|
|
356
|
+
}
|
|
357
|
+
const len = Number(res.headers.get('content-length') || '0');
|
|
358
|
+
if (len > MAX_TEXT_BYTES) {
|
|
359
|
+
tooLarge.value = true;
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const text = await res.text();
|
|
363
|
+
if (text.length > MAX_TEXT_BYTES) {
|
|
364
|
+
tooLarge.value = true;
|
|
365
|
+
rawText.value = text.slice(0, MAX_TEXT_BYTES);
|
|
366
|
+
} else {
|
|
367
|
+
rawText.value = text;
|
|
368
|
+
}
|
|
369
|
+
} catch (err) {
|
|
370
|
+
fetchError.value = err instanceof Error ? err.message : String(err);
|
|
371
|
+
} finally {
|
|
372
|
+
loading.value = false;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// --- Markdown rendering (lazy markdown-it) ---
|
|
377
|
+
//
|
|
378
|
+
// Beyond the base markdown render we walk the resulting DOM for two
|
|
379
|
+
// enrichments:
|
|
380
|
+
// - ```mermaid``` fences → swap the `<pre>` for a rendered SVG via
|
|
381
|
+
// the `mermaid` peer (when installed).
|
|
382
|
+
// - ```math``` / `$$ … $$` blocks → render with `katex` (peer).
|
|
383
|
+
// Both enrichments degrade gracefully when the peer isn't available
|
|
384
|
+
// (the original `<pre>` stays in place).
|
|
385
|
+
|
|
386
|
+
const markdownHtml = ref<string>('');
|
|
387
|
+
const markdownEl = ref<HTMLDivElement | null>(null);
|
|
388
|
+
|
|
389
|
+
async function renderMarkdown(text: string): Promise<void> {
|
|
390
|
+
try {
|
|
391
|
+
const mod = (await import(/* @vite-ignore */ 'markdown-it').catch(() => null)) as any;
|
|
392
|
+
if (!mod) {
|
|
393
|
+
markdownHtml.value = '';
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const Md = (mod as { default: any }).default ?? (mod as any);
|
|
397
|
+
// html: true so inline HTML in README.md / docs renders (the
|
|
398
|
+
// GitHub / GitLab contract — operators expect `<img>`, tables,
|
|
399
|
+
// `<details>`, etc. to work). We sanitize the output below to
|
|
400
|
+
// strip scripts / event handlers before injecting via v-html.
|
|
401
|
+
const md = new Md({
|
|
402
|
+
html: true,
|
|
403
|
+
linkify: true,
|
|
404
|
+
breaks: true,
|
|
405
|
+
typographer: true,
|
|
406
|
+
});
|
|
407
|
+
const raw = md.render(text);
|
|
408
|
+
markdownHtml.value = sanitizeHtml(raw);
|
|
409
|
+
// Wait for Vue to flush the v-html into the DOM before walking it.
|
|
410
|
+
await new Promise<void>((r) => setTimeout(r, 0));
|
|
411
|
+
await enrichMarkdown();
|
|
412
|
+
} catch (err) {
|
|
413
|
+
markdownHtml.value = '';
|
|
414
|
+
fetchError.value = err instanceof Error ? err.message : String(err);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Sanitize the markdown-it output before v-html injection.
|
|
420
|
+
*
|
|
421
|
+
* GitHub / GitLab let README authors embed inline HTML (img, table,
|
|
422
|
+
* details, kbd …) so the viewer ships with `html: true`. This filter
|
|
423
|
+
* strips the executable surface: `<script>`, `<iframe>`, `<object>`,
|
|
424
|
+
* `<embed>`, any `on*` event-handler attribute, and `javascript:`
|
|
425
|
+
* URLs. Conservative — README content with inline handlers is
|
|
426
|
+
* exceptional and silent over-removal beats silent XSS.
|
|
427
|
+
*
|
|
428
|
+
* No external dep — keeping the package install-free is the trade
|
|
429
|
+
* we want. Integrators who need a heavier sanitizer can run their
|
|
430
|
+
* own DOMPurify pass on the rendered output before showing it.
|
|
431
|
+
*/
|
|
432
|
+
function sanitizeHtml(html: string): string {
|
|
433
|
+
return html
|
|
434
|
+
.replace(/<\s*script\b[^<]*(?:(?!<\s*\/\s*script\s*>)<[^<]*)*<\s*\/\s*script\s*>/gi, '')
|
|
435
|
+
.replace(/<\s*iframe\b[\s\S]*?<\s*\/\s*iframe\s*>/gi, '')
|
|
436
|
+
.replace(/<\s*object\b[\s\S]*?<\s*\/\s*object\s*>/gi, '')
|
|
437
|
+
.replace(/<\s*embed\b[^>]*>/gi, '')
|
|
438
|
+
.replace(/\son\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
|
439
|
+
.replace(/(href|src|action|formaction|xlink:href)\s*=\s*(?:"\s*javascript:[^"]*"|'\s*javascript:[^']*')/gi, '$1="#"');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function enrichMarkdown(): Promise<void> {
|
|
443
|
+
if (!markdownEl.value) return;
|
|
444
|
+
const root = markdownEl.value;
|
|
445
|
+
const blocks = Array.from(root.querySelectorAll('pre > code')) as HTMLElement[];
|
|
446
|
+
if (blocks.length === 0) return;
|
|
447
|
+
|
|
448
|
+
let mermaid: any = undefined;
|
|
449
|
+
let katex: any = undefined;
|
|
450
|
+
|
|
451
|
+
for (const block of blocks) {
|
|
452
|
+
const cls = block.className || '';
|
|
453
|
+
const isMermaid =
|
|
454
|
+
/\blanguage-mermaid\b/.test(cls) || /\blanguage-mmd\b/.test(cls);
|
|
455
|
+
const isMath =
|
|
456
|
+
/\blanguage-math\b/.test(cls) ||
|
|
457
|
+
/\blanguage-latex\b/.test(cls) ||
|
|
458
|
+
/\blanguage-tex\b/.test(cls);
|
|
459
|
+
|
|
460
|
+
if (isMermaid) {
|
|
461
|
+
if (mermaid === undefined) {
|
|
462
|
+
try {
|
|
463
|
+
const m = await import(/* @vite-ignore */ 'mermaid');
|
|
464
|
+
mermaid = m.default ?? m;
|
|
465
|
+
mermaid.initialize?.({
|
|
466
|
+
startOnLoad: false,
|
|
467
|
+
securityLevel: 'strict',
|
|
468
|
+
});
|
|
469
|
+
} catch {
|
|
470
|
+
mermaid = null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (!mermaid) continue;
|
|
474
|
+
try {
|
|
475
|
+
const id = `filex-md-mermaid-${Math.random().toString(36).slice(2)}`;
|
|
476
|
+
const { svg } = await mermaid.render(id, block.textContent || '');
|
|
477
|
+
const wrap = document.createElement('div');
|
|
478
|
+
wrap.className = 'fe-preview__md-mermaid';
|
|
479
|
+
wrap.innerHTML = svg;
|
|
480
|
+
block.parentElement?.replaceWith(wrap);
|
|
481
|
+
} catch {
|
|
482
|
+
/* leave fenced block */
|
|
483
|
+
}
|
|
484
|
+
} else if (isMath) {
|
|
485
|
+
if (katex === undefined) {
|
|
486
|
+
try {
|
|
487
|
+
const m = await import(/* @vite-ignore */ 'katex');
|
|
488
|
+
katex = m.default ?? m;
|
|
489
|
+
} catch {
|
|
490
|
+
katex = null;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (!katex) continue;
|
|
494
|
+
try {
|
|
495
|
+
const html = katex.renderToString(block.textContent || '', {
|
|
496
|
+
displayMode: true,
|
|
497
|
+
throwOnError: false,
|
|
498
|
+
});
|
|
499
|
+
const wrap = document.createElement('div');
|
|
500
|
+
wrap.className = 'fe-preview__md-math';
|
|
501
|
+
wrap.innerHTML = html;
|
|
502
|
+
block.parentElement?.replaceWith(wrap);
|
|
503
|
+
} catch {
|
|
504
|
+
/* leave fenced block */
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// --- Code highlight (highlight.js fallback) ---
|
|
511
|
+
//
|
|
512
|
+
// Used as the placeholder render until Monaco resolves, AND as the
|
|
513
|
+
// permanent renderer when Monaco isn't installed at all (peer missing).
|
|
514
|
+
|
|
515
|
+
const codeHtml = ref<string>('');
|
|
516
|
+
|
|
517
|
+
async function highlightCode(text: string, language: string): Promise<void> {
|
|
518
|
+
try {
|
|
519
|
+
const mod = (await ensureHighlight()) as any;
|
|
520
|
+
if (!mod) {
|
|
521
|
+
codeHtml.value = '';
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const hljs = mod.default ?? mod;
|
|
525
|
+
if (hljs.getLanguage(language)) {
|
|
526
|
+
const result = hljs.highlight(text, { language, ignoreIllegals: true });
|
|
527
|
+
codeHtml.value = result.value;
|
|
528
|
+
} else {
|
|
529
|
+
const result = hljs.highlightAuto(text);
|
|
530
|
+
codeHtml.value = result.value;
|
|
531
|
+
}
|
|
532
|
+
} catch (err) {
|
|
533
|
+
codeHtml.value = '';
|
|
534
|
+
fetchError.value = err instanceof Error ? err.message : String(err);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// --- Monaco editor (lazy) ---
|
|
539
|
+
//
|
|
540
|
+
// We attempt to instantiate Monaco when the modal opens for a code/text
|
|
541
|
+
// file. If the module isn't loaded yet, the highlight.js placeholder
|
|
542
|
+
// renders first, and the editor mounts in-place once Monaco resolves.
|
|
543
|
+
// Save endpoint required for editing — read-only Monaco still loads if
|
|
544
|
+
// the user just wants the IDE-grade syntax/colour view.
|
|
545
|
+
|
|
546
|
+
const monacoEl = ref<HTMLDivElement | null>(null);
|
|
547
|
+
let monacoEditor: any = null;
|
|
548
|
+
let codeAutosaveTimer: ReturnType<typeof setTimeout> | undefined;
|
|
549
|
+
const monacoReady = ref(false);
|
|
550
|
+
const saving = ref(false);
|
|
551
|
+
const saveOk = ref(false);
|
|
552
|
+
const saveError = ref<string | null>(null);
|
|
553
|
+
|
|
554
|
+
function disposeMonaco(): void {
|
|
555
|
+
if (monacoEditor) {
|
|
556
|
+
try {
|
|
557
|
+
monacoEditor.dispose();
|
|
558
|
+
} catch {
|
|
559
|
+
/* ignore */
|
|
560
|
+
}
|
|
561
|
+
monacoEditor = null;
|
|
562
|
+
}
|
|
563
|
+
monacoReady.value = false;
|
|
564
|
+
saveOk.value = false;
|
|
565
|
+
saveError.value = null;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Map an extension to a Monaco language id. Anything not in the map
|
|
570
|
+
* just becomes 'plaintext' — Monaco still gives line numbers + Ctrl+S.
|
|
571
|
+
*/
|
|
572
|
+
function monacoLanguageFor(extension: string): string {
|
|
573
|
+
const map: Record<string, string> = {
|
|
574
|
+
js: 'javascript', mjs: 'javascript', cjs: 'javascript',
|
|
575
|
+
ts: 'typescript', tsx: 'typescript', jsx: 'javascript',
|
|
576
|
+
json: 'json', jsonc: 'json',
|
|
577
|
+
css: 'css', scss: 'scss', sass: 'scss', less: 'less',
|
|
578
|
+
html: 'html', htm: 'html', vue: 'html', svelte: 'html',
|
|
579
|
+
xml: 'xml', svg: 'xml',
|
|
580
|
+
md: 'markdown', markdown: 'markdown',
|
|
581
|
+
php: 'php', py: 'python',
|
|
582
|
+
yml: 'yaml', yaml: 'yaml',
|
|
583
|
+
go: 'go', rs: 'rust', sql: 'sql',
|
|
584
|
+
cpp: 'cpp', c: 'c', h: 'cpp', hpp: 'cpp',
|
|
585
|
+
sh: 'shell', bash: 'shell',
|
|
586
|
+
dockerfile: 'dockerfile',
|
|
587
|
+
toml: 'ini', ini: 'ini', conf: 'ini', cfg: 'ini',
|
|
588
|
+
};
|
|
589
|
+
return map[extension] ?? 'plaintext';
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function tryMountMonaco(text: string, extension: string): Promise<boolean> {
|
|
593
|
+
const monaco = (await ensureMonaco()) as any;
|
|
594
|
+
if (!monaco || !monacoEl.value) return false;
|
|
595
|
+
try {
|
|
596
|
+
disposeMonaco();
|
|
597
|
+
const editable = !!props.saveTextEndpoint && props.openMode !== 'view';
|
|
598
|
+
// Honour the explicit theme prop when set; fall back to the OS
|
|
599
|
+
// preference otherwise. Without this the Monaco editor stays on
|
|
600
|
+
// vs-dark on OS-dark systems even when the host admin shell is
|
|
601
|
+
// light, producing a jarring light-card + dark-code combo inside
|
|
602
|
+
// the in-page preview modal.
|
|
603
|
+
const dark = props.theme === 'dark'
|
|
604
|
+
? true
|
|
605
|
+
: props.theme === 'light'
|
|
606
|
+
? false
|
|
607
|
+
: window.matchMedia?.('(prefers-color-scheme: dark)').matches;
|
|
608
|
+
monacoEditor = monaco.editor.create(monacoEl.value, {
|
|
609
|
+
value: text,
|
|
610
|
+
language: monacoLanguageFor(extension),
|
|
611
|
+
readOnly: !editable,
|
|
612
|
+
theme: dark ? 'vs-dark' : 'vs',
|
|
613
|
+
automaticLayout: true,
|
|
614
|
+
minimap: { enabled: false },
|
|
615
|
+
fontSize: 13,
|
|
616
|
+
});
|
|
617
|
+
if (editable) {
|
|
618
|
+
monacoEditor.addCommand(
|
|
619
|
+
monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
|
|
620
|
+
() => void saveCode(),
|
|
621
|
+
);
|
|
622
|
+
// Autosave: 1.5s after the last keystroke we POST the buffer.
|
|
623
|
+
// Ctrl+S still works for the impatient — saveCode() guards against
|
|
624
|
+
// overlap, so a manual save during the debounce window just wins
|
|
625
|
+
// and the queued autosave becomes a no-op.
|
|
626
|
+
monacoEditor.onDidChangeModelContent(() => {
|
|
627
|
+
if (codeAutosaveTimer) clearTimeout(codeAutosaveTimer);
|
|
628
|
+
codeAutosaveTimer = setTimeout(() => {
|
|
629
|
+
void saveCode();
|
|
630
|
+
}, 1500);
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
monacoReady.value = true;
|
|
634
|
+
return true;
|
|
635
|
+
} catch (err) {
|
|
636
|
+
fetchError.value = `Monaco mount fail: ${(err as Error).message}`;
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function saveCode(): Promise<void> {
|
|
642
|
+
if (!props.saveTextEndpoint || !props.file) return;
|
|
643
|
+
if (props.openMode === 'view') return;
|
|
644
|
+
if (saving.value) return;
|
|
645
|
+
saving.value = true;
|
|
646
|
+
saveOk.value = false;
|
|
647
|
+
saveError.value = null;
|
|
648
|
+
try {
|
|
649
|
+
const text = monacoEditor ? monacoEditor.getValue() : '';
|
|
650
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
651
|
+
if (props.authHeaders) Object.assign(headers, props.authHeaders());
|
|
652
|
+
const res = await fetch(props.saveTextEndpoint, {
|
|
653
|
+
method: 'POST',
|
|
654
|
+
headers,
|
|
655
|
+
credentials: props.authCredentials || 'same-origin',
|
|
656
|
+
body: JSON.stringify({
|
|
657
|
+
path: stripAdapter(props.file.path),
|
|
658
|
+
content: text,
|
|
659
|
+
}),
|
|
660
|
+
});
|
|
661
|
+
if (!res.ok) {
|
|
662
|
+
const txt = await res.text().catch(() => '');
|
|
663
|
+
throw new Error(`${res.status} ${res.statusText}${txt ? ' — ' + txt.slice(0, 150) : ''}`);
|
|
664
|
+
}
|
|
665
|
+
saveOk.value = true;
|
|
666
|
+
setTimeout(() => {
|
|
667
|
+
saveOk.value = false;
|
|
668
|
+
}, 2500);
|
|
669
|
+
} catch (err) {
|
|
670
|
+
saveError.value = (err as Error).message;
|
|
671
|
+
} finally {
|
|
672
|
+
saving.value = false;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// --- Orchestration: fetch + render when modal opens ---
|
|
677
|
+
|
|
678
|
+
async function runOrchestration(open: boolean, url: string, k: string): Promise<void> {
|
|
679
|
+
rawText.value = '';
|
|
680
|
+
markdownHtml.value = '';
|
|
681
|
+
codeHtml.value = '';
|
|
682
|
+
fetchError.value = null;
|
|
683
|
+
officeError.value = null;
|
|
684
|
+
viewerCmp.value = null;
|
|
685
|
+
viewerLoadError.value = null;
|
|
686
|
+
pdfFallbackToNative.value = false;
|
|
687
|
+
disposeOnlyOfficeEditor();
|
|
688
|
+
disposeMonaco();
|
|
689
|
+
if (!open || !url) return;
|
|
690
|
+
if (k === 'viewer') {
|
|
691
|
+
await loadViewerFor(ext(props.file));
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (k === 'markdown' || k === 'code' || k === 'text') {
|
|
695
|
+
await fetchText(url);
|
|
696
|
+
if (tooLarge.value) return;
|
|
697
|
+
if (k === 'markdown') {
|
|
698
|
+
await renderMarkdown(rawText.value);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
// For code/text — try Monaco first when the cached module is
|
|
702
|
+
// already in memory. If not, render the highlight.js placeholder
|
|
703
|
+
// immediately, then attempt to upgrade to Monaco when the import
|
|
704
|
+
// settles.
|
|
705
|
+
const lang = CODE_LANGS[ext(props.file)] || '';
|
|
706
|
+
const monacoCached = getMonaco();
|
|
707
|
+
if (monacoCached) {
|
|
708
|
+
await new Promise<void>((r) => setTimeout(r, 0));
|
|
709
|
+
const ok = await tryMountMonaco(rawText.value, ext(props.file));
|
|
710
|
+
if (!ok) await highlightCode(rawText.value, lang);
|
|
711
|
+
} else {
|
|
712
|
+
// Render highlight.js immediately for read-only colour. Then
|
|
713
|
+
// kick off the Monaco load and swap it in once ready.
|
|
714
|
+
if (k === 'code') await highlightCode(rawText.value, lang);
|
|
715
|
+
ensureMonaco().then(async (m) => {
|
|
716
|
+
if (!m) return;
|
|
717
|
+
if (!props.open) return;
|
|
718
|
+
// Wait one tick so the placeholder DOM exists before swapping.
|
|
719
|
+
await new Promise<void>((r) => setTimeout(r, 0));
|
|
720
|
+
await tryMountMonaco(rawText.value, ext(props.file));
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
} else if (k === 'office') {
|
|
724
|
+
await new Promise<void>((r) => setTimeout(r, 0));
|
|
725
|
+
await mountOnlyOfficeEditor();
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
watch(
|
|
730
|
+
() => [props.open, src.value, kind.value, props.openMode] as const,
|
|
731
|
+
([open, url, k]) => {
|
|
732
|
+
void runOrchestration(open, url, k);
|
|
733
|
+
},
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
// Hand-fire on mount so the standalone Editor.vue route (which mounts
|
|
737
|
+
// us with `open` already true) actually runs the orchestration. The
|
|
738
|
+
// watcher itself is non-immediate because `immediate: true` would fire
|
|
739
|
+
// before the rest of <script setup> finishes, hitting the TDZ on
|
|
740
|
+
// `officeEditor`/Monaco state below. Doing this in onMounted+nextTick
|
|
741
|
+
// guarantees every `let`/`function` in the file has been hoisted.
|
|
742
|
+
onMounted(() => {
|
|
743
|
+
void nextTick(() => {
|
|
744
|
+
if (!props.open) return;
|
|
745
|
+
void runOrchestration(props.open, src.value, kind.value);
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
const codeLanguage = computed(() => CODE_LANGS[ext(props.file)] || 'plaintext');
|
|
750
|
+
|
|
751
|
+
// --- OnlyOffice DocEditor (real, JWT-signed) ---
|
|
752
|
+
|
|
753
|
+
const officeEl = ref<HTMLDivElement | null>(null);
|
|
754
|
+
const officeError = ref<string | null>(null);
|
|
755
|
+
let officeEditor: any = null;
|
|
756
|
+
|
|
757
|
+
async function mountOnlyOfficeEditor(): Promise<void> {
|
|
758
|
+
officeError.value = null;
|
|
759
|
+
if (!props.file || kind.value !== 'office') return;
|
|
760
|
+
if (!props.onlyOfficeConfigEndpoint) {
|
|
761
|
+
officeError.value = 'OnlyOffice yapılandırması yok';
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (!officeEl.value) return;
|
|
765
|
+
|
|
766
|
+
try {
|
|
767
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
768
|
+
if (props.authHeaders) Object.assign(headers, props.authHeaders());
|
|
769
|
+
const res = await fetch(props.onlyOfficeConfigEndpoint, {
|
|
770
|
+
method: 'POST',
|
|
771
|
+
headers,
|
|
772
|
+
credentials: props.authCredentials || 'same-origin',
|
|
773
|
+
body: JSON.stringify({
|
|
774
|
+
// Send the FULL adapter-qualified path. The backend resolves
|
|
775
|
+
// `<adapter>://<rel>` against ListEnabledStorages; passing the
|
|
776
|
+
// bare relative path falls back to storages[0] which 404s for
|
|
777
|
+
// anything sitting on a non-primary storage (e.g. s3-test).
|
|
778
|
+
path: props.file.path,
|
|
779
|
+
mode: props.openMode || 'edit',
|
|
780
|
+
}),
|
|
781
|
+
});
|
|
782
|
+
if (!res.ok) {
|
|
783
|
+
throw new Error(`Config fetch ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
784
|
+
}
|
|
785
|
+
const { config, documentServerUrl } = (await res.json()) as {
|
|
786
|
+
config: any;
|
|
787
|
+
documentServerUrl: string;
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
await loadOnlyOfficeScript(documentServerUrl);
|
|
791
|
+
disposeOnlyOfficeEditor();
|
|
792
|
+
|
|
793
|
+
const mountId = 'fe-onlyoffice-mount';
|
|
794
|
+
officeEl.value.id = mountId;
|
|
795
|
+
|
|
796
|
+
config.events = {
|
|
797
|
+
onError: (err: any) => {
|
|
798
|
+
officeError.value = formatOnlyOfficeError(err);
|
|
799
|
+
},
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
const W = window as any;
|
|
803
|
+
if (!W.DocsAPI || !W.DocsAPI.DocEditor) {
|
|
804
|
+
throw new Error('DocsAPI not available after script load');
|
|
805
|
+
}
|
|
806
|
+
officeEditor = new W.DocsAPI.DocEditor(mountId, config);
|
|
807
|
+
} catch (err) {
|
|
808
|
+
officeError.value = err instanceof Error ? err.message : String(err);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function disposeOnlyOfficeEditor(): void {
|
|
813
|
+
try {
|
|
814
|
+
officeEditor?.destroyEditor?.();
|
|
815
|
+
} catch {
|
|
816
|
+
/* ignore */
|
|
817
|
+
}
|
|
818
|
+
officeEditor = null;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
onBeforeUnmount(() => {
|
|
822
|
+
disposeOnlyOfficeEditor();
|
|
823
|
+
disposeMonaco();
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* OnlyOffice fires onError with an event-like object:
|
|
828
|
+
* { type:'error', data:{ errorCode, errorDescription, ... } }
|
|
829
|
+
* The legacy stringification produced "[object Object]" for objects
|
|
830
|
+
* whose `data` was itself an object. Walk one level deeper so the
|
|
831
|
+
* user sees the actual error description instead of a useless cast.
|
|
832
|
+
*/
|
|
833
|
+
function formatOnlyOfficeError(err: unknown): string {
|
|
834
|
+
if (typeof err === 'string') return err;
|
|
835
|
+
const e = err as { data?: unknown; message?: unknown; errorDescription?: unknown };
|
|
836
|
+
if (e?.data && typeof e.data === 'object') {
|
|
837
|
+
const d = e.data as { errorDescription?: unknown; errorCode?: unknown; message?: unknown };
|
|
838
|
+
if (typeof d.errorDescription === 'string') return d.errorDescription;
|
|
839
|
+
if (typeof d.message === 'string') return d.message;
|
|
840
|
+
if (d.errorCode !== undefined) return `OnlyOffice error ${d.errorCode}`;
|
|
841
|
+
}
|
|
842
|
+
if (typeof e?.data === 'string') return e.data;
|
|
843
|
+
if (typeof e?.errorDescription === 'string') return e.errorDescription;
|
|
844
|
+
if (typeof e?.message === 'string') return e.message;
|
|
845
|
+
try {
|
|
846
|
+
return JSON.stringify(err);
|
|
847
|
+
} catch {
|
|
848
|
+
return 'OnlyOffice error';
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const ONLYOFFICE_SCRIPT_ID = 'fe-onlyoffice-api-js';
|
|
853
|
+
function loadOnlyOfficeScript(base: string): Promise<void> {
|
|
854
|
+
return new Promise((resolve, reject) => {
|
|
855
|
+
if ((window as any).DocsAPI?.DocEditor) {
|
|
856
|
+
resolve();
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
const existing = document.getElementById(ONLYOFFICE_SCRIPT_ID) as HTMLScriptElement | null;
|
|
860
|
+
if (existing) {
|
|
861
|
+
existing.addEventListener('load', () => resolve());
|
|
862
|
+
existing.addEventListener('error', () => reject(new Error('OnlyOffice api.js load failed')));
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
const script = document.createElement('script');
|
|
866
|
+
script.id = ONLYOFFICE_SCRIPT_ID;
|
|
867
|
+
script.src = `${base.replace(/\/$/, '')}/web-apps/apps/api/documents/api.js`;
|
|
868
|
+
script.async = true;
|
|
869
|
+
script.onload = () => resolve();
|
|
870
|
+
script.onerror = () => reject(new Error('OnlyOffice api.js load failed'));
|
|
871
|
+
document.head.appendChild(script);
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
</script>
|
|
875
|
+
|
|
876
|
+
<template>
|
|
877
|
+
<Modal :open="open" size="xl" :title="file?.basename || ''" :chromeless="chromeless" :theme="theme" @close="emit('close')">
|
|
878
|
+
<div v-if="file" class="fe-preview">
|
|
879
|
+
<template v-if="kind === 'image'">
|
|
880
|
+
<img :src="src" :alt="file.basename" class="fe-preview__image" />
|
|
881
|
+
</template>
|
|
882
|
+
<template v-else-if="kind === 'video'">
|
|
883
|
+
<video :src="src" controls preload="metadata" class="fe-preview__video" />
|
|
884
|
+
</template>
|
|
885
|
+
<template v-else-if="kind === 'audio'">
|
|
886
|
+
<audio :src="src" controls class="fe-preview__audio" />
|
|
887
|
+
</template>
|
|
888
|
+
<template v-else-if="kind === 'pdf'">
|
|
889
|
+
<object :data="src" type="application/pdf" class="fe-preview__iframe">
|
|
890
|
+
<div class="fe-preview__fallback">
|
|
891
|
+
<span class="fe-preview__fallback-icon">📕</span>
|
|
892
|
+
<p>Tarayıcı PDF'i inline açamadı.</p>
|
|
893
|
+
<a :href="download" class="fe-btn fe-btn--primary" target="_blank" rel="noopener">PDF'i Yeni Sekmede Aç</a>
|
|
894
|
+
</div>
|
|
895
|
+
</object>
|
|
896
|
+
</template>
|
|
897
|
+
|
|
898
|
+
<template v-else-if="kind === 'markdown'">
|
|
899
|
+
<div v-if="loading" class="fe-preview__fallback">{{ t('viewer.loading') }}</div>
|
|
900
|
+
<div v-else-if="tooLarge" class="fe-preview__fallback">
|
|
901
|
+
Dosya çok büyük (>1 MB). <a :href="download" class="fe-btn">{{ t('viewer.download') }}</a>
|
|
902
|
+
</div>
|
|
903
|
+
<div
|
|
904
|
+
v-else-if="openMode === 'edit' && saveTextEndpoint"
|
|
905
|
+
class="fe-preview__md-split"
|
|
906
|
+
>
|
|
907
|
+
<div class="fe-preview__md-split-bar">
|
|
908
|
+
<span class="fe-preview__md-split-label">MARKDOWN</span>
|
|
909
|
+
<span v-if="fetchError" class="fe-preview__md-split-error">{{ fetchError }}</span>
|
|
910
|
+
<button
|
|
911
|
+
type="button"
|
|
912
|
+
class="fe-btn fe-btn--primary"
|
|
913
|
+
:disabled="!mdDirty || mdSaving"
|
|
914
|
+
@click="saveMarkdown"
|
|
915
|
+
>
|
|
916
|
+
{{ mdSaving ? 'Kaydediliyor…' : (mdDirty ? 'Kaydet (Ctrl+S)' : 'Kaydedildi') }}
|
|
917
|
+
</button>
|
|
918
|
+
</div>
|
|
919
|
+
<div class="fe-preview__md-split-body">
|
|
920
|
+
<textarea
|
|
921
|
+
class="fe-preview__md-split-input"
|
|
922
|
+
v-model="rawText"
|
|
923
|
+
@input="onMdInput"
|
|
924
|
+
@keydown.ctrl.s.prevent="saveMarkdown"
|
|
925
|
+
@keydown.meta.s.prevent="saveMarkdown"
|
|
926
|
+
spellcheck="false"
|
|
927
|
+
placeholder="# Markdown buraya…"
|
|
928
|
+
/>
|
|
929
|
+
<div
|
|
930
|
+
ref="markdownEl"
|
|
931
|
+
class="fe-preview__md-split-output fe-preview__md"
|
|
932
|
+
v-html="markdownHtml"
|
|
933
|
+
></div>
|
|
934
|
+
</div>
|
|
935
|
+
</div>
|
|
936
|
+
<div v-else-if="fetchError" class="fe-preview__fallback">
|
|
937
|
+
<p>{{ fetchError }}</p>
|
|
938
|
+
<a :href="download" class="fe-btn fe-btn--primary">{{ t('viewer.download') }}</a>
|
|
939
|
+
</div>
|
|
940
|
+
<div v-else-if="markdownHtml" ref="markdownEl" class="fe-preview__md" v-html="markdownHtml"></div>
|
|
941
|
+
<pre v-else class="fe-preview__pre">{{ rawText }}</pre>
|
|
942
|
+
</template>
|
|
943
|
+
|
|
944
|
+
<template v-else-if="kind === 'viewer'">
|
|
945
|
+
<div v-if="viewerLoadError && !pdfFallbackToNative" class="fe-preview__fallback">
|
|
946
|
+
<span class="fe-preview__fallback-icon">⚠️</span>
|
|
947
|
+
<p>{{ viewerLoadError }}</p>
|
|
948
|
+
<a :href="download" class="fe-btn fe-btn--primary">{{ t('viewer.download') }}</a>
|
|
949
|
+
</div>
|
|
950
|
+
<div v-else-if="!viewerCmp" class="fe-preview__fallback">
|
|
951
|
+
<span class="fe-preview__fallback-icon">⏳</span>
|
|
952
|
+
<p>{{ t('viewer.loading') }}</p>
|
|
953
|
+
</div>
|
|
954
|
+
<component
|
|
955
|
+
v-else
|
|
956
|
+
:is="viewerCmp"
|
|
957
|
+
v-bind="viewerProps"
|
|
958
|
+
class="fe-preview__viewer"
|
|
959
|
+
@fallback="onPdfFallback"
|
|
960
|
+
/>
|
|
961
|
+
</template>
|
|
962
|
+
|
|
963
|
+
<template v-else-if="kind === 'code' || kind === 'text'">
|
|
964
|
+
<div v-if="loading" class="fe-preview__fallback">{{ t('viewer.loading') }}</div>
|
|
965
|
+
<div v-else-if="tooLarge" class="fe-preview__fallback">
|
|
966
|
+
Dosya çok büyük (>1 MB). <a :href="download" class="fe-btn">{{ t('viewer.download') }}</a>
|
|
967
|
+
</div>
|
|
968
|
+
<div v-else-if="fetchError" class="fe-preview__fallback">
|
|
969
|
+
<p>{{ fetchError }}</p>
|
|
970
|
+
<a :href="download" class="fe-btn fe-btn--primary">{{ t('viewer.download') }}</a>
|
|
971
|
+
</div>
|
|
972
|
+
<div v-else class="fe-preview__code-wrap">
|
|
973
|
+
<div class="fe-preview__code-toolbar">
|
|
974
|
+
<span class="fe-preview__code-lang">{{ codeLanguage }}</span>
|
|
975
|
+
<span v-if="!monacoReady && codeHtml" class="fe-preview__code-status">
|
|
976
|
+
{{ saveTextEndpoint ? 'Editör yükleniyor…' : 'Salt okunur' }}
|
|
977
|
+
</span>
|
|
978
|
+
<span v-if="saveOk" class="fe-preview__code-status fe-preview__code-status--ok">✓ Kaydedildi</span>
|
|
979
|
+
<span v-if="saveError" class="fe-preview__code-status fe-preview__code-status--err" :title="saveError">✗ Hata</span>
|
|
980
|
+
<span v-if="openMode === 'view'" class="fe-preview__code-status">Salt okunur</span>
|
|
981
|
+
<button
|
|
982
|
+
v-else-if="saveTextEndpoint && monacoReady"
|
|
983
|
+
type="button"
|
|
984
|
+
class="fe-btn fe-btn--primary"
|
|
985
|
+
:disabled="saving"
|
|
986
|
+
@click="saveCode"
|
|
987
|
+
>{{ saving ? 'Kaydediliyor…' : 'Kaydet (Ctrl+S)' }}</button>
|
|
988
|
+
</div>
|
|
989
|
+
<!-- Monaco target — hidden until ready, then occupies the slot. -->
|
|
990
|
+
<div ref="monacoEl" class="fe-preview__code-editor" :class="{ 'is-hidden': !monacoReady }" />
|
|
991
|
+
<!-- Highlight.js read-only fallback — visible until Monaco mounts. -->
|
|
992
|
+
<pre
|
|
993
|
+
v-if="!monacoReady && codeHtml"
|
|
994
|
+
class="fe-preview__pre fe-preview__code hljs"
|
|
995
|
+
><code :class="`language-${codeLanguage}`" v-html="codeHtml"></code></pre>
|
|
996
|
+
<pre
|
|
997
|
+
v-else-if="!monacoReady && !codeHtml"
|
|
998
|
+
class="fe-preview__pre"
|
|
999
|
+
:data-lang="codeLanguage"
|
|
1000
|
+
>{{ rawText }}</pre>
|
|
1001
|
+
</div>
|
|
1002
|
+
</template>
|
|
1003
|
+
|
|
1004
|
+
<template v-else-if="kind === 'office'">
|
|
1005
|
+
<div v-if="officeError" class="fe-preview__fallback">
|
|
1006
|
+
<span class="fe-preview__fallback-icon">📄</span>
|
|
1007
|
+
<p>{{ officeError }}</p>
|
|
1008
|
+
<a :href="download" class="fe-btn fe-btn--primary">İndir</a>
|
|
1009
|
+
</div>
|
|
1010
|
+
<div v-else ref="officeEl" class="fe-preview__office" />
|
|
1011
|
+
</template>
|
|
1012
|
+
|
|
1013
|
+
<template v-else>
|
|
1014
|
+
<div class="fe-preview__fallback">
|
|
1015
|
+
<span class="fe-preview__fallback-icon">📎</span>
|
|
1016
|
+
<p>Bu dosya tipi için önizleme henüz yok.</p>
|
|
1017
|
+
<a :href="download" class="fe-btn fe-btn--primary">İndir</a>
|
|
1018
|
+
</div>
|
|
1019
|
+
</template>
|
|
1020
|
+
</div>
|
|
1021
|
+
<template #actions>
|
|
1022
|
+
<button
|
|
1023
|
+
v-if="file && openMode === 'view' && canEditKind"
|
|
1024
|
+
type="button"
|
|
1025
|
+
class="fe-btn fe-btn--primary"
|
|
1026
|
+
@click="openEditInNewTab"
|
|
1027
|
+
>✏ Düzenle</button>
|
|
1028
|
+
<button
|
|
1029
|
+
v-if="file"
|
|
1030
|
+
type="button"
|
|
1031
|
+
class="fe-btn"
|
|
1032
|
+
@click="openInNewTab"
|
|
1033
|
+
>↗ {{ t('viewer.open_in_new_tab') }}</button>
|
|
1034
|
+
<a v-if="file" :href="download" class="fe-btn">{{ t('viewer.download') }}</a>
|
|
1035
|
+
<button type="button" class="fe-btn" @click="emit('close')">Kapat</button>
|
|
1036
|
+
</template>
|
|
1037
|
+
</Modal>
|
|
1038
|
+
</template>
|
|
1039
|
+
|
|
1040
|
+
<style>
|
|
1041
|
+
.fe-preview__code-editor.is-hidden {
|
|
1042
|
+
display: none;
|
|
1043
|
+
}
|
|
1044
|
+
/* Wrapper for the dynamic viewers — they render their own toolbars
|
|
1045
|
+
* inside the wrapper, so we just give them the full pane height. */
|
|
1046
|
+
.fe-preview__viewer {
|
|
1047
|
+
width: 100%;
|
|
1048
|
+
height: 100%;
|
|
1049
|
+
min-height: 70vh;
|
|
1050
|
+
display: flex;
|
|
1051
|
+
flex-direction: column;
|
|
1052
|
+
}
|
|
1053
|
+
.fe-preview .fe-preview__viewer {
|
|
1054
|
+
align-self: stretch;
|
|
1055
|
+
}
|
|
1056
|
+
.fe-preview__md-mermaid {
|
|
1057
|
+
display: flex;
|
|
1058
|
+
justify-content: center;
|
|
1059
|
+
margin: 16px 0;
|
|
1060
|
+
}
|
|
1061
|
+
.fe-preview__md-mermaid svg {
|
|
1062
|
+
max-width: 100%;
|
|
1063
|
+
height: auto;
|
|
1064
|
+
}
|
|
1065
|
+
.fe-preview__md-math {
|
|
1066
|
+
margin: 16px 0;
|
|
1067
|
+
text-align: center;
|
|
1068
|
+
overflow-x: auto;
|
|
1069
|
+
}
|
|
1070
|
+
</style>
|