Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/desktop/src/renderer/components/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import { scrollBehavior } from '../utils/motion';
import { useEditorBufferStore } from '../stores/editorBufferStore';
import { useSettingsStore, selectEditor } from '../stores/settings';
import { createNesExtension, requestNesCompletion } from '../editor/nes';
import { editorPolishExtensions } from '../editor/editorPolish';
import { setEditorView } from '../hooks/useCommandRegistry';
import { emojiShortcodeCompletions } from '../plugins/emojiShortcodes';
import {
Expand Down Expand Up @@ -442,6 +443,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
// Embed inline preview (shows images after ![[...]] syntax)
embedInlinePreview(target => getEmbedUrlRef.current?.(target) ?? null),

// Checked-task strike, link URL tooltip, fence copy
...editorPolishExtensions,

// Placeholder
EditorView.contentAttributes.of({ 'data-placeholder': placeholder }),

Expand Down
29 changes: 27 additions & 2 deletions apps/desktop/src/renderer/components/editor/FenceBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { Children, isValidElement, type ReactElement, type ReactNode } from 'react';
import { splitCodeLines } from '../../utils/splitCodeLines';
import {
Children,
isValidElement,
useCallback,
useState,
type ReactElement,
type ReactNode,
} from 'react';
import { nodeText, splitCodeLines } from '../../utils/splitCodeLines';
import styles from './MarkdownPreview.module.css';

interface FenceBlockProps {
Expand All @@ -8,6 +15,16 @@ interface FenceBlockProps {

export function FenceBlock({ children }: FenceBlockProps) {
const code = findCodeElement(children);
const [copied, setCopied] = useState(false);

const copy = useCallback(async () => {
if (!code) return;
const source = nodeText(code.props.children);
await navigator.clipboard.writeText(source.replace(/\n$/, ''));
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
}, [code]);

if (!code) {
return <pre>{children}</pre>;
}
Expand All @@ -19,6 +36,14 @@ export function FenceBlock({ children }: FenceBlockProps) {
return (
<div className={styles.fence}>
{filename ? <div className={styles.fenceChip}>{filename}</div> : null}
<button
type="button"
className={styles.fenceCopy}
aria-label="Copy code block"
onClick={() => void copy()}
>
{copied ? 'Copied' : 'Copy'}
</button>
<pre>
<code className={className} {...rest}>
{lines.map((line, index) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,11 @@
gap: 8px;
}

.markdown-preview li:global(.task-list-item):has(input:checked) {
color: var(--text-muted);
text-decoration: line-through;
}

.markdown-preview input[type="checkbox"] {
-webkit-appearance: none;
appearance: none;
Expand Down Expand Up @@ -397,6 +402,7 @@
}

.fence {
position: relative;
margin: 1em 0;
border: 1px solid var(--border-subtle);
border-radius: 8px;
Expand All @@ -416,6 +422,32 @@
white-space: nowrap;
}

.fenceCopy {
position: absolute;
top: 6px;
right: 8px;
z-index: 1;
opacity: 0;
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-elevated);
color: var(--text-secondary);
font-size: 11px;
line-height: 1.4;
cursor: pointer;
}

.fence:hover .fenceCopy,
.fence:focus-within .fenceCopy {
opacity: 1;
}

.fenceCopy:hover {
color: var(--text-primary);
background: var(--bg-hover);
}

.markdown-preview .fence pre {
margin: 0;
padding: 8px 0 10px;
Expand Down
36 changes: 36 additions & 0 deletions apps/desktop/src/renderer/components/editorTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export function createEditorTheme(fontSize: number, fontFamily: string, lineHeig
},
'.cm-scroller': {
overflow: 'auto',
position: 'relative',
},
'.cm-line': {
padding: '0 4px',
Expand Down Expand Up @@ -104,6 +105,41 @@ export function createEditorTheme(fontSize: number, fontFamily: string, lineHeig
'.cm-completionLabel': {
fontWeight: '500',
},
'.cm-task-checked': {
textDecoration: 'line-through',
color: 'var(--cm-strikethrough, var(--text-muted))',
},
'.cm-md-link-tooltip': {
backgroundColor: 'var(--cm-tooltip-bg, var(--bg-elevated))',
color: 'var(--cm-link, var(--accent))',
border: '1px solid var(--cm-tooltip-border, var(--border))',
borderRadius: '6px',
padding: '4px 8px',
fontSize: '12px',
fontFamily: "'Inter', -apple-system, sans-serif",
maxWidth: '360px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
'.cm-fence-copy': {
position: 'absolute',
right: '10px',
zIndex: '2',
padding: '2px 8px',
fontSize: '11px',
lineHeight: '1.4',
fontFamily: "'Inter', -apple-system, sans-serif",
color: 'var(--text-secondary)',
backgroundColor: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: '6px',
cursor: 'pointer',
},
'.cm-fence-copy:hover': {
color: 'var(--text-primary)',
backgroundColor: 'var(--bg-hover)',
},
});
}

Expand Down
138 changes: 138 additions & 0 deletions apps/desktop/src/renderer/editor/editorPolish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* Source-mode chrome: strike checked tasks, URL tooltips, fence copy.
*/

import { RangeSetBuilder } from '@codemirror/state';
import {
Decoration,
EditorView,
ViewPlugin,
hoverTooltip,
type DecorationSet,
type ViewUpdate,
} from '@codemirror/view';
import { checkedTaskMarks, fenceAt, markdownLinkAt } from '@dripnex/commands';

const taskMark = Decoration.mark({ class: 'cm-task-checked' });

function taskDecorations(doc: string): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
for (const mark of checkedTaskMarks(doc)) {
if (mark.to > mark.from) builder.add(mark.from, mark.to, taskMark);
}
return builder.finish();
}

export const checkedTaskStrike = ViewPlugin.fromClass(
class {
decorations: DecorationSet;

constructor(view: EditorView) {
this.decorations = taskDecorations(view.state.doc.toString());
}

update(update: ViewUpdate) {
if (update.docChanged) {
this.decorations = taskDecorations(update.state.doc.toString());
}
}
},
{ decorations: plugin => plugin.decorations }
);

export const markdownLinkTooltip = hoverTooltip(
(view, pos) => {
const line = view.state.doc.lineAt(pos);
const hit = markdownLinkAt(line.text, pos - line.from);
if (!hit) return null;
return {
pos: line.from + hit.from,
end: line.from + hit.to,
above: true,
create() {
const dom = document.createElement('div');
dom.className = 'cm-md-link-tooltip';
dom.textContent = hit.url;
return { dom };
},
};
},
{ hoverTime: 400 }
);

export function fenceCopyExtension(
copy: (text: string) => void | Promise<void> = text => navigator.clipboard.writeText(text)
) {
return ViewPlugin.fromClass(
class {
button: HTMLButtonElement;
label = 'Copy';

constructor(readonly view: EditorView) {
this.button = document.createElement('button');
this.button.type = 'button';
this.button.className = 'cm-fence-copy';
this.button.textContent = this.label;
this.button.setAttribute('aria-label', 'Copy code block');
this.button.addEventListener('mousedown', event => event.preventDefault());
this.button.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
const fence = fenceAt(
this.view.state.doc.toString(),
this.view.state.selection.main.head
);
if (!fence) return;
void Promise.resolve(copy(fence.body)).then(() => {
this.button.textContent = 'Copied';
window.setTimeout(() => {
this.button.textContent = this.label;
}, 1200);
});
});
this.button.hidden = true;
this.view.scrollDOM.appendChild(this.button);
this.sync();
}

update(update: ViewUpdate) {
if (
update.docChanged ||
update.selectionSet ||
update.viewportChanged ||
update.geometryChanged
) {
this.sync();
}
}

sync() {
const fence = fenceAt(this.view.state.doc.toString(), this.view.state.selection.main.head);
if (!fence) {
this.button.hidden = true;
return;
}
const from = Math.max(fence.openFrom, this.view.viewport.from);
const coords = this.view.coordsAtPos(from);
const scroller = this.view.scrollDOM.getBoundingClientRect();
if (!coords || coords.top > scroller.bottom - 24) {
this.button.hidden = true;
return;
}
const top = Math.max(4, coords.top - scroller.top) + this.view.scrollDOM.scrollTop;
this.button.hidden = false;
this.button.style.top = `${top}px`;
}

destroy() {
this.button.remove();
}
}
);
}

export const editorPolishExtensions = [
checkedTaskStrike,
markdownLinkTooltip,
fenceCopyExtension(),
];
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createElement } from 'react';
import { describe, expect, it } from 'vitest';
import { splitCodeLines } from '../splitCodeLines';
import { nodeText, splitCodeLines } from '../splitCodeLines';

describe('splitCodeLines', () => {
it('splits a plain string on newlines and drops a trailing empty line', () => {
Expand All @@ -21,3 +21,10 @@ describe('splitCodeLines', () => {
expect(lines).toHaveLength(2);
});
});

describe('nodeText', () => {
it('joins highlighted children into the source string', () => {
const keyword = createElement('span', { className: 'hljs-keyword' }, 'const');
expect(nodeText([keyword, ' x = 1'])).toBe('const x = 1');
});
});
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/utils/splitCodeLines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function splitCodeLines(children: ReactNode): ReactNode[][] {
return lines;
}

function nodeText(node: ReactNode): string {
export function nodeText(node: ReactNode): string {
if (node == null || node === false) return '';
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(nodeText).join('');
Expand Down
9 changes: 9 additions & 0 deletions packages/commands/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,12 @@ export {
sanitizeLinkTitle,
} from './markdown/urlPaste.js';
export type { UrlPasteFormat } from './markdown/urlPaste.js';

export {
checkedTaskMarks,
checkedTaskTextOnLine,
fenceAt,
markdownLinkAt,
markdownLinksInLine,
} from './markdown/editorPolish.js';
export type { FenceRange, MarkdownLinkHit, TextSpan } from './markdown/editorPolish.js';
Loading
Loading