From 614cb94b46d4d4c3589b5cd343be8b616e4f778b Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 27 Oct 2026 23:40:55 +0100 Subject: [PATCH] Add visual and Markdown editors for ticket descriptions. Replace the plain textarea with a dual-mode editor so ticket content can be written as WYSIWYG or Markdown while still storing Markdown. Co-authored-by: Cursor --- web/package-lock.json | 42 +++++++++ web/package.json | 5 + web/src/App.svelte | 3 +- web/src/app.css | 148 ++++++++++++++++++++++++++++++ web/src/lib/MarkdownEditor.svelte | 136 +++++++++++++++++++++++++++ web/src/lib/markdown.js | 42 +++++++++ 6 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/MarkdownEditor.svelte create mode 100644 web/src/lib/markdown.js diff --git a/web/package-lock.json b/web/package-lock.json index 05b9dd9..f94dd2c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -7,6 +7,11 @@ "": { "name": "web", "version": "0.0.0", + "dependencies": { + "marked": "^18.1.2", + "turndown": "^7.2.4", + "turndown-plugin-gfm": "^1.0.2" + }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.3.1", "svelte": "^5.57.2", @@ -63,6 +68,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, "node_modules/@oxc-project/types": { "version": "0.155.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", @@ -818,6 +829,18 @@ "@jridgewell/sourcemap-codec": "^1.6.0" } }, + "node_modules/marked": { + "version": "18.1.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.1.2.tgz", + "integrity": "sha512-DfOnvLAQ6WYeWxWO1u4RyFOeqyHup6dks5PPqnoFiooU7ipRvidO/2j8plhAHIiZg574E9ZlzSjUiPMLAzFkgg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/nanoid": { "version": "3.3.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", @@ -998,6 +1021,25 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/turndown": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", + "integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + }, + "engines": { + "node": ">=18", + "npm": ">=9" + } + }, + "node_modules/turndown-plugin-gfm": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz", + "integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==", + "license": "MIT" + }, "node_modules/vite": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", diff --git a/web/package.json b/web/package.json index f314b8c..c191b9f 100644 --- a/web/package.json +++ b/web/package.json @@ -12,5 +12,10 @@ "@sveltejs/vite-plugin-svelte": "^7.3.1", "svelte": "^5.57.2", "vite": "^8.3.5" + }, + "dependencies": { + "marked": "^18.1.2", + "turndown": "^7.2.4", + "turndown-plugin-gfm": "^1.0.2" } } diff --git a/web/src/App.svelte b/web/src/App.svelte index 2d5b5b9..6eb236a 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte' import { api } from './lib/api.js' import { parseRoute, buildHash, currentHash } from './lib/route.js' + import MarkdownEditor from './lib/MarkdownEditor.svelte' let user = $state(null) let loading = $state(true) @@ -888,7 +889,7 @@ - +

Time

diff --git a/web/src/app.css b/web/src/app.css index cc40c47..07426e9 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -109,6 +109,13 @@ code { padding: 0.05em 0.35em; font-size: 0.9em; } +pre code { + background: none; + border: none; + border-radius: 0; + padding: 0; + font-size: inherit; +} .app-shell { width: 100%; @@ -444,3 +451,144 @@ td, th { th { color: var(--muted); font-weight: 600; } label { display: grid; gap: 0.3rem; color: var(--muted); font-size: 0.9rem; } label > :is(input, select, textarea) { color: var(--ink); } + +.md-editor { + display: grid; + gap: 0.35rem; +} +.md-editor-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +} +.md-editor-label { + color: var(--muted); + font-size: 0.9rem; +} +.md-editor-modes { + display: inline-flex; + border: 1px solid var(--line); + border-radius: 6px; + overflow: hidden; + background: #10151a; +} +.md-editor-modes button { + padding: 0.3rem 0.75rem; + border-radius: 0; + font-size: 0.82rem; + color: var(--muted); + background: transparent; +} +.md-editor-modes button:hover { + color: var(--ink); + background: color-mix(in srgb, var(--panel-2) 70%, transparent); +} +.md-editor-modes button.active { + color: var(--ink); + background: color-mix(in srgb, var(--accent) 28%, transparent); +} +.md-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.15rem; + padding: 0.25rem; + border: 1px solid var(--line); + border-bottom: none; + border-radius: 6px 6px 0 0; + background: var(--panel-2); +} +.md-toolbar button { + padding: 0.3rem 0.55rem; + font-size: 0.82rem; + border-radius: 4px; + color: var(--muted); +} +.md-toolbar button:hover { + color: var(--ink); + background: color-mix(in srgb, var(--panel) 80%, transparent); +} +.md-visual, +.md-source { + min-height: 14rem; + border: 1px solid var(--line); + border-radius: 0 0 6px 6px; + padding: 0.75rem 0.85rem; + background: #10151a; + color: var(--ink); + outline: none; +} +.md-source { + border-radius: 6px; + resize: vertical; + font-family: "IBM Plex Mono", "SF Mono", ui-monospace, monospace; + font-size: 0.9rem; + line-height: 1.5; +} +.md-visual { + overflow-y: auto; + line-height: 1.55; +} +.md-visual:focus, +.md-source:focus { + border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); +} +.md-visual :first-child { margin-top: 0; } +.md-visual :last-child { margin-bottom: 0; } +.md-visual h1, +.md-visual h2, +.md-visual h3 { + margin: 0.85em 0 0.4em; + font-weight: 650; + color: #f0f4f8; +} +.md-visual h1 { font-size: 1.35rem; } +.md-visual h2 { font-size: 1.15rem; } +.md-visual h3 { font-size: 1.05rem; } +.md-visual p { margin: 0.5em 0; } +.md-visual ul, +.md-visual ol { + margin: 0.5em 0; + padding-left: 1.4rem; +} +.md-visual li { margin: 0.2em 0; } +.md-visual li > input[type="checkbox"] { + width: auto; + margin-right: 0.4rem; + accent-color: var(--accent); + vertical-align: middle; +} +.md-visual a { color: var(--accent); } +.md-visual pre, +.md-visual code { + font-family: "IBM Plex Mono", "SF Mono", ui-monospace, monospace; + font-size: 0.88em; +} +.md-visual pre { + margin: 0.6em 0; + padding: 0.65rem 0.75rem; + border: 1px solid var(--line); + border-radius: 4px; + background: #0c1014; + overflow-x: auto; + white-space: pre-wrap; +} +.md-visual pre code { + background: none; + border: none; + padding: 0; + font-size: inherit; + color: inherit; +} +.md-visual blockquote { + margin: 0.6em 0; + padding: 0.15rem 0 0.15rem 0.75rem; + border-left: 3px solid color-mix(in srgb, var(--accent) 50%, var(--line)); + color: var(--muted); +} +.md-visual hr { + border: none; + border-top: 1px solid var(--line); + margin: 1em 0; +} diff --git a/web/src/lib/MarkdownEditor.svelte b/web/src/lib/MarkdownEditor.svelte new file mode 100644 index 0000000..084eec6 --- /dev/null +++ b/web/src/lib/MarkdownEditor.svelte @@ -0,0 +1,136 @@ + + +
+
+ {label} +
+ + +
+
+ + {#if mode === 'visual'} + +
+ {:else} + + {/if} +
diff --git a/web/src/lib/markdown.js b/web/src/lib/markdown.js new file mode 100644 index 0000000..cba525d --- /dev/null +++ b/web/src/lib/markdown.js @@ -0,0 +1,42 @@ +import { marked } from 'marked' +import TurndownService from 'turndown' +import { gfm } from 'turndown-plugin-gfm' + +marked.setOptions({ + gfm: true, + breaks: false, +}) + +const turndown = new TurndownService({ + headingStyle: 'atx', + codeBlockStyle: 'fenced', + bulletListMarker: '-', + emDelimiter: '*', +}) +turndown.use(gfm) + +// Browsers' formatBlock('pre') often yields bare
 without .
+// Default turndown only fences 
; keep bare 
 as fenced blocks.
+turndown.addRule('barePre', {
+  filter(node) {
+    return (
+      node.nodeName === 'PRE' &&
+      !(node.firstChild && node.firstChild.nodeName === 'CODE')
+    )
+  },
+  replacement(_content, node) {
+    const text = node.textContent.replace(/\n$/, '')
+    return `\n\n\`\`\`\n${text}\n\`\`\`\n\n`
+  },
+})
+
+export function markdownToHtml(md) {
+  if (!md || !md.trim()) return '


' + return marked.parse(md) +} + +export function htmlToMarkdown(html) { + if (!html) return '' + const md = turndown.turndown(html) + return md.replace(/\n{3,}/g, '\n\n').trim() +}