Add entry attachment links with in-app navigation

This commit is contained in:
Jacob Schmidt 2026-02-27 20:35:40 -06:00
parent 3789492de3
commit d559d9c18d
5 changed files with 295 additions and 4 deletions

View File

@ -9,12 +9,32 @@
export let openDocumentName = "Daily Notes";
export let openDocumentContent = "";
export let onDocumentContentChange: (content: string) => void = () => {};
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => 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 = () => {};
export let onDeleteDocument: (id: string) => void = () => {};
export let showLinkedBackButton = false;
export let onLinkedBack: () => void = () => {};
export let previewOnly = true;
</script>
<main class="editor-panel" aria-label="Editor area">
{#if showLinkedBackButton}
<div class="editor-nav">
<button type="button" class="back-btn" on:click={onLinkedBack} aria-label="Back to source entry">
<span class="material-symbols-outlined" aria-hidden="true">arrow_back</span>
</button>
</div>
{/if}
{#if !openDocumentId}
<div class="editor-empty">
<span class="material-symbols-outlined empty-icon">edit_note</span>
@ -50,6 +70,7 @@
{openDocumentName}
{openDocumentContent}
{onDocumentContentChange}
{onOpenDocument}
{previewOnly}
/>
{/if}
@ -66,6 +87,30 @@
overflow: hidden;
}
.editor-nav {
display: flex;
align-items: center;
justify-content: flex-start;
}
.back-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
color: var(--text-primary);
padding: 0;
cursor: pointer;
}
.back-btn:hover {
background: var(--bg-hover);
}
.editor-empty {
flex: 1;
display: flex;

View File

@ -1,15 +1,32 @@
<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 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;
@ -20,6 +37,9 @@
let templateError = "";
let templateRefreshRequested = false;
let listMode: ListMode = null;
let fragmentAttachmentOptions: AttachmentOption[] = [];
let listAttachmentOptions: AttachmentOption[] = [];
let todoAttachmentOptions: AttachmentOption[] = [];
function updateDraft(value: string) {
markdownText = value;
@ -150,6 +170,113 @@
});
}
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 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 {
@ -245,6 +372,15 @@
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>
@ -260,8 +396,14 @@
{templatesBusy}
{templateOptions}
{listMode}
fragmentOptions={fragmentAttachmentOptions}
listOptions={listAttachmentOptions}
todoOptions={todoAttachmentOptions}
onApplyHeading={applyHeading}
onApplyTemplate={(filePath) => void applyTemplateByPath(filePath)}
onAttachFragment={(option) => attachReference("Fragment", option)}
onAttachList={(option) => attachReference("List", option)}
onAttachTodo={(option) => attachReference("To-Do", option)}
onBold={() => applyWrap("**")}
onItalic={() => applyWrap("*")}
onLink={insertLink}
@ -276,7 +418,7 @@
<div class="editor-workspace">
{#if previewOnly}
<article class="markdown-preview" aria-label="Markdown preview">
<article class="markdown-preview" aria-label="Markdown preview" use:interceptJournalLinks>
{@html renderedHtml}
</article>
{:else}

View File

@ -1,13 +1,20 @@
<script lang="ts">
import type { EntryTemplateItemDto } from "$lib/backend/templates";
type AttachmentOption = { id: string; label: string };
export let isEntryDocument = false;
export let templatesBusy = false;
export let templateOptions: EntryTemplateItemDto[] = [];
export let listMode: "ul" | "ol" | null = null;
export let fragmentOptions: AttachmentOption[] = [];
export let listOptions: AttachmentOption[] = [];
export let todoOptions: AttachmentOption[] = [];
export let onApplyHeading: (level: number) => void = () => {};
export let onApplyTemplate: (filePath: string) => void = () => {};
export let onAttachFragment: (option: AttachmentOption) => void = () => {};
export let onAttachList: (option: AttachmentOption) => void = () => {};
export let onAttachTodo: (option: AttachmentOption) => void = () => {};
export let onBold: () => void = () => {};
export let onItalic: () => void = () => {};
export let onLink: () => void = () => {};
@ -55,6 +62,63 @@
<option value={template.filePath}>{template.fileName.replace(/\.template\.md$/i, "")}</option>
{/each}
</select>
<select
class="toolbar-select"
aria-label="Attach fragment"
disabled={fragmentOptions.length === 0}
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const selected = fragmentOptions.find((option) => option.id === target.value);
if (selected) {
onAttachFragment(selected);
}
target.value = "";
}}
>
<option value="">Attach Fragment</option>
{#each fragmentOptions as option}
<option value={option.id}>{option.label}</option>
{/each}
</select>
<select
class="toolbar-select"
aria-label="Attach list"
disabled={listOptions.length === 0}
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const selected = listOptions.find((option) => option.id === target.value);
if (selected) {
onAttachList(selected);
}
target.value = "";
}}
>
<option value="">Attach List</option>
{#each listOptions as option}
<option value={option.id}>{option.label}</option>
{/each}
</select>
<select
class="toolbar-select"
aria-label="Attach to-do list"
disabled={todoOptions.length === 0}
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const selected = todoOptions.find((option) => option.id === target.value);
if (selected) {
onAttachTodo(selected);
}
target.value = "";
}}
>
<option value="">Attach To-Do</option>
{#each todoOptions as option}
<option value={option.id}>{option.label}</option>
{/each}
</select>
{/if}
</div>
<div class="toolbar-divider" aria-hidden="true"></div>

View File

@ -12,6 +12,10 @@ export function parseInline(input: string): string {
value = value.replace(/`([^`]+)`/g, "<code>$1</code>");
value = value.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
value = value.replace(/\*([^*]+)\*/g, "<em>$1</em>");
value = value.replace(
/\[([^\]]+)\]\((journal:[^\s)]+)\)/g,
'<a href="$2">$1</a>'
);
value = value.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
'<a href="$2" target="_blank" rel="noreferrer">$1</a>'

View File

@ -19,6 +19,12 @@
id: string;
label: string;
initialContent: string;
linkedFrom?: {
id: string;
label: string;
initialContent: string;
section: StartupView;
};
};
const initialEntry = getDefaultEntry(get(entriesStore));
@ -48,6 +54,7 @@
let fragmentBootstrapInFlight = false;
let pendingDeleteItemId = "";
let templateRefreshToken = 0;
let linkedBackTarget: OpenDocument["linkedFrom"] | null = null;
function resolveStartupSection(value: string): StartupView {
switch (value) {
@ -77,6 +84,14 @@
}
}
function sectionFromDocumentId(id: string): StartupView | null {
if (id.startsWith("entries/")) return "entries";
if (id.startsWith("fragments/")) return "fragments";
if (id.startsWith("todos/")) return "todos";
if (id.startsWith("lists/")) return "lists";
return null;
}
function applyStartupSection(section: StartupView) {
selectedSection = section;
editMode = false;
@ -352,6 +367,13 @@
const prevActiveId = activeDocumentId;
await saveCurrentDocument();
editMode = false;
linkedBackTarget = doc.linkedFrom ?? null;
const targetSection = sectionFromDocumentId(doc.id);
const effectiveSection = targetSection ?? selectedSection;
if (targetSection && targetSection !== selectedSection) {
selectedSection = targetSection;
panelOpen = true;
}
// If saveCurrentDocument promoted a draft to a file-backed entry and the
// caller passed the now-stale draft reference, the editor is already
@ -361,7 +383,7 @@
}
let resolvedDoc = doc;
if (selectedSection === "entries" && doc.id.startsWith("entries/file/") && !doc.initialContent) {
if (effectiveSection === "entries" && doc.id.startsWith("entries/file/") && !doc.initialContent) {
try {
const loaded = await loadEntryByStoreId(doc.id);
if (loaded) {
@ -370,7 +392,7 @@
} catch {
// entry content will use initialContent fallback
}
} else if (selectedSection === "entries" && doc.id.startsWith("entries/template-file/") && !doc.initialContent) {
} else if (effectiveSection === "entries" && doc.id.startsWith("entries/template-file/") && !doc.initialContent) {
try {
const filePath = toTemplatePath(doc.id);
if (filePath) {
@ -393,6 +415,18 @@
activeDocumentLabel = resolvedDoc.label;
}
async function handleLinkedBack() {
if (!linkedBackTarget) return;
const target = linkedBackTarget;
linkedBackTarget = null;
await handleOpenDocument({
id: target.id,
label: target.label,
initialContent: target.initialContent
});
}
function handleDocumentContentChange(content: string) {
openDocuments = { ...openDocuments, [activeDocumentId]: content };
}
@ -488,6 +522,8 @@
onDocumentContentChange={handleDocumentContentChange}
onOpenDocument={handleOpenDocument}
onDeleteDocument={handleDeleteDocument}
showLinkedBackButton={linkedBackTarget !== null}
onLinkedBack={handleLinkedBack}
previewOnly={!editMode}
/>
</div>