@@ -104,15 +104,6 @@ async function openCitation(citation: Citation) {
.avatar { display: grid; place-items: center; width: 34px; height: 34px; border-radius: var(--radius-full); background: var(--color-background-tertiary); font-weight: 700; }
.assistant .avatar { background: var(--color-accent-soft); color: var(--color-accent-primary); }
.message-content { white-space: pre-wrap; line-height: var(--line-height-relaxed); }
-.markdown-content { white-space: normal; user-select: text; }
-.markdown-content :deep(p), .markdown-content :deep(ul), .markdown-content :deep(ol), .markdown-content :deep(pre), .markdown-content :deep(blockquote) { margin: .65em 0; }
-.markdown-content :deep(h1), .markdown-content :deep(h2), .markdown-content :deep(h3) { margin: 1em 0 .5em; line-height: var(--line-height-tight); }
-.markdown-content :deep(ul) { padding-left: 1.5em; list-style: disc; }.markdown-content :deep(ol) { padding-left: 1.5em; list-style: decimal; }
-.markdown-content :deep(pre) { overflow: auto; padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-background-secondary); }
-.markdown-content :deep(code) { padding: .1em .3em; border-radius: var(--radius-sm); background: var(--color-background-tertiary); font-family: var(--font-ui-mono); }.markdown-content :deep(pre code) { padding: 0; background: transparent; }
-.markdown-content :deep(blockquote) { padding-left: 1em; border-left: 3px solid var(--color-accent-primary); color: var(--color-text-secondary); }
-.markdown-content :deep(table) { width: 100%; margin: .65em 0; border-collapse: collapse; }.markdown-content :deep(th), .markdown-content :deep(td) { padding: .45em .65em; border: 1px solid var(--color-border-default); text-align: left; }
-.markdown-content :deep(img) { max-width: 100%; }.markdown-content :deep(hr) { margin: 1em 0; border: 0; border-top: 1px solid var(--color-border-default); }
.thinking { margin-bottom: var(--space-sm); color: var(--color-text-secondary); }.thinking p { margin-top: var(--space-sm); white-space: pre-wrap; }
.tool-calls { display: grid; gap: var(--space-sm); margin-top: var(--space-md); }.tool-calls .item-card { display: grid; gap: var(--space-xs); }.tool-calls pre { overflow: auto; font-size: var(--font-size-xs); }
.usage { display: block; margin-top: var(--space-xs); color: var(--color-text-tertiary); }
diff --git a/frontend/src/features/editor/EditorPane.vue b/frontend/src/features/editor/EditorPane.vue
index 6da29c9..2e64f78 100644
--- a/frontend/src/features/editor/EditorPane.vue
+++ b/frontend/src/features/editor/EditorPane.vue
@@ -1,13 +1,10 @@
-
+
@@ -39,21 +33,5 @@ function updateContent(event: Event) {
line-height: 1.7;
user-select: text;
}
-.writing-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); flex: 1; min-height: 0; }
-.writing-input { border-right: 1px solid var(--color-border-default); font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); }
-.markdown-preview { width: min(100%, var(--editor-line-width, 80ch)); overflow: auto; margin: 0 auto; padding: var(--space-3xl) var(--space-xl); font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); user-select: text; }
-.markdown-preview :deep(h1), .markdown-preview :deep(h2), .markdown-preview :deep(h3) { margin: 1.4em 0 .6em; line-height: var(--line-height-tight); color: var(--color-text-primary); }
-.markdown-preview :deep(h1:first-child), .markdown-preview :deep(h2:first-child) { margin-top: 0; }
-.markdown-preview :deep(p), .markdown-preview :deep(ul), .markdown-preview :deep(ol), .markdown-preview :deep(blockquote), .markdown-preview :deep(pre), .markdown-preview :deep(table) { margin: .8em 0; }
-.markdown-preview :deep(ul), .markdown-preview :deep(ol) { padding-left: 1.6em; }
-.markdown-preview :deep(ul) { list-style: disc; }.markdown-preview :deep(ol) { list-style: decimal; }
-.markdown-preview :deep(blockquote) { padding-left: 1em; border-left: 3px solid var(--color-accent-primary); color: var(--color-text-secondary); }
-.markdown-preview :deep(code) { padding: .15em .35em; border-radius: var(--radius-sm); background: var(--color-background-tertiary); font-family: var(--font-editor-mono); }
-.markdown-preview :deep(pre) { overflow: auto; padding: var(--space-lg); border-radius: var(--radius-md); background: var(--color-background-secondary); }
-.markdown-preview :deep(pre code) { padding: 0; background: transparent; }
-.markdown-preview :deep(table) { width: 100%; border-collapse: collapse; }.markdown-preview :deep(th), .markdown-preview :deep(td) { padding: .5em .7em; border: 1px solid var(--color-border-default); text-align: left; }
-.markdown-preview :deep(img) { max-width: 100%; }.markdown-preview :deep(a) { color: var(--color-text-link); }
-.markdown-preview :deep(hr) { margin: 1.5em 0; border: 0; border-top: 1px solid var(--color-border-default); }
.editor-pane.source { font-family: var(--font-editor-mono); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); }
-@media (max-width: 900px) { .writing-layout { grid-template-columns: 1fr; grid-template-rows: minmax(220px, 1fr) minmax(220px, 1fr); overflow: auto; }.writing-input { min-height: 220px; border-right: 0; border-bottom: 1px solid var(--color-border-default); }.markdown-preview { min-height: 220px; } }
diff --git a/frontend/src/features/editor/VisualMarkdownEditor.vue b/frontend/src/features/editor/VisualMarkdownEditor.vue
new file mode 100644
index 0000000..ee235c9
--- /dev/null
+++ b/frontend/src/features/editor/VisualMarkdownEditor.vue
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/utils/markdown.ts b/frontend/src/utils/markdown.ts
index 1e391c2..6592fcc 100644
--- a/frontend/src/utils/markdown.ts
+++ b/frontend/src/utils/markdown.ts
@@ -1,9 +1,51 @@
import DOMPurify from 'dompurify'
import { marked } from 'marked'
+import { createHighlighterCore } from 'shiki/core'
+import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript'
+import css from '@shikijs/langs/css'
+import html from '@shikijs/langs/html'
+import javascript from '@shikijs/langs/javascript'
+import json from '@shikijs/langs/json'
+import markdown from '@shikijs/langs/markdown'
+import python from '@shikijs/langs/python'
+import shell from '@shikijs/langs/shellscript'
+import sql from '@shikijs/langs/sql'
+import typescript from '@shikijs/langs/typescript'
+import githubDark from '@shikijs/themes/github-dark'
+import githubLight from '@shikijs/themes/github-light'
marked.setOptions({ gfm: true, breaks: true })
-export function renderMarkdown(source: string): string {
- const html = marked.parse(source, { async: false }) as string
- return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } })
+const highlighter = createHighlighterCore({
+ themes: [githubLight, githubDark],
+ langs: [markdown, html, css, javascript, typescript, json, python, shell, sql],
+ engine: createJavaScriptRegexEngine(),
+})
+
+const languageAliases: Record
= {
+ bash: 'shell', js: 'javascript', md: 'markdown', plaintext: 'text', py: 'python', sh: 'shell', ts: 'typescript',
+}
+
+export async function highlightCode(source: string, requestedLanguage = 'text'): Promise {
+ const shiki = await highlighter
+ const language = languageAliases[requestedLanguage] ?? requestedLanguage
+ const loadedLanguage = shiki.getLoadedLanguages().includes(language as never) ? language : 'markdown'
+ return shiki.codeToHtml(source, {
+ lang: loadedLanguage,
+ themes: { light: 'github-light', dark: 'github-dark' },
+ defaultColor: false,
+ })
+}
+
+export async function renderMarkdown(source: string): Promise {
+ const html = marked.parse(source, { async: false }) as string
+ const documentNode = new DOMParser().parseFromString(`${html}`, 'text/html')
+ for (const code of documentNode.querySelectorAll('pre > code')) {
+ const requestedLanguage = [...code.classList].find((name) => name.startsWith('language-'))?.slice(9) || 'text'
+ const highlighted = await highlightCode(code.textContent ?? '', requestedLanguage)
+ const fragment = document.createRange().createContextualFragment(highlighted)
+ code.parentElement?.replaceWith(fragment)
+ }
+
+ return DOMPurify.sanitize(documentNode.body.innerHTML, { USE_PROFILES: { html: true } })
}
diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json
index 4c5cfb1..a534d70 100644
--- a/frontend/tsconfig.app.json
+++ b/frontend/tsconfig.app.json
@@ -9,7 +9,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
- "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "lib": ["ES2022", "ESNext.Disposable", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"baseUrl": ".",
"paths": {