900 lines
25 KiB
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>
|