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",
|
"name": "web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"marked": "^18.1.2",
|
||||||
|
"turndown": "^7.2.4",
|
||||||
|
"turndown-plugin-gfm": "^1.0.2"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||||
"svelte": "^5.57.2",
|
"svelte": "^5.57.2",
|
||||||
@@ -63,6 +68,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.155.0",
|
"version": "0.155.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
||||||
@@ -818,6 +829,18 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.6.0"
|
"@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": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.20",
|
"version": "3.3.20",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||||
@@ -998,6 +1021,25 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "8.3.5",
|
"version": "8.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
||||||
|
|||||||
@@ -12,5 +12,10 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||||
"svelte": "^5.57.2",
|
"svelte": "^5.57.2",
|
||||||
"vite": "^8.3.5"
|
"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 { onMount } from 'svelte'
|
||||||
import { api } from './lib/api.js'
|
import { api } from './lib/api.js'
|
||||||
import { parseRoute, buildHash, currentHash } from './lib/route.js'
|
import { parseRoute, buildHash, currentHash } from './lib/route.js'
|
||||||
|
import MarkdownEditor from './lib/MarkdownEditor.svelte'
|
||||||
|
|
||||||
let user = $state(null)
|
let user = $state(null)
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
@@ -888,7 +889,7 @@
|
|||||||
<option value="done">DONE</option>
|
<option value="done">DONE</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Description<textarea bind:value={editDesc}></textarea></label>
|
<MarkdownEditor bind:value={editDesc} label="Description" />
|
||||||
<button class="primary" onclick={saveTicket}>Save</button>
|
<button class="primary" onclick={saveTicket}>Save</button>
|
||||||
|
|
||||||
<h3>Time</h3>
|
<h3>Time</h3>
|
||||||
|
|||||||
+148
@@ -109,6 +109,13 @@ code {
|
|||||||
padding: 0.05em 0.35em;
|
padding: 0.05em 0.35em;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
pre code {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -444,3 +451,144 @@ td, th {
|
|||||||
th { color: var(--muted); font-weight: 600; }
|
th { color: var(--muted); font-weight: 600; }
|
||||||
label { display: grid; gap: 0.3rem; color: var(--muted); font-size: 0.9rem; }
|
label { display: grid; gap: 0.3rem; color: var(--muted); font-size: 0.9rem; }
|
||||||
label > :is(input, select, textarea) { color: var(--ink); }
|
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