From 6e0c28260e4ae2cccdf6a5e9785be9dc2f85efb9 Mon Sep 17 00:00:00 2001 From: liyunze <50455574+liyunze-coding@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:50:45 +0800 Subject: [PATCH] updated codeblocks --- package-lock.json | 10 ++ package.json | 1 + src/lib/components/CommentNode.svelte | 7 +- src/lib/components/Markdown.svelte | 43 ++++++- src/lib/components/MarkdownEditor.svelte | 12 +- src/lib/markdown.spec.ts | 20 +++ src/lib/markdown.ts | 26 ++++ src/routes/layout.css | 153 +++++++++++++++++++++-- 8 files changed, 253 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9d6e9fe..455e421 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "dsec-notebook", "version": "1.0.0", "dependencies": { + "highlight.js": "^11.12.0", "katex": "^0.18.5", "markdown-it": "^15.0.1", "markdown-it-texmath": "^1.0.0", @@ -5875,6 +5876,15 @@ "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": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/ico-endec/-/ico-endec-0.1.6.tgz", diff --git a/package.json b/package.json index add4e04..392c469 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "lint:fix": "oxlint --fix" }, "dependencies": { + "highlight.js": "^11.12.0", "katex": "^0.18.5", "markdown-it": "^15.0.1", "markdown-it-texmath": "^1.0.0", diff --git a/src/lib/components/CommentNode.svelte b/src/lib/components/CommentNode.svelte index a543cae..51035ad 100644 --- a/src/lib/components/CommentNode.svelte +++ b/src/lib/components/CommentNode.svelte @@ -5,6 +5,7 @@ import { timeAgo } from "$lib/time"; import CommentNode from "./CommentNode.svelte"; import Avatar from "./Avatar.svelte"; + import Markdown from "./Markdown.svelte"; import type { CommentDoc } from "$lib/types"; interface CommentTreeNode { @@ -165,9 +166,9 @@ {editError} {:else} -

- {comment.content} -

+
+ +
{/if}
diff --git a/src/lib/components/Markdown.svelte b/src/lib/components/Markdown.svelte index 2b839fc..abf22e7 100644 --- a/src/lib/components/Markdown.svelte +++ b/src/lib/components/Markdown.svelte @@ -2,6 +2,47 @@ import { renderMarkdown } from "$lib/markdown"; let { content }: { content: string } = $props(); + + const copiedTimers = new WeakMap>(); + + 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); + }, + }; + } -
{@html renderMarkdown(content)}
+
{@html renderMarkdown(content)}
diff --git a/src/lib/components/MarkdownEditor.svelte b/src/lib/components/MarkdownEditor.svelte index 43b2d33..edb5ad0 100644 --- a/src/lib/components/MarkdownEditor.svelte +++ b/src/lib/components/MarkdownEditor.svelte @@ -99,11 +99,11 @@ function insertCodeBlock() { const { start, end, text } = selection(); 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 { value: next, - start: start + 4, - end: start + 4 + text.length, + start: start + 6, + end: start + 6 + text.length, }; }); } @@ -309,9 +309,9 @@ onchange={onFilesSelected} />

- Markdown supported: **bold**, _italic_, `code`, [links](url), lists, quotes and code - blocks. LaTeX equations use $inline$ or $$display$$ notation. Paste or insert images to - embed them. + Markdown supported: **bold**, _italic_, `code`, [links](url), lists, quotes and fenced + code blocks (```ts). LaTeX equations use $inline$ or $$display$$ notation. Paste or + insert images to embed them.

{#if uploading}

Uploading image...

diff --git a/src/lib/markdown.spec.ts b/src/lib/markdown.spec.ts index bea2258..a1ce06c 100644 --- a/src/lib/markdown.spec.ts +++ b/src/lib/markdown.spec.ts @@ -14,6 +14,26 @@ describe("renderMarkdown", () => { it("renders invalid LaTeX as an error instead of throwing", () => { 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(" { + const rendered = renderMarkdown("```\n
&
\n```"); + + expect(rendered).toContain("<div>"); + expect(rendered).toContain("&"); + expect(rendered).not.toContain("
"); + }); }); describe("previewMarkdown", () => { diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 2c778b3..a0b319f 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -1,11 +1,13 @@ import MarkdownIt from "markdown-it"; import texmath from "markdown-it-texmath"; import katex from "katex"; +import hljs from "highlight.js/lib/common"; const md = new MarkdownIt({ html: false, linkify: true, breaks: true, + highlight: highlightCode, }); 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 `
${COPY_BUTTON}${highlighted}
`; +} + +const COPY_BUTTON = ``; + export function renderMarkdown(source: string): string { return md.render(source ?? ""); } diff --git a/src/routes/layout.css b/src/routes/layout.css index 522cf3e..8b16f4d 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -239,20 +239,155 @@ button { font-size: 0.9em; } -.markdown pre { - background: var(--color-ink); - color: var(--color-surface); - border-radius: 6px; - padding: 0.75rem 1rem; - overflow-x: auto; - margin: 0.75em 0; +.markdown pre, +.markdown pre.code-block { + --code-bg: color-mix(in srgb, var(--color-rule) 55%, var(--color-surface)); + --code-fg: var(--color-ink); + --code-comment: var(--color-muted); + --code-keyword: var(--color-primary-dark); + --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; color: inherit; 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 {