From 4ca2d4b1a769de5fb84df6bec24162ebea944f00 Mon Sep 17 00:00:00 2001 From: Vadim Melnicuk Date: Sun, 6 Sep 2026 08:01:40 +0100 Subject: [PATCH] fix: restore floating toolbar and spelling suggestions with CSpell --- CHANGELOG.md | 15 +++---- src/extension/panelSession.ts | 40 +++++++++++------- src/spell/cspellCodeActions.ts | 48 +++++++++++++++++++++ webview/src/editor.ts | 77 +++++++++++++++++++++++++++------- webview/src/index.ts | 2 - 5 files changed, 142 insertions(+), 40 deletions(-) create mode 100644 src/spell/cspellCodeActions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c0aa836..da5656c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,15 @@ # Markdown Editor Optimized (MEO) --- ## Unreleased -- Added the theme color `activeLineBackground` to customize the active-line highlight in Live and Source -- Added a shared Read Only toggle beside Spell Check that blocks editing in Live and Source while preserving reading, selection, copying, and external updates; the preference defaults to off and persists across documents, sessions, and workspaces -- Added visual multiline editing to live table cells while storing line breaks as `
` in Markdown -- Added link-reference definitions as visible linked lists in HTML and PDF exports, and restored copying selections across rendered blocks -- Added configurable CodeMirror keymap via `markdownEditorOptimized.keymap` -- Fixed live table cell clicks placing the caret at the end instead of the clicked position +- Added a shared Read Only toggle +- Added the theme color to customize the active-line highlight +- Added visual multiline editing to live table cells +- Added link-reference definitions as visible linked lists in HTML and PDF exports +- Added configurable CodeMirror keymap in settings +- Fixed live table cell clicks caret placing - Fixed the selection formatting toolbox flickering while highlighting text -- Hardened external document sync (git/LLM/outside editors): prefer host content on conflict, fix stuck applyingExternal, Reload recovery +- Fixed disappearing floating toolbar and missing spelling suggestions +- Hardened external document sync ## 0.1.26 - Improved dark Mermaid diagram line contrast diff --git a/src/extension/panelSession.ts b/src/extension/panelSession.ts index 527b71a..7864e37 100644 --- a/src/extension/panelSession.ts +++ b/src/extension/panelSession.ts @@ -35,6 +35,7 @@ import type { ExportStyleEnvironment } from '../export/runtime'; import type { ThemeSettings } from '../shared/themeDefaults'; import type { RawVscodeTheme } from '../shared/vscodeTheme'; import type { OutlinePosition } from '../shared/extensionConfig'; +import { cspellReplacementEdit } from '../spell/cspellCodeActions'; import { collectMeoSpellDiagnostics, collectMeoSpellSuggestions, @@ -1679,36 +1680,45 @@ function simpleReplacementFromCodeAction( requestedRange: { from: number; to: number }, action: vscode.Command | vscode.CodeAction ): string | null { - if (!('edit' in action) || !action.edit || ('disabled' in action && action.disabled)) { + if ('disabled' in action && action.disabled) { return null; } - const entries = action.edit.entries(); - if (entries.length !== 1) { - return null; + let edit: vscode.TextEdit; + if ('edit' in action && action.edit) { + const entries = action.edit.entries(); + if (entries.length !== 1) { + return null; + } + const [uri, edits] = entries[0]; + if (uri.toString() !== document.uri.toString() || edits.length !== 1) { + return null; + } + [edit] = edits; + } else { + const commandEdit = cspellReplacementEdit(action, document.uri.toString(), document.version); + if (!commandEdit) { + return null; + } + edit = new vscode.TextEdit(new vscode.Range( + commandEdit.range.start.line, + commandEdit.range.start.character, + commandEdit.range.end.line, + commandEdit.range.end.character + ), commandEdit.newText); } - const [uri, edits] = entries[0]; - if (uri.toString() !== document.uri.toString() || edits.length !== 1) { - return null; - } - - const [edit] = edits; const documentText = document.getText(); const editFrom = mapDocumentOffsetToNormalizedOffset(documentText, document.offsetAt(edit.range.start)); const editTo = mapDocumentOffsetToNormalizedOffset(documentText, document.offsetAt(edit.range.end)); const editRange = clampDiagnosticRange(editFrom, editTo, documentText.replace(/\r\n?/g, '\n').length); - if (!editRange || !rangesOverlap(editRange, requestedRange)) { + if (!editRange || editRange.from !== requestedRange.from || editRange.to !== requestedRange.to) { return null; } return edit.newText; } -function rangesOverlap(left: { from: number; to: number }, right: { from: number; to: number }): boolean { - return left.from < right.to && right.from < left.to; -} - async function handleSaveImageFromClipboard( message: SaveImageFromClipboardMessage, documentUri: vscode.Uri diff --git a/src/spell/cspellCodeActions.ts b/src/spell/cspellCodeActions.ts new file mode 100644 index 0000000..cf3664b --- /dev/null +++ b/src/spell/cspellCodeActions.ts @@ -0,0 +1,48 @@ +import type * as vscode from 'vscode'; + +type Position = { line: number; character: number }; +type ReplacementEdit = { range: { start: Position; end: Position }; newText: string }; + +const isRecord = (value: unknown): value is Record => ( + typeof value === 'object' && value !== null +); + +function isPosition(value: unknown): value is Position { + return isRecord(value) && + Number.isInteger(value.line) && Number(value.line) >= 0 && + Number.isInteger(value.character) && Number(value.character) >= 0; +} + +export function cspellReplacementEdit( + action: vscode.Command | vscode.CodeAction, + documentUri: string, + documentVersion: number +): ReplacementEdit | null { + const command = typeof action.command === 'string' ? action : action.command; + if (!command || command.command !== 'cSpell.editText') { + return null; + } + + // Read CSpell's replacement without executing its command or moving editor focus. + const args: unknown = 'arguments' in command ? command.arguments : undefined; + if (!Array.isArray(args) || args.length !== 3) { + return null; + } + const [uri, version, edits] = args; + if (uri !== documentUri || version !== documentVersion || !Array.isArray(edits) || edits.length !== 1) { + return null; + } + + const edit: unknown = edits[0]; + if (!isRecord(edit) || typeof edit.newText !== 'string' || !isRecord(edit.range)) { + return null; + } + const { start, end } = edit.range; + if (!isPosition(start) || !isPosition(end)) { + return null; + } + if (end.line < start.line || (end.line === start.line && end.character <= start.character)) { + return null; + } + return { range: { start, end }, newText: edit.newText }; +} diff --git a/webview/src/editor.ts b/webview/src/editor.ts index f4a55ad..7d247ae 100644 --- a/webview/src/editor.ts +++ b/webview/src/editor.ts @@ -53,6 +53,7 @@ import { } from './helpers/tables'; import { parseFrontmatter, sourceFrontmatterField } from './helpers/frontmatter'; import { collectLatexMathRanges } from './helpers/math'; +import type { SelectionMenuState } from './helpers/selectionMenu'; import { diagnosticDataField, diagnosticField, setDiagnosticsEffect, type EditorDiagnostic } from './helpers/diagnostics'; declare module '@codemirror/view' { @@ -214,6 +215,7 @@ export function createEditor({ anchorX: number; anchorY: number; anchorBottomY: number; + suggestions?: string[]; } | null = null; const expandVimLeader = (keys: string, leaderKey: string) => keys.replace(//gi, leaderKey || '\\'); @@ -734,13 +736,10 @@ export function createEditor({ return coords; }; - const getActiveTableSelectionState = (input) => { + const getActiveTableSelectionState = (input: HTMLTextAreaElement): (SelectionMenuState & { from: number; to: number }) | null => { const selection = getTableInputDocumentSelection(input); if (!selection) return null; const diagnostic = diagnosticForRange(selection.from, selection.to); - if (diagnostic) { - requestDiagnosticSuggestionsFor(diagnostic, selection); - } return { visible: true, from: selection.from, @@ -1015,6 +1014,37 @@ export function createEditor({ diagnostic.from === from && diagnostic.to === to )); + const publishSelectionMenu = ( + state: SelectionMenuState & { from?: number; to?: number } + ): void => { + if (!state.visible || state.from === undefined || state.to === undefined) { + clearDiagnosticSuggestionState(); + onSelectionChange?.(state); + return; + } + + const diagnostic = diagnosticForRange(state.from, state.to); + if (!diagnostic) { + clearDiagnosticSuggestionState(); + onSelectionChange?.(state); + return; + } + + requestDiagnosticSuggestionsFor(diagnostic, { + anchorX: state.anchorX ?? 0, + anchorY: state.anchorY ?? 0, + anchorBottomY: state.anchorBottomY + }); + onSelectionChange?.({ + ...state, + diagnosticSuggestions: pendingDiagnosticSuggestionRequest?.suggestions?.map((text) => ({ + from: diagnostic.from, + to: diagnostic.to, + text + })) + }); + }; + const emitSelectionChange = () => { if (!view || typeof onSelectionChange !== 'function') { return; @@ -1022,7 +1052,7 @@ export function createEditor({ const activeTableInput = getActiveTableInput(); if (activeTableInput) { - onSelectionChange(getActiveTableSelectionState(activeTableInput) ?? { visible: false }); + publishSelectionMenu(getActiveTableSelectionState(activeTableInput) ?? { visible: false }); return; } @@ -1032,26 +1062,26 @@ export function createEditor({ const selection = view.state.selection.main; if (selection.empty) { - onSelectionChange({ visible: false }); + publishSelectionMenu({ visible: false }); return; } const from = Math.min(selection.from, selection.to); const to = Math.max(selection.from, selection.to); if (isSearchMatchSelection(from, to)) { - onSelectionChange({ visible: false }); + publishSelectionMenu({ visible: false }); return; } if (!isRegularInlineSelection(view.state, from, to)) { - onSelectionChange({ visible: false }); + publishSelectionMenu({ visible: false }); return; } const align = isDiagnosticSelectionRange(from, to) ? 'start' : undefined; const nativeAnchor = resolveNativeSelectionAnchor(); if (nativeAnchor) { - onSelectionChange({ + publishSelectionMenu({ visible: true, from, to, @@ -1066,7 +1096,7 @@ export function createEditor({ const fromCoords = view.coordsAtPos(from); const toCoords = view.coordsAtPos(to); if (!fromCoords || !toCoords) { - onSelectionChange({ visible: false }); + publishSelectionMenu({ visible: false }); return; } @@ -1075,7 +1105,7 @@ export function createEditor({ const anchorY = fromCharCoords ? Math.min(fromCoords.top, fromCharCoords.top) : fromCoords.top; const anchorBottomY = fromCharCoords ? Math.max(fromCoords.bottom, fromCharCoords.bottom) : fromCoords.bottom; - onSelectionChange({ + publishSelectionMenu({ visible: true, from, to, @@ -1771,6 +1801,10 @@ export function createEditor({ syncGitGutterVisibility(); emitSearchStateChange(); + if (update.docChanged) { + clearDiagnosticSuggestionState(); + } + if (update.selectionSet) { syncSelectionClass(); emitSelectionChange(); @@ -1779,10 +1813,6 @@ export function createEditor({ onViewportChange?.(); } - if (update.docChanged) { - clearDiagnosticSuggestionState(); - } - if (!update.docChanged || applyingExternal || applyingRenumber) { return; } @@ -2322,8 +2352,17 @@ export function createEditor({ }, setDiagnostics(diagnostics: EditorDiagnostic[]) { currentDiagnostics = Array.isArray(diagnostics) ? diagnostics : []; - clearDiagnosticSuggestionState(); + // Providers can publish diagnostics while a suggestion request is in flight. + const keepSuggestionRequest = pendingDiagnosticSuggestionRequest !== null && currentDiagnostics.some( + (diagnostic) => diagnosticKey(diagnostic) === pendingDiagnosticSuggestionRequest?.key + ); + if (!keepSuggestionRequest) { + clearDiagnosticSuggestionState(); + } view.dispatch({ effects: setDiagnosticsEffect.of(currentDiagnostics) }); + if (!keepSuggestionRequest) { + emitSelectionChange(); + } }, showDiagnosticSuggestions(requestId, payload) { if ( @@ -2347,6 +2386,12 @@ export function createEditor({ return; } + pendingDiagnosticSuggestionRequest.suggestions = payload.suggestions; + // Pointer-up will render the completed selection, including fast responses. + if (selectionPointerId !== null) { + return; + } + onSelectionChange?.({ visible: true, from: payload.from, diff --git a/webview/src/index.ts b/webview/src/index.ts index 01b2798..d40c1b6 100644 --- a/webview/src/index.ts +++ b/webview/src/index.ts @@ -1053,8 +1053,6 @@ const focusEditorFromHost = () => { const applyDiagnosticsFromHost = (diagnostics: unknown): void => { const nextDiagnostics = Array.isArray(diagnostics) ? diagnostics : []; pendingDiagnostics = nextDiagnostics; - pendingDiagnosticSuggestionRequests.clear(); - selectionMenuController.hide(); editor?.setDiagnostics?.(nextDiagnostics); };