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 <cursoragent@cursor.com>
This commit is contained in:
2026-10-27 23:40:55 +01:00
co-authored by Cursor
parent f97856b8a6
commit 614cb94b46
6 changed files with 375 additions and 1 deletions
+42
View File
@@ -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",
+5
View File
@@ -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"
}
}
+2 -1
View File
@@ -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 @@
<option value="done">DONE</option>
</select>
</label>
<label>Description<textarea bind:value={editDesc}></textarea></label>
<MarkdownEditor bind:value={editDesc} label="Description" />
<button class="primary" onclick={saveTicket}>Save</button>
<h3>Time</h3>
+148
View File
@@ -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;
}
+136
View File
@@ -0,0 +1,136 @@
<script>
import { markdownToHtml, htmlToMarkdown } from './markdown.js'
let { value = $bindable(''), label = 'Description' } = $props()
let mode = $state('visual')
let visualEl = $state(null)
let lastApplied = ''
let appliedTo = null
function enableCheckboxes(root) {
if (!root) return
root.querySelectorAll('input[type="checkbox"]').forEach((box) => {
box.disabled = false
box.onchange = readVisual
})
}
function applyVisual(md) {
if (!visualEl) return
visualEl.innerHTML = markdownToHtml(md || '')
enableCheckboxes(visualEl)
lastApplied = md || ''
appliedTo = visualEl
}
function readVisual() {
if (!visualEl) return
value = htmlToMarkdown(visualEl.innerHTML)
lastApplied = value
}
function setMode(next) {
if (next === mode) return
if (mode === 'visual') readVisual()
mode = next
}
$effect(() => {
if (mode !== 'visual') return
const el = visualEl
if (!el) return
const md = value ?? ''
// Remount (mode switch) or external value change (e.g. restore revision)
if (el !== appliedTo || md !== lastApplied) applyVisual(md)
})
function exec(cmd, arg) {
visualEl?.focus()
document.execCommand(cmd, false, arg)
readVisual()
}
function insertChecklist() {
visualEl?.focus()
document.execCommand(
'insertHTML',
false,
'<ul><li><input type="checkbox"> Task</li></ul>',
)
enableCheckboxes(visualEl)
readVisual()
}
function promptLink() {
const url = window.prompt('URL')
if (!url) return
exec('createLink', url)
}
function formatCodeBlock() {
visualEl?.focus()
const sel = window.getSelection()
const text = sel?.toString() || 'code'
// Prefer <pre><code> so turndown emits a fenced markdown block.
const escaped = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
document.execCommand('insertHTML', false, `<pre><code>${escaped}</code></pre>`)
readVisual()
}
</script>
<div class="md-editor">
<div class="md-editor-head">
<span class="md-editor-label">{label}</span>
<div class="md-editor-modes" role="tablist">
<button
type="button"
role="tab"
class:active={mode === 'visual'}
aria-selected={mode === 'visual'}
onclick={() => setMode('visual')}
>Visual</button>
<button
type="button"
role="tab"
class:active={mode === 'markdown'}
aria-selected={mode === 'markdown'}
onclick={() => setMode('markdown')}
>Markdown</button>
</div>
</div>
{#if mode === 'visual'}
<div class="md-toolbar" role="toolbar" aria-label="Formatting">
<button type="button" title="Bold" onclick={() => exec('bold')}><b>B</b></button>
<button type="button" title="Italic" onclick={() => exec('italic')}><i>I</i></button>
<button type="button" title="Heading" onclick={() => exec('formatBlock', 'h2')}>H</button>
<button type="button" title="Bullet list" onclick={() => exec('insertUnorderedList')}>• List</button>
<button type="button" title="Numbered list" onclick={() => exec('insertOrderedList')}>1.</button>
<button type="button" title="Checklist" onclick={insertChecklist}>☑</button>
<button type="button" title="Link" onclick={promptLink}>Link</button>
<button type="button" title="Code block" onclick={formatCodeBlock}>Code</button>
</div>
<div
class="md-visual"
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-label={label}
bind:this={visualEl}
oninput={readVisual}
onblur={readVisual}
></div>
{:else}
<textarea
class="md-source"
bind:value
aria-label="{label} (Markdown)"
placeholder="Write Markdown…"
spellcheck="true"
></textarea>
{/if}
</div>
+42
View File
@@ -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 <pre> without <code>.
// Default turndown only fences <pre><code>; keep bare <pre> 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 '<p><br></p>'
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()
}