- 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>
273 lines
7.1 KiB
Svelte
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>
|