feat: use CodeMirror for always-editable code file preview
- Add FileCodeEditor.svelte: CodeMirror wrapper with auto language detection, dark mode, Ctrl+S save, reactive to value/filePath changes - Replace Shiki read-only highlighting + textarea editing with always-editable CodeMirror for code files in FileNav preview - Show persistent Save button for code files in toolbar - Non-code text files keep existing Edit/Save/Cancel textarea flow - SVG retains Shiki highlighting for visual preview mode
This commit is contained in:
@@ -659,7 +659,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
{#if (isMarkdown || isCsv || isHtml || isCode || isJson || isSvg || isNotebook) && fileContent !== null && !editing}
|
{#if (isMarkdown || isCsv || isHtml || isJson || isSvg || isNotebook) && fileContent !== null && !editing}
|
||||||
<Tooltip content={showRaw ? $i18n.t('Preview') : $i18n.t('Source')}>
|
<Tooltip content={showRaw ? $i18n.t('Preview') : $i18n.t('Source')}>
|
||||||
<button
|
<button
|
||||||
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
|
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
|
||||||
@@ -709,7 +709,24 @@
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
{#if isTextFile}
|
{#if isTextFile}
|
||||||
{#if editing}
|
{#if isCode}
|
||||||
|
<Tooltip content={$i18n.t('Save')}>
|
||||||
|
<button
|
||||||
|
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
|
||||||
|
on:click={() => filePreviewRef?.saveCodeFile()}
|
||||||
|
disabled={saving}
|
||||||
|
aria-label={$i18n.t('Save')}
|
||||||
|
>
|
||||||
|
{#if saving}
|
||||||
|
<Spinner className="size-3.5" />
|
||||||
|
{:else}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
|
||||||
|
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
{:else if editing}
|
||||||
<Tooltip content={$i18n.t('Cancel')}>
|
<Tooltip content={$i18n.t('Cancel')}>
|
||||||
<button
|
<button
|
||||||
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
|
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '$lib/utils/codemirror';
|
||||||
|
import { basicSetup, EditorView } from 'codemirror';
|
||||||
|
import { keymap } from '@codemirror/view';
|
||||||
|
import { Compartment, EditorState } from '@codemirror/state';
|
||||||
|
import { indentWithTab } from '@codemirror/commands';
|
||||||
|
import { indentUnit, LanguageDescription } from '@codemirror/language';
|
||||||
|
import { languages } from '@codemirror/language-data';
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
export let value = '';
|
||||||
|
export let filePath: string | null = null;
|
||||||
|
export let onSave: ((content: string) => Promise<void>) | null = null;
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let editor: EditorView | null = null;
|
||||||
|
let editorTheme = new Compartment();
|
||||||
|
let editorLanguage = new Compartment();
|
||||||
|
let internalValue = '';
|
||||||
|
|
||||||
|
/** Return the current editor content */
|
||||||
|
export const getValue = (): string => {
|
||||||
|
return editor?.state.doc.toString() ?? value;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Replace editor content */
|
||||||
|
export const setValue = (newValue: string) => {
|
||||||
|
if (!editor) return;
|
||||||
|
internalValue = newValue;
|
||||||
|
editor.dispatch({
|
||||||
|
changes: { from: 0, to: editor.state.doc.length, insert: newValue }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const focus = () => {
|
||||||
|
editor?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const detectLanguage = async (path: string | null) => {
|
||||||
|
if (!path) return;
|
||||||
|
const match = LanguageDescription.matchFilename(languages, path);
|
||||||
|
if (match) {
|
||||||
|
const lang = await match.load();
|
||||||
|
if (lang && editor) {
|
||||||
|
editor.dispatch({ effects: editorLanguage.reconfigure(lang) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// React to external value changes (e.g. switching files)
|
||||||
|
$: if (editor && value !== internalValue) {
|
||||||
|
internalValue = value;
|
||||||
|
editor.dispatch({
|
||||||
|
changes: { from: 0, to: editor.state.doc.length, insert: value }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// React to filePath changes for language detection
|
||||||
|
$: if (editor && filePath) {
|
||||||
|
detectLanguage(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
internalValue = value;
|
||||||
|
|
||||||
|
const extensions = [
|
||||||
|
basicSetup,
|
||||||
|
keymap.of([
|
||||||
|
indentWithTab,
|
||||||
|
{
|
||||||
|
key: 'Mod-s',
|
||||||
|
run: () => {
|
||||||
|
if (onSave) {
|
||||||
|
onSave(editor?.state.doc.toString() ?? '');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
indentUnit.of(' '),
|
||||||
|
EditorView.updateListener.of((e) => {
|
||||||
|
if (e.docChanged) {
|
||||||
|
internalValue = e.state.doc.toString();
|
||||||
|
value = internalValue;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
editorTheme.of(isDark ? oneDark : []),
|
||||||
|
editorLanguage.of([]),
|
||||||
|
EditorView.theme({
|
||||||
|
'&': { fontSize: '0.75rem', height: '100%' },
|
||||||
|
'.cm-content': {
|
||||||
|
padding: '0.5rem 0',
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'
|
||||||
|
},
|
||||||
|
'.cm-scroller': { overflow: 'auto' },
|
||||||
|
'.cm-focused': { outline: 'none' }
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
editor = new EditorView({
|
||||||
|
state: EditorState.create({ doc: value, extensions }),
|
||||||
|
parent: container
|
||||||
|
});
|
||||||
|
|
||||||
|
detectLanguage(filePath);
|
||||||
|
|
||||||
|
// Watch dark mode
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const dark = document.documentElement.classList.contains('dark');
|
||||||
|
editor?.dispatch({ effects: editorTheme.reconfigure(dark ? oneDark : []) });
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
editor?.destroy();
|
||||||
|
editor = null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
editor?.destroy();
|
||||||
|
editor = null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={container} class="file-code-editor" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.file-code-editor {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.file-code-editor :global(.cm-editor) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,15 +4,17 @@
|
|||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { settings } from '$lib/stores';
|
import { settings } from '$lib/stores';
|
||||||
import { isCodeFile, highlightCode } from '$lib/utils/codeHighlight';
|
import { isCodeFile } from '$lib/utils/codeHighlight';
|
||||||
import { initMermaid, renderMermaidDiagram } from '$lib/utils';
|
import { initMermaid, renderMermaidDiagram } from '$lib/utils';
|
||||||
import Spinner from '../../common/Spinner.svelte';
|
import Spinner from '../../common/Spinner.svelte';
|
||||||
import PDFViewer from '../../common/PDFViewer.svelte';
|
import PDFViewer from '../../common/PDFViewer.svelte';
|
||||||
import JsonTreeView from './JsonTreeView.svelte';
|
import JsonTreeView from './JsonTreeView.svelte';
|
||||||
import NotebookView from './NotebookView.svelte';
|
import NotebookView from './NotebookView.svelte';
|
||||||
import SqliteView from './SqliteView.svelte';
|
import SqliteView from './SqliteView.svelte';
|
||||||
|
import FileCodeEditor from './FileCodeEditor.svelte';
|
||||||
|
|
||||||
let pdfViewerRef: PDFViewer;
|
let pdfViewerRef: PDFViewer;
|
||||||
|
let fileCodeEditorRef: FileCodeEditor;
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
@@ -76,6 +78,15 @@
|
|||||||
editContent = '';
|
editContent = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Save code file directly from CodeMirror */
|
||||||
|
export const saveCodeFile = async () => {
|
||||||
|
if (!onSave) return;
|
||||||
|
saving = true;
|
||||||
|
const content = fileCodeEditorRef?.getValue() ?? '';
|
||||||
|
await onSave(content);
|
||||||
|
saving = false;
|
||||||
|
};
|
||||||
|
|
||||||
$: isTextFile = fileContent !== null && fileImageUrl === null && filePdfData === null;
|
$: isTextFile = fileContent !== null && fileImageUrl === null && filePdfData === null;
|
||||||
|
|
||||||
const MD_EXTS = new Set(['md', 'markdown', 'mdx']);
|
const MD_EXTS = new Set(['md', 'markdown', 'mdx']);
|
||||||
@@ -175,24 +186,21 @@
|
|||||||
$: csvHeader = csvRows.length > 0 ? csvRows[0] : [];
|
$: csvHeader = csvRows.length > 0 ? csvRows[0] : [];
|
||||||
$: csvBody = csvRows.length > 1 ? csvRows.slice(1) : [];
|
$: csvBody = csvRows.length > 1 ? csvRows.slice(1) : [];
|
||||||
|
|
||||||
// ── Shiki code highlighting ─────────────────────────────────────────
|
// ── Shiki code highlighting (SVG only) ──────────────────────────────
|
||||||
let highlightedHtml: string | null = null;
|
let highlightedHtml: string | null = null;
|
||||||
let highlightingFile: string | null = null; // track which file we're highlighting
|
let highlightingFile: string | null = null;
|
||||||
|
|
||||||
$: if ((isCode || isSvg) && fileContent !== null && selectedFile) {
|
$: if (isSvg && fileContent !== null && selectedFile) {
|
||||||
const currentFile = selectedFile;
|
const currentFile = selectedFile;
|
||||||
highlightingFile = currentFile;
|
highlightingFile = currentFile;
|
||||||
const lang = isSvg ? 'xml' : undefined;
|
import('shiki')
|
||||||
(lang
|
.then(({ codeToHtml }) =>
|
||||||
? import('shiki').then(({ codeToHtml }) =>
|
codeToHtml(fileContent!, {
|
||||||
codeToHtml(fileContent!, {
|
lang: 'xml',
|
||||||
lang: 'xml',
|
themes: { light: 'github-light', dark: 'github-dark' },
|
||||||
themes: { light: 'github-light', dark: 'github-dark' },
|
defaultColor: 'light'
|
||||||
defaultColor: 'light'
|
})
|
||||||
})
|
)
|
||||||
)
|
|
||||||
: highlightCode(fileContent!, selectedFile!)
|
|
||||||
)
|
|
||||||
.then((html) => {
|
.then((html) => {
|
||||||
if (highlightingFile === currentFile) highlightedHtml = html;
|
if (highlightingFile === currentFile) highlightedHtml = html;
|
||||||
})
|
})
|
||||||
@@ -428,7 +436,16 @@
|
|||||||
<div class="svg-preview w-full h-full flex items-center justify-center overflow-auto p-3">
|
<div class="svg-preview w-full h-full flex items-center justify-center overflow-auto p-3">
|
||||||
{@html DOMPurify.sanitize(fileContent, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['use'] })}
|
{@html DOMPurify.sanitize(fileContent, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['use'] })}
|
||||||
</div>
|
</div>
|
||||||
{:else if (isCode || isSvg) && highlightedHtml && !showRaw}
|
{:else if isCode && !showRaw}
|
||||||
|
<div class="h-full">
|
||||||
|
<FileCodeEditor
|
||||||
|
bind:this={fileCodeEditorRef}
|
||||||
|
value={fileContent ?? ''}
|
||||||
|
filePath={selectedFile}
|
||||||
|
{onSave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else if isSvg && highlightedHtml && !showRaw}
|
||||||
<div class="shiki-preview overflow-auto h-full text-xs">
|
<div class="shiki-preview overflow-auto h-full text-xs">
|
||||||
{@html highlightedHtml}
|
{@html highlightedHtml}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user