mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
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:
parent
1b868cbc35
commit
e5f75564af
10 changed files with 2556 additions and 1315 deletions
|
|
@ -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",
|
||||
|
|
|
|||
924
pnpm-lock.yaml
924
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
506
src/renderer/src/assets/markdown-preview.css
Normal file
506
src/renderer/src/assets/markdown-preview.css
Normal 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;
|
||||
}
|
||||
611
src/renderer/src/assets/rich-markdown-editor.css
Normal file
611
src/renderer/src/assets/rich-markdown-editor.css
Normal 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);
|
||||
}
|
||||
279
src/renderer/src/assets/terminal.css
Normal file
279
src/renderer/src/assets/terminal.css
Normal 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;
|
||||
}
|
||||
74
src/renderer/src/components/editor/CodeBlockCopyButton.tsx
Normal file
74
src/renderer/src/components/editor/CodeBlockCopyButton.tsx
Normal 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 ''
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
75
src/renderer/src/components/editor/MermaidBlock.tsx
Normal file
75
src/renderer/src/components/editor/MermaidBlock.tsx
Normal 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} />
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue