snapshot
This commit is contained in:
Binary file not shown.
@@ -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, """);
|
||||
}
|
||||
|
||||
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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user