diff --git a/src/assets/fonts/Inter-var.woff2 b/src/assets/fonts/Inter-var.woff2 new file mode 100644 index 0000000..5a8d3e7 Binary files /dev/null and b/src/assets/fonts/Inter-var.woff2 differ diff --git a/src/features/snippets/SnippetCard.tsx b/src/features/snippets/SnippetCard.tsx index 17ea97c..8534f5d 100644 --- a/src/features/snippets/SnippetCard.tsx +++ b/src/features/snippets/SnippetCard.tsx @@ -1,5 +1,6 @@ import { Pencil, Trash2 } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { MouseEvent } from "react"; import type { Snippet } from "@/types/snippet"; import { deadlineToDateInput } from "@/lib/snippetDeadline"; import { Button } from "@/components/ui/button"; @@ -9,6 +10,7 @@ import { SnippetEditDialog } from "@/features/snippets/components/SnippetEditDia import { TagPill } from "@/features/snippets/components/TagPill"; import { colorForTagName, tagsFromString } from "@/features/snippets/lib/tagFieldUtils"; import { formatWhen } from "@/features/snippets/lib/formatWhen"; +import { toggleMarkdownTaskAt } from "@/features/snippets/lib/markdownTasks"; import { parseSnippetMarkdown } from "@/features/snippets/lib/parseSnippetMarkdown"; type Props = { @@ -92,6 +94,41 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep } }; + const handleMarkdownMouseDown = useCallback( + (e: MouseEvent) => { + const t = e.target; + if (!(t instanceof HTMLInputElement)) return; + if (!t.classList.contains("snippet-task-cb") || t.type !== "checkbox") return; + e.preventDefault(); + e.stopPropagation(); + const idx = Number(t.getAttribute("data-snippet-task-idx")); + if (!Number.isFinite(idx) || busy) return; + const newBody = toggleMarkdownTaskAt(snippet.body, idx); + if (newBody === null) return; + void (async () => { + setBusy(true); + setError(null); + try { + const res = await fetch(`/api/snippets/${snippet.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: null, body: newBody }), + }); + if (!res.ok) { + const err = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(err?.error ?? res.statusText); + } + onReplace((await res.json()) as Snippet); + } catch (err) { + setError(err instanceof Error ? err.message : "Aufgabe konnte nicht gespeichert werden"); + } finally { + setBusy(false); + } + })(); + }, + [snippet.body, snippet.id, busy, onReplace], + ); + async function clearDeadlineRemote() { setBusy(true); setError(null); @@ -151,8 +188,12 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep } }} > -
-
+
+
diff --git a/src/features/snippets/components/SnippetEditDialog.tsx b/src/features/snippets/components/SnippetEditDialog.tsx index 56dde68..7b0c17e 100644 --- a/src/features/snippets/components/SnippetEditDialog.tsx +++ b/src/features/snippets/components/SnippetEditDialog.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { MouseEvent } from "react"; import type { Snippet } from "@/types/snippet"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -10,6 +11,7 @@ import { useTagSuggestions } from "@/features/snippets/hooks/useTagSuggestions"; import { TagPill } from "@/features/snippets/components/TagPill"; import { TagSuggestDropdown } from "@/features/snippets/components/TagSuggestDropdown"; import { colorForTagName, splitTagsField, tagsFromString } from "@/features/snippets/lib/tagFieldUtils"; +import { toggleMarkdownTaskAt } from "@/features/snippets/lib/markdownTasks"; import { parseSnippetMarkdown } from "@/features/snippets/lib/parseSnippetMarkdown"; type Props = { @@ -55,6 +57,21 @@ export function SnippetEditDialog({ const previewHtml = useMemo(() => parseSnippetMarkdown(body), [body]); + const handlePreviewMouseDown = useCallback( + (e: MouseEvent) => { + const t = e.target; + if (!(t instanceof HTMLInputElement)) return; + if (!t.classList.contains("snippet-task-cb") || t.type !== "checkbox") return; + e.preventDefault(); + e.stopPropagation(); + const idx = Number(t.getAttribute("data-snippet-task-idx")); + if (!Number.isFinite(idx) || busy) return; + const next = toggleMarkdownTaskAt(body, idx); + if (next !== null) onBodyChange(next); + }, + [body, busy, onBodyChange], + ); + useEffect(() => { if (!open) setShowPreview(false); }, [open]); @@ -136,7 +153,11 @@ export function SnippetEditDialog({ placeholder="Markdown …" /> {showPreview ? ( -
+
) : null}
diff --git a/src/features/snippets/lib/markdownTasks.ts b/src/features/snippets/lib/markdownTasks.ts new file mode 100644 index 0000000..7067e49 --- /dev/null +++ b/src/features/snippets/lib/markdownTasks.ts @@ -0,0 +1,31 @@ +/** + * GFM-Tasklisten (`- [ ]` / `- [x]`) in Dokumentreihenfolge erkennen und umschalten. + */ + +/** GFM task lines: `- [ ]`, `* [x]`, `1. [ ]`; optional indent; text after `]` optional. */ +const TASK_LINE = + /^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s*(.*)$/; + +export function listMarkdownTaskLineIndices(body: string): number[] { + const lines = body.split(/\r?\n/); + const out: number[] = []; + for (let i = 0; i < lines.length; i++) { + if (TASK_LINE.test(lines[i]!)) out.push(i); + } + return out; +} + +/** Kippt die n-te Task-Zeile im Dokument (`[ ]` ↔ `[x]`). `null` bei ungültigem Index. */ +export function toggleMarkdownTaskAt(body: string, taskIndex: number): string | null { + const lines = body.split(/\r?\n/); + const taskLineIndices = listMarkdownTaskLineIndices(body); + if (taskIndex < 0 || taskIndex >= taskLineIndices.length) return null; + const li = taskLineIndices[taskIndex]!; + const line = lines[li]!; + const m = line.match(TASK_LINE); + if (!m) return null; + const checked = m[3] === "x" || m[3] === "X"; + const next = checked ? " " : "x"; + lines[li] = line.replace(/\[[ xX]\]/, `[${next}]`); + return lines.join("\n"); +} diff --git a/src/features/snippets/lib/parseSnippetMarkdown.ts b/src/features/snippets/lib/parseSnippetMarkdown.ts index fcc09fd..f0837d1 100644 --- a/src/features/snippets/lib/parseSnippetMarkdown.ts +++ b/src/features/snippets/lib/parseSnippetMarkdown.ts @@ -1,7 +1,10 @@ import hljs from "highlight.js/lib/common"; import { marked } from "marked"; -marked.setOptions({ gfm: true }); +marked.setOptions({ gfm: true, pedantic: false }); + +/** Pro `parseSnippetMarkdown`-Aufruf: Checkbox-Indizes von 0 … */ +const taskCheckboxSeq = { n: 0 }; /** Markdown-Sprachkennungen → highlight.js-Name */ const LANG_ALIASES: Record = { @@ -41,11 +44,11 @@ function escapeHtml(s: string): string { .replace(/"/g, """); } -let hljsConfigured = false; +let markedExtensionsConfigured = false; -function ensureMarkedHighlighter(): void { - if (hljsConfigured) return; - hljsConfigured = true; +function ensureMarkedExtensions(): void { + if (markedExtensionsConfigured) return; + markedExtensionsConfigured = true; marked.use({ renderer: { code({ text, lang }) { @@ -58,12 +61,18 @@ function ensureMarkedHighlighter(): void { return `
${escapeHtml(text)}
\n`; } }, + checkbox({ checked }) { + const idx = taskCheckboxSeq.n++; + const chk = checked ? " checked" : ""; + return ` `; + }, }, }); } -ensureMarkedHighlighter(); +ensureMarkedExtensions(); export function parseSnippetMarkdown(md: string): string { + taskCheckboxSeq.n = 0; return marked.parse(md) as string; } diff --git a/src/index.css b/src/index.css index 3fd9796..fb83fe0 100644 --- a/src/index.css +++ b/src/index.css @@ -12,7 +12,8 @@ @font-face { font-family: 'Inter'; - src: url('./assets/fonts/Inter-Variable.ttf') format('truetype'); + /* src: url('./assets/fonts/Inter-Variable.ttf') format('truetype'); */ + src: url('./assets/fonts/Inter-var.woff2') format('woff2'); font-style: normal; font-weight: normal; font-display: swap; @@ -62,7 +63,7 @@ body::before { /* Markdown rendering (marked HTML) */ @layer components { .snippet-md { - font-size: 0.8rem; + font-size: 0.76rem; font-family: 'Inter'; } @@ -92,7 +93,7 @@ body::before { border-radius: var(--radius-sm, 0.25rem); background-color: var(--muted); padding: 0.125rem 0.25rem; - font-size: 0.8rem; + font-size: 0.76rem; } .snippet-md :where(pre:not(.snippet-pre)) { @@ -106,7 +107,7 @@ body::before { .snippet-md :where(pre:not(.snippet-pre) code) { background-color: transparent; padding: 0; - font-size: 0.8rem; + font-size: 0.76rem; } /* Highlight.js Codeblöcke */ @@ -129,7 +130,7 @@ body::before { width: max-content; min-width: 100%; padding: 0; - font-size: 0.8rem; + font-size: 0.76rem; line-height: 1.55; tab-size: 2; background: transparent !important; @@ -153,26 +154,27 @@ body::before { .snippet-md :where(h1) { margin-block: 0 0.5rem; - font-size: 0.8rem; + font-size: 0.76rem; font-weight: 600; } .snippet-md :where(h2) { margin-block: 0.75rem 0.5rem; - font-size: 0.8rem; + font-size: 0.76rem; font-weight: 600; } .snippet-md :where(h3) { margin-block: 0.5rem 0.25rem; - font-size: 0.8rem; + font-size: 0.76rem; font-weight: 600; } + /* Listen: outside + Einzug, damit Verschachtelung sichtbar ist */ .snippet-md :where(ul, ol) { margin-block: 0.5rem; - list-style-position: inside; - padding-inline-start: 0; + list-style-position: outside; + padding-inline-start: 1.35rem; } .snippet-md :where(ul) { @@ -184,14 +186,30 @@ body::before { } .snippet-md :where(li) { - margin-block: 0.125rem; - padding-inline-start: 0.25rem; + margin-block: 0.2rem; + padding-inline-start: 0.15rem; + } + + .snippet-md :where(li > ul, li > ol) { + margin-block: 0.35rem; + } + + .snippet-md input.snippet-task-cb { + margin-inline-end: 0.35rem; + vertical-align: middle; + accent-color: var(--primary); + cursor: pointer; + } + + /* Karteninhalt: begrenzte Höhe, langer Text scrollt */ + .snippet-md-viewport { + max-height: min(52vh, 28rem); } .snippet-md :where(table) { width: 100%; border-collapse: collapse; - font-size: 0.8rem; + font-size: 0.76rem; } .snippet-md :where(th, td) { @@ -212,7 +230,7 @@ body::before { /* Snippet-Karten: etwas dichter, Fokus auf Fließtext */ .snippet-md-card-wrap .snippet-md { - font-size: 0.8rem; + font-size: 0.76rem; line-height: 1.55; } @@ -266,7 +284,7 @@ body::before { } .dialog-snippet-preview { - @apply min-h-0 flex-1 overflow-auto rounded-md border bg-muted/30 p-3 lg:w-1/2; + @apply min-h-0 max-h-[min(50vh,26rem)] flex-1 overflow-auto rounded-md border bg-muted/30 p-3 lg:w-1/2; } .dialog-snippet-textarea {