Merge pull request #16 from dsec-hub/feature/codeblock

updated codeblocks
This commit is contained in:
RythonDev 2026-09-04 10:38:56 +08:00 committed by GitHub
commit 492f1e2107
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 253 additions and 19 deletions

10
package-lock.json generated
View file

@ -8,6 +8,7 @@
"name": "dsec-notebook", "name": "dsec-notebook",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"highlight.js": "^11.12.0",
"katex": "^0.18.5", "katex": "^0.18.5",
"markdown-it": "^15.0.1", "markdown-it": "^15.0.1",
"markdown-it-texmath": "^1.0.0", "markdown-it-texmath": "^1.0.0",
@ -5875,6 +5876,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/highlight.js": {
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz",
"integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/ico-endec": { "node_modules/ico-endec": {
"version": "0.1.6", "version": "0.1.6",
"resolved": "https://registry.npmjs.org/ico-endec/-/ico-endec-0.1.6.tgz", "resolved": "https://registry.npmjs.org/ico-endec/-/ico-endec-0.1.6.tgz",

View file

@ -18,6 +18,7 @@
"lint:fix": "oxlint --fix" "lint:fix": "oxlint --fix"
}, },
"dependencies": { "dependencies": {
"highlight.js": "^11.12.0",
"katex": "^0.18.5", "katex": "^0.18.5",
"markdown-it": "^15.0.1", "markdown-it": "^15.0.1",
"markdown-it-texmath": "^1.0.0", "markdown-it-texmath": "^1.0.0",

View file

@ -5,6 +5,7 @@
import { timeAgo } from "$lib/time"; import { timeAgo } from "$lib/time";
import CommentNode from "./CommentNode.svelte"; import CommentNode from "./CommentNode.svelte";
import Avatar from "./Avatar.svelte"; import Avatar from "./Avatar.svelte";
import Markdown from "./Markdown.svelte";
import type { CommentDoc } from "$lib/types"; import type { CommentDoc } from "$lib/types";
interface CommentTreeNode { interface CommentTreeNode {
@ -165,9 +166,9 @@
<span class="text-primary text-xs">{editError}</span> <span class="text-primary text-xs">{editError}</span>
</div> </div>
{:else} {:else}
<p class="text-ink mt-1 text-sm leading-relaxed whitespace-pre-wrap"> <div class="text-ink mt-1 text-sm leading-relaxed">
{comment.content} <Markdown content={comment.content} />
</p> </div>
{/if} {/if}
<div class="mt-2 flex items-center gap-3"> <div class="mt-2 flex items-center gap-3">

View file

@ -2,6 +2,47 @@
import { renderMarkdown } from "$lib/markdown"; import { renderMarkdown } from "$lib/markdown";
let { content }: { content: string } = $props(); let { content }: { content: string } = $props();
const copiedTimers = new WeakMap<HTMLButtonElement, ReturnType<typeof setTimeout>>();
function codeCopy(node: HTMLElement) {
async function onClick(event: MouseEvent) {
const button = (event.target as HTMLElement | null)?.closest?.("button.code-copy");
if (!(button instanceof HTMLButtonElement)) return;
const code = button.closest("pre")?.querySelector("code");
const text = code?.textContent ?? "";
if (!text) return;
try {
await navigator.clipboard.writeText(text);
} catch {
return;
}
button.classList.add("copied");
button.setAttribute("aria-label", "Copied");
button.setAttribute("title", "Copied");
const previous = copiedTimers.get(button);
if (previous) clearTimeout(previous);
copiedTimers.set(
button,
setTimeout(() => {
button.classList.remove("copied");
button.setAttribute("aria-label", "Copy code");
button.setAttribute("title", "Copy");
}, 1600),
);
}
node.addEventListener("click", onClick);
return {
destroy() {
node.removeEventListener("click", onClick);
},
};
}
</script> </script>
<div class="markdown">{@html renderMarkdown(content)}</div> <div class="markdown" use:codeCopy>{@html renderMarkdown(content)}</div>

View file

@ -99,11 +99,11 @@
function insertCodeBlock() { function insertCodeBlock() {
const { start, end, text } = selection(); const { start, end, text } = selection();
applyEdit((value) => { applyEdit((value) => {
const next = value.slice(0, start) + "```\n" + text + "\n```" + value.slice(end); const next = value.slice(0, start) + "```ts\n" + text + "\n```" + value.slice(end);
return { return {
value: next, value: next,
start: start + 4, start: start + 6,
end: start + 4 + text.length, end: start + 6 + text.length,
}; };
}); });
} }
@ -309,9 +309,9 @@
onchange={onFilesSelected} onchange={onFilesSelected}
/> />
<p class="text-faint mt-2 text-xs"> <p class="text-faint mt-2 text-xs">
Markdown supported: **bold**, _italic_, `code`, [links](url), lists, quotes and code Markdown supported: **bold**, _italic_, `code`, [links](url), lists, quotes and fenced
blocks. LaTeX equations use $inline$ or $$display$$ notation. Paste or insert images to code blocks (```ts). LaTeX equations use $inline$ or $$display$$ notation. Paste or
embed them. insert images to embed them.
</p> </p>
{#if uploading} {#if uploading}
<p class="text-muted mt-1 text-xs">Uploading image...</p> <p class="text-muted mt-1 text-xs">Uploading image...</p>

View file

@ -14,6 +14,26 @@ describe("renderMarkdown", () => {
it("renders invalid LaTeX as an error instead of throwing", () => { it("renders invalid LaTeX as an error instead of throwing", () => {
expect(() => renderMarkdown("$\\notacommand{$")).not.toThrow(); expect(() => renderMarkdown("$\\notacommand{$")).not.toThrow();
}); });
it("syntax-highlights fenced code blocks", () => {
const rendered = renderMarkdown("```ts\nconst value: number = 1;\n```");
expect(rendered).toContain('class="code-block"');
expect(rendered).toContain('class="code-copy"');
expect(rendered).toContain('data-lang="ts"');
expect(rendered).toContain("hljs");
expect(rendered).toContain("hljs-keyword");
expect(rendered).not.toContain("code-lang");
expect(rendered).not.toContain("<script");
});
it("escapes unhighlighted fenced code", () => {
const rendered = renderMarkdown("```\n<div>&</div>\n```");
expect(rendered).toContain("&lt;div&gt;");
expect(rendered).toContain("&amp;");
expect(rendered).not.toContain("<div>");
});
}); });
describe("previewMarkdown", () => { describe("previewMarkdown", () => {

View file

@ -1,11 +1,13 @@
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import texmath from "markdown-it-texmath"; import texmath from "markdown-it-texmath";
import katex from "katex"; import katex from "katex";
import hljs from "highlight.js/lib/common";
const md = new MarkdownIt({ const md = new MarkdownIt({
html: false, html: false,
linkify: true, linkify: true,
breaks: true, breaks: true,
highlight: highlightCode,
}); });
md.use(texmath, { md.use(texmath, {
@ -17,6 +19,30 @@ md.use(texmath, {
}, },
}); });
function highlightCode(source: string, lang: string): string {
const language = lang.trim().toLowerCase();
const escapedLang = language ? md.utils.escapeHtml(language) : "";
let highlighted = md.utils.escapeHtml(source);
if (language && hljs.getLanguage(language)) {
try {
highlighted = hljs.highlight(source, { language, ignoreIllegals: true }).value;
} catch {
highlighted = md.utils.escapeHtml(source);
}
}
const langClass = escapedLang ? ` language-${escapedLang}` : "";
const langAttr = escapedLang ? ` data-lang="${escapedLang}"` : "";
return `<pre class="code-block"${langAttr}>${COPY_BUTTON}<code class="hljs${langClass}">${highlighted}</code></pre>`;
}
const COPY_BUTTON = `<button type="button" class="code-copy" aria-label="Copy code" title="Copy">
<svg class="code-copy-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
<svg class="code-copy-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6L9 17l-5-5"></path></svg>
</button>`;
export function renderMarkdown(source: string): string { export function renderMarkdown(source: string): string {
return md.render(source ?? ""); return md.render(source ?? "");
} }

View file

@ -239,20 +239,155 @@ button {
font-size: 0.9em; font-size: 0.9em;
} }
.markdown pre { .markdown pre,
background: var(--color-ink); .markdown pre.code-block {
color: var(--color-surface); --code-bg: color-mix(in srgb, var(--color-rule) 55%, var(--color-surface));
border-radius: 6px; --code-fg: var(--color-ink);
padding: 0.75rem 1rem; --code-comment: var(--color-muted);
overflow-x: auto; --code-keyword: var(--color-primary-dark);
margin: 0.75em 0; --code-string: var(--color-secondary-dark);
--code-number: #b45309;
--code-title: var(--color-ink);
--code-attr: #6d28d9;
--code-built: var(--color-secondary-dark);
--code-meta: var(--color-muted);
position: relative;
background: var(--code-bg);
color: var(--code-fg);
border: 1px solid var(--color-rule);
border-radius: 8px;
padding: 2rem 1rem 0.9rem;
overflow: visible;
margin: 0.9em 0;
font-size: 0.85em;
line-height: 1.6;
} }
.markdown pre code { .dark .markdown pre,
.dark .markdown pre.code-block {
--code-bg: #0c0a0b;
--code-fg: #f3eef0;
--code-comment: #9a8f93;
--code-keyword: #ea4c65;
--code-string: #7fdfea;
--code-number: #f0b429;
--code-title: #f5d0d6;
--code-attr: #c9b8ff;
--code-built: #7fdfea;
--code-meta: #b3a8ac;
border-color: color-mix(in srgb, var(--color-rule) 70%, transparent);
}
.markdown pre .code-copy {
position: absolute;
top: 0.4rem;
right: 0.45rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--code-comment);
transition:
color 0.15s ease,
background 0.15s ease;
}
.markdown pre .code-copy:hover {
color: var(--code-fg);
background: color-mix(in srgb, var(--code-fg) 8%, transparent);
}
.markdown pre .code-copy.copied {
color: var(--code-string);
}
.markdown pre .code-copy-check {
display: none;
}
.markdown pre .code-copy.copied .code-copy-icon {
display: none;
}
.markdown pre .code-copy.copied .code-copy-check {
display: block;
}
.markdown pre code,
.markdown pre code.hljs {
display: block;
background: transparent; background: transparent;
color: inherit; color: inherit;
padding: 0; padding: 0;
font-size: 0.85em; font-size: inherit;
overflow-x: auto;
white-space: pre;
}
.markdown .hljs-comment,
.markdown .hljs-quote {
color: var(--code-comment);
font-style: italic;
}
.markdown .hljs-keyword,
.markdown .hljs-selector-tag {
color: var(--code-keyword);
}
.markdown .hljs-number,
.markdown .hljs-literal,
.markdown .hljs-symbol,
.markdown .hljs-bullet,
.markdown .hljs-deletion {
color: var(--code-number);
}
.markdown .hljs-string,
.markdown .hljs-regexp,
.markdown .hljs-addition {
color: var(--code-string);
}
.markdown .hljs-title,
.markdown .hljs-section,
.markdown .hljs-name,
.markdown .hljs-selector-id,
.markdown .hljs-selector-class {
color: var(--code-title);
}
.markdown .hljs-type,
.markdown .hljs-class .hljs-title,
.markdown .hljs-attribute,
.markdown .hljs-attr,
.markdown .hljs-variable,
.markdown .hljs-template-variable {
color: var(--code-attr);
}
.markdown .hljs-built_in,
.markdown .hljs-builtin-name,
.markdown .hljs-params {
color: var(--code-built);
}
.markdown .hljs-meta,
.markdown .hljs-doctag,
.markdown .hljs-tag {
color: var(--code-meta);
}
.markdown .hljs-emphasis {
font-style: italic;
}
.markdown .hljs-strong {
font-weight: 600;
} }
.markdown blockquote { .markdown blockquote {