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);
};