journal/Journal.App/src/lib/components/editor/MarkdownEditor.svelte

900 lines
25 KiB
Svelte

<!-- @format -->
<script lang="ts">
import {
listEntryTemplates,
loadEntryTemplate,
type EntryTemplateItemDto,
} from "$lib/backend/templates";
import MarkdownToolbar from "$lib/components/editor/MarkdownToolbar.svelte";
import { entriesStore } from "$lib/stores/entries";
import { fragmentsStore } from "$lib/stores/fragments";
import { listsStore } from "$lib/stores/lists";
import {
serializeTodoList,
todoListsStore,
todosStore,
} from "$lib/stores/todos";
import { renderMarkdown, extractEditorTitle } from "$lib/utils/markdown";
import { onMount } from "svelte";
import { get } from "svelte/store";
export let openDocumentId = "";
export let openDocumentName = "";
export let openDocumentContent = "";
export let onDocumentContentChange: (content: string) => void = () => {};
export let onForceSave: () => Promise<void> | void = () => {};
export let onOpenDocument: (doc: {
id: string;
label: string;
initialContent: string;
linkedFrom?: {
id: string;
label: string;
initialContent: string;
section: "entries" | "fragments" | "todos" | "lists" | "calendar";
};
}) => void = () => {};
type ListMode = "ul" | "ol" | null;
type AttachmentOption = { id: string; label: string };
let markdownText = openDocumentContent;
let lastOpenDocumentId = openDocumentId;
export let previewOnly = true;
let editorInput: HTMLTextAreaElement | null = null;
let templateOptions: EntryTemplateItemDto[] = [];
let templatesBusy = false;
let templateError = "";
let templateRefreshRequested = false;
let listMode: ListMode = null;
let isManualSaving = false;
let manualSaveError = "";
let fragmentAttachmentOptions: AttachmentOption[] = [];
let listAttachmentOptions: AttachmentOption[] = [];
let todoAttachmentOptions: AttachmentOption[] = [];
let attachmentModalOpen = false;
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)");
}
function lineMatchesListMode(
line: string,
mode: Exclude<ListMode, null>,
): boolean {
if (mode === "ul") {
return /^\s*[-*+]\s/.test(line);
}
return /^\s*\d+\.\s/.test(line);
}
function isMarkerOnlyLine(
line: string,
mode: Exclude<ListMode, null>,
): boolean {
if (mode === "ul") {
return /^\s*[-*+]\s$/.test(line);
}
return /^\s*\d+\.\s$/.test(line);
}
function handleMarkdownInput(event: Event) {
const target = event.currentTarget as HTMLTextAreaElement;
const nextValue = target.value;
updateDraft(nextValue);
if (!listMode) return;
const native = event as InputEvent;
if (native.inputType !== "deleteContentBackward") return;
const cursor = target.selectionStart ?? 0;
const lineStart = nextValue.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1;
const lineEndIndex = nextValue.indexOf("\n", cursor);
const lineEnd = lineEndIndex === -1 ? nextValue.length : lineEndIndex;
const line = nextValue.slice(lineStart, lineEnd);
if (!lineMatchesListMode(line, listMode)) {
listMode = null;
}
}
function toggleListMode(mode: Exclude<ListMode, null>) {
if (listMode === mode) {
listMode = null;
return;
}
listMode = mode;
applyLinePrefix(mode === "ul" ? "- " : "1. ");
queueMicrotask(() => editorInput?.focus());
}
async function refreshTemplates() {
templatesBusy = true;
templateError = "";
try {
templateOptions = await listEntryTemplates();
} catch (error) {
templateError = String(error);
templateOptions = [];
} finally {
templatesBusy = false;
}
}
function insertTextAtCursor(content: string) {
const current = markdownText;
if (!editorInput) {
const spacer = current.endsWith("\n") || !current ? "" : "\n\n";
updateDraft(`${current}${spacer}${content}`);
return;
}
const start = editorInput.selectionStart ?? current.length;
const end = editorInput.selectionEnd ?? start;
const next = `${current.slice(0, start)}${content}${current.slice(end)}`;
updateDraft(next);
queueMicrotask(() => {
if (!editorInput) return;
const cursor = start + content.length;
editorInput.focus();
editorInput.setSelectionRange(cursor, cursor);
});
}
function escapeMarkdownLinkText(value: string): string {
return value.replace(/]/g, "\\]");
}
function appendToAttachmentsSection(
lineToAppend: string,
attachmentId: string,
) {
const current = markdownText;
const normalized = current.replace(/\r\n/g, "\n");
if (normalized.includes(`(journal:${attachmentId})`)) {
return;
}
const attachmentsHeaderPattern = /^##\s+Attachments\s*$/im;
const headerMatch = attachmentsHeaderPattern.exec(normalized);
if (!headerMatch || headerMatch.index < 0) {
const spacer = normalized.trim().length > 0 ? "\n\n" : "";
updateDraft(`${normalized}${spacer}## Attachments\n${lineToAppend}\n`);
return;
}
const headerStart = headerMatch.index;
const headerEnd = headerStart + headerMatch[0].length;
const bodyStart = normalized.indexOf("\n", headerEnd);
const sectionBodyStart =
bodyStart === -1 ? normalized.length : bodyStart + 1;
const nextHeaderMatch = /^##\s+/m.exec(normalized.slice(sectionBodyStart));
const sectionEnd = nextHeaderMatch
? sectionBodyStart + nextHeaderMatch.index
: normalized.length;
const sectionBody = normalized.slice(sectionBodyStart, sectionEnd);
const bodyPrefix =
sectionBody.length > 0 && !sectionBody.endsWith("\n") ? "\n" : "";
const insertion = `${bodyPrefix}${lineToAppend}\n`;
const next = `${normalized.slice(0, sectionEnd)}${insertion}${normalized.slice(sectionEnd)}`;
updateDraft(next);
}
function attachReference(
kind: "Fragment" | "List" | "To-Do",
option: AttachmentOption,
) {
const label = escapeMarkdownLinkText(option.label.trim() || `${kind} Item`);
const line = `- ${kind}: [${label}](journal:${option.id})`;
appendToAttachmentsSection(line, option.id);
}
function openAttachmentModal() {
attachmentModalOpen = true;
}
function closeAttachmentModal() {
attachmentModalOpen = false;
}
function attachFromModal(
kind: "Fragment" | "List" | "To-Do",
option: AttachmentOption,
) {
attachReference(kind, option);
attachmentModalOpen = false;
}
function insertTagToken() {
if (!editorInput) return;
const current = markdownText;
const start = editorInput.selectionStart ?? 0;
const end = editorInput.selectionEnd ?? start;
const selected = current.slice(start, end);
const insertion = selected ? `[[${selected}]]` : "[[]]";
const next = `${current.slice(0, start)}${insertion}${current.slice(end)}`;
updateDraft(next);
queueMicrotask(() => {
if (!editorInput) return;
const cursor = selected ? start + insertion.length : start + 2;
editorInput.focus();
editorInput.setSelectionRange(cursor, cursor);
});
}
function resolveJournalLinkTarget(
targetId: string,
): { id: string; label: string; initialContent: string } | null {
if (!targetId) return null;
if (targetId.startsWith("fragments/")) {
const fragment = get(fragmentsStore).find((item) => item.id === targetId);
if (!fragment) return null;
return {
id: fragment.id,
label: fragment.label,
initialContent: fragment.initialContent,
};
}
if (targetId.startsWith("lists/")) {
const list = get(listsStore).find((item) => item.id === targetId);
if (!list) return null;
return {
id: list.id,
label: list.label,
initialContent: list.initialContent,
};
}
if (targetId.startsWith("todos/")) {
const todoList = get(todoListsStore).find((item) => item.id === targetId);
if (!todoList) return null;
const todoItems = get(todosStore)[targetId] ?? [];
return {
id: todoList.id,
label: todoList.label,
initialContent: serializeTodoList(todoList.label, todoItems),
};
}
if (targetId.startsWith("entries/")) {
const entry = get(entriesStore).find((item) => item.id === targetId);
if (!entry) return null;
return {
id: entry.id,
label: entry.label,
initialContent: entry.initialContent,
};
}
return null;
}
function handlePreviewClick(event: MouseEvent) {
const target = event.target as HTMLElement | null;
const anchor = target?.closest("a");
const href = anchor?.getAttribute("href");
if (!href || !href.startsWith("journal:")) return;
const targetId = href.slice("journal:".length).trim();
const doc = resolveJournalLinkTarget(targetId);
if (!doc) return;
event.preventDefault();
onOpenDocument({
...doc,
linkedFrom: {
id: openDocumentId,
label: openDocumentName,
initialContent: openDocumentContent,
section: "entries",
},
});
}
function interceptJournalLinks(node: HTMLElement) {
const onClick = (event: MouseEvent) => handlePreviewClick(event);
node.addEventListener("click", onClick);
return {
destroy() {
node.removeEventListener("click", onClick);
},
};
}
async function applyTemplateByPath(filePath: string) {
if (!filePath) return;
try {
const loaded = await loadEntryTemplate(filePath);
insertTextAtCursor(loaded.content);
} catch (error) {
templateError = String(error);
}
}
function handleMarkdownKeydown(event: KeyboardEvent) {
if (!editorInput) return;
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
const key = event.key.toLowerCase();
if (key === "u") {
event.preventDefault();
applyWrap("++");
return;
}
}
if (event.key === "Escape" && listMode) {
event.preventDefault();
const current = markdownText;
const start = editorInput.selectionStart ?? current.length;
const lineStart = current.lastIndexOf("\n", Math.max(0, start - 1)) + 1;
const lineEndIndex = current.indexOf("\n", start);
const lineEnd = lineEndIndex === -1 ? current.length : lineEndIndex;
const line = current.slice(lineStart, lineEnd);
if (isMarkerOnlyLine(line, listMode)) {
const next = `${current.slice(0, lineStart)}${current.slice(lineEnd)}`;
updateDraft(next);
queueMicrotask(() => {
if (!editorInput) return;
editorInput.focus();
editorInput.setSelectionRange(lineStart, lineStart);
});
}
listMode = null;
return;
}
if (event.key !== "Enter") return;
const current = markdownText;
const start = editorInput.selectionStart ?? current.length;
const lineStart = current.lastIndexOf("\n", Math.max(0, start - 1)) + 1;
const lineUntilCursor = current.slice(lineStart, start);
let effectiveListMode: ListMode = listMode;
if (!effectiveListMode) {
if (/^\s*[-*+]\s/.test(lineUntilCursor)) {
effectiveListMode = "ul";
} else if (/^\s*\d+\.\s/.test(lineUntilCursor)) {
effectiveListMode = "ol";
}
}
if (!effectiveListMode) return;
event.preventDefault();
let marker = "- ";
if (effectiveListMode === "ol") {
const match = lineUntilCursor.match(/^(\s*)(\d+)\.\s/);
marker = match ? `${match[1]}${Number(match[2]) + 1}. ` : "1. ";
} else {
const match = lineUntilCursor.match(/^(\s*)[-*+]\s/);
marker = match ? `${match[1]}- ` : "- ";
}
listMode = effectiveListMode;
const insertion = `\n${marker}`;
const next = `${current.slice(0, start)}${insertion}${current.slice(start)}`;
updateDraft(next);
queueMicrotask(() => {
if (!editorInput) return;
const cursor = start + insertion.length;
editorInput.focus();
editorInput.setSelectionRange(cursor, cursor);
});
}
async function handleManualSave() {
if (isManualSaving) return;
isManualSaving = true;
manualSaveError = "";
try {
await onForceSave();
} catch (error) {
manualSaveError = String(error);
} finally {
isManualSaving = false;
}
}
onMount(() => {
void refreshTemplates();
});
$: if (!previewOnly && !templateRefreshRequested) {
templateRefreshRequested = true;
void refreshTemplates();
}
$: if (previewOnly) {
templateRefreshRequested = false;
}
$: if (openDocumentId !== lastOpenDocumentId) {
markdownText = openDocumentContent;
lastOpenDocumentId = openDocumentId;
listMode = null;
}
$: isEntryDocument =
openDocumentId.startsWith("entries/file/") ||
openDocumentId.startsWith("entries/draft-");
$: fragmentAttachmentOptions = $fragmentsStore
.filter((item) => item.id && item.label)
.map((item) => ({ id: item.id, label: item.label }));
$: listAttachmentOptions = $listsStore
.filter((item) => item.id && item.label)
.map((item) => ({ id: item.id, label: item.label }));
$: todoAttachmentOptions = $todoListsStore
.filter((item) => item.id && item.label)
.map((item) => ({ id: item.id, label: item.label }));
$: 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}
<MarkdownToolbar
{isEntryDocument}
{templatesBusy}
{templateOptions}
{listMode}
attachmentsDisabled={fragmentAttachmentOptions.length +
listAttachmentOptions.length +
todoAttachmentOptions.length ===
0}
onApplyHeading={applyHeading}
onApplyTemplate={(filePath) => void applyTemplateByPath(filePath)}
onOpenAttachments={openAttachmentModal}
onBold={() => applyWrap("**")}
onItalic={() => applyWrap("*")}
onUnderline={() => applyWrap("++")}
onTag={insertTagToken}
onLink={insertLink}
onToggleUl={() => toggleListMode("ul")}
onToggleOl={() => toggleListMode("ol")}
onCode={() => applyWrap("`")}
onSave={() => void handleManualSave()}
saveBusy={isManualSaving}
/>
{#if templateError}
<p class="template-error">{templateError}</p>
{/if}
{#if manualSaveError}
<p class="template-error">{manualSaveError}</p>
{/if}
{/if}
{#if attachmentModalOpen}
<div class="attachment-modal-backdrop" role="presentation">
<div
class="attachment-modal"
role="dialog"
aria-modal="true"
aria-label="Attach item"
>
<header class="attachment-modal-header">
<h2>Attach Item</h2>
<button
type="button"
class="attachment-modal-close"
on:click={closeAttachmentModal}
aria-label="Close attach dialog"
>
<span class="material-symbols-outlined" aria-hidden="true"
>close</span
>
</button>
</header>
{#if fragmentAttachmentOptions.length + listAttachmentOptions.length + todoAttachmentOptions.length === 0}
<p class="attachment-empty">
No fragments, lists, or to-do lists are available to attach.
</p>
{:else}
{#if fragmentAttachmentOptions.length > 0}
<div class="attachment-group">
<h3>Fragments</h3>
<select
class="attachment-select"
aria-label="Attach fragment"
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const selected = fragmentAttachmentOptions.find(
(option) => option.id === target.value,
);
if (selected) attachFromModal("Fragment", selected);
target.value = "";
}}
>
<option value="">Select fragment...</option>
{#each fragmentAttachmentOptions as option}
<option value={option.id}>{option.label}</option>
{/each}
</select>
</div>
{/if}
{#if listAttachmentOptions.length > 0}
<div class="attachment-group">
<h3>Lists</h3>
<select
class="attachment-select"
aria-label="Attach list"
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const selected = listAttachmentOptions.find(
(option) => option.id === target.value,
);
if (selected) attachFromModal("List", selected);
target.value = "";
}}
>
<option value="">Select list...</option>
{#each listAttachmentOptions as option}
<option value={option.id}>{option.label}</option>
{/each}
</select>
</div>
{/if}
{#if todoAttachmentOptions.length > 0}
<div class="attachment-group">
<h3>To-Do Lists</h3>
<select
class="attachment-select"
aria-label="Attach to-do list"
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const selected = todoAttachmentOptions.find(
(option) => option.id === target.value,
);
if (selected) attachFromModal("To-Do", selected);
target.value = "";
}}
>
<option value="">Select to-do list...</option>
{#each todoAttachmentOptions as option}
<option value={option.id}>{option.label}</option>
{/each}
</select>
</div>
{/if}
{/if}
</div>
</div>
{/if}
<div class="editor-workspace">
{#if previewOnly}
<article
class="markdown-preview"
aria-label="Markdown preview"
use:interceptJournalLinks
>
{@html renderedHtml}
</article>
{:else}
<textarea
bind:this={editorInput}
class="markdown-input"
bind:value={markdownText}
on:input={handleMarkdownInput}
on:keydown={handleMarkdownKeydown}
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: 0;
background: transparent;
padding: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 10px;
}
.editor-surface.preview-only {
grid-template-rows: minmax(0, 1fr);
}
.template-error {
color: #e74c3c;
font-size: 0.78rem;
margin: -2px 0 0;
padding: 0 14px;
}
.attachment-modal-backdrop {
position: fixed;
inset: 0;
background: color-mix(in srgb, var(--bg-app) 60%, #000 40%);
display: grid;
place-items: center;
padding: 20px;
z-index: 35;
}
.attachment-modal {
width: min(680px, 92vw);
max-height: 78vh;
overflow: auto;
border: 1px solid var(--border-soft);
border-radius: 12px;
background: color-mix(in srgb, var(--surface-1) 94%, var(--bg-editor) 6%);
padding: 12px;
display: grid;
gap: 10px;
}
.attachment-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.attachment-modal-header h2 {
font-size: 0.9rem;
color: var(--text-primary);
}
.attachment-modal-close {
border: 1px solid var(--border-soft);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-2) 92%, var(--bg-editor) 8%);
color: var(--text-primary);
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.attachment-modal-close:hover {
background: var(--bg-hover);
}
.attachment-empty {
color: var(--text-muted);
font-size: 0.82rem;
}
.attachment-group {
display: grid;
gap: 6px;
}
.attachment-group h3 {
font-size: 0.82rem;
color: var(--text-muted);
}
.attachment-select {
width: 100%;
border: 1px solid var(--border-soft);
border-radius: 8px;
background-color: color-mix(
in srgb,
var(--surface-2) 90%,
var(--bg-editor) 10%
);
color: var(--text-primary);
padding: 8px 34px 8px 10px;
font-size: 0.82rem;
cursor: pointer;
}
.attachment-select:hover,
.attachment-select:focus-visible {
background-color: var(--bg-hover);
border-color: var(--border-strong);
outline: none;
}
.attachment-select option {
color: #111827;
background: #ffffff;
}
.editor-workspace {
min-height: 0;
height: 100%;
overflow: auto;
padding: 0 14px 14px;
}
.markdown-input,
.markdown-preview {
min-height: 0;
width: min(100%, 920px);
height: auto;
min-height: 100%;
margin: 0 auto;
border: none;
border-radius: 0;
background: transparent;
color: var(--text-primary);
padding: 28px 36px;
font-size: 0.92rem;
line-height: 1.65;
overflow: visible;
font-family:
"Segoe UI Variable", "Segoe UI", "Segoe UI Emoji", "Apple Color Emoji",
"Noto Color Emoji", Inter, Roboto, Helvetica, Arial, sans-serif;
}
.markdown-input {
display: block;
resize: none;
overflow: auto;
}
.markdown-input:focus {
outline: none;
box-shadow: none;
}
.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;
}
.markdown-preview :global(.markdown-tag-list) {
display: inline-flex;
flex-wrap: wrap;
gap: 6px;
vertical-align: middle;
}
.markdown-preview :global(.markdown-tag-chip) {
display: inline-flex;
align-items: center;
border: 1px solid var(--border-soft);
border-radius: 999px;
padding: 1px 8px;
font-size: 0.74rem;
line-height: 1.35;
color: var(--text-muted);
background: color-mix(in srgb, var(--surface-2) 90%, var(--bg-editor) 10%);
}
@media (max-width: 980px) {
.editor-workspace {
padding: 4px 8px 10px;
}
.template-error {
padding: 0 8px;
}
.markdown-input,
.markdown-preview {
width: 100%;
border-radius: 0;
padding: 18px 16px;
font-size: 0.89rem;
}
}
</style>