mirror of
https://github.com/dsec-hub/dsec-notebook.git
synced 2026-09-22 07:24:27 +00:00
Merge pull request #16 from dsec-hub/feature/codeblock
updated codeblocks
This commit is contained in:
commit
492f1e2107
8 changed files with 253 additions and 19 deletions
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<span class="text-primary text-xs">{editError}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-ink mt-1 text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{comment.content}
|
||||
</p>
|
||||
<div class="text-ink mt-1 text-sm leading-relaxed">
|
||||
<Markdown content={comment.content} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-2 flex items-center gap-3">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,47 @@
|
|||
import { renderMarkdown } from "$lib/markdown";
|
||||
|
||||
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>
|
||||
|
||||
<div class="markdown">{@html renderMarkdown(content)}</div>
|
||||
<div class="markdown" use:codeCopy>{@html renderMarkdown(content)}</div>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
<p class="text-faint mt-2 text-xs">
|
||||
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.
|
||||
</p>
|
||||
{#if uploading}
|
||||
<p class="text-muted mt-1 text-xs">Uploading image...</p>
|
||||
|
|
|
|||
|
|
@ -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("<script");
|
||||
});
|
||||
|
||||
it("escapes unhighlighted fenced code", () => {
|
||||
const rendered = renderMarkdown("```\n<div>&</div>\n```");
|
||||
|
||||
expect(rendered).toContain("<div>");
|
||||
expect(rendered).toContain("&");
|
||||
expect(rendered).not.toContain("<div>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewMarkdown", () => {
|
||||
|
|
|
|||
|
|
@ -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 `<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 {
|
||||
return md.render(source ?? "");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue