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:
parent
383a9546df
commit
120ff7d6fe
|
|
@ -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
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue