From 07a8bd3ba9e21c1cb1b93abff7bf94a12a4cb0e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:46:14 +0000 Subject: [PATCH 1/4] Initial plan From f7e8739acb3886a22d5374150f7c0b01c1f6272a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:55:03 +0000 Subject: [PATCH 2/4] 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> --- main.ts | 9 +- src/extension.ts | 255 +++++++++++++++++++++++++++++++++++++++++++++++ src/lint.ts | 10 +- 3 files changed, 259 insertions(+), 15 deletions(-) create mode 100644 src/extension.ts diff --git a/main.ts b/main.ts index 3f10a46..a174902 100644 --- a/main.ts +++ b/main.ts @@ -1,7 +1,4 @@ -import { lint } from './src/lint'; +import { MarkEdit } from 'markedit-api'; +import { proofreadingExtension } from './src/extension'; -(async () => { - // For testing purpose only - const results = lint('Helllo, is this something you want?'); - console.log(results); -})(); +MarkEdit.addExtension(proofreadingExtension()); diff --git a/src/extension.ts b/src/extension.ts new file mode 100644 index 0000000..3a48595 --- /dev/null +++ b/src/extension.ts @@ -0,0 +1,255 @@ +import { EditorView, Decoration, ViewPlugin, hoverTooltip } from '@codemirror/view'; +import type { DecorationSet, ViewUpdate } from '@codemirror/view'; +import { StateField, StateEffect } from '@codemirror/state'; +import type { Extension } from '@codemirror/state'; +import { SuggestionKind } from 'harper.js'; +import type { Lint, Suggestion } from 'harper.js'; +import { lint } from './lint'; + +// Diagnostic produced from a Harper lint result +interface Diagnostic { + from: number; + to: number; + title: string; + messageHtml: string; + actions: DiagnosticAction[]; +} + +interface DiagnosticAction { + name: string; + apply: (view: EditorView, from: number, to: number) => void; +} + +// State effect to replace all diagnostics +const setDiagnosticsEffect = StateEffect.define(); + +// State field storing current diagnostics and their decorations +const diagnosticsField = StateField.define<{ diagnostics: Diagnostic[]; decorations: DecorationSet }>({ + create() { + return { diagnostics: [], decorations: Decoration.none }; + }, + update(value, tr) { + if (tr.docChanged && value.decorations !== Decoration.none) { + value = { diagnostics: value.diagnostics, decorations: value.decorations.map(tr.changes) }; + } + + for (const effect of tr.effects) { + if (effect.is(setDiagnosticsEffect)) { + const diagnostics = effect.value; + const ranges = diagnostics + .filter(d => d.from < d.to) + .map(d => Decoration.mark({ class: 'cm-harper-lint', diagnostic: d }).range(d.from, d.to)); + + value = { diagnostics, decorations: Decoration.set(ranges, true) }; + } + } + + return value; + }, + provide: f => EditorView.decorations.from(f, val => val.decorations), +}); + +// Debounce delay in milliseconds before re-linting after a document change +const lintDelay = 500; + +// View plugin that schedules lint runs on document changes +const lintScheduler = ViewPlugin.fromClass(class { + private timeout: ReturnType | undefined; + + constructor(readonly view: EditorView) { + this.scheduleLint(); + } + + update(update: ViewUpdate) { + if (update.docChanged) { + this.scheduleLint(); + } + } + + scheduleLint() { + clearTimeout(this.timeout); + this.timeout = setTimeout(() => { void this.runLint(); }, lintDelay); + } + + async runLint() { + const doc = this.view.state.doc; + const text = doc.sliceString(0); + const lints = await lint(text); + + // Discard results if the document changed while linting + if (this.view.state.doc !== doc) { + return; + } + + this.view.dispatch({ effects: setDiagnosticsEffect.of(lints.map(lintToDiagnostic)) }); + } + + destroy() { + clearTimeout(this.timeout); + } +}); + +// Convert a Harper Lint into a Diagnostic +function lintToDiagnostic(l: Lint): Diagnostic { + const span = l.span(); + + return { + from: span.start, + to: span.end, + title: l.lint_kind_pretty(), + messageHtml: l.message_html(), + actions: l.suggestions().map(suggestionToAction), + }; +} + +function suggestionToAction(sug: Suggestion): DiagnosticAction { + const kind = sug.kind(); + const replacement = sug.get_replacement_text(); + + let name: string; + if (kind === SuggestionKind.Remove) { + name = 'Remove'; + } else if (kind === SuggestionKind.InsertAfter) { + name = `Insert "${replacement}"`; + } else { + name = replacement; + } + + return { + name, + apply(view, from, to) { + if (kind === SuggestionKind.Remove) { + view.dispatch({ changes: { from, to, insert: '' }, selection: { anchor: from } }); + } else if (kind === SuggestionKind.Replace) { + view.dispatch({ changes: { from, to, insert: replacement }, selection: { anchor: from + replacement.length } }); + } else if (kind === SuggestionKind.InsertAfter) { + view.dispatch({ changes: { from: to, to, insert: replacement }, selection: { anchor: to + replacement.length } }); + } + }, + }; +} + +// Hover tooltip that displays diagnostics and suggestions +const lintTooltip = hoverTooltip((view, pos, side) => { + const { diagnostics } = view.state.field(diagnosticsField); + const found = diagnostics.filter(d => + pos >= d.from && pos <= d.to && + (pos > d.from || side > 0) && + (pos < d.to || side < 0), + ); + + if (found.length === 0) { + return null; + } + + return { + pos: found[0].from, + end: found[found.length - 1].to, + above: true, + create(tooltipView) { + const dom = document.createElement('div'); + dom.className = 'cm-harper-tooltip'; + + for (const diagnostic of found) { + const item = document.createElement('div'); + item.className = 'cm-harper-diagnostic'; + + const title = document.createElement('div'); + title.className = 'cm-harper-title'; + title.textContent = diagnostic.title; + item.appendChild(title); + + const message = document.createElement('div'); + message.className = 'cm-harper-message'; + message.innerHTML = diagnostic.messageHtml; + item.appendChild(message); + + if (diagnostic.actions.length > 0) { + const actions = document.createElement('div'); + actions.className = 'cm-harper-actions'; + + for (const action of diagnostic.actions) { + const button = document.createElement('button'); + button.className = 'cm-harper-action'; + button.textContent = action.name; + button.onmousedown = (e) => { + e.preventDefault(); + const current = view.state.field(diagnosticsField).diagnostics.find(d => d === diagnostic); + if (current) { + action.apply(tooltipView, current.from, current.to); + } + }; + actions.appendChild(button); + } + + item.appendChild(actions); + } + + dom.appendChild(item); + } + + return { dom }; + }, + }; +}); + +// Base theme for lint decorations and tooltips +const baseTheme = EditorView.baseTheme({ + '.cm-harper-lint': { + 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")', + backgroundRepeat: 'repeat-x', + backgroundPosition: 'bottom', + paddingBottom: '0.7px', + }, + '.cm-harper-tooltip': { + padding: '4px 8px', + maxWidth: '400px', + }, + '.cm-harper-diagnostic + .cm-harper-diagnostic': { + marginTop: '8px', + paddingTop: '8px', + borderTop: '1px solid #ddd', + }, + '.cm-harper-title': { + fontWeight: 'bold', + marginBottom: '2px', + fontSize: '13px', + }, + '.cm-harper-message': { + fontSize: '12px', + lineHeight: '1.4', + marginBottom: '4px', + }, + '.cm-harper-actions': { + display: 'flex', + flexWrap: 'wrap', + gap: '4px', + }, + '.cm-harper-action': { + padding: '2px 8px', + border: '1px solid #ccc', + borderRadius: '4px', + background: '#f5f5f5', + cursor: 'pointer', + fontSize: '12px', + '&:hover': { + background: '#e0e0e0', + }, + }, + '&dark .cm-harper-diagnostic + .cm-harper-diagnostic': { + borderTopColor: '#444', + }, + '&dark .cm-harper-action': { + borderColor: '#555', + background: '#333', + color: '#eee', + '&:hover': { + background: '#444', + }, + }, +}); + +// Public extension to add to the editor +export function proofreadingExtension(): Extension { + return [diagnosticsField, lintScheduler, lintTooltip, baseTheme]; +} diff --git a/src/lint.ts b/src/lint.ts index 565eb38..d1b6e9e 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -1,14 +1,6 @@ import { LocalLinter, binaryInlined } from 'harper.js'; -const linter = new LocalLinter({ - binary: binaryInlined, -}); - -linter.lint('Helllo, is this something you want?').then(results => { - for (const result of results) { - console.log(result.suggestions()); - } -}); +const linter = new LocalLinter({ binary: binaryInlined }); export async function lint(text: string) { return await linter.lint(text); From 24ab470c26eca20f766c9151125f734ddabcb627 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:56:38 +0000 Subject: [PATCH 3/4] Address code review: remove custom decoration property, use textContent for messages Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> --- src/extension.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 3a48595..d329b78 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,7 +11,7 @@ interface Diagnostic { from: number; to: number; title: string; - messageHtml: string; + message: string; actions: DiagnosticAction[]; } @@ -38,7 +38,7 @@ const diagnosticsField = StateField.define<{ diagnostics: Diagnostic[]; decorati const diagnostics = effect.value; const ranges = diagnostics .filter(d => d.from < d.to) - .map(d => Decoration.mark({ class: 'cm-harper-lint', diagnostic: d }).range(d.from, d.to)); + .map(d => Decoration.mark({ class: 'cm-harper-lint' }).range(d.from, d.to)); value = { diagnostics, decorations: Decoration.set(ranges, true) }; } @@ -97,7 +97,7 @@ function lintToDiagnostic(l: Lint): Diagnostic { from: span.start, to: span.end, title: l.lint_kind_pretty(), - messageHtml: l.message_html(), + message: l.message(), actions: l.suggestions().map(suggestionToAction), }; } @@ -161,7 +161,7 @@ const lintTooltip = hoverTooltip((view, pos, side) => { const message = document.createElement('div'); message.className = 'cm-harper-message'; - message.innerHTML = diagnostic.messageHtml; + message.textContent = diagnostic.message; item.appendChild(message); if (diagnostic.actions.length > 0) { From cea8716ada7cd19998a3852584331250832df6f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 01:10:09 +0000 Subject: [PATCH 4/4] Refactor: split extension.ts into styling, decoration, and tooltip files Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> --- src/decoration.ts | 84 ++++++++++++++++++ src/extension.ts | 218 ++-------------------------------------------- src/styling.ts | 56 ++++++++++++ src/tooltip.ts | 65 ++++++++++++++ 4 files changed, 211 insertions(+), 212 deletions(-) create mode 100644 src/decoration.ts create mode 100644 src/styling.ts create mode 100644 src/tooltip.ts diff --git a/src/decoration.ts b/src/decoration.ts new file mode 100644 index 0000000..b2dcfd4 --- /dev/null +++ b/src/decoration.ts @@ -0,0 +1,84 @@ +import { EditorView, Decoration } from '@codemirror/view'; +import type { DecorationSet } from '@codemirror/view'; +import { StateField, StateEffect } from '@codemirror/state'; +import { SuggestionKind } from 'harper.js'; +import type { Lint, Suggestion } from 'harper.js'; + +export interface Diagnostic { + from: number; + to: number; + title: string; + message: string; + actions: DiagnosticAction[]; +} + +export interface DiagnosticAction { + name: string; + apply: (view: EditorView, from: number, to: number) => void; +} + +export const setDiagnosticsEffect = StateEffect.define(); + +export const diagnosticsField = StateField.define<{ diagnostics: Diagnostic[]; decorations: DecorationSet }>({ + create() { + return { diagnostics: [], decorations: Decoration.none }; + }, + update(value, tr) { + if (tr.docChanged && value.decorations !== Decoration.none) { + value = { diagnostics: value.diagnostics, decorations: value.decorations.map(tr.changes) }; + } + + for (const effect of tr.effects) { + if (effect.is(setDiagnosticsEffect)) { + const diagnostics = effect.value; + const ranges = diagnostics + .filter(d => d.from < d.to) + .map(d => Decoration.mark({ class: 'cm-harper-lint' }).range(d.from, d.to)); + + value = { diagnostics, decorations: Decoration.set(ranges, true) }; + } + } + + return value; + }, + provide: f => EditorView.decorations.from(f, val => val.decorations), +}); + +export function lintToDiagnostic(l: Lint): Diagnostic { + const span = l.span(); + + return { + from: span.start, + to: span.end, + title: l.lint_kind_pretty(), + message: l.message(), + actions: l.suggestions().map(suggestionToAction), + }; +} + +function suggestionToAction(sug: Suggestion): DiagnosticAction { + const kind = sug.kind(); + const replacement = sug.get_replacement_text(); + + let name: string; + if (kind === SuggestionKind.Remove) { + name = 'Remove'; + } else if (kind === SuggestionKind.InsertAfter) { + name = `Insert "${replacement}"`; + } else { + name = replacement; + } + + return { + name, + apply(view, from, to) { + if (kind === SuggestionKind.Remove) { + view.dispatch({ changes: { from, to, insert: '' }, selection: { anchor: from } }); + } else if (kind === SuggestionKind.Replace) { + view.dispatch({ changes: { from, to, insert: replacement }, selection: { anchor: from + replacement.length } }); + } else if (kind === SuggestionKind.InsertAfter) { + view.dispatch({ changes: { from: to, to, insert: replacement }, selection: { anchor: to + replacement.length } }); + } + }, + }; +} diff --git a/src/extension.ts b/src/extension.ts index d329b78..531098e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,58 +1,14 @@ -import { EditorView, Decoration, ViewPlugin, hoverTooltip } from '@codemirror/view'; -import type { DecorationSet, ViewUpdate } from '@codemirror/view'; -import { StateField, StateEffect } from '@codemirror/state'; +import { ViewPlugin } from '@codemirror/view'; +import type { ViewUpdate } from '@codemirror/view'; import type { Extension } from '@codemirror/state'; -import { SuggestionKind } from 'harper.js'; -import type { Lint, Suggestion } from 'harper.js'; +import { EditorView } from '@codemirror/view'; +import { diagnosticsField, setDiagnosticsEffect, lintToDiagnostic } from './decoration'; +import { lintTooltip } from './tooltip'; +import { baseTheme } from './styling'; import { lint } from './lint'; -// Diagnostic produced from a Harper lint result -interface Diagnostic { - from: number; - to: number; - title: string; - message: string; - actions: DiagnosticAction[]; -} - -interface DiagnosticAction { - name: string; - apply: (view: EditorView, from: number, to: number) => void; -} - -// State effect to replace all diagnostics -const setDiagnosticsEffect = StateEffect.define(); - -// State field storing current diagnostics and their decorations -const diagnosticsField = StateField.define<{ diagnostics: Diagnostic[]; decorations: DecorationSet }>({ - create() { - return { diagnostics: [], decorations: Decoration.none }; - }, - update(value, tr) { - if (tr.docChanged && value.decorations !== Decoration.none) { - value = { diagnostics: value.diagnostics, decorations: value.decorations.map(tr.changes) }; - } - - for (const effect of tr.effects) { - if (effect.is(setDiagnosticsEffect)) { - const diagnostics = effect.value; - const ranges = diagnostics - .filter(d => d.from < d.to) - .map(d => Decoration.mark({ class: 'cm-harper-lint' }).range(d.from, d.to)); - - value = { diagnostics, decorations: Decoration.set(ranges, true) }; - } - } - - return value; - }, - provide: f => EditorView.decorations.from(f, val => val.decorations), -}); - -// Debounce delay in milliseconds before re-linting after a document change const lintDelay = 500; -// View plugin that schedules lint runs on document changes const lintScheduler = ViewPlugin.fromClass(class { private timeout: ReturnType | undefined; @@ -76,7 +32,6 @@ const lintScheduler = ViewPlugin.fromClass(class { const text = doc.sliceString(0); const lints = await lint(text); - // Discard results if the document changed while linting if (this.view.state.doc !== doc) { return; } @@ -89,167 +44,6 @@ const lintScheduler = ViewPlugin.fromClass(class { } }); -// Convert a Harper Lint into a Diagnostic -function lintToDiagnostic(l: Lint): Diagnostic { - const span = l.span(); - - return { - from: span.start, - to: span.end, - title: l.lint_kind_pretty(), - message: l.message(), - actions: l.suggestions().map(suggestionToAction), - }; -} - -function suggestionToAction(sug: Suggestion): DiagnosticAction { - const kind = sug.kind(); - const replacement = sug.get_replacement_text(); - - let name: string; - if (kind === SuggestionKind.Remove) { - name = 'Remove'; - } else if (kind === SuggestionKind.InsertAfter) { - name = `Insert "${replacement}"`; - } else { - name = replacement; - } - - return { - name, - apply(view, from, to) { - if (kind === SuggestionKind.Remove) { - view.dispatch({ changes: { from, to, insert: '' }, selection: { anchor: from } }); - } else if (kind === SuggestionKind.Replace) { - view.dispatch({ changes: { from, to, insert: replacement }, selection: { anchor: from + replacement.length } }); - } else if (kind === SuggestionKind.InsertAfter) { - view.dispatch({ changes: { from: to, to, insert: replacement }, selection: { anchor: to + replacement.length } }); - } - }, - }; -} - -// Hover tooltip that displays diagnostics and suggestions -const lintTooltip = hoverTooltip((view, pos, side) => { - const { diagnostics } = view.state.field(diagnosticsField); - const found = diagnostics.filter(d => - pos >= d.from && pos <= d.to && - (pos > d.from || side > 0) && - (pos < d.to || side < 0), - ); - - if (found.length === 0) { - return null; - } - - return { - pos: found[0].from, - end: found[found.length - 1].to, - above: true, - create(tooltipView) { - const dom = document.createElement('div'); - dom.className = 'cm-harper-tooltip'; - - for (const diagnostic of found) { - const item = document.createElement('div'); - item.className = 'cm-harper-diagnostic'; - - const title = document.createElement('div'); - title.className = 'cm-harper-title'; - title.textContent = diagnostic.title; - item.appendChild(title); - - const message = document.createElement('div'); - message.className = 'cm-harper-message'; - message.textContent = diagnostic.message; - item.appendChild(message); - - if (diagnostic.actions.length > 0) { - const actions = document.createElement('div'); - actions.className = 'cm-harper-actions'; - - for (const action of diagnostic.actions) { - const button = document.createElement('button'); - button.className = 'cm-harper-action'; - button.textContent = action.name; - button.onmousedown = (e) => { - e.preventDefault(); - const current = view.state.field(diagnosticsField).diagnostics.find(d => d === diagnostic); - if (current) { - action.apply(tooltipView, current.from, current.to); - } - }; - actions.appendChild(button); - } - - item.appendChild(actions); - } - - dom.appendChild(item); - } - - return { dom }; - }, - }; -}); - -// Base theme for lint decorations and tooltips -const baseTheme = EditorView.baseTheme({ - '.cm-harper-lint': { - 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")', - backgroundRepeat: 'repeat-x', - backgroundPosition: 'bottom', - paddingBottom: '0.7px', - }, - '.cm-harper-tooltip': { - padding: '4px 8px', - maxWidth: '400px', - }, - '.cm-harper-diagnostic + .cm-harper-diagnostic': { - marginTop: '8px', - paddingTop: '8px', - borderTop: '1px solid #ddd', - }, - '.cm-harper-title': { - fontWeight: 'bold', - marginBottom: '2px', - fontSize: '13px', - }, - '.cm-harper-message': { - fontSize: '12px', - lineHeight: '1.4', - marginBottom: '4px', - }, - '.cm-harper-actions': { - display: 'flex', - flexWrap: 'wrap', - gap: '4px', - }, - '.cm-harper-action': { - padding: '2px 8px', - border: '1px solid #ccc', - borderRadius: '4px', - background: '#f5f5f5', - cursor: 'pointer', - fontSize: '12px', - '&:hover': { - background: '#e0e0e0', - }, - }, - '&dark .cm-harper-diagnostic + .cm-harper-diagnostic': { - borderTopColor: '#444', - }, - '&dark .cm-harper-action': { - borderColor: '#555', - background: '#333', - color: '#eee', - '&:hover': { - background: '#444', - }, - }, -}); - -// Public extension to add to the editor export function proofreadingExtension(): Extension { return [diagnosticsField, lintScheduler, lintTooltip, baseTheme]; } diff --git a/src/styling.ts b/src/styling.ts new file mode 100644 index 0000000..f2e5de5 --- /dev/null +++ b/src/styling.ts @@ -0,0 +1,56 @@ +import { EditorView } from '@codemirror/view'; + +export const baseTheme = EditorView.baseTheme({ + '.cm-harper-lint': { + 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")', + backgroundRepeat: 'repeat-x', + backgroundPosition: 'bottom', + paddingBottom: '0.7px', + }, + '.cm-harper-tooltip': { + padding: '4px 8px', + maxWidth: '400px', + }, + '.cm-harper-diagnostic + .cm-harper-diagnostic': { + marginTop: '8px', + paddingTop: '8px', + borderTop: '1px solid #ddd', + }, + '.cm-harper-title': { + fontWeight: 'bold', + marginBottom: '2px', + fontSize: '13px', + }, + '.cm-harper-message': { + fontSize: '12px', + lineHeight: '1.4', + marginBottom: '4px', + }, + '.cm-harper-actions': { + display: 'flex', + flexWrap: 'wrap', + gap: '4px', + }, + '.cm-harper-action': { + padding: '2px 8px', + border: '1px solid #ccc', + borderRadius: '4px', + background: '#f5f5f5', + cursor: 'pointer', + fontSize: '12px', + '&:hover': { + background: '#e0e0e0', + }, + }, + '&dark .cm-harper-diagnostic + .cm-harper-diagnostic': { + borderTopColor: '#444', + }, + '&dark .cm-harper-action': { + borderColor: '#555', + background: '#333', + color: '#eee', + '&:hover': { + background: '#444', + }, + }, +}); diff --git a/src/tooltip.ts b/src/tooltip.ts new file mode 100644 index 0000000..7bed160 --- /dev/null +++ b/src/tooltip.ts @@ -0,0 +1,65 @@ +import { hoverTooltip } from '@codemirror/view'; +import { diagnosticsField } from './decoration'; + +export const lintTooltip = hoverTooltip((view, pos, side) => { + const { diagnostics } = view.state.field(diagnosticsField); + const found = diagnostics.filter(d => + pos >= d.from && pos <= d.to && + (pos > d.from || side > 0) && + (pos < d.to || side < 0), + ); + + if (found.length === 0) { + return null; + } + + return { + pos: found[0].from, + end: found[found.length - 1].to, + above: true, + create(tooltipView) { + const dom = document.createElement('div'); + dom.className = 'cm-harper-tooltip'; + + for (const diagnostic of found) { + const item = document.createElement('div'); + item.className = 'cm-harper-diagnostic'; + + const title = document.createElement('div'); + title.className = 'cm-harper-title'; + title.textContent = diagnostic.title; + item.appendChild(title); + + const message = document.createElement('div'); + message.className = 'cm-harper-message'; + message.textContent = diagnostic.message; + item.appendChild(message); + + if (diagnostic.actions.length > 0) { + const actions = document.createElement('div'); + actions.className = 'cm-harper-actions'; + + for (const action of diagnostic.actions) { + const button = document.createElement('button'); + button.className = 'cm-harper-action'; + button.textContent = action.name; + button.onmousedown = (e) => { + e.preventDefault(); + const current = view.state.field(diagnosticsField).diagnostics.find(d => d === diagnostic); + if (current) { + action.apply(tooltipView, current.from, current.to); + } + }; + actions.appendChild(button); + } + + item.appendChild(actions); + } + + dom.appendChild(item); + } + + return { dom }; + }, + }; +});