refactor: unified EditorPanel for notes and files, SaveFileText backend

- Create EditorPanel.svelte: shared editor component for notes + files
  - Markdown: edit/preview/split modes via MarkdownEditor + MarkdownPreview
  - Plain text: monospace textarea
  - Header: title, mode switcher, external open button
  - Footer: save + close buttons, dirty indicator
- Rewrite NoteEditorPanel.svelte as thin wrapper over EditorPanel
- FilesTab: browser/editor modes (editor replaces file list, not appended)
- Add SaveFileText Wails binding: writes text files atomically (tmp+rename)
- Add WriteText to files.Service with SHA256 update
- Remove separate TextFileEditor.svelte (replaced by EditorPanel)
- Note context: SaveNote API, noteId/title props
- File context: SaveFileText API, fileId prop
- Linked .md (CheckFileAction=note) → note editor tab
- Unlinked .md / text files → EditorPanel in FilesTab
- Images/PDF/binary → FilePreviewModal only

Co-Authored-By: OWL (Hermes Agent) <hermes@nousresearch.com>
This commit is contained in:
mirivlad 2026-06-16 10:40:09 +08:00
parent 383a9546df
commit 120ff7d6fe
9 changed files with 566 additions and 463 deletions

View File

@ -210,6 +210,46 @@ func (a *App) CheckFileAction(fileID string) (*PreflightFileAction, error) {
return &PreflightFileAction{Action: "preview", FileName: fileRec.Filename}, nil return &PreflightFileAction{Action: "preview", FileName: fileRec.Filename}, nil
} }
// SaveFileText saves text content to a file record.
// Only works for vault-contained text files (not binary).
func (a *App) SaveFileText(fileID string, content string) error {
if err := a.requireVault(); err != nil {
return err
}
fileRec, err := a.files.Get(fileID)
if err != nil {
return fmt.Errorf("get file: %w", err)
}
// Safety: only save text-like files
name := strings.ToLower(fileRec.Filename)
isText := strings.HasPrefix(fileRec.MIME, "text/") ||
strings.HasSuffix(name, ".md") ||
strings.HasSuffix(name, ".txt") ||
strings.HasSuffix(name, ".json") ||
strings.HasSuffix(name, ".yaml") ||
strings.HasSuffix(name, ".yml") ||
strings.HasSuffix(name, ".csv") ||
strings.HasSuffix(name, ".xml") ||
strings.HasSuffix(name, ".ini") ||
strings.HasSuffix(name, ".conf") ||
strings.HasSuffix(name, ".sh") ||
strings.HasSuffix(name, ".py") ||
strings.HasSuffix(name, ".js") ||
strings.HasSuffix(name, ".ts") ||
strings.HasSuffix(name, ".css") ||
strings.HasSuffix(name, ".html") ||
strings.HasSuffix(name, ".log")
if !isText {
return fmt.Errorf("refusing to save binary file: %s", fileRec.Filename)
}
if err := a.files.WriteText(fileRec, content); err != nil {
return fmt.Errorf("write file: %w", err)
}
// Record activity
_ = a.activity.Record(fileRec.NodeID, activity.TargetFile, fileID, "", activity.TypeFileModified, fileRec.Filename, "")
return nil
}
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) { func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
if err := a.requireVault(); err != nil { if err := a.requireVault(); err != nil {
return nil, err return nil, err

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -19,8 +19,8 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-D23Sgy9Q.js"></script> <script type="module" crossorigin src="/assets/main-B99YW--H.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CoirOqGf.css"> <link rel="stylesheet" crossorigin href="/assets/main-CtgLvi_n.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -6,8 +6,7 @@
import ConfirmModal from '../../ConfirmModal.svelte' import ConfirmModal from '../../ConfirmModal.svelte'
import { canPreviewFile, needsBase64Preview, isTextFile, isMarkdownFile } from '../../fileUtils.js' import { canPreviewFile, needsBase64Preview, isTextFile, isMarkdownFile } from '../../fileUtils.js'
import { t } from '../../i18n' import { t } from '../../i18n'
import MarkdownEditor from '../notes/MarkdownEditor.svelte' import EditorPanel from '../notes/EditorPanel.svelte'
import MarkdownPreview from '../notes/MarkdownPreview.svelte'
// ===== Props ===== // ===== Props =====
export let selectedNode = null export let selectedNode = null
@ -37,8 +36,10 @@
let textEditorLoading = false let textEditorLoading = false
let textEditorError = '' let textEditorError = ''
let textEditorIsMarkdown = false let textEditorIsMarkdown = false
let textEditorMode = 'view' // 'view' | 'edit' | 'split' let textEditorMode = 'edit' // 'edit' | 'preview' | 'split'
let textEditorDirty = false let textEditorDirty = false
let textEditorIsNote = false
let textEditorNoteId = ''
let clipboard = { items: [], mode: 'copy' } let clipboard = { items: [], mode: 'copy' }
let selectedIds = [] let selectedIds = []
let dragIds = [] let dragIds = []
@ -195,12 +196,16 @@
_fdl('no suitable opener for ' + item.name) _fdl('no suitable opener for ' + item.name)
} }
async function openTextEditor(item, isMarkdown) { async function openTextEditor(item, isMarkdown, noteId) {
textEditorFile = item textEditorFile = item
textEditorContent = '' textEditorContent = ''
textEditorError = '' textEditorError = ''
textEditorLoading = true textEditorLoading = true
textEditorIsMarkdown = isMarkdown textEditorIsMarkdown = isMarkdown
textEditorMode = 'edit'
textEditorDirty = false
textEditorIsNote = !!noteId
textEditorNoteId = noteId || ''
try { try {
textEditorContent = await wailsCall('ReadFileText', item.fileId) || '' textEditorContent = await wailsCall('ReadFileText', item.fileId) || ''
} catch (e) { } catch (e) {
@ -652,60 +657,28 @@
<!-- Files tab template --> <!-- Files tab template -->
<div class="files-tab"> <div class="files-tab">
{#if textEditorFile} {#if textEditorFile}
<!-- ===== Editor mode ===== --> <!-- ===== Editor mode: reuse the same EditorPanel as note editor ===== -->
<div class="file-editor-mode"> <EditorPanel
<div class="fe-toolbar"> content={textEditorContent}
<button class="btn btn-sm back-btn" on:click={closeTextEditor}> title={textEditorFile.name}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> isMarkdown={textEditorIsMarkdown}
{t('file.backToFiles')} loading={textEditorLoading}
</button> error={textEditorError}
<span class="fe-filename" title={textEditorFile.name}>{textEditorFile.name}</span> dirty={textEditorDirty}
{#if textEditorIsMarkdown} viewMode={textEditorMode}
<span class="fe-badge">markdown</span> on:content-change={(e) => { textEditorContent = e.detail.content; textEditorDirty = true }}
{/if} on:save={() => {
<div class="fe-spacer"></div> if (textEditorIsNote) {
{#if textEditorIsMarkdown} wailsCall('SaveNote', textEditorNoteId, textEditorContent)
<div class="fe-mode-switcher" role="tablist" aria-label="View mode"> } else {
<button type="button" class="fe-mode-btn" class:active={textEditorMode === 'view'} on:click={() => textEditorMode = 'view'}>{t('note.mode.preview')}</button> wailsCall('SaveFileText', textEditorFile.fileId, textEditorContent)
<button type="button" class="fe-mode-btn" class:active={textEditorMode === 'edit'} on:click={() => textEditorMode = 'edit'}>{t('note.mode.edit')}</button> }
<button type="button" class="fe-mode-btn" class:active={textEditorMode === 'split'} on:click={() => textEditorMode = 'split'}>{t('note.mode.split')}</button> textEditorDirty = false
</div> }}
{/if} on:close={closeTextEditor}
<button class="btn btn-sm" on:click={() => wailsCall('OpenFile', textEditorFile.fileId)}>{t('file.openExternal')}</button> on:openExternal={() => wailsCall('OpenFile', textEditorFile.fileId)}
</div> on:mode-change={(e) => textEditorMode = e.detail.mode}
<div class="fe-body"> />
{#if textEditorLoading}
<div class="fe-status"><p>{t('common.loading')}</p></div>
{:else if textEditorError}
<div class="fe-status fe-error">
<p>{textEditorError}</p>
<button class="btn btn-sm" on:click={() => wailsCall('OpenFile', textEditorFile.fileId)}>{t('file.openExternal')}</button>
</div>
{:else if textEditorIsMarkdown && textEditorMode === 'view'}
<div class="fe-markdown-preview">
<svelte:component this={MarkdownPreview} content={textEditorContent} />
</div>
{:else if textEditorIsMarkdown && textEditorMode === 'edit'}
<svelte:component this={MarkdownEditor} content={textEditorContent} viewMode="edit" on:content-change={(e) => { textEditorContent = e.detail.content; textEditorDirty = true }} on:save={() => saveTextEditor()} />
{:else if textEditorIsMarkdown && textEditorMode === 'split'}
<div class="fe-split">
<div class="fe-split-pane fe-split-editor">
<svelte:component this={MarkdownEditor} content={textEditorContent} viewMode="split" on:content-change={(e) => { textEditorContent = e.detail.content; textEditorDirty = true }} on:save={() => saveTextEditor()} />
</div>
<div class="fe-split-pane fe-split-preview">
<svelte:component this={MarkdownPreview} content={textEditorContent} />
</div>
</div>
{:else}
<pre class="fe-plain-text"><code>{textEditorContent}</code></pre>
{/if}
</div>
{#if textEditorDirty}
<div class="fe-footer">
<button class="btn btn-primary btn-sm" on:click={saveTextEditor}>{t('common.save')}</button>
</div>
{/if}
</div>
{:else} {:else}
<!-- ===== Browser mode ===== --> <!-- ===== Browser mode ===== -->
<div class="tab-toolbar"> <div class="tab-toolbar">
@ -881,155 +854,4 @@
.btn-sm { padding: 4px 10px; font-size: 12px; } .btn-sm { padding: 4px 10px; font-size: 12px; }
.btn-danger { color: #ff6b6b; border-color: #4a2222; } .btn-danger { color: #ff6b6b; border-color: #4a2222; }
.btn-danger:hover { background: #3a2222; } .btn-danger:hover { background: #3a2222; }
/* ===== File editor mode ===== */
.file-editor-mode {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.fe-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid #2a2a3c;
background: #16161f;
flex-shrink: 0;
}
.fe-filename {
font-size: 13px;
font-weight: 500;
color: #e4e4ef;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}
.fe-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
background: #2a2a3c;
color: #888;
flex-shrink: 0;
}
.fe-spacer {
flex: 1;
}
.fe-mode-switcher {
display: flex;
gap: 2px;
}
.fe-mode-btn {
padding: 3px 10px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #888;
font-size: 11px;
font-family: inherit;
cursor: pointer;
}
.fe-mode-btn:hover {
color: #ccc;
background: #1e1e30;
}
.fe-mode-btn.active {
color: #e4e4ef;
background: #22223a;
border-color: #333350;
}
.fe-body {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.fe-status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
color: #888;
font-size: 13px;
}
.fe-error {
color: #ff8888;
}
.fe-markdown-preview {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.fe-split {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.fe-split-pane {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.fe-split-editor {
border-right: 1px solid #2a2a3c;
}
.fe-split-preview {
background: #11111c;
overflow-y: auto;
padding: 16px 20px;
}
.fe-plain-text {
flex: 1;
margin: 0;
padding: 16px 20px;
overflow: auto;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 13px;
line-height: 1.6;
color: #ccc;
white-space: pre-wrap;
word-break: break-word;
background: #13131f;
}
.fe-plain-text code {
font-family: inherit;
background: none;
padding: 0;
}
.fe-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid #2a2a3c;
flex-shrink: 0;
}
</style> </style>

View File

@ -0,0 +1,345 @@
<script>
import { createEventDispatcher } from 'svelte'
import MarkdownEditor from './MarkdownEditor.svelte'
import MarkdownPreview from './MarkdownPreview.svelte'
import { isMarkdownFile } from '../../fileUtils.js'
import { t } from '../../i18n'
// ===== Props =====
export let content = ''
export let title = ''
export let subtitle = ''
export let loading = false
export let error = ''
export let dirty = false
export let isMarkdown = false
export let readonly = false
// ===== Events =====
const dispatch = createEventDispatcher()
// ===== State =====
let viewMode = 'edit' // 'edit' | 'preview' | 'split'
let editorRef = undefined
// ===== Public API =====
export function insertText(text) {
if (editorRef) editorRef.insertText(text)
}
// ===== Handlers =====
function handleContentChange(e) {
content = e.detail.content
dirty = true
dispatch('content-change', e.detail)
}
function handleSave() {
dispatch('save', { content })
}
function handleClose() {
dispatch('close')
}
function handleOpenExternal() {
dispatch('openExternal')
}
</script>
<div class="editor-panel">
<header class="ep-header">
<div class="ep-title-row">
{#if title}
<span class="ep-title" title={title}>{title}</span>
{/if}
{#if subtitle}
<span class="ep-subtitle">{subtitle}</span>
{/if}
{#if isMarkdown}
<span class="ep-badge">markdown</span>
{/if}
{#if dirty}
<span class="ep-dirty" title="Unsaved changes"></span>
{/if}
</div>
<div class="ep-actions">
{#if isMarkdown}
<div class="ep-mode-switcher" role="tablist" aria-label="View mode">
<button type="button" class="ep-mode-btn" class:active={viewMode === 'edit'} on:click={() => viewMode = 'edit'}>
{t('note.mode.edit')}
</button>
<button type="button" class="ep-mode-btn" class:active={viewMode === 'preview'} on:click={() => viewMode = 'preview'}>
{t('note.mode.preview')}
</button>
<button type="button" class="ep-mode-btn" class:active={viewMode === 'split'} on:click={() => viewMode = 'split'}>
{t('note.mode.split')}
</button>
</div>
{/if}
<button class="btn btn-sm" on:click={handleOpenExternal}>{t('file.openExternal')}</button>
</div>
</header>
<div class="ep-body">
{#if loading}
<div class="ep-status"><p>{t('common.loading')}</p></div>
{:else if error}
<div class="ep-status ep-error">
<p>{error}</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>{t('file.openExternal')}</button>
</div>
{:else if isMarkdown && viewMode === 'edit'}
<MarkdownEditor
bind:this={editorRef}
{content}
viewMode="edit"
on:content-change={handleContentChange}
on:save={handleSave}
/>
{:else if isMarkdown && viewMode === 'preview'}
<div class="ep-preview-pane">
<MarkdownPreview {content} />
</div>
{:else if isMarkdown && viewMode === 'split'}
<div class="ep-split">
<div class="ep-split-pane ep-split-editor">
<MarkdownEditor
bind:this={editorRef}
{content}
viewMode="split"
on:content-change={handleContentChange}
on:save={handleSave}
/>
</div>
<div class="ep-split-pane ep-split-preview">
<MarkdownPreview {content} />
</div>
</div>
{:else}
<!-- Plain text / code -->
<textarea
class="ep-textarea"
bind:value={content}
on:input={() => { dirty = true; dispatch('content-change', { content }) }}
{readonly}
spellcheck="false"
></textarea>
{/if}
</div>
<footer class="ep-footer">
<div class="ep-footer-left">
{#if dirty && !readonly}
<span class="ep-dirty-hint">{t('editor.unsaved')}</span>
{/if}
</div>
<div class="ep-footer-right">
{#if !readonly}
<button class="btn btn-primary btn-sm" on:click={handleSave} disabled={!dirty}>{t('common.save')}</button>
{/if}
<button class="btn btn-sm" on:click={handleClose}>{t('common.close')}</button>
</div>
</footer>
</div>
<style>
.editor-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: #13131f;
}
.ep-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
border-bottom: 1px solid #2a2a3c;
background: #16161f;
flex-shrink: 0;
}
.ep-title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.ep-title {
font-size: 13px;
font-weight: 500;
color: #e4e4ef;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ep-subtitle {
font-size: 11px;
color: #666;
flex-shrink: 0;
}
.ep-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
background: #2a2a3c;
color: #888;
flex-shrink: 0;
}
.ep-dirty {
color: #f59e0b;
font-size: 8px;
flex-shrink: 0;
}
.ep-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.ep-mode-switcher {
display: flex;
gap: 2px;
}
.ep-mode-btn {
padding: 3px 10px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #888;
font-size: 11px;
font-family: inherit;
cursor: pointer;
}
.ep-mode-btn:hover {
color: #ccc;
background: #1e1e30;
}
.ep-mode-btn.active {
color: #e4e4ef;
background: #22223a;
border-color: #333350;
}
.ep-body {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.ep-status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
color: #888;
font-size: 13px;
}
.ep-error {
color: #ff8888;
}
.ep-preview-pane {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.ep-split {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.ep-split-pane {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.ep-split-editor {
border-right: 1px solid #2a2a3c;
}
.ep-split-preview {
background: #11111c;
overflow-y: auto;
padding: 16px 20px;
}
.ep-textarea {
flex: 1;
width: 100%;
min-height: 0;
border: none;
outline: none;
background: #13131f;
color: #e4e4ef;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 13px;
line-height: 1.65;
padding: 16px 20px;
resize: none;
tab-size: 2;
overflow-y: auto;
}
.ep-textarea:read-only {
color: #999;
}
.ep-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid #2a2a3c;
flex-shrink: 0;
}
.ep-footer-left {
flex: 1;
min-width: 0;
}
.ep-footer-right {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.ep-dirty-hint {
font-size: 11px;
color: #f59e0b;
}
.btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
.btn:hover { background: #222233; }
.btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
.btn-primary:hover { background: #4f46e5; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
</style>

View File

@ -1,188 +1,62 @@
<script> <script>
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte'
import MarkdownEditor from './MarkdownEditor.svelte'; import EditorPanel from './EditorPanel.svelte'
import MarkdownPreview from './MarkdownPreview.svelte'; import { t } from '../../i18n'
import { t } from '../../i18n';
export let content = ''; // ===== Props (note context) =====
export let viewMode = 'edit'; export let content = ''
export let placeholder = ''; export let viewMode = 'edit'
export let placeholder = ''
export let noteId = ''
export let noteTitle = ''
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher()
let activeEditor = undefined; // bind:this ref for the visible MarkdownEditor let editorRef = undefined
function setMode(mode) { // ===== Public API =====
dispatch('mode-change', { mode }); export function insertText(text) {
if (editorRef) editorRef.insertText(text)
} }
// ===== Handlers =====
function handleContentChange(e) { function handleContentChange(e) {
content = e.detail.content; content = e.detail.content
dispatch('content-change', e.detail); dispatch('content-change', e.detail)
} }
function handleSave() { function handleSave() {
dispatch('save'); dispatch('save')
} }
function handleInsertLink() { function handleInsertLink() {
dispatch('insert-link'); dispatch('insert-link')
} }
function handleInsertInternalLink() { function handleInsertInternalLink() {
dispatch('insert-internal-link'); dispatch('insert-internal-link')
} }
function handleVerstakLink(e) { function handleVerstakLink(e) {
dispatch('verstak-link', e.detail); dispatch('verstak-link', e.detail)
} }
// ─── Public API ────────────────────────────────────────────────── function handleModeChange(e) {
export function insertText(text) { viewMode = e.detail.mode
if (activeEditor && typeof activeEditor.insertText === 'function') { dispatch('mode-change', e.detail)
activeEditor.insertText(text);
}
} }
</script> </script>
<div class="note-editor-panel" class:mode-edit={viewMode === 'edit'} class:mode-preview={viewMode === 'preview'} class:mode-split={viewMode === 'split'}> <EditorPanel
<!-- Mode switcher --> bind:this={editorRef}
<div class="mode-switcher" role="tablist" aria-label="Note view mode">
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'edit'} class:active={viewMode === 'edit'} on:click={() => setMode('edit')}>
{t('note.mode.edit')}
</button>
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'preview'} class:active={viewMode === 'preview'} on:click={() => setMode('preview')}>
{t('note.mode.preview')}
</button>
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'split'} class:active={viewMode === 'split'} on:click={() => setMode('split')}>
{t('note.mode.split')}
</button>
</div>
<!-- Content area -->
<div class="panel-content">
{#if viewMode === 'edit'}
<MarkdownEditor
bind:this={activeEditor}
{content} {content}
{placeholder} title={noteTitle}
viewMode="edit" isMarkdown={true}
{viewMode}
on:content-change={handleContentChange} on:content-change={handleContentChange}
on:save={handleSave} on:save={handleSave}
on:mode-change={handleModeChange}
on:insert-link={handleInsertLink} on:insert-link={handleInsertLink}
on:insert-internal-link={handleInsertInternalLink} on:insert-internal-link={handleInsertInternalLink}
/> on:verstak-link={handleVerstakLink}
{:else if viewMode === 'preview'} on:close={() => dispatch('close')}
<div class="preview-pane"> />
<MarkdownPreview {content} on:verstak-link={handleVerstakLink} />
</div>
{:else if viewMode === 'split'}
<div class="split-pane">
<div class="split-editor">
<MarkdownEditor
bind:this={activeEditor}
{content}
{placeholder}
viewMode="split"
on:content-change={handleContentChange}
on:save={handleSave}
on:insert-link={handleInsertLink}
on:insert-internal-link={handleInsertInternalLink}
/>
</div>
<div class="split-preview">
<MarkdownPreview {content} on:verstak-link={handleVerstakLink} />
</div>
</div>
{/if}
</div>
</div>
<style>
.note-editor-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
}
.mode-switcher {
display: flex;
align-items: center;
gap: 2px;
padding: 5px 12px;
border-bottom: 1px solid #2a2a3c;
background: #14141f;
flex-shrink: 0;
}
.mode-btn {
padding: 4px 12px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #888;
font-size: 12px;
font-family: inherit;
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.mode-btn:hover {
color: #ccc;
background: #1e1e30;
}
.mode-btn.active {
color: #e4e4ef;
background: #22223a;
border-color: #333350;
font-weight: 500;
}
.mode-btn:focus-visible {
outline: 2px solid #818cf8;
outline-offset: 1px;
}
.panel-content {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.mode-preview .panel-content {
overflow-y: auto;
}
.preview-pane {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.split-pane {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.split-editor {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
border-right: 1px solid #2a2a3c;
overflow: hidden;
}
.split-preview {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 20px 24px;
background: #11111c;
}
</style>

View File

@ -330,6 +330,28 @@ func (s *Service) ReadText(id string) (string, error) {
return string(b), nil return string(b), nil
} }
// WriteText writes text content to a file on disk and updates the record.
func (s *Service) WriteText(rec *Record, content string) error {
abs, err := s.absPathSafe(rec)
if err != nil {
return err
}
tmp := abs + ".tmp"
if err := os.WriteFile(tmp, []byte(content), 0o640); err != nil {
return fmt.Errorf("write temp: %w", err)
}
if err := os.Rename(tmp, abs); err != nil {
os.Remove(tmp)
return fmt.Errorf("rename: %w", err)
}
now := time.Now().UTC().Format(time.RFC3339)
sum := sha256.Sum256([]byte(content))
_, err = s.db.Exec(
`UPDATE files SET size=?, sha256=?, updated_at=?, missing=? WHERE id=?`,
len(content), fmt.Sprintf("%x", sum[:]), now, 0, rec.ID)
return err
}
// ReadBase64 reads a file and returns a data URI (base64-encoded). // ReadBase64 reads a file and returns a data URI (base64-encoded).
func (s *Service) ReadBase64(id string) (string, error) { func (s *Service) ReadBase64(id string) (string, error) {
rec, err := s.Get(id) rec, err := s.Get(id)