journal/Journal.App/src/lib/components/editor/MarkdownEditor.svelte
Jacob Schmidt d1e4989303 Add edit/delete buttons to SidePanel for all sections, custom entry filenames, vault persistence
- Add edit/delete icon buttons to SidePanel items for entries, todos, lists, and fragments
- Move fragment edit/delete controls from FragmentEditor main panel to SidePanel
- Add externalEditRequested prop to FragmentEditor for parent-controlled edit mode
- Add fragment delete handling in +page.svelte performDelete flow
- Support custom entry filenames via FileName parameter in EntrySavePayload
- Fix vault persistence for custom-named entries (non-date-formatted .md files)
- Add VaultStorageService SaveCustomEntries helper for _custom_entries.vault
- Add entries.delete backend command and wire through EntryFileService
- Remove Write/Preview toggle from MarkdownEditor (previewOnly controlled by parent)
- Add smoke tests for vault custom entry roundtrip and entry save with custom filename

Co-Authored-By: Oz <oz-agent@warp.dev>
2026-02-26 19:40:43 -06:00

273 lines
7.1 KiB
Svelte

<script lang="ts">
import { renderMarkdown, extractEditorTitle } from "$lib/utils/markdown";
export let openDocumentId = "";
export let openDocumentName = "";
export let openDocumentContent = "";
export let onDocumentContentChange: (content: string) => void = () => {};
let markdownText = openDocumentContent;
let lastOpenDocumentId = openDocumentId;
export let previewOnly = true;
let editorInput: HTMLTextAreaElement | null = null;
function updateDraft(value: string) {
markdownText = value;
onDocumentContentChange(value);
}
function applyWrap(before: string, after = before) {
if (!editorInput) return;
const current = markdownText;
const start = editorInput.selectionStart ?? 0;
const end = editorInput.selectionEnd ?? start;
const selected = current.slice(start, end);
const insertion = `${before}${selected}${after}`;
const next = `${current.slice(0, start)}${insertion}${current.slice(end)}`;
markdownText = next;
onDocumentContentChange(next);
queueMicrotask(() => {
if (!editorInput) return;
const cursor = start + insertion.length;
editorInput.focus();
editorInput.setSelectionRange(cursor, cursor);
});
}
function applyLinePrefix(prefix: string) {
if (!editorInput) return;
const current = markdownText;
const start = editorInput.selectionStart ?? 0;
const end = editorInput.selectionEnd ?? start;
const blockStart = current.lastIndexOf("\n", start - 1) + 1;
const blockEndIndex = current.indexOf("\n", end);
const blockEnd = blockEndIndex === -1 ? current.length : blockEndIndex;
const block = current.slice(blockStart, blockEnd);
const nextBlock = block
.split("\n")
.map((line) => `${prefix}${line}`)
.join("\n");
const next = `${current.slice(0, blockStart)}${nextBlock}${current.slice(blockEnd)}`;
markdownText = next;
onDocumentContentChange(next);
}
function applyHeading(level: number) {
if (!Number.isFinite(level) || level < 1 || level > 6) return;
applyLinePrefix(`${"#".repeat(level)} `);
}
function insertLink() {
applyWrap("[", "](https://example.com)");
}
$: if (openDocumentId !== lastOpenDocumentId) {
markdownText = openDocumentContent;
lastOpenDocumentId = openDocumentId;
}
$: editorTitle = extractEditorTitle(markdownText, openDocumentName);
$: renderedHtml = renderMarkdown(markdownText);
</script>
<header class="editor-header">
<h1>{editorTitle}</h1>
</header>
<section class="editor-surface" class:preview-only={previewOnly}>
{#if !previewOnly}
<div class="editor-toolbar">
<select
class="toolbar-select"
aria-label="Header size"
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const level = Number(target.value);
if (level) applyHeading(level);
target.value = "";
}}
>
<option value="">Heading</option>
<option value="1">H1</option>
<option value="2">H2</option>
<option value="3">H3</option>
<option value="4">H4</option>
<option value="5">H5</option>
<option value="6">H6</option>
</select>
<button type="button" on:click={() => applyWrap("**")}>Bold</button>
<button type="button" on:click={() => applyWrap("*")}>Italic</button>
<button type="button" on:click={insertLink}>Link</button>
<button type="button" on:click={() => applyLinePrefix("- ")}>UL</button>
<button type="button" on:click={() => applyLinePrefix("1. ")}>OL</button>
<button type="button" on:click={() => applyWrap("`")}>Code</button>
</div>
{/if}
<div class="editor-workspace">
{#if previewOnly}
<article class="markdown-preview" aria-label="Markdown preview">
{@html renderedHtml}
</article>
{:else}
<textarea
bind:this={editorInput}
class="markdown-input"
bind:value={markdownText}
on:input={(event) => updateDraft((event.currentTarget as HTMLTextAreaElement).value)}
aria-label="Markdown input"
></textarea>
{/if}
</div>
</section>
<style>
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.editor-header h1 {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.editor-surface {
min-height: 0;
flex: 1;
border-radius: 10px;
border: 1px solid var(--border-soft);
background: var(--surface-1);
padding: 10px;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 8px;
}
.editor-surface.preview-only {
grid-template-rows: minmax(0, 1fr);
}
.editor-toolbar {
display: flex;
flex-wrap: wrap;
gap: 6px;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: var(--bg-app);
padding: 6px;
}
.editor-toolbar button {
border-radius: 6px;
border: 1px solid var(--border-soft);
background: var(--surface-2);
color: var(--text-muted);
padding: 5px 8px;
font-size: 0.74rem;
cursor: pointer;
}
.toolbar-select {
border-radius: 6px;
border: 1px solid var(--border-soft);
background: var(--surface-2);
color: var(--text-muted);
padding: 5px 8px;
font-size: 0.74rem;
cursor: pointer;
}
.editor-toolbar button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.toolbar-select:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.editor-workspace {
min-height: 0;
height: 100%;
display: block;
}
.markdown-input,
.markdown-preview {
min-height: 0;
width: 100%;
height: 100%;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: var(--bg-app);
color: var(--text-primary);
padding: 12px;
font-size: 0.88rem;
line-height: 1.5;
overflow: auto;
}
.markdown-input {
display: block;
resize: none;
}
.markdown-input:focus {
outline: none;
border-color: var(--border-strong);
}
.markdown-preview :global(h1),
.markdown-preview :global(h2),
.markdown-preview :global(h3),
.markdown-preview :global(h4),
.markdown-preview :global(h5),
.markdown-preview :global(h6) {
margin: 0 0 8px;
color: var(--text-primary);
}
.markdown-preview :global(p),
.markdown-preview :global(blockquote),
.markdown-preview :global(pre),
.markdown-preview :global(ul),
.markdown-preview :global(ol) {
margin: 0 0 10px;
}
.markdown-preview :global(ul),
.markdown-preview :global(ol) {
padding-left: 18px;
}
.markdown-preview :global(code) {
background: var(--surface-2);
border: 1px solid var(--border-soft);
border-radius: 4px;
padding: 1px 4px;
font-family: Consolas, "Courier New", monospace;
font-size: 0.82rem;
}
.markdown-preview :global(pre code) {
display: block;
padding: 8px;
white-space: pre-wrap;
}
.markdown-preview :global(blockquote) {
border-left: 3px solid var(--border-strong);
padding-left: 10px;
color: var(--text-muted);
}
.markdown-preview :global(a) {
color: var(--text-primary);
text-decoration: underline;
}
</style>