snapshot
This commit is contained in:
Binary file not shown.
@@ -1,5 +1,6 @@
|
|||||||
import { Pencil, Trash2 } from "lucide-react";
|
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 type { Snippet } from "@/types/snippet";
|
||||||
import { deadlineToDateInput } from "@/lib/snippetDeadline";
|
import { deadlineToDateInput } from "@/lib/snippetDeadline";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { TagPill } from "@/features/snippets/components/TagPill";
|
||||||
import { colorForTagName, tagsFromString } from "@/features/snippets/lib/tagFieldUtils";
|
import { colorForTagName, tagsFromString } from "@/features/snippets/lib/tagFieldUtils";
|
||||||
import { formatWhen } from "@/features/snippets/lib/formatWhen";
|
import { formatWhen } from "@/features/snippets/lib/formatWhen";
|
||||||
|
import { toggleMarkdownTaskAt } from "@/features/snippets/lib/markdownTasks";
|
||||||
import { parseSnippetMarkdown } from "@/features/snippets/lib/parseSnippetMarkdown";
|
import { parseSnippetMarkdown } from "@/features/snippets/lib/parseSnippetMarkdown";
|
||||||
|
|
||||||
type Props = {
|
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() {
|
async function clearDeadlineRemote() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -151,8 +188,12 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="snippet-md-card-wrap">
|
<div className="snippet-md-card-wrap snippet-md-viewport overflow-hidden rounded-sm">
|
||||||
<div className="snippet-md overflow-auto" dangerouslySetInnerHTML={{ __html: viewHtml }} />
|
<div
|
||||||
|
className="snippet-md max-h-full overflow-x-auto overflow-y-auto pr-0.5"
|
||||||
|
dangerouslySetInnerHTML={{ __html: viewHtml }}
|
||||||
|
onMouseDown={handleMarkdownMouseDown}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</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 type { Snippet } from "@/types/snippet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
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 { TagPill } from "@/features/snippets/components/TagPill";
|
||||||
import { TagSuggestDropdown } from "@/features/snippets/components/TagSuggestDropdown";
|
import { TagSuggestDropdown } from "@/features/snippets/components/TagSuggestDropdown";
|
||||||
import { colorForTagName, splitTagsField, tagsFromString } from "@/features/snippets/lib/tagFieldUtils";
|
import { colorForTagName, splitTagsField, tagsFromString } from "@/features/snippets/lib/tagFieldUtils";
|
||||||
|
import { toggleMarkdownTaskAt } from "@/features/snippets/lib/markdownTasks";
|
||||||
import { parseSnippetMarkdown } from "@/features/snippets/lib/parseSnippetMarkdown";
|
import { parseSnippetMarkdown } from "@/features/snippets/lib/parseSnippetMarkdown";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -55,6 +57,21 @@ export function SnippetEditDialog({
|
|||||||
|
|
||||||
const previewHtml = useMemo(() => parseSnippetMarkdown(body), [body]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) setShowPreview(false);
|
if (!open) setShowPreview(false);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
@@ -136,7 +153,11 @@ export function SnippetEditDialog({
|
|||||||
placeholder="Markdown …"
|
placeholder="Markdown …"
|
||||||
/>
|
/>
|
||||||
{showPreview ? (
|
{showPreview ? (
|
||||||
<div className="dialog-snippet-preview snippet-md" dangerouslySetInnerHTML={{ __html: previewHtml }} />
|
<div
|
||||||
|
className="dialog-snippet-preview snippet-md"
|
||||||
|
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
||||||
|
onMouseDown={handlePreviewMouseDown}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-wrap items-end gap-2">
|
<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 hljs from "highlight.js/lib/common";
|
||||||
import { marked } from "marked";
|
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 */
|
/** Markdown-Sprachkennungen → highlight.js-Name */
|
||||||
const LANG_ALIASES: Record<string, string> = {
|
const LANG_ALIASES: Record<string, string> = {
|
||||||
@@ -41,11 +44,11 @@ function escapeHtml(s: string): string {
|
|||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
let hljsConfigured = false;
|
let markedExtensionsConfigured = false;
|
||||||
|
|
||||||
function ensureMarkedHighlighter(): void {
|
function ensureMarkedExtensions(): void {
|
||||||
if (hljsConfigured) return;
|
if (markedExtensionsConfigured) return;
|
||||||
hljsConfigured = true;
|
markedExtensionsConfigured = true;
|
||||||
marked.use({
|
marked.use({
|
||||||
renderer: {
|
renderer: {
|
||||||
code({ text, lang }) {
|
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`;
|
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 {
|
export function parseSnippetMarkdown(md: string): string {
|
||||||
|
taskCheckboxSeq.n = 0;
|
||||||
return marked.parse(md) as string;
|
return marked.parse(md) as string;
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-15
@@ -12,7 +12,8 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Inter';
|
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-style: normal;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
@@ -62,7 +63,7 @@ body::before {
|
|||||||
/* Markdown rendering (marked HTML) */
|
/* Markdown rendering (marked HTML) */
|
||||||
@layer components {
|
@layer components {
|
||||||
.snippet-md {
|
.snippet-md {
|
||||||
font-size: 0.8rem;
|
font-size: 0.76rem;
|
||||||
font-family: 'Inter';
|
font-family: 'Inter';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +93,7 @@ body::before {
|
|||||||
border-radius: var(--radius-sm, 0.25rem);
|
border-radius: var(--radius-sm, 0.25rem);
|
||||||
background-color: var(--muted);
|
background-color: var(--muted);
|
||||||
padding: 0.125rem 0.25rem;
|
padding: 0.125rem 0.25rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.76rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.snippet-md :where(pre:not(.snippet-pre)) {
|
.snippet-md :where(pre:not(.snippet-pre)) {
|
||||||
@@ -106,7 +107,7 @@ body::before {
|
|||||||
.snippet-md :where(pre:not(.snippet-pre) code) {
|
.snippet-md :where(pre:not(.snippet-pre) code) {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.76rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Highlight.js Codeblöcke */
|
/* Highlight.js Codeblöcke */
|
||||||
@@ -129,7 +130,7 @@ body::before {
|
|||||||
width: max-content;
|
width: max-content;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.76rem;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
tab-size: 2;
|
tab-size: 2;
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
@@ -153,26 +154,27 @@ body::before {
|
|||||||
|
|
||||||
.snippet-md :where(h1) {
|
.snippet-md :where(h1) {
|
||||||
margin-block: 0 0.5rem;
|
margin-block: 0 0.5rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.76rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.snippet-md :where(h2) {
|
.snippet-md :where(h2) {
|
||||||
margin-block: 0.75rem 0.5rem;
|
margin-block: 0.75rem 0.5rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.76rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.snippet-md :where(h3) {
|
.snippet-md :where(h3) {
|
||||||
margin-block: 0.5rem 0.25rem;
|
margin-block: 0.5rem 0.25rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.76rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Listen: outside + Einzug, damit Verschachtelung sichtbar ist */
|
||||||
.snippet-md :where(ul, ol) {
|
.snippet-md :where(ul, ol) {
|
||||||
margin-block: 0.5rem;
|
margin-block: 0.5rem;
|
||||||
list-style-position: inside;
|
list-style-position: outside;
|
||||||
padding-inline-start: 0;
|
padding-inline-start: 1.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.snippet-md :where(ul) {
|
.snippet-md :where(ul) {
|
||||||
@@ -184,14 +186,30 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.snippet-md :where(li) {
|
.snippet-md :where(li) {
|
||||||
margin-block: 0.125rem;
|
margin-block: 0.2rem;
|
||||||
padding-inline-start: 0.25rem;
|
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) {
|
.snippet-md :where(table) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 0.8rem;
|
font-size: 0.76rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.snippet-md :where(th, td) {
|
.snippet-md :where(th, td) {
|
||||||
@@ -212,7 +230,7 @@ body::before {
|
|||||||
|
|
||||||
/* Snippet-Karten: etwas dichter, Fokus auf Fließtext */
|
/* Snippet-Karten: etwas dichter, Fokus auf Fließtext */
|
||||||
.snippet-md-card-wrap .snippet-md {
|
.snippet-md-card-wrap .snippet-md {
|
||||||
font-size: 0.8rem;
|
font-size: 0.76rem;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +284,7 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dialog-snippet-preview {
|
.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 {
|
.dialog-snippet-textarea {
|
||||||
|
|||||||
Reference in New Issue
Block a user