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:
Generated
+42
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
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>
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user