This commit is contained in:
Stefan ATZLESBERGER
2026-04-21 12:39:46 +02:00
parent a8cc169816
commit e4bdd7c9bb
6 changed files with 146 additions and 26 deletions
Binary file not shown.
+44 -3
View File
@@ -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<HTMLDivElement>) => {
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
}
}}
>
<div className="snippet-md-card-wrap">
<div className="snippet-md overflow-auto" dangerouslySetInnerHTML={{ __html: viewHtml }} />
<div className="snippet-md-card-wrap snippet-md-viewport overflow-hidden rounded-sm">
<div
className="snippet-md max-h-full overflow-x-auto overflow-y-auto pr-0.5"
dangerouslySetInnerHTML={{ __html: viewHtml }}
onMouseDown={handleMarkdownMouseDown}
/>
</div>
</div>
</CardContent>
@@ -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<HTMLDivElement>) => {
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 ? (
<div className="dialog-snippet-preview snippet-md" dangerouslySetInnerHTML={{ __html: previewHtml }} />
<div
className="dialog-snippet-preview snippet-md"
dangerouslySetInnerHTML={{ __html: previewHtml }}
onMouseDown={handlePreviewMouseDown}
/>
) : null}
</div>
<div className="flex shrink-0 flex-wrap items-end gap-2">
@@ -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");
}
@@ -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<string, string> = {
@@ -41,11 +44,11 @@ function escapeHtml(s: string): string {
.replace(/"/g, "&quot;");
}
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 `<pre class="snippet-pre"><code class="hljs language-plaintext">${escapeHtml(text)}</code></pre>\n`;
}
},
checkbox({ checked }) {
const idx = taskCheckboxSeq.n++;
const chk = checked ? " checked" : "";
return `<input type="checkbox" class="snippet-task-cb"${chk} data-snippet-task-idx="${idx}" aria-label="Aufgabe umschalten" /> `;
},
},
});
}
ensureMarkedHighlighter();
ensureMarkedExtensions();
export function parseSnippetMarkdown(md: string): string {
taskCheckboxSeq.n = 0;
return marked.parse(md) as string;
}
+33 -15
View File
@@ -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 {