added markdown editor

This commit is contained in:
liyunze 2026-09-01 16:48:20 +08:00
parent 2bb6c09b5e
commit efb364d7cc
12 changed files with 3640 additions and 3344 deletions

View file

@ -21,9 +21,9 @@ if (!self.define) {
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return (
registry[uri] ||
new Promise((resolve) => {
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
@ -47,31 +47,27 @@ if (!self.define) {
};
self.define = (depsNames, factory) => {
const uri =
nextDefineUri ||
("document" in self ? document.currentScript.src : "") ||
location.href;
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = (depUri) => singleRequire(depUri, uri);
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require,
require
};
registry[uri] = Promise.all(
depsNames.map((depName) => specialDeps[depName] || require(depName)),
).then((deps) => {
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(["./workbox-7e5eb42b"], function (workbox) {
"use strict";
define(['./workbox-7e5eb42b'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
@ -80,19 +76,13 @@ define(["./workbox-7e5eb42b"], function (workbox) {
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute(
[
{
url: "/",
revision: "0.or5ntbjovsg",
},
],
{},
);
workbox.precacheAndRoute([{
"url": "/",
"revision": "0.7vtfun0cbi"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(
new workbox.NavigationRoute(workbox.createHandlerBoundToURL("/"), {
allowlist: [/^\/$/],
}),
);
});
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("/"), {
allowlist: [/^\/$/]
}));
}));

File diff suppressed because it is too large Load diff

96
package-lock.json generated
View file

@ -8,6 +8,7 @@
"name": "dsec-notebook",
"version": "0.0.1",
"dependencies": {
"markdown-it": "^15.0.1",
"resend": "^6.25.0"
},
"devDependencies": {
@ -4094,6 +4095,22 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/argparse": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.1.tgz",
"integrity": "sha512-nM4mHF/KM1v59ZNKX7zfusQz5wUAxR511YG8Vo6TyiV4aqhu++rbJW4v04xsWhpSsHFj66flT8P7znVpyO20xQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "PSF-2.0"
},
"node_modules/aria-query": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
@ -4820,6 +4837,18 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-abstract": {
"version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@ -6366,6 +6395,25 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/linkify-it": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz",
"integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"uc.micro": "^3.0.0"
}
},
"node_modules/locate-character": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
@ -6400,6 +6448,33 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/markdown-it": {
"version": "15.0.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.1.tgz",
"integrity": "sha512-9/7gE95FNPkfUWrjJIoHZza2iLmuJlPD0UNMxPi7bxUrbCR525YZY0r+zyfes0dZI5ZZ/uNIXUJca0pJvtw41g==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^3.0.0",
"entities": "^8.0.0",
"linkify-it": "^6.0.0",
"mdurl": "^2.1.0",
"punycode.js": "^2.3.1",
"uc.micro": "^3.0.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -6410,6 +6485,12 @@
"node": ">= 0.4"
}
},
"node_modules/mdurl": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz",
"integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==",
"license": "MIT"
},
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@ -6799,6 +6880,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/quansync": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz",
@ -7928,6 +8018,12 @@
"node": ">=14.17"
}
},
"node_modules/uc.micro": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz",
"integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==",
"license": "MIT"
},
"node_modules/unbox-primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",

View file

@ -16,6 +16,7 @@
"fmt:check": "oxfmt --check"
},
"dependencies": {
"markdown-it": "^15.0.1",
"resend": "^6.25.0"
},
"devDependencies": {

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { renderMarkdown } from "$lib/markdown";
let { content }: { content: string } = $props();
</script>
<div class="markdown">{@html renderMarkdown(content)}</div>

View file

@ -0,0 +1,208 @@
<script lang="ts">
import { tick } from "svelte";
import Markdown from "./Markdown.svelte";
let {
content = $bindable(""),
label = "Content",
placeholder = "",
rows = 10,
}: {
content?: string;
label?: string;
placeholder?: string;
rows?: number;
} = $props();
let textarea: HTMLTextAreaElement | undefined = $state();
let mode = $state<"write" | "preview">("write");
function selection(): { start: number; end: number; text: string } {
if (!textarea) return { start: content.length, end: content.length, text: "" };
const start = textarea.selectionStart ?? content.length;
const end = textarea.selectionEnd ?? content.length;
return { start, end, text: content.slice(start, end) };
}
function applyEdit(
build: (
value: string,
start: number,
end: number,
) => {
value: string;
start: number;
end: number;
},
) {
const { start, end } = selection();
const result = build(content, start, end);
content = result.value;
void tick().then(() => {
if (!textarea) return;
textarea.focus();
textarea.setSelectionRange(result.start, result.end);
});
}
function wrap(before: string, after = before) {
const { start, end, text } = selection();
applyEdit((value) => {
const next = value.slice(0, start) + before + text + after + value.slice(end);
return {
value: next,
start: start + before.length,
end: start + before.length + text.length,
};
});
}
function prefixLines(prefix: string) {
const { start, end } = selection();
applyEdit((value) => {
const lineStart = value.lastIndexOf("\n", start - 1) + 1;
let lineEnd = end;
if (start === end) {
const nextNewline = value.indexOf("\n", end);
lineEnd = nextNewline === -1 ? value.length : nextNewline;
}
const block = value.slice(lineStart, lineEnd);
const prefixed = block
.split("\n")
.map((line) => prefix + line)
.join("\n");
return {
value: value.slice(0, lineStart) + prefixed + value.slice(lineEnd),
start: lineStart,
end: lineStart + prefixed.length,
};
});
}
function insertLink() {
const { start, end, text } = selection();
const label = text || "link text";
const insertion = `[${label}](url)`;
applyEdit((value) => {
const next = value.slice(0, start) + insertion + value.slice(end);
const selectStart = start + insertion.length - 4;
const selectEnd = start + insertion.length - 1;
return { value: next, start: selectStart, end: selectEnd };
});
}
function insertCodeBlock() {
const { start, end, text } = selection();
applyEdit((value) => {
const next = value.slice(0, start) + "```\n" + text + "\n```" + value.slice(end);
return {
value: next,
start: start + 4,
end: start + 4 + text.length,
};
});
}
</script>
<div>
{#if label}
<label for="markdown-editor" class="kicker mb-2 block">{label}</label>
{/if}
{#if mode === "write"}
<div
class="border-rule bg-surface mb-2 flex flex-wrap items-center gap-1 rounded-sm border p-1"
>
<button type="button" class="editor-tool" title="Bold" onclick={() => wrap("**")}>
<strong>B</strong>
</button>
<button type="button" class="editor-tool" title="Italic" onclick={() => wrap("_")}>
<em>I</em>
</button>
<button
type="button"
class="editor-tool"
title="Strikethrough"
onclick={() => wrap("~~")}
>
<s>S</s>
</button>
<button type="button" class="editor-tool" title="Inline code" onclick={() => wrap("`")}>
&lt;/&gt;
</button>
<button
type="button"
class="editor-tool"
title="Heading"
onclick={() => prefixLines("## ")}
>
H
</button>
<button type="button" class="editor-tool" title="Link" onclick={insertLink}>🔗</button>
<button type="button" class="editor-tool" title="Code block" onclick={insertCodeBlock}>
{`{ }`}
</button>
<button
type="button"
class="editor-tool"
title="Bullet list"
onclick={() => prefixLines("- ")}
>
• List
</button>
<button
type="button"
class="editor-tool"
title="Blockquote"
onclick={() => prefixLines("> ")}
>
</button>
</div>
{/if}
<div class="border-rule mb-2 flex items-center gap-2 border-b">
<button
type="button"
class="kicker {mode === 'write'
? 'text-ink'
: 'hover:text-primary'} border-b-2 border-transparent pb-2"
onclick={() => (mode = "write")}
>
Write
</button>
<button
type="button"
class="kicker {mode === 'preview'
? 'text-ink'
: 'hover:text-primary'} border-b-2 border-transparent pb-2"
onclick={() => (mode = "preview")}
>
Preview
</button>
</div>
{#if mode === "write"}
<textarea
id="markdown-editor"
bind:this={textarea}
bind:value={content}
{rows}
{placeholder}
class="field resize-y"></textarea>
<p class="text-faint mt-2 text-xs">
Markdown supported: **bold**, _italic_, `code`, [links](url), lists, quotes and code
blocks.
</p>
{:else}
<div
class="border-rule bg-surface text-ink min-h-40 rounded-sm border px-3 py-2.5 text-sm leading-relaxed"
>
{#if content.trim()}
<Markdown {content} />
{:else}
<p class="text-faint">Nothing to preview yet.</p>
{/if}
</div>
{/if}
</div>

11
src/lib/markdown.ts Normal file
View file

@ -0,0 +1,11 @@
import MarkdownIt from "markdown-it";
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
});
export function renderMarkdown(source: string): string {
return md.render(source ?? "");
}

View file

@ -119,3 +119,140 @@ button {
.nav-link-active {
@apply text-ink;
}
.editor-tool {
@apply text-ink hover:border-primary hover:text-primary bg-surface inline-flex h-8 min-w-8 items-center justify-center rounded-sm border border-transparent px-1.5 font-sans text-xs font-medium transition-colors;
}
.markdown {
font-size: inherit;
line-height: inherit;
color: inherit;
word-break: break-word;
}
.markdown > :first-child {
margin-top: 0;
}
.markdown > :last-child {
margin-bottom: 0;
}
.markdown p {
margin: 0.75em 0;
}
.markdown h1,
.markdown h2,
.markdown h3,
.markdown h4 {
font-family: var(--font-serif);
font-weight: 500;
line-height: 1.25;
margin: 1.25em 0 0.5em;
}
.markdown h1 {
font-size: 1.5em;
}
.markdown h2 {
font-size: 1.25em;
}
.markdown h3 {
font-size: 1.1em;
}
.markdown h4 {
font-size: 1em;
}
.markdown ul,
.markdown ol {
margin: 0.75em 0;
padding-left: 1.5em;
}
.markdown ul {
list-style: disc;
}
.markdown ol {
list-style: decimal;
}
.markdown li {
margin: 0.25em 0;
}
.markdown a {
color: var(--color-secondary);
text-decoration: underline;
text-underline-offset: 2px;
}
.markdown a:hover {
color: var(--color-secondary-dark);
}
.markdown code {
background: var(--color-rule);
color: var(--color-ink);
border-radius: 4px;
padding: 0.15em 0.35em;
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 code {
background: transparent;
color: inherit;
padding: 0;
font-size: 0.85em;
}
.markdown blockquote {
border-left: 3px solid var(--color-primary);
color: var(--color-muted);
margin: 0.75em 0;
padding: 0.25em 0 0.25em 1em;
}
.markdown hr {
border: 0;
border-top: 1px solid var(--color-rule);
margin: 1.5em 0;
}
.markdown img {
max-width: 100%;
border-radius: 6px;
}
.markdown table {
border-collapse: collapse;
margin: 0.75em 0;
width: 100%;
}
.markdown th,
.markdown td {
border: 1px solid var(--color-rule);
padding: 0.4em 0.6em;
text-align: left;
}
.markdown th {
background: var(--color-rule);
font-weight: 600;
}

View file

@ -5,6 +5,7 @@
import { onMount } from "svelte";
import { get } from "svelte/store";
import VoteStack from "$lib/components/VoteStack.svelte";
import Markdown from "$lib/components/Markdown.svelte";
import { timeAgo } from "$lib/time";
import type { NoteDoc, CommentDoc, TopicDoc, UnitDoc } from "$lib/types";
@ -115,10 +116,8 @@
</div>
</div>
<div
class="border-rule text-ink mt-8 border-t pt-8 text-[15px] leading-relaxed whitespace-pre-wrap"
>
{note.content}
<div class="border-rule text-ink mt-8 border-t pt-8 text-[15px] leading-relaxed">
<Markdown content={note.content} />
</div>
<section class="border-rule mt-12 border-t pt-8">

View file

@ -4,6 +4,7 @@
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { get } from "svelte/store";
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
import type { TopicDoc, UnitDoc } from "$lib/types";
let topics: TopicDoc[] = $state([]);
@ -121,14 +122,12 @@
</div>
<div>
<label for="content" class="kicker mb-2 block">Content</label>
<textarea
id="content"
bind:value={content}
rows={10}
<MarkdownEditor
bind:content
label="Content"
placeholder="Write your notes here..."
class="field resize-y"
required></textarea>
rows={10}
/>
</div>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">

View file

@ -4,6 +4,7 @@
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { get } from "svelte/store";
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
import type { TopicDoc, UnitDoc } from "$lib/types";
let topics: TopicDoc[] = $state([]);
@ -121,14 +122,12 @@
</div>
<div>
<label for="content" class="kicker mb-2 block">Details</label>
<textarea
id="content"
bind:value={content}
rows={10}
<MarkdownEditor
bind:content
label="Details"
placeholder="Describe your question in detail..."
class="field resize-y"
required></textarea>
rows={10}
/>
</div>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">

View file

@ -5,6 +5,7 @@
import { onMount } from "svelte";
import { get } from "svelte/store";
import VoteStack from "$lib/components/VoteStack.svelte";
import Markdown from "$lib/components/Markdown.svelte";
import { timeAgo } from "$lib/time";
import type { QuestionDoc, CommentDoc, TopicDoc, UnitDoc } from "$lib/types";
@ -113,10 +114,8 @@
</div>
</div>
<div
class="border-rule text-ink mt-8 border-t pt-8 text-[15px] leading-relaxed whitespace-pre-wrap"
>
{question.content}
<div class="border-rule text-ink mt-8 border-t pt-8 text-[15px] leading-relaxed">
<Markdown content={question.content} />
</div>
<section class="border-rule mt-12 border-t pt-8">