fix: restore floating toolbar and spelling suggestions with CSpell

This commit is contained in:
Vadim Melnicuk
2026-09-06 08:01:40 +01:00
parent 9c2273b06e
commit 4ca2d4b1a7
5 changed files with 142 additions and 40 deletions
+8 -7
View File
@@ -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 `<br>` 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
+25 -15
View File
@@ -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
+48
View File
@@ -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<string, unknown> => (
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 };
}
+61 -16
View File
@@ -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(/<leader>/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,
-2
View File
@@ -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);
};