Skip to content

Commit c9f78f7

Browse files
Copilotcyanzhong
andauthored
Initial change to integrate Harper.js (#3)
* Initial plan * Implement Harper.js proofreading extension for MarkEdit - Refactor src/lint.ts: clean Harper linter wrapper using LocalLinter - Create src/extension.ts: CodeMirror extension with decorations, tooltips, and update listener - Update main.ts: MarkEdit API integration using addExtension Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * Address code review: remove custom decoration property, use textContent for messages Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * Refactor: split extension.ts into styling, decoration, and tooltip files Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com>
1 parent 9ae5ab3 commit c9f78f7

6 files changed

Lines changed: 258 additions & 15 deletions

File tree

main.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
import { lint } from './src/lint';
1+
import { MarkEdit } from 'markedit-api';
2+
import { proofreadingExtension } from './src/extension';
23

3-
(async () => {
4-
// For testing purpose only
5-
const results = lint('Helllo, is this something you want?');
6-
console.log(results);
7-
})();
4+
MarkEdit.addExtension(proofreadingExtension());

src/decoration.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { EditorView, Decoration } from '@codemirror/view';
2+
import type { DecorationSet } from '@codemirror/view';
3+
import { StateField, StateEffect } from '@codemirror/state';
4+
import { SuggestionKind } from 'harper.js';
5+
import type { Lint, Suggestion } from 'harper.js';
6+
7+
export interface Diagnostic {
8+
from: number;
9+
to: number;
10+
title: string;
11+
message: string;
12+
actions: DiagnosticAction[];
13+
}
14+
15+
export interface DiagnosticAction {
16+
name: string;
17+
apply: (view: EditorView, from: number, to: number) => void;
18+
}
19+
20+
export const setDiagnosticsEffect = StateEffect.define<Diagnostic[]>();
21+
22+
export const diagnosticsField = StateField.define<{ diagnostics: Diagnostic[]; decorations: DecorationSet }>({
23+
create() {
24+
return { diagnostics: [], decorations: Decoration.none };
25+
},
26+
update(value, tr) {
27+
if (tr.docChanged && value.decorations !== Decoration.none) {
28+
value = { diagnostics: value.diagnostics, decorations: value.decorations.map(tr.changes) };
29+
}
30+
31+
for (const effect of tr.effects) {
32+
if (effect.is(setDiagnosticsEffect)) {
33+
const diagnostics = effect.value;
34+
const ranges = diagnostics
35+
.filter(d => d.from < d.to)
36+
.map(d => Decoration.mark({ class: 'cm-harper-lint' }).range(d.from, d.to));
37+
38+
value = { diagnostics, decorations: Decoration.set(ranges, true) };
39+
}
40+
}
41+
42+
return value;
43+
},
44+
provide: f => EditorView.decorations.from(f, val => val.decorations),
45+
});
46+
47+
export function lintToDiagnostic(l: Lint): Diagnostic {
48+
const span = l.span();
49+
50+
return {
51+
from: span.start,
52+
to: span.end,
53+
title: l.lint_kind_pretty(),
54+
message: l.message(),
55+
actions: l.suggestions().map(suggestionToAction),
56+
};
57+
}
58+
59+
function suggestionToAction(sug: Suggestion): DiagnosticAction {
60+
const kind = sug.kind();
61+
const replacement = sug.get_replacement_text();
62+
63+
let name: string;
64+
if (kind === SuggestionKind.Remove) {
65+
name = 'Remove';
66+
} else if (kind === SuggestionKind.InsertAfter) {
67+
name = `Insert "${replacement}"`;
68+
} else {
69+
name = replacement;
70+
}
71+
72+
return {
73+
name,
74+
apply(view, from, to) {
75+
if (kind === SuggestionKind.Remove) {
76+
view.dispatch({ changes: { from, to, insert: '' }, selection: { anchor: from } });
77+
} else if (kind === SuggestionKind.Replace) {
78+
view.dispatch({ changes: { from, to, insert: replacement }, selection: { anchor: from + replacement.length } });
79+
} else if (kind === SuggestionKind.InsertAfter) {
80+
view.dispatch({ changes: { from: to, to, insert: replacement }, selection: { anchor: to + replacement.length } });
81+
}
82+
},
83+
};
84+
}

src/extension.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { ViewPlugin } from '@codemirror/view';
2+
import type { ViewUpdate } from '@codemirror/view';
3+
import type { Extension } from '@codemirror/state';
4+
import { EditorView } from '@codemirror/view';
5+
import { diagnosticsField, setDiagnosticsEffect, lintToDiagnostic } from './decoration';
6+
import { lintTooltip } from './tooltip';
7+
import { baseTheme } from './styling';
8+
import { lint } from './lint';
9+
10+
const lintDelay = 500;
11+
12+
const lintScheduler = ViewPlugin.fromClass(class {
13+
private timeout: ReturnType<typeof setTimeout> | undefined;
14+
15+
constructor(readonly view: EditorView) {
16+
this.scheduleLint();
17+
}
18+
19+
update(update: ViewUpdate) {
20+
if (update.docChanged) {
21+
this.scheduleLint();
22+
}
23+
}
24+
25+
scheduleLint() {
26+
clearTimeout(this.timeout);
27+
this.timeout = setTimeout(() => { void this.runLint(); }, lintDelay);
28+
}
29+
30+
async runLint() {
31+
const doc = this.view.state.doc;
32+
const text = doc.sliceString(0);
33+
const lints = await lint(text);
34+
35+
if (this.view.state.doc !== doc) {
36+
return;
37+
}
38+
39+
this.view.dispatch({ effects: setDiagnosticsEffect.of(lints.map(lintToDiagnostic)) });
40+
}
41+
42+
destroy() {
43+
clearTimeout(this.timeout);
44+
}
45+
});
46+
47+
export function proofreadingExtension(): Extension {
48+
return [diagnosticsField, lintScheduler, lintTooltip, baseTheme];
49+
}

src/lint.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,6 @@
11
import { LocalLinter, binaryInlined } from 'harper.js';
22

3-
const linter = new LocalLinter({
4-
binary: binaryInlined,
5-
});
6-
7-
linter.lint('Helllo, is this something you want?').then(results => {
8-
for (const result of results) {
9-
console.log(result.suggestions());
10-
}
11-
});
3+
const linter = new LocalLinter({ binary: binaryInlined });
124

135
export async function lint(text: string) {
146
return await linter.lint(text);

src/styling.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { EditorView } from '@codemirror/view';
2+
3+
export const baseTheme = EditorView.baseTheme({
4+
'.cm-harper-lint': {
5+
backgroundImage: 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'6\' height=\'3\'%3E%3Cpath d=\'m0 3 l2 -2 l1 0 l2 2 l1 0\' stroke=\'%23d4a017\' fill=\'none\' stroke-width=\'.7\'/%3E%3C/svg%3E")',
6+
backgroundRepeat: 'repeat-x',
7+
backgroundPosition: 'bottom',
8+
paddingBottom: '0.7px',
9+
},
10+
'.cm-harper-tooltip': {
11+
padding: '4px 8px',
12+
maxWidth: '400px',
13+
},
14+
'.cm-harper-diagnostic + .cm-harper-diagnostic': {
15+
marginTop: '8px',
16+
paddingTop: '8px',
17+
borderTop: '1px solid #ddd',
18+
},
19+
'.cm-harper-title': {
20+
fontWeight: 'bold',
21+
marginBottom: '2px',
22+
fontSize: '13px',
23+
},
24+
'.cm-harper-message': {
25+
fontSize: '12px',
26+
lineHeight: '1.4',
27+
marginBottom: '4px',
28+
},
29+
'.cm-harper-actions': {
30+
display: 'flex',
31+
flexWrap: 'wrap',
32+
gap: '4px',
33+
},
34+
'.cm-harper-action': {
35+
padding: '2px 8px',
36+
border: '1px solid #ccc',
37+
borderRadius: '4px',
38+
background: '#f5f5f5',
39+
cursor: 'pointer',
40+
fontSize: '12px',
41+
'&:hover': {
42+
background: '#e0e0e0',
43+
},
44+
},
45+
'&dark .cm-harper-diagnostic + .cm-harper-diagnostic': {
46+
borderTopColor: '#444',
47+
},
48+
'&dark .cm-harper-action': {
49+
borderColor: '#555',
50+
background: '#333',
51+
color: '#eee',
52+
'&:hover': {
53+
background: '#444',
54+
},
55+
},
56+
});

src/tooltip.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { hoverTooltip } from '@codemirror/view';
2+
import { diagnosticsField } from './decoration';
3+
4+
export const lintTooltip = hoverTooltip((view, pos, side) => {
5+
const { diagnostics } = view.state.field(diagnosticsField);
6+
const found = diagnostics.filter(d =>
7+
pos >= d.from && pos <= d.to &&
8+
(pos > d.from || side > 0) &&
9+
(pos < d.to || side < 0),
10+
);
11+
12+
if (found.length === 0) {
13+
return null;
14+
}
15+
16+
return {
17+
pos: found[0].from,
18+
end: found[found.length - 1].to,
19+
above: true,
20+
create(tooltipView) {
21+
const dom = document.createElement('div');
22+
dom.className = 'cm-harper-tooltip';
23+
24+
for (const diagnostic of found) {
25+
const item = document.createElement('div');
26+
item.className = 'cm-harper-diagnostic';
27+
28+
const title = document.createElement('div');
29+
title.className = 'cm-harper-title';
30+
title.textContent = diagnostic.title;
31+
item.appendChild(title);
32+
33+
const message = document.createElement('div');
34+
message.className = 'cm-harper-message';
35+
message.textContent = diagnostic.message;
36+
item.appendChild(message);
37+
38+
if (diagnostic.actions.length > 0) {
39+
const actions = document.createElement('div');
40+
actions.className = 'cm-harper-actions';
41+
42+
for (const action of diagnostic.actions) {
43+
const button = document.createElement('button');
44+
button.className = 'cm-harper-action';
45+
button.textContent = action.name;
46+
button.onmousedown = (e) => {
47+
e.preventDefault();
48+
const current = view.state.field(diagnosticsField).diagnostics.find(d => d === diagnostic);
49+
if (current) {
50+
action.apply(tooltipView, current.from, current.to);
51+
}
52+
};
53+
actions.appendChild(button);
54+
}
55+
56+
item.appendChild(actions);
57+
}
58+
59+
dom.appendChild(item);
60+
}
61+
62+
return { dom };
63+
},
64+
};
65+
});

0 commit comments

Comments
 (0)