feat: add mermaid diagram rendering and code block copy button (#418)

* feat: add mermaid diagram rendering and code block copy button

- Add mermaid library for rendering mermaid diagrams in markdown preview
- Create MermaidBlock component for diagram rendering
- Create CodeBlockCopyButton component for copying code blocks
- Extract CSS into separate files for better organization

* fix: address review findings

- Sanitize mermaid SVG output with DOMPurify before innerHTML assignment
- Remove dead `initialized` flag in MermaidBlock
- Serialize concurrent mermaid.render() calls with a promise queue
- Add .catch() handlers for clipboard write promises
- Skip CodeBlockCopyButton wrapper for mermaid diagram blocks
This commit is contained in:
Jinjing 2026-04-09 12:21:11 -07:00 committed by GitHub
parent 1b868cbc35
commit e5f75564af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 2556 additions and 1315 deletions

View file

@ -69,10 +69,12 @@
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.3.3",
"electron-updater": "^6.8.3",
"hosted-git-info": "^9.0.2",
"lowlight": "^3.3.0",
"lucide-react": "^0.577.0",
"mermaid": "^11.14.0",
"monaco-editor": "^0.55.1",
"node-pty": "^1.1.0",
"radix-ui": "^1.4.3",

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,506 @@
/* ── Markdown Preview ────────────────────────────────── */
.markdown-preview {
position: relative;
padding: 24px 32px;
font-size: inherit;
line-height: 1.7;
}
.markdown-preview:focus-visible {
outline: none;
}
.markdown-preview-search {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
gap: 0;
width: fit-content;
max-width: min(100%, 460px);
margin-left: auto;
margin-right: -20px;
margin-bottom: 6px;
padding: 0 2px 0 4px;
border: 1px solid var(--border);
border-radius: 0 0 0 4px;
background: var(--background);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.16);
}
.markdown-preview-search-field {
display: flex;
align-items: center;
min-width: 0;
width: 220px;
border: 1px solid var(--ring);
background: var(--background);
flex: 0 1 auto;
}
.markdown-preview-search-status {
min-width: 0;
padding: 0 6px;
font-size: 12px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
white-space: nowrap;
line-height: 1;
flex: 0 0 auto;
}
.markdown-preview-search-divider {
width: 1px;
height: 16px;
margin: 0 2px;
background: var(--border);
}
.markdown-preview-search-input {
font-size: 13px;
line-height: 1;
}
.markdown-preview-search-input::placeholder {
color: var(--muted-foreground);
}
.markdown-preview-search-button {
width: 22px;
height: 22px;
min-width: 22px;
padding: 0;
border-radius: 2px;
color: var(--muted-foreground);
}
.markdown-preview-search-button:hover:not(:disabled) {
background: var(--accent);
color: var(--foreground);
}
.markdown-preview-search-match {
padding: 0;
border-radius: 2px;
background: color-mix(in srgb, #facc15 50%, transparent);
color: inherit;
}
.markdown-preview-search-match[data-active] {
background: color-mix(in srgb, #fb923c 60%, transparent);
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.3;
}
.markdown-body > :first-child {
margin-top: 0;
}
.markdown-body h1 {
font-size: 1.85em;
font-weight: 700;
letter-spacing: -0.02em;
}
.markdown-body h2 {
font-size: 1.4em;
border-bottom: 1px solid var(--border);
padding-bottom: 0.3em;
}
.markdown-body h3 {
font-size: 1.15em;
}
.markdown-body p {
margin: 0.75em 0;
}
/* Links use a proper blue instead of --primary (which is near-black/white and
visually indistinguishable from body text). */
.markdown-body a {
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
text-underline-offset: 2px;
transition: text-decoration-color 150ms;
}
.markdown-body a:hover {
text-decoration-color: currentColor;
}
.markdown-light .markdown-body a {
color: #0969da;
}
.markdown-dark .markdown-body a {
color: #58a6ff;
}
.markdown-body code {
padding: 0.2em 0.4em;
border-radius: 5px;
font-size: 0.88em;
font-family: var(--font-mono, monospace);
}
.markdown-dark .markdown-body code {
background: rgba(255, 255, 255, 0.1);
}
.markdown-light .markdown-body code {
background: rgba(0, 0, 0, 0.07);
}
.markdown-body pre {
margin: 1em 0;
padding: 14px 18px;
border-radius: 8px;
overflow-x: auto;
line-height: 1.55;
}
.markdown-dark .markdown-body pre {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.markdown-light .markdown-body pre {
background: #f6f8fa;
border: 1px solid rgba(0, 0, 0, 0.08);
}
.markdown-body pre code {
padding: 0;
background: none;
border: none;
font-size: inherit;
border-radius: 0;
}
/* Code copy button — appears on hover over code blocks in preview mode */
.code-block-wrapper {
position: relative;
}
.code-block-copy-btn {
position: absolute;
top: 8px;
right: 8px;
opacity: 0;
transition: opacity 0.15s ease;
background: none;
border: 1px solid transparent;
border-radius: 4px;
padding: 4px;
cursor: pointer;
color: var(--muted-foreground);
display: flex;
align-items: center;
justify-content: center;
}
.code-block-wrapper:hover .code-block-copy-btn {
opacity: 1;
}
.code-block-copy-btn:hover {
background: var(--accent);
color: var(--foreground);
}
/* Always show on touch devices that lack hover */
@media (hover: none) {
.code-block-copy-btn {
opacity: 1;
}
}
.code-block-copy-label {
font-size: 11px;
margin-left: 3px;
font-family: var(--font-sans, sans-serif);
}
/* Mermaid diagram container */
.mermaid-block svg {
max-width: 100%;
height: auto;
}
.mermaid-error {
color: var(--color-warning, #d29922);
font-size: 0.85em;
margin-bottom: 0.5em;
}
.markdown-body ul,
.markdown-body ol {
margin: 0.75em 0;
padding-left: 1.5em;
}
/* Restore list markers inside markdown preview because the app-wide reset removes
them for navigation UI. Markdown content needs native list semantics to match
source documents, so this override should stay scoped to the preview surface. */
.markdown-body ul {
list-style: disc;
}
.markdown-body ol {
list-style: decimal;
}
.markdown-body ul ul {
list-style: circle;
}
.markdown-body ul ul ul {
list-style: square;
}
/* GFM task lists already render an explicit checkbox input, so keeping the
browser marker here would show both a bullet and a checkbox for one item. */
.markdown-body ul.contains-task-list,
.markdown-body li.task-list-item {
list-style: none;
}
.markdown-body li {
margin: 0.25em 0;
}
.markdown-body li > input[type='checkbox'] {
margin-right: 0.5em;
}
.markdown-body blockquote {
margin: 0.75em 0;
padding: 0.5em 1em;
border-left: 3px solid var(--border);
border-radius: 0 6px 6px 0;
}
.markdown-dark .markdown-body blockquote {
background: rgba(255, 255, 255, 0.025);
color: rgba(255, 255, 255, 0.72);
}
.markdown-light .markdown-body blockquote {
background: rgba(0, 0, 0, 0.02);
color: rgba(0, 0, 0, 0.6);
}
.markdown-body table {
margin: 1em 0;
border-collapse: collapse;
width: 100%;
font-size: 0.95em;
}
.markdown-body th,
.markdown-body td {
padding: 8px 14px;
border: 1px solid var(--border);
text-align: left;
}
.markdown-body th {
font-weight: 600;
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.markdown-dark .markdown-body th {
background: rgba(255, 255, 255, 0.06);
}
.markdown-light .markdown-body th {
background: rgba(0, 0, 0, 0.04);
}
.markdown-dark .markdown-body tr:nth-child(even) td {
background: rgba(255, 255, 255, 0.02);
}
.markdown-light .markdown-body tr:nth-child(even) td {
background: rgba(0, 0, 0, 0.015);
}
.markdown-body hr {
margin: 2em 0;
border: none;
border-top: 1px solid var(--border);
}
.markdown-body img {
max-width: 100%;
border-radius: 6px;
}
/* ── Syntax Highlighting (GitHub-inspired) ────────── */
.markdown-light .hljs {
color: #24292f;
}
.markdown-light .hljs-comment,
.markdown-light .hljs-quote {
color: #6e7781;
font-style: italic;
}
.markdown-light .hljs-keyword,
.markdown-light .hljs-selector-tag,
.markdown-light .hljs-template-tag {
color: #cf222e;
}
.markdown-light .hljs-string,
.markdown-light .hljs-doctag,
.markdown-light .hljs-regexp {
color: #0a3069;
}
.markdown-light .hljs-number,
.markdown-light .hljs-literal {
color: #0550ae;
}
.markdown-light .hljs-title,
.markdown-light .hljs-section {
color: #8250df;
}
.markdown-light .hljs-type,
.markdown-light .hljs-built_in {
color: #0550ae;
}
.markdown-light .hljs-attr,
.markdown-light .hljs-attribute {
color: #0550ae;
}
.markdown-light .hljs-variable,
.markdown-light .hljs-template-variable {
color: #953800;
}
.markdown-light .hljs-tag,
.markdown-light .hljs-name {
color: #116329;
}
.markdown-light .hljs-symbol,
.markdown-light .hljs-bullet,
.markdown-light .hljs-selector-class,
.markdown-light .hljs-selector-id {
color: #0550ae;
}
.markdown-light .hljs-meta {
color: #1f7f34;
}
.markdown-light .hljs-addition {
color: #116329;
background: rgba(46, 160, 67, 0.1);
}
.markdown-light .hljs-deletion {
color: #82071e;
background: rgba(248, 81, 73, 0.1);
}
.markdown-light .hljs-emphasis {
font-style: italic;
}
.markdown-light .hljs-strong {
font-weight: 700;
}
.markdown-dark .hljs {
color: #e6edf3;
}
.markdown-dark .hljs-comment,
.markdown-dark .hljs-quote {
color: #8b949e;
font-style: italic;
}
.markdown-dark .hljs-keyword,
.markdown-dark .hljs-selector-tag,
.markdown-dark .hljs-template-tag {
color: #ff7b72;
}
.markdown-dark .hljs-string,
.markdown-dark .hljs-doctag,
.markdown-dark .hljs-regexp {
color: #a5d6ff;
}
.markdown-dark .hljs-number,
.markdown-dark .hljs-literal {
color: #79c0ff;
}
.markdown-dark .hljs-title,
.markdown-dark .hljs-section {
color: #d2a8ff;
}
.markdown-dark .hljs-type,
.markdown-dark .hljs-built_in {
color: #ffa657;
}
.markdown-dark .hljs-attr,
.markdown-dark .hljs-attribute {
color: #79c0ff;
}
.markdown-dark .hljs-variable,
.markdown-dark .hljs-template-variable {
color: #ffa657;
}
.markdown-dark .hljs-tag,
.markdown-dark .hljs-name {
color: #7ee787;
}
.markdown-dark .hljs-symbol,
.markdown-dark .hljs-bullet,
.markdown-dark .hljs-selector-class,
.markdown-dark .hljs-selector-id {
color: #d2a8ff;
}
.markdown-dark .hljs-meta {
color: #79c0ff;
}
.markdown-dark .hljs-addition {
color: #aff5b4;
background: rgba(46, 160, 67, 0.15);
}
.markdown-dark .hljs-deletion {
color: #ffdcd7;
background: rgba(248, 81, 73, 0.15);
}
.markdown-dark .hljs-emphasis {
font-style: italic;
}
.markdown-dark .hljs-strong {
font-weight: 700;
}

View file

@ -0,0 +1,611 @@
/* ── Rich Markdown Editor ─────────────────────────────── */
.rich-markdown-editor-shell {
position: relative;
display: flex;
min-height: 0;
height: 100%;
flex-direction: column;
background: var(--editor-surface);
}
.rich-markdown-editor-toolbar {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 14px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
background: color-mix(in srgb, var(--background) 84%, transparent);
}
.rich-markdown-toolbar-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 0 8px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--muted-foreground);
font-size: 12px;
font-weight: 600;
}
.rich-markdown-toolbar-separator {
width: 1px;
height: 18px;
background: color-mix(in srgb, var(--border) 72%, transparent);
flex-shrink: 0;
}
.rich-markdown-toolbar-button:hover,
.rich-markdown-toolbar-button.is-active {
border-color: color-mix(in srgb, var(--border) 82%, transparent);
background: var(--accent);
color: var(--foreground);
}
.rich-markdown-search {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
gap: 0;
width: fit-content;
max-width: min(100%, 460px);
margin-left: auto;
margin-right: 12px;
margin-top: 8px;
margin-bottom: 6px;
padding: 0 2px 0 4px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--background);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.16);
}
.rich-markdown-search-field {
display: flex;
align-items: center;
min-width: 0;
width: 220px;
border: 1px solid var(--ring);
background: var(--background);
flex: 0 1 auto;
}
.rich-markdown-search-status {
min-width: 0;
padding: 0 6px;
font-size: 12px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
white-space: nowrap;
line-height: 1;
flex: 0 0 auto;
}
.rich-markdown-search-divider {
width: 1px;
height: 16px;
margin: 0 2px;
background: var(--border);
}
.rich-markdown-search-input {
font-size: 13px;
line-height: 1;
}
.rich-markdown-search-button {
width: 22px;
height: 22px;
min-width: 22px;
padding: 0;
border-radius: 2px;
color: var(--muted-foreground);
}
.rich-markdown-search-button:hover:not(:disabled) {
background: var(--accent);
color: var(--foreground);
}
.rich-markdown-search-match {
padding: 0;
border-radius: 2px;
background: color-mix(in srgb, #facc15 50%, transparent);
}
.rich-markdown-search-match[data-active='true'] {
background: color-mix(in srgb, #fb923c 60%, transparent);
}
.rich-markdown-editor {
min-height: 100%;
padding: 24px 32px 96px;
font-size: calc(14px + (var(--editor-font-zoom-level, 0) * 1px));
line-height: 1.7;
outline: none;
}
.rich-markdown-editor p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
color: var(--muted-foreground);
}
.rich-markdown-editor h1,
.rich-markdown-editor h2,
.rich-markdown-editor h3,
.rich-markdown-editor h4,
.rich-markdown-editor h5,
.rich-markdown-editor h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.3;
}
.rich-markdown-editor > :first-child {
margin-top: 0;
}
.rich-markdown-editor h1 {
font-size: 1.85em;
font-weight: 700;
letter-spacing: -0.02em;
}
.rich-markdown-editor h2 {
font-size: 1.4em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--border);
}
.rich-markdown-editor h3 {
font-size: 1.15em;
}
.rich-markdown-editor p,
.rich-markdown-editor ul,
.rich-markdown-editor ol,
.rich-markdown-editor blockquote {
margin: 0.75em 0;
}
.rich-markdown-editor ul,
.rich-markdown-editor ol {
padding-left: 1.5em;
}
.rich-markdown-editor ul {
list-style: disc;
}
.rich-markdown-editor ol {
list-style: decimal;
}
/* Tiptap wraps each list item's content in a <p>, which inherits the global
paragraph margin and bloats inter-item spacing. Suppress it so lists render
tight, matching the preview surface. */
.rich-markdown-editor li > p {
margin: 0;
}
.rich-markdown-editor li {
margin: 0.15em 0;
}
.rich-markdown-editor ul[data-type='taskList'] {
padding-left: 0;
list-style: none;
}
/* Why: nested task lists inside a task item need left padding so they indent
under the parent checkbox, matching how nested bullet/ordered lists look. */
.rich-markdown-editor ul[data-type='taskList'] ul[data-type='taskList'] {
padding-left: 0.75em;
}
.rich-markdown-editor ul[data-type='taskList'] li {
display: flex;
align-items: flex-start;
gap: 6px;
}
.rich-markdown-editor ul[data-type='taskList'] li > label {
flex-shrink: 0;
display: flex;
align-items: center;
height: 1.55em;
cursor: pointer;
user-select: none;
}
.rich-markdown-editor ul[data-type='taskList'] li > label input[type='checkbox'] {
appearance: none;
width: 16px;
height: 16px;
border: 1.5px solid color-mix(in srgb, var(--foreground) 35%, transparent);
border-radius: 4px;
background: transparent;
cursor: pointer;
position: relative;
flex-shrink: 0;
}
.rich-markdown-editor ul[data-type='taskList'] li > label input[type='checkbox']:checked {
background: var(--primary);
border-color: var(--primary);
}
.rich-markdown-editor ul[data-type='taskList'] li > label input[type='checkbox']:checked::after {
content: '';
position: absolute;
left: 4px;
top: 1px;
width: 5px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.rich-markdown-editor ul[data-type='taskList'] li > div {
flex: 1;
min-width: 0;
}
.rich-markdown-editor ul[data-type='taskList'] li > div > p {
margin: 0;
}
.rich-markdown-editor ul[data-type='taskList'] li[data-checked='true'] > div {
text-decoration: line-through;
color: var(--muted-foreground);
}
.rich-markdown-editor blockquote {
padding: 0.5em 1em;
border-left: 3px solid var(--border);
border-radius: 0 6px 6px 0;
color: var(--muted-foreground);
background: color-mix(in srgb, var(--foreground) 2%, transparent);
}
.rich-markdown-editor code {
padding: 0.2em 0.4em;
border-radius: 5px;
background: color-mix(in srgb, var(--foreground) 8%, transparent);
font-size: 0.88em;
font-family: var(--font-mono, monospace);
}
.rich-markdown-code-block-wrapper {
position: relative;
border-radius: 8px;
background: color-mix(in srgb, var(--foreground) 6%, transparent);
border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);
margin: 0.75em 0;
}
.rich-markdown-code-block-lang {
position: absolute;
top: 6px;
right: 6px;
z-index: 1;
padding: 1px 4px;
border-radius: 4px;
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
background: color-mix(in srgb, var(--background) 80%, transparent);
color: var(--muted-foreground);
font-size: 11px;
font-family: var(--font-mono, monospace);
cursor: pointer;
}
/* Copy button inside rich-mode code blocks sits to the left of the
language selector, appears on hover. */
.rich-markdown-code-block-wrapper .code-block-copy-btn {
position: absolute;
top: 6px;
right: 115px;
z-index: 1;
opacity: 0;
transition: opacity 0.15s ease;
background: none;
border: 1px solid transparent;
border-radius: 4px;
padding: 2px;
cursor: pointer;
color: var(--muted-foreground);
display: flex;
align-items: center;
justify-content: center;
}
.rich-markdown-code-block-wrapper:hover .code-block-copy-btn {
opacity: 1;
}
.rich-markdown-code-block-wrapper .code-block-copy-btn:hover {
background: var(--accent);
color: var(--foreground);
}
/* Live mermaid diagram preview rendered below the editable source */
.mermaid-preview {
padding: 12px 18px;
border-top: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
}
.rich-markdown-editor pre {
padding: 14px 18px;
border-radius: 8px;
overflow-x: auto;
line-height: 1.55;
background: transparent;
}
.rich-markdown-editor pre code {
padding: 0;
background: transparent;
}
/* Why: CodeBlockLowlight's decoration plugin adds hljs class spans directly
inside the <pre> contentDOM there is no <code> wrapper when using a
React NodeView, so selectors must target pre > .hljs-* directly. */
.rich-markdown-code-block-wrapper .hljs-keyword {
color: #cf222e;
}
.rich-markdown-code-block-wrapper .hljs-string {
color: #0a3069;
}
.rich-markdown-code-block-wrapper .hljs-number {
color: #0550ae;
}
.rich-markdown-code-block-wrapper .hljs-comment {
color: #6e7781;
font-style: italic;
}
.rich-markdown-code-block-wrapper .hljs-function {
color: #8250df;
}
.rich-markdown-code-block-wrapper .hljs-title {
color: #8250df;
}
.rich-markdown-code-block-wrapper .hljs-built_in {
color: #0550ae;
}
.rich-markdown-code-block-wrapper .hljs-type {
color: #0550ae;
}
.rich-markdown-code-block-wrapper .hljs-attr {
color: #0550ae;
}
.rich-markdown-code-block-wrapper .hljs-selector-class {
color: #0550ae;
}
.rich-markdown-code-block-wrapper .hljs-variable {
color: #953800;
}
.rich-markdown-code-block-wrapper .hljs-meta {
color: #6e7781;
}
.rich-markdown-code-block-wrapper .hljs-tag {
color: #116329;
}
.rich-markdown-code-block-wrapper .hljs-name {
color: #116329;
}
.rich-markdown-code-block-wrapper .hljs-params {
color: #953800;
}
.rich-markdown-code-block-wrapper .hljs-literal {
color: #0550ae;
}
.rich-markdown-code-block-wrapper .hljs-regexp {
color: #0a3069;
}
.rich-markdown-code-block-wrapper .hljs-operator {
color: #cf222e;
}
.rich-markdown-code-block-wrapper .hljs-property {
color: #0550ae;
}
.dark .rich-markdown-code-block-wrapper .hljs-keyword {
color: #ff7b72;
}
.dark .rich-markdown-code-block-wrapper .hljs-string {
color: #a5d6ff;
}
.dark .rich-markdown-code-block-wrapper .hljs-number {
color: #79c0ff;
}
.dark .rich-markdown-code-block-wrapper .hljs-comment {
color: #8b949e;
font-style: italic;
}
.dark .rich-markdown-code-block-wrapper .hljs-function {
color: #d2a8ff;
}
.dark .rich-markdown-code-block-wrapper .hljs-title {
color: #d2a8ff;
}
.dark .rich-markdown-code-block-wrapper .hljs-built_in {
color: #79c0ff;
}
.dark .rich-markdown-code-block-wrapper .hljs-type {
color: #79c0ff;
}
.dark .rich-markdown-code-block-wrapper .hljs-attr {
color: #79c0ff;
}
.dark .rich-markdown-code-block-wrapper .hljs-selector-class {
color: #79c0ff;
}
.dark .rich-markdown-code-block-wrapper .hljs-variable {
color: #ffa657;
}
.dark .rich-markdown-code-block-wrapper .hljs-meta {
color: #8b949e;
}
.dark .rich-markdown-code-block-wrapper .hljs-tag {
color: #7ee787;
}
.dark .rich-markdown-code-block-wrapper .hljs-name {
color: #7ee787;
}
.dark .rich-markdown-code-block-wrapper .hljs-params {
color: #ffa657;
}
.dark .rich-markdown-code-block-wrapper .hljs-literal {
color: #79c0ff;
}
.dark .rich-markdown-code-block-wrapper .hljs-regexp {
color: #a5d6ff;
}
.dark .rich-markdown-code-block-wrapper .hljs-operator {
color: #ff7b72;
}
.dark .rich-markdown-code-block-wrapper .hljs-property {
color: #79c0ff;
}
.rich-markdown-editor hr {
margin: 1.5em 0;
border: none;
border-top: 1px solid var(--border);
}
.rich-markdown-editor a {
color: #0969da;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
text-underline-offset: 2px;
}
.dark .rich-markdown-editor a {
color: #58a6ff;
}
.rich-markdown-editor img {
max-width: 100%;
border-radius: 8px;
}
.rich-markdown-editor .ProseMirror-selectednode {
outline: 2px solid color-mix(in srgb, var(--ring) 78%, transparent);
outline-offset: 2px;
}
.rich-markdown-slash-menu {
position: absolute;
z-index: 30;
display: flex;
width: min(320px, calc(100% - 24px));
max-height: 280px;
flex-direction: column;
overflow-y: auto;
border: 1px solid color-mix(in srgb, var(--border) 76%, transparent);
border-radius: 12px;
background: color-mix(in srgb, var(--background) 92%, transparent);
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(16px);
}
.rich-markdown-slash-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: none;
background: transparent;
color: inherit;
text-align: left;
}
.rich-markdown-slash-item:hover,
.rich-markdown-slash-item.is-active {
background: var(--accent);
}
.rich-markdown-slash-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 8px;
background: color-mix(in srgb, var(--foreground) 6%, transparent);
color: var(--muted-foreground);
}
/* ── Link Bubble ──────────────────────────────────────── */
.rich-markdown-link-bubble {
position: absolute;
z-index: 30;
display: flex;
align-items: center;
gap: 2px;
padding: 4px 6px;
border: 1px solid color-mix(in srgb, var(--border) 76%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--background) 92%, transparent);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
backdrop-filter: blur(16px);
}
.rich-markdown-link-url {
padding: 2px 6px;
color: var(--muted-foreground);
font-size: 12px;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono, monospace);
}
.rich-markdown-link-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
min-width: 24px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--muted-foreground);
}
.rich-markdown-link-button:hover {
background: var(--accent);
color: var(--foreground);
}
.rich-markdown-link-input {
font-size: 13px;
width: 280px;
padding: 4px 8px;
border: none;
background: transparent;
color: var(--foreground);
outline: none;
font-family: var(--font-mono, monospace);
}
.rich-markdown-link-input::placeholder {
color: var(--muted-foreground);
}

View file

@ -0,0 +1,279 @@
/* ── Terminal ────────────────────────────────────────── */
.terminal-container {
flex: 1;
overflow: hidden;
position: relative;
}
/* Ensure pane manager root fills its absolutely-positioned container */
.pane-manager-root {
width: 100% !important;
height: 100% !important;
}
/* Reset the global border/outline base rule inside terminal panes */
.pane-manager-root *,
.pane-manager-root *::before,
.pane-manager-root *::after {
border-color: transparent;
outline: none;
}
.pane-manager-root .xterm {
height: 100%;
width: 100%;
}
.pane-manager-root .xterm-viewport {
overflow-y: auto;
}
/* Divider: the element is a wide transparent hit area; the visible line is
drawn by ::after so that setting `background` on the element never hides it. */
.pane-divider.is-vertical,
.pane-divider.is-horizontal {
--divider-thickness: 4px;
background: transparent !important;
}
.pane-divider::after {
content: '';
position: absolute;
border-radius: 1px;
background: var(--orca-terminal-divider-color, transparent);
transition: background 100ms ease;
}
.pane-divider.is-vertical::after {
/* Negative insets on the cross axis extend the line into perpendicular
divider hit zones so that intersecting splits visually connect ( not |-). */
top: calc(-1 * var(--divider-extension, 5px));
bottom: calc(-1 * var(--divider-extension, 5px));
left: 50%;
width: var(--divider-thickness);
transform: translateX(-50%);
}
.pane-divider.is-horizontal::after {
left: calc(-1 * var(--divider-extension, 5px));
right: calc(-1 * var(--divider-extension, 5px));
top: 50%;
height: var(--divider-thickness);
transform: translateY(-50%);
}
.pane-divider.is-vertical:hover::after,
.pane-divider.is-vertical.is-dragging::after,
.pane-divider.is-horizontal:hover::after,
.pane-divider.is-horizontal.is-dragging::after {
background: var(
--orca-terminal-divider-color-strong,
var(--orca-terminal-divider-color, transparent)
);
}
/* ── Pane drag handle ─────────────────────────────────── */
.pane-drag-handle {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 12px;
z-index: 10;
cursor: grab;
opacity: 0;
transition: opacity 150ms ease;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.15);
}
/* Three-dot ellipsis indicator */
.pane-drag-handle::after {
content: '···';
font-size: 10px;
letter-spacing: 1px;
line-height: 1;
color: rgba(255, 255, 255, 0.45);
}
/* Only show when hovering the handle itself, and only with multiple panes */
.has-multiple-panes .pane-drag-handle {
pointer-events: auto;
}
.has-multiple-panes .pane-drag-handle:hover {
opacity: 1;
}
.pane-drag-handle:active {
cursor: grabbing;
}
/* Dim the source pane while dragging */
.pane.is-drag-source {
opacity: 0.4 !important;
}
/* Suppress pointer events on terminals during pane drag so overlay works */
.is-pane-dragging .pane {
pointer-events: none;
}
/* ── Drop overlay ─────────────────────────────────────── */
.pane-drop-overlay {
position: absolute;
z-index: 9999;
background: rgba(59, 130, 246, 0.2);
border: 2px solid rgba(59, 130, 246, 0.5);
border-radius: 4px;
pointer-events: none;
transition:
left 80ms ease,
top 80ms ease,
width 80ms ease,
height 80ms ease;
}
/* ── Pane title bar ──────────────────────────────────── */
/* Floating tab style no full-width background bar. The title sits as a
lightweight inline label so the terminal content isn't visually "capped."
Uses muted grey + monospace to read as metadata, not actionable text. */
.pane-title-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 20px;
z-index: 5; /* below drag handle (z:10) */
display: flex;
align-items: center;
padding: 0 8px;
font-size: 13px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
color: rgba(255, 255, 255, 0.52);
background: transparent;
border-bottom: none;
cursor: pointer; /* click to edit title */
user-select: none;
}
.pane-title-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.pane-title-close {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
padding: 0;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
line-height: 14px;
transform: translateY(-1px);
cursor: pointer;
opacity: 0;
transition:
opacity 120ms ease,
color 120ms ease;
}
.pane-title-bar:hover .pane-title-close {
opacity: 1;
}
.pane-title-close:hover {
color: rgba(255, 255, 255, 0.8);
}
/* Inline edit input matches the title text styling exactly to prevent
layout shift. A subtle bottom border indicates active edit mode. */
/* When inline-editing, remove the bar's side padding so the input
stretches edge-to-edge; the input carries its own left padding. */
.pane-title-bar[data-editing] {
padding: 0;
}
.pane-title-input {
flex: 1;
min-width: 0;
width: 100%;
height: 20px;
box-sizing: border-box;
padding: 0 8px;
margin: 0;
border: none;
border-radius: 0;
background: transparent;
outline: none;
font-size: 13px;
line-height: 20px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
color: rgba(255, 255, 255, 0.7);
caret-color: rgba(255, 255, 255, 0.6);
}
/* Baseline xterm-container layout the 4px inset padding that pane-lifecycle.ts
previously set as inline styles now lives here so that the data-has-title
CSS attribute selector override can take effect (CSS attribute selectors
cannot override inline styles). */
.xterm-container {
position: relative;
width: calc(100% - 4px);
height: calc(100% - 4px);
margin-top: 4px;
margin-left: 4px;
}
/* When a pane has a title, shift the terminal content down to make room.
The .pane container uses position: relative, so the absolutely-positioned
title bar occupies the top 20px. Height is reduced by the full 24px
(20px title bar + 4px base margin-top) to prevent overflow/clipping. */
.pane[data-has-title] .xterm-container {
margin-top: 24px; /* 20px title bar + 4px base margin-top */
height: calc(100% - 24px); /* must match margin-top to avoid overflow */
}
/* Override the inline overflow:hidden set by pane-manager so the title
separator line can extend into the divider hit-padding zone.
overflow:clip behaves identically to overflow:hidden for content
clipping but supports overflow-clip-margin to widen the clip region.
The 5px margin matches the divider's hit-padding (10px total / 2). */
.pane[data-has-title] {
overflow: clip !important;
overflow-clip-margin: 5px;
}
/* Title separator line rendered on the .pane itself (not the portaled
title bar) so the line spans the full pane width into the divider zone. */
.pane[data-has-title]::after {
content: '';
position: absolute;
top: 20px;
left: -5px;
right: -5px;
height: 1px;
background: rgba(255, 255, 255, 0.06);
z-index: 6;
pointer-events: none;
}
/* Hide the drag handle on titled panes the title bar already provides
visual identification, and a grab-cursor strip right at the border
creates a confusing "looks draggable" affordance that misleads users. */
.pane[data-has-title] .pane-drag-handle {
display: none;
}

View file

@ -0,0 +1,74 @@
import React, { useCallback, useState } from 'react'
import { Copy, Check } from 'lucide-react'
type CodeBlockCopyButtonProps = React.HTMLAttributes<HTMLPreElement> & {
children?: React.ReactNode
}
export default function CodeBlockCopyButton({
children,
...props
}: CodeBlockCopyButtonProps): React.JSX.Element {
const [copied, setCopied] = useState(false)
const handleCopy = useCallback(() => {
// Extract the text content from the nested <code> element rendered by
// react-markdown inside <pre>. We walk the React children tree to grab the
// raw string so clipboard receives plain text, not markup.
let text = ''
React.Children.forEach(children, (child) => {
if (React.isValidElement(child) && child.props) {
const inner = (child.props as { children?: React.ReactNode }).children
text += typeof inner === 'string' ? inner : extractText(inner)
} else if (typeof child === 'string') {
text += child
}
})
void window.api.ui
.writeClipboardText(text)
.then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
.catch(() => {
// Silently swallow clipboard write failures (e.g. permission denied).
})
}, [children])
return (
<div className="code-block-wrapper">
<pre {...props}>{children}</pre>
<button
type="button"
className="code-block-copy-btn"
onClick={handleCopy}
aria-label="Copy code"
title="Copy code"
>
{copied ? (
<>
<Check size={14} />
<span className="code-block-copy-label">Copied</span>
</>
) : (
<Copy size={14} />
)}
</button>
</div>
)
}
/** Recursively extract text from React children. */
function extractText(node: React.ReactNode): string {
if (typeof node === 'string' || typeof node === 'number') {
return String(node)
}
if (Array.isArray(node)) {
return node.map(extractText).join('')
}
if (React.isValidElement(node) && node.props) {
return extractText((node.props as { children?: React.ReactNode }).children)
}
return ''
}

View file

@ -12,6 +12,8 @@ import { computeEditorFontSize } from '@/lib/editor-font-zoom'
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
import { getMarkdownPreviewLinkTarget } from './markdown-preview-links'
import { useLocalImageSrc } from './useLocalImageSrc'
import CodeBlockCopyButton from './CodeBlockCopyButton'
import MermaidBlock from './MermaidBlock'
import {
applyMarkdownPreviewSearchHighlights,
clearMarkdownPreviewSearchHighlights,
@ -259,6 +261,31 @@ export default function MarkdownPreview({
// are valid here despite the lowercase function name.
const resolvedSrc = useLocalImageSrc(src, filePath)
return <img {...props} src={resolvedSrc} alt={alt ?? ''} />
},
// Why: Intercept code elements to detect mermaid fenced blocks. rehype-highlight
// sets className="language-mermaid" on the <code> inside <pre> for ```mermaid blocks.
// We render those as SVG diagrams instead of highlighted source.
code: ({ className, children, ...props }) => {
if (/language-mermaid/.test(className || '')) {
return <MermaidBlock content={String(children).trimEnd()} isDark={isDark} />
}
return (
<code className={className} {...props}>
{children}
</code>
)
},
// Why: Wrap <pre> blocks with a positioned container so a copy button can
// overlay the code block. Mermaid diagrams are detected and passed through
// unwrapped — MermaidBlock renders via useEffect/innerHTML, not React children,
// so CodeBlockCopyButton's extractText() would copy an empty string, and a
// <div> inside <pre> produces invalid HTML.
pre: ({ children, ...props }) => {
const child = React.Children.toArray(children)[0]
if (React.isValidElement(child) && child.type === MermaidBlock) {
return <>{children}</>
}
return <CodeBlockCopyButton {...props}>{children}</CodeBlockCopyButton>
}
}

View file

@ -0,0 +1,75 @@
import React, { useEffect, useId, useRef, useState } from 'react'
import mermaid from 'mermaid'
import DOMPurify from 'dompurify'
type MermaidBlockProps = {
content: string
isDark: boolean
}
// Why: mermaid.render() manipulates global DOM state (element IDs, internal
// parser state). Running multiple renders concurrently causes race conditions
// where one render can clobber another's temporary DOM node. Serializing all
// render calls through a single promise chain avoids this.
let renderQueue: Promise<void> = Promise.resolve()
/**
* Renders a mermaid diagram string as SVG. Falls back to raw source with an
* error banner if the syntax is invalid never breaks the rest of the preview.
*/
export default function MermaidBlock({ content, isDark }: MermaidBlockProps): React.JSX.Element {
const id = useId().replace(/:/g, '_')
const containerRef = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const theme = isDark ? 'dark' : 'default'
// Re-initialize on every effect so the theme stays in sync with the
// current appearance. mermaid.initialize() is cheap and idempotent.
mermaid.initialize({ startOnLoad: false, theme })
let cancelled = false
const render = async (): Promise<void> => {
try {
const { svg } = await mermaid.render(`mermaid-${id}`, content)
if (!cancelled && containerRef.current) {
// Why: although mermaid uses DOMPurify internally, we add an explicit
// sanitization pass as defense-in-depth against XSS in case upstream
// behaviour changes or a mermaid version ships without sanitization.
containerRef.current.innerHTML = DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true }
})
setError(null)
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Invalid mermaid syntax')
// Mermaid leaves an error element in the DOM on failure — clean it up.
const errorEl = document.getElementById(`d${`mermaid-${id}`}`)
errorEl?.remove()
}
}
}
// Serialize render calls through a module-level queue to avoid race
// conditions from concurrent mermaid.render() invocations.
renderQueue = renderQueue.then(render, render)
return () => {
cancelled = true
}
}, [content, isDark, id])
if (error) {
return (
<div className="mermaid-block">
<div className="mermaid-error">Diagram error: {error}</div>
<pre>
<code>{content}</code>
</pre>
</div>
)
}
return <div className="mermaid-block" ref={containerRef} />
}

View file

@ -1,6 +1,9 @@
import React, { useCallback } from 'react'
import React, { useCallback, useState } from 'react'
import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
import type { NodeViewProps } from '@tiptap/react'
import { Copy, Check } from 'lucide-react'
import { useAppStore } from '@/store'
import MermaidBlock from './MermaidBlock'
/**
* Common languages shown in the selector. The user can also type a language
@ -22,6 +25,7 @@ const LANGUAGES = [
{ value: 'json', label: 'JSON' },
{ value: 'kotlin', label: 'Kotlin' },
{ value: 'markdown', label: 'Markdown' },
{ value: 'mermaid', label: 'Mermaid' },
{ value: 'python', label: 'Python' },
{ value: 'ruby', label: 'Ruby' },
{ value: 'rust', label: 'Rust' },
@ -39,6 +43,13 @@ export function RichMarkdownCodeBlock({
updateAttributes
}: NodeViewProps): React.JSX.Element {
const language = (node.attrs.language as string) || ''
const [copied, setCopied] = useState(false)
const settings = useAppStore((s) => s.settings)
const isDark =
settings?.theme === 'dark' ||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const isMermaid = language === 'mermaid'
const onChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
@ -47,6 +58,23 @@ export function RichMarkdownCodeBlock({
[updateAttributes]
)
const handleCopy = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
const text = node.textContent
void window.api.ui
.writeClipboardText(text)
.then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
.catch(() => {
// Silently swallow clipboard write failures (e.g. permission denied).
})
},
[node]
)
return (
<NodeViewWrapper className="rich-markdown-code-block-wrapper">
<select
@ -65,7 +93,32 @@ export function RichMarkdownCodeBlock({
<option value={language}>{language}</option>
) : null}
</select>
<button
type="button"
className="code-block-copy-btn"
contentEditable={false}
onClick={handleCopy}
aria-label="Copy code"
title="Copy code"
>
{copied ? (
<>
<Check size={14} />
<span className="code-block-copy-label">Copied</span>
</>
) : (
<Copy size={14} />
)}
</button>
<NodeViewContent<'pre'> as="pre" />
{/* Why: mermaid diagrams render as a live SVG preview below the editable
source so users can see the result while editing. The code block stays
editable the diagram is read-only output. */}
{isMermaid && node.textContent.trim() && (
<div contentEditable={false} className="mermaid-preview">
<MermaidBlock content={node.textContent.trim()} isDark={isDark} />
</div>
)}
</NodeViewWrapper>
)
}