Add entry attachment links with in-app navigation
This commit is contained in:
parent
3789492de3
commit
d559d9c18d
@ -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;
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>'
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user