Skip to content

Commit bb42882

Browse files
committed
feat(editor): strike tasks, link tips, fence copy
Checked to-dos, hover URLs, and a copy button in fences were the remaining source-mode gaps versus Inkdrop v6.
1 parent 96f850b commit bb42882

10 files changed

Lines changed: 518 additions & 4 deletions

File tree

apps/desktop/src/renderer/components/MarkdownEditor.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import { scrollBehavior } from '../utils/motion';
6767
import { useEditorBufferStore } from '../stores/editorBufferStore';
6868
import { useSettingsStore, selectEditor } from '../stores/settings';
6969
import { createNesExtension, requestNesCompletion } from '../editor/nes';
70+
import { editorPolishExtensions } from '../editor/editorPolish';
7071
import { setEditorView } from '../hooks/useCommandRegistry';
7172
import { emojiShortcodeCompletions } from '../plugins/emojiShortcodes';
7273
import {
@@ -434,6 +435,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
434435
// Embed inline preview (shows images after ![[...]] syntax)
435436
embedInlinePreview(target => getEmbedUrlRef.current?.(target) ?? null),
436437

438+
// Checked-task strike, link URL tooltip, fence copy
439+
...editorPolishExtensions,
440+
437441
// Placeholder
438442
EditorView.contentAttributes.of({ 'data-placeholder': placeholder }),
439443

apps/desktop/src/renderer/components/editor/FenceBlock.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1-
import { Children, isValidElement, type ReactElement, type ReactNode } from 'react';
2-
import { splitCodeLines } from '../../utils/splitCodeLines';
1+
import {
2+
Children,
3+
isValidElement,
4+
useCallback,
5+
useState,
6+
type ReactElement,
7+
type ReactNode,
8+
} from 'react';
9+
import { nodeText, splitCodeLines } from '../../utils/splitCodeLines';
310
import styles from './MarkdownPreview.module.css';
411

512
interface FenceBlockProps {
@@ -8,6 +15,16 @@ interface FenceBlockProps {
815

916
export function FenceBlock({ children }: FenceBlockProps) {
1017
const code = findCodeElement(children);
18+
const [copied, setCopied] = useState(false);
19+
20+
const copy = useCallback(async () => {
21+
if (!code) return;
22+
const source = nodeText(code.props.children);
23+
await navigator.clipboard.writeText(source.replace(/\n$/, ''));
24+
setCopied(true);
25+
window.setTimeout(() => setCopied(false), 1200);
26+
}, [code]);
27+
1128
if (!code) {
1229
return <pre>{children}</pre>;
1330
}
@@ -19,6 +36,14 @@ export function FenceBlock({ children }: FenceBlockProps) {
1936
return (
2037
<div className={styles.fence}>
2138
{filename ? <div className={styles.fenceChip}>{filename}</div> : null}
39+
<button
40+
type="button"
41+
className={styles.fenceCopy}
42+
aria-label="Copy code block"
43+
onClick={() => void copy()}
44+
>
45+
{copied ? 'Copied' : 'Copy'}
46+
</button>
2247
<pre>
2348
<code className={className} {...rest}>
2449
{lines.map((line, index) => (

apps/desktop/src/renderer/components/editor/MarkdownPreview.module.css

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,11 @@
276276
gap: 8px;
277277
}
278278

279+
.markdown-preview li:global(.task-list-item):has(input:checked) {
280+
color: var(--text-muted);
281+
text-decoration: line-through;
282+
}
283+
279284
.markdown-preview input[type="checkbox"] {
280285
-webkit-appearance: none;
281286
appearance: none;
@@ -397,6 +402,7 @@
397402
}
398403

399404
.fence {
405+
position: relative;
400406
margin: 1em 0;
401407
border: 1px solid var(--border-subtle);
402408
border-radius: 8px;
@@ -416,6 +422,32 @@
416422
white-space: nowrap;
417423
}
418424

425+
.fenceCopy {
426+
position: absolute;
427+
top: 6px;
428+
right: 8px;
429+
z-index: 1;
430+
opacity: 0;
431+
padding: 2px 8px;
432+
border: 1px solid var(--border);
433+
border-radius: 6px;
434+
background: var(--bg-elevated);
435+
color: var(--text-secondary);
436+
font-size: 11px;
437+
line-height: 1.4;
438+
cursor: pointer;
439+
}
440+
441+
.fence:hover .fenceCopy,
442+
.fence:focus-within .fenceCopy {
443+
opacity: 1;
444+
}
445+
446+
.fenceCopy:hover {
447+
color: var(--text-primary);
448+
background: var(--bg-hover);
449+
}
450+
419451
.markdown-preview .fence pre {
420452
margin: 0;
421453
padding: 8px 0 10px;

apps/desktop/src/renderer/components/editorTheme.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export function createEditorTheme(fontSize: number, fontFamily: string, lineHeig
6565
},
6666
'.cm-scroller': {
6767
overflow: 'auto',
68+
position: 'relative',
6869
},
6970
'.cm-line': {
7071
padding: '0 4px',
@@ -104,6 +105,41 @@ export function createEditorTheme(fontSize: number, fontFamily: string, lineHeig
104105
'.cm-completionLabel': {
105106
fontWeight: '500',
106107
},
108+
'.cm-task-checked': {
109+
textDecoration: 'line-through',
110+
color: 'var(--cm-strikethrough, var(--text-muted))',
111+
},
112+
'.cm-md-link-tooltip': {
113+
backgroundColor: 'var(--cm-tooltip-bg, var(--bg-elevated))',
114+
color: 'var(--cm-link, var(--accent))',
115+
border: '1px solid var(--cm-tooltip-border, var(--border))',
116+
borderRadius: '6px',
117+
padding: '4px 8px',
118+
fontSize: '12px',
119+
fontFamily: "'Inter', -apple-system, sans-serif",
120+
maxWidth: '360px',
121+
overflow: 'hidden',
122+
textOverflow: 'ellipsis',
123+
whiteSpace: 'nowrap',
124+
},
125+
'.cm-fence-copy': {
126+
position: 'absolute',
127+
right: '10px',
128+
zIndex: '2',
129+
padding: '2px 8px',
130+
fontSize: '11px',
131+
lineHeight: '1.4',
132+
fontFamily: "'Inter', -apple-system, sans-serif",
133+
color: 'var(--text-secondary)',
134+
backgroundColor: 'var(--bg-elevated)',
135+
border: '1px solid var(--border)',
136+
borderRadius: '6px',
137+
cursor: 'pointer',
138+
},
139+
'.cm-fence-copy:hover': {
140+
color: 'var(--text-primary)',
141+
backgroundColor: 'var(--bg-hover)',
142+
},
107143
});
108144
}
109145

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* Source-mode chrome: strike checked tasks, URL tooltips, fence copy.
3+
*/
4+
5+
import { RangeSetBuilder } from '@codemirror/state';
6+
import {
7+
Decoration,
8+
EditorView,
9+
ViewPlugin,
10+
hoverTooltip,
11+
type DecorationSet,
12+
type ViewUpdate,
13+
} from '@codemirror/view';
14+
import { checkedTaskMarks, fenceAt, markdownLinkAt } from '@dripnex/commands';
15+
16+
const taskMark = Decoration.mark({ class: 'cm-task-checked' });
17+
18+
function taskDecorations(doc: string): DecorationSet {
19+
const builder = new RangeSetBuilder<Decoration>();
20+
for (const mark of checkedTaskMarks(doc)) {
21+
if (mark.to > mark.from) builder.add(mark.from, mark.to, taskMark);
22+
}
23+
return builder.finish();
24+
}
25+
26+
export const checkedTaskStrike = ViewPlugin.fromClass(
27+
class {
28+
decorations: DecorationSet;
29+
30+
constructor(view: EditorView) {
31+
this.decorations = taskDecorations(view.state.doc.toString());
32+
}
33+
34+
update(update: ViewUpdate) {
35+
if (update.docChanged) {
36+
this.decorations = taskDecorations(update.state.doc.toString());
37+
}
38+
}
39+
},
40+
{ decorations: plugin => plugin.decorations }
41+
);
42+
43+
export const markdownLinkTooltip = hoverTooltip(
44+
(view, pos) => {
45+
const line = view.state.doc.lineAt(pos);
46+
const hit = markdownLinkAt(line.text, pos - line.from);
47+
if (!hit) return null;
48+
return {
49+
pos: line.from + hit.from,
50+
end: line.from + hit.to,
51+
above: true,
52+
create() {
53+
const dom = document.createElement('div');
54+
dom.className = 'cm-md-link-tooltip';
55+
dom.textContent = hit.url;
56+
return { dom };
57+
},
58+
};
59+
},
60+
{ hoverTime: 400 }
61+
);
62+
63+
export function fenceCopyExtension(
64+
copy: (text: string) => void | Promise<void> = text => navigator.clipboard.writeText(text)
65+
) {
66+
return ViewPlugin.fromClass(
67+
class {
68+
button: HTMLButtonElement;
69+
label = 'Copy';
70+
71+
constructor(readonly view: EditorView) {
72+
this.button = document.createElement('button');
73+
this.button.type = 'button';
74+
this.button.className = 'cm-fence-copy';
75+
this.button.textContent = this.label;
76+
this.button.setAttribute('aria-label', 'Copy code block');
77+
this.button.addEventListener('mousedown', event => event.preventDefault());
78+
this.button.addEventListener('click', event => {
79+
event.preventDefault();
80+
event.stopPropagation();
81+
const fence = fenceAt(
82+
this.view.state.doc.toString(),
83+
this.view.state.selection.main.head
84+
);
85+
if (!fence) return;
86+
void Promise.resolve(copy(fence.body)).then(() => {
87+
this.button.textContent = 'Copied';
88+
window.setTimeout(() => {
89+
this.button.textContent = this.label;
90+
}, 1200);
91+
});
92+
});
93+
this.button.hidden = true;
94+
this.view.scrollDOM.appendChild(this.button);
95+
this.sync();
96+
}
97+
98+
update(update: ViewUpdate) {
99+
if (
100+
update.docChanged ||
101+
update.selectionSet ||
102+
update.viewportChanged ||
103+
update.geometryChanged
104+
) {
105+
this.sync();
106+
}
107+
}
108+
109+
sync() {
110+
const fence = fenceAt(this.view.state.doc.toString(), this.view.state.selection.main.head);
111+
if (!fence) {
112+
this.button.hidden = true;
113+
return;
114+
}
115+
const from = Math.max(fence.openFrom, this.view.viewport.from);
116+
const coords = this.view.coordsAtPos(from);
117+
const scroller = this.view.scrollDOM.getBoundingClientRect();
118+
if (!coords || coords.top > scroller.bottom - 24) {
119+
this.button.hidden = true;
120+
return;
121+
}
122+
const top = Math.max(4, coords.top - scroller.top) + this.view.scrollDOM.scrollTop;
123+
this.button.hidden = false;
124+
this.button.style.top = `${top}px`;
125+
}
126+
127+
destroy() {
128+
this.button.remove();
129+
}
130+
}
131+
);
132+
}
133+
134+
export const editorPolishExtensions = [
135+
checkedTaskStrike,
136+
markdownLinkTooltip,
137+
fenceCopyExtension(),
138+
];

apps/desktop/src/renderer/utils/__tests__/splitCodeLines.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createElement } from 'react';
22
import { describe, expect, it } from 'vitest';
3-
import { splitCodeLines } from '../splitCodeLines';
3+
import { nodeText, splitCodeLines } from '../splitCodeLines';
44

55
describe('splitCodeLines', () => {
66
it('splits a plain string on newlines and drops a trailing empty line', () => {
@@ -21,3 +21,10 @@ describe('splitCodeLines', () => {
2121
expect(lines).toHaveLength(2);
2222
});
2323
});
24+
25+
describe('nodeText', () => {
26+
it('joins highlighted children into the source string', () => {
27+
const keyword = createElement('span', { className: 'hljs-keyword' }, 'const');
28+
expect(nodeText([keyword, ' x = 1'])).toBe('const x = 1');
29+
});
30+
});

apps/desktop/src/renderer/utils/splitCodeLines.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export function splitCodeLines(children: ReactNode): ReactNode[][] {
4545
return lines;
4646
}
4747

48-
function nodeText(node: ReactNode): string {
48+
export function nodeText(node: ReactNode): string {
4949
if (node == null || node === false) return '';
5050
if (typeof node === 'string' || typeof node === 'number') return String(node);
5151
if (Array.isArray(node)) return node.map(nodeText).join('');

packages/commands/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,12 @@ export {
4646
sanitizeLinkTitle,
4747
} from './markdown/urlPaste.js';
4848
export type { UrlPasteFormat } from './markdown/urlPaste.js';
49+
50+
export {
51+
checkedTaskMarks,
52+
checkedTaskTextOnLine,
53+
fenceAt,
54+
markdownLinkAt,
55+
markdownLinksInLine,
56+
} from './markdown/editorPolish.js';
57+
export type { FenceRange, MarkdownLinkHit, TextSpan } from './markdown/editorPolish.js';

0 commit comments

Comments
 (0)