From 589d398a57644af599524643e78cd9983c628406 Mon Sep 17 00:00:00 2001 From: Apostol Apostolov Date: Sun, 19 Jul 2026 04:19:32 -0700 Subject: [PATCH 01/10] feat: Live read-only reading view Add markdownEditorOptimized.live.readOnly (default false), a toolbar toggle, and a command so Live mode can act as a calm reading surface: no active-line highlight, no active-line source reveal, and no edits. Selection and copy still work; Source remains the edit surface (or turn the option off to edit in Live). Skips auto-focus steal while reading so chat/agent inputs keep the keyboard. External doc sync still applies via annotated transactions. Closes #71 --- CHANGELOG.md | 4 ++ README.md | 1 + package.json | 11 ++++ src/extension.ts | 16 ++++++ src/extension/panelSession.ts | 22 ++++++++ src/shared/extensionConfig.ts | 5 ++ webview/src/editor.ts | 102 ++++++++++++++++++++++++++++++---- webview/src/helpers/tables.ts | 6 ++ webview/src/index.ts | 90 ++++++++++++++++++++++++++++-- webview/src/liveMode.ts | 13 ++++- webview/src/styles.css | 34 ++++++++++++ webview/src/types.d.ts | 5 +- 12 files changed, 292 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91ce234..b21bbcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Markdown Editor Optimized (MEO) --- +## Unreleased +- Added Live read-only reading view (`markdownEditorOptimized.live.readOnly`) with toolbar toggle and command +- Read-only Live disables active-line highlight/source reveal and blocks edits while keeping selection/copy + ## 0.1.26 - Improved dark Mermaid diagram line contrast - Fixed live find matches and preserve active highlight diff --git a/README.md b/README.md index f072e05..8805ccf 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ An optimized markdown editor with live editing mode for VS Code. ### Writing & Editing - **Live/Source modes** - Switch between clean writing and raw markdown in a single tab +- **Live read-only** - Optional reading view (no active-line source swap, no accidental edits); edit in Source or turn the option off - **Toolbar formatting** - Insert headings, lists, tasks, tables, code blocks, links, images, and quotes in one click - **Floating selection menu** - Instantly apply bold, italic, strikethrough, inline code, or links on any text selection - **Spellcheck** - Fix issues with built-in spelling suggestions diff --git a/package.json b/package.json index c504a16..b3aa73d 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,11 @@ "title": "Markdown Editor Optimized: Toggle Live/Source Mode", "icon": "$(sync)" }, + { + "command": "markdownEditorOptimized.toggleLiveReadOnly", + "title": "Markdown Editor Optimized: Toggle Live Read-only", + "icon": "$(book)" + }, { "command": "markdownEditorOptimized.exportHtml", "title": "Markdown Editor Optimized: Export as HTML", @@ -185,6 +190,12 @@ "order": 3, "description": "Constrain the editor content to a centered 800px column." }, + "markdownEditorOptimized.live.readOnly": { + "type": "boolean", + "default": false, + "order": 3, + "description": "When enabled, Live mode is a read-only reading view: no active-line source reveal, no active-line highlight, and no typing/edits. Switch to Source (or turn this off) to edit. Default off." + }, "markdownEditorOptimized.lineNumbers.visible": { "type": "boolean", "default": true, diff --git a/src/extension.ts b/src/extension.ts index 327ae98..1c43255 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -39,6 +39,7 @@ import { VIM_MODE_SETTING_KEY, CODE_BLOCKS_VSCODE_THEME_SETTING_KEY, CONTENT_MAX_WIDTH_SETTING_KEY, + LIVE_READ_ONLY_SETTING_KEY, SPELL_CHECK_SETTING_KEY, getUseVscodeThemeForCodeBlocks, getCodeBlockVscodeTheme, @@ -53,6 +54,7 @@ import { getOutlinePosition, getOutlineVisible, getContentMaxWidthEnabled, + getLiveReadOnlyEnabled, getSpellCheckEnabled, getThemeSettings, getVimKeybindings, @@ -435,6 +437,9 @@ export function activate(context: vscode.ExtensionContext): void { ); context.subscriptions.push( + vscode.commands.registerCommand('markdownEditorOptimized.toggleLiveReadOnly', async () => { + await provider.toggleLiveReadOnly(); + }), vscode.commands.registerCommand('markdownEditorOptimized.toggleMode', async () => { await provider.toggleActiveEditorMode(); }) @@ -557,6 +562,10 @@ class MarkdownWebviewProvider implements vscode.CustomTextEditorProvider { this.broadcast({ type: 'contentMaxWidthChanged', enabled: getContentMaxWidthEnabled(this.context) }); } + if (event.affectsConfiguration(`${EXTENSION_CONFIG_SECTION}.${LIVE_READ_ONLY_SETTING_KEY}`)) { + this.broadcast({ type: 'liveReadOnlyChanged', enabled: getLiveReadOnlyEnabled() }); + } + if ( event.affectsConfiguration(`${EXTENSION_CONFIG_SECTION}.${VIM_MODE_BEHAVIOR_SETTING_KEY}`) || event.affectsConfiguration(`${EXTENSION_CONFIG_SECTION}.${VIM_MODE_SETTING_KEY}`) || @@ -623,6 +632,13 @@ class MarkdownWebviewProvider implements vscode.CustomTextEditorProvider { await session.panel.webview.postMessage({ type: 'toggleMode' }); } + async toggleLiveReadOnly(): Promise { + const config = vscode.workspace.getConfiguration(EXTENSION_CONFIG_SECTION); + const current = getLiveReadOnlyEnabled(); + await config.update(LIVE_READ_ONLY_SETTING_KEY, !current, vscode.ConfigurationTarget.Global); + // Configuration listener broadcasts liveReadOnlyChanged. + } + async resolveCustomTextEditor( document: vscode.TextDocument, panel: vscode.WebviewPanel, diff --git a/src/extension/panelSession.ts b/src/extension/panelSession.ts index 08f2f6e..fa4f152 100644 --- a/src/extension/panelSession.ts +++ b/src/extension/panelSession.ts @@ -6,12 +6,14 @@ import { LINE_NUMBERS_SETTING_KEY, GIT_CHANGES_GUTTER_SETTING_KEY, CONTENT_MAX_WIDTH_SETTING_KEY, + LIVE_READ_ONLY_SETTING_KEY, SPELL_CHECK_SETTING_KEY, getContentMaxWidthEnabled, getLineNumbersEnabled, getGitChangesGutterEnabled, getGitDiffLineHighlightsEnabled, getSpellCheckEnabled, + getLiveReadOnlyEnabled, getOutlinePosition, getOutlineVisible, getRememberPositionLines, @@ -53,6 +55,7 @@ type InitMessage = { diagnostics: SerializedDiagnostic[]; mode: EditorMode; lineNumbers: boolean; + liveReadOnly: boolean; gitChangesGutter: boolean; gitDiffLineHighlights: boolean; spellCheckEnabled: boolean; @@ -185,6 +188,11 @@ type SetContentMaxWidthMessage = { enabled: boolean; }; +type SetLiveReadOnlyMessage = { + type: 'setLiveReadOnly'; + enabled: boolean; +}; + type SetFindOptionsMessage = { type: 'setFindOptions'; wholeWord?: boolean; @@ -311,6 +319,7 @@ type WebviewMessage = | SetSpellCheckMessage | SetOutlineVisibleMessage | SetContentMaxWidthMessage + | SetLiveReadOnlyMessage | SetFindOptionsMessage | ViewPositionChangedMessage | OpenLinkMessage @@ -546,6 +555,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam diagnostics: serializeDiagnostics(document), mode, lineNumbers: getLineNumbersEnabled(context), + liveReadOnly: getLiveReadOnlyEnabled(), gitChangesGutter: getGitChangesGutterEnabled(context), gitDiffLineHighlights: getGitDiffLineHighlightsEnabled(), spellCheckEnabled: getSpellCheckEnabled(), @@ -850,6 +860,10 @@ export function createPanelSessionController(params: PanelSessionControllerParam if (!webviewReady) { return; } + // Avoid stealing keyboard focus from chat/agent inputs while Live is in reading mode. + if (mode === 'live' && getLiveReadOnlyEnabled()) { + return; + } await ensureInitDelivered(); if (!initDelivered) { return; @@ -998,6 +1012,14 @@ export function createPanelSessionController(params: PanelSessionControllerParam .getConfiguration(EXTENSION_CONFIG_SECTION) .update(CONTENT_MAX_WIDTH_SETTING_KEY, raw.enabled === true, vscode.ConfigurationTarget.Global); return; + case 'setLiveReadOnly': { + const enabled = raw.enabled === true; + await vscode.workspace + .getConfiguration(EXTENSION_CONFIG_SECTION) + .update(LIVE_READ_ONLY_SETTING_KEY, enabled, vscode.ConfigurationTarget.Global); + return; + } + case 'setFindOptions': { const wholeWord = raw.findOptions?.wholeWord ?? raw.wholeWord; const caseSensitive = raw.findOptions?.caseSensitive ?? raw.caseSensitive; diff --git a/src/shared/extensionConfig.ts b/src/shared/extensionConfig.ts index f0c1f95..a8e9e96 100644 --- a/src/shared/extensionConfig.ts +++ b/src/shared/extensionConfig.ts @@ -18,6 +18,7 @@ export const VIM_MODE_SETTING_KEY = 'vimMode.enabled'; export const CODE_BLOCKS_VSCODE_THEME_SETTING_KEY = 'codeBlocks.useVscodeTheme'; export const REMEMBER_POSITION_LINES_SETTING_KEY = 'rememberPosition.lines'; export const CONTENT_MAX_WIDTH_SETTING_KEY = 'contentMaxWidth.visible'; +export const LIVE_READ_ONLY_SETTING_KEY = 'live.readOnly'; export const LINE_NUMBERS_LEGACY_SETTING_KEY = 'lineNumbers.enabled'; export const LINE_NUMBERS_LEGACY_VISIBLE_SETTING_KEY = 'lineNumbers.visibility'; export const GIT_CHANGES_GUTTER_LEGACY_VISIBLE_SETTING_KEY = 'gitChanges.visibility'; @@ -75,6 +76,10 @@ export function getSpellCheckEnabled(): boolean { return vscode.workspace.getConfiguration(EXTENSION_CONFIG_SECTION).get(SPELL_CHECK_SETTING_KEY, true); } +export function getLiveReadOnlyEnabled(): boolean { + return vscode.workspace.getConfiguration(EXTENSION_CONFIG_SECTION).get(LIVE_READ_ONLY_SETTING_KEY, false); +} + export function getVimModeEnabled(context: vscode.ExtensionContext): boolean { const config = vscode.workspace.getConfiguration(EXTENSION_CONFIG_SECTION); const behavior = getVimModeBehavior(config); diff --git a/webview/src/editor.ts b/webview/src/editor.ts index 94fd0bd..33db8d7 100644 --- a/webview/src/editor.ts +++ b/webview/src/editor.ts @@ -1,4 +1,4 @@ -import { EditorState, Compartment, Transaction, StateEffect, StateField, RangeSetBuilder, type ChangeSpec } from '@codemirror/state'; +import { EditorState, Compartment, Transaction, StateEffect, StateField, RangeSetBuilder, Annotation, type ChangeSpec } from '@codemirror/state'; import { EditorView, keymap, highlightActiveLine, lineNumbers, highlightActiveLineGutter, scrollPastEnd, Decoration, type ViewUpdate } from '@codemirror/view'; import { defaultKeymap, history, historyKeymap, indentMore, indentLess, undo, redo } from '@codemirror/commands'; import { markdown, markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown'; @@ -6,7 +6,7 @@ import { indentUnit, syntaxHighlighting, syntaxTree, forceParsing } from '@codem import { vim, Vim } from '@replit/codemirror-vim'; import { highlightStyle } from './theme'; import { shikiCodeHighlight } from './helpers/shikiDecorations'; -import { liveModeExtensions } from './liveMode'; +import { liveModeExtensions, liveReadingFacet } from './liveMode'; import { headingCollapseSharedExtensions, headingCollapseSourceSpacerExtensions } from './helpers/headingCollapse'; import { resolveCodeLanguage, insertCodeBlock, sourceCodeBlockField } from './helpers/codeBlocks'; import { sourceStrikeMarkerField } from './helpers/strikeMarkers'; @@ -83,6 +83,7 @@ type MarkerReplacementContext = { }; const setSearchQueryEffect = StateEffect.define(); +const meoExternalSyncAnnotation = Annotation.define(); const refreshDecorationsEffect = StateEffect.define(); const searchMatchMark = Decoration.mark({ class: 'meo-search-match' }); const activeSearchMatchMark = Decoration.mark({ class: 'meo-search-match meo-search-match-active' }); @@ -166,6 +167,7 @@ export function createEditor({ initialTopLine = null, initialTopLineOffset = 0, initialLineNumbers = true, + initialLiveReadOnly = false, initialGitGutter = true, initialVimMode = false, initialVimKeybindings = [], @@ -179,8 +181,11 @@ export function createEditor({ const modeCompartment = new Compartment(); const gitGutterCompartment = new Compartment(); const vimCompartment = new Compartment(); + const readingCompartment = new Compartment(); + const activeLineHighlightCompartment = new Compartment(); const startMode = initialMode === 'live' ? 'live' : 'source'; let lineNumbersVisible = initialLineNumbers !== false; + let liveReadOnlyEnabled = initialLiveReadOnly === true; let gitGutterVisible = initialGitGutter !== false; let vimModeEnabled = initialVimMode === true; let vimKeybindings = initialVimKeybindings; @@ -246,6 +251,50 @@ export function createEditor({ let view = null; let currentMode = startMode; let applyingRenumber = false; + + const isReadingLive = () => currentMode === 'live' && liveReadOnlyEnabled; + const readingExtensions = (enabled: boolean) => { + if (!enabled) { + return []; + } + return [ + liveReadingFacet.of(true), + EditorView.editable.of(false), + EditorState.transactionFilter.of((tr) => { + if (tr.annotation(meoExternalSyncAnnotation)) { + return tr; + } + if (tr.docChanged) { + return []; + } + return tr; + }) + ]; + }; + const activeLineHighlightExtensions = (enabled: boolean) => ( + enabled ? [highlightActiveLineGutter(), highlightActiveLine()] : [] + ); + const syncReadingPresentation = () => { + if (!view) { + return; + } + const reading = isReadingLive(); + view.dom.classList.toggle('meo-live-reading', reading); + view.dom.classList.toggle('meo-active-line-highlight-hidden', reading); + }; + const reconfigureReadingState = () => { + if (!view) { + return; + } + const reading = isReadingLive(); + view.dispatch({ + effects: [ + readingCompartment.reconfigure(readingExtensions(reading)), + activeLineHighlightCompartment.reconfigure(activeLineHighlightExtensions(!reading)) + ] + }); + syncReadingPresentation(); + }; let lastSearchStateSignature = ''; // External syncs may carry stale selections in their history entries. // Preserve the user's current cursor once on the next undo of such a change. @@ -1479,8 +1528,8 @@ export function createEditor({ lineNumbers(), ...gitDiffGutterBaselineExtensions(), gitGutterCompartment.of(startMode === 'live' ? gitDiffGutterLiveRenderExtensions() : gitDiffGutterRenderExtensions()), - highlightActiveLineGutter(), - highlightActiveLine(), + readingCompartment.of(readingExtensions(startMode === 'live' && liveReadOnlyEnabled)), + activeLineHighlightCompartment.of(activeLineHighlightExtensions(!(startMode === 'live' && liveReadOnlyEnabled))), shikiCodeHighlight, EditorView.lineWrapping, scrollPastEnd(), @@ -1747,6 +1796,7 @@ export function createEditor({ syncLineNumbersVisibility(); syncGitGutterVisibility(); syncSelectionClass(); + syncReadingPresentation(); view.dispatch({ effects: setDiagnosticsEffect.of(currentDiagnostics) }); emitSelectionChange(); @@ -1892,6 +1942,7 @@ export function createEditor({ setText(textValue) { gitBlameHover?.hide(); clearDiagnosticSuggestionState(); + commitActiveTableInput(); const currentText = view.state.doc.toString(); const syncChange = findSyncChange(currentText, textValue); if (!syncChange) { @@ -1903,12 +1954,16 @@ export function createEditor({ const mappedAnchor = Math.min(mapPositionThroughChange(anchor, syncChange), newLength); const mappedHead = Math.min(mapPositionThroughChange(head, syncChange), newLength); applyingExternal = true; - view.dispatch({ - changes: syncChange, - selection: { anchor: mappedAnchor, head: mappedHead } - }); - applyingExternal = false; - pendingExternalUndoSelectionPreserve = true; + try { + view.dispatch({ + changes: syncChange, + selection: { anchor: mappedAnchor, head: mappedHead }, + annotations: meoExternalSyncAnnotation.of(true) + }); + pendingExternalUndoSelectionPreserve = true; + } finally { + applyingExternal = false; + } syncSelectionClass(); emitSelectionChange(); }, @@ -1926,13 +1981,16 @@ export function createEditor({ const previousMode = currentMode; currentMode = nextMode; try { + const reading = nextMode === 'live' && liveReadOnlyEnabled; view.dispatch({ effects: [ modeCompartment.reconfigure(nextMode === 'live' ? liveModeExtensions() : sourceMode()), gitGutterCompartment.reconfigure( nextMode === 'live' ? gitDiffGutterLiveRenderExtensions() : gitDiffGutterRenderExtensions() ), - vimCompartment.reconfigure(vimExtensionsForState()) + vimCompartment.reconfigure(vimExtensionsForState()), + readingCompartment.reconfigure(readingExtensions(reading)), + activeLineHighlightCompartment.reconfigure(activeLineHighlightExtensions(!reading)) ] }); forceParsing(view, view.state.doc.length, 500); @@ -1943,9 +2001,28 @@ export function createEditor({ } syncModeClasses(); syncGitGutterVisibility(); + syncReadingPresentation(); restoreTopVisibleLine(topPosition.lineNumber, topPosition.lineOffset, { syncCursor: false }); }, + setLiveReadOnly(enabled) { + const nextEnabled = enabled === true; + if (nextEnabled === liveReadOnlyEnabled) { + syncReadingPresentation(); + return; + } + liveReadOnlyEnabled = nextEnabled; + if (liveReadOnlyEnabled) { + commitActiveTableInput(); + } + reconfigureReadingState(); + }, + isLiveReadOnly() { + return liveReadOnlyEnabled; + }, + isReadingLive() { + return isReadingLive(); + }, setLineNumbers(visible) { const nextVisible = visible !== false; if (nextVisible === lineNumbersVisible) { @@ -1985,6 +2062,9 @@ export function createEditor({ } }, insertFormat(action, level) { + if (isReadingLive()) { + return; + } const activeTableInput = getActiveTableInput(); if (activeTableInput) { return insertFormatInActiveTableInput(activeTableInput, action); diff --git a/webview/src/helpers/tables.ts b/webview/src/helpers/tables.ts index 74a139a..2f717f1 100644 --- a/webview/src/helpers/tables.ts +++ b/webview/src/helpers/tables.ts @@ -1,6 +1,7 @@ import { StateField } from '@codemirror/state'; import { syntaxTree } from '@codemirror/language'; import { Decoration, EditorView, WidgetType } from '@codemirror/view'; +import { isLiveReadingMode } from '../liveMode'; import { undo, redo } from '@codemirror/commands'; import { ImageWidget } from './images'; import { emojiData } from './emoji'; @@ -1584,6 +1585,10 @@ class HtmlTableWidget extends WidgetType { } focusCellInput(cell, { updateSelection = false } = {}) { + if (this.view && isLiveReadingMode(this.view.state)) { + return false; + } + const input = cell.querySelector('textarea'); if (!this.focusTableInput(input)) return false; if (!updateSelection) return true; @@ -1927,6 +1932,7 @@ class HtmlTableWidget extends WidgetType { } commit(dom) { + if (this.view && isLiveReadingMode(this.view.state)) return; if (!this.hasPendingCellEdits) return; this.commitMatrix(this.readCellMatrix(), dom); } diff --git a/webview/src/index.ts b/webview/src/index.ts index a488612..af5dd13 100644 --- a/webview/src/index.ts +++ b/webview/src/index.ts @@ -1,4 +1,4 @@ -import { createElement, Heading, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, List, ListOrdered, ListTodo, ListTree, Hash, Code, Terminal, Quote, Minus, Table2, Link, Brackets, Image, Bold, Italic, Strikethrough, Search, Share, GitCompare, PanelLeftRightDashed, SpellCheck2 } from 'lucide'; +import { createElement, Heading, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, List, ListOrdered, ListTodo, ListTree, Hash, Code, Terminal, Quote, Minus, Table2, Link, Brackets, Image, Bold, Italic, Strikethrough, Search, Share, GitCompare, PanelLeftRightDashed, SpellCheck2, BookOpen } from 'lucide'; import { setImageSrcResolver, initializeImageHandling, resolveImageSrc, settleImageSrcRequest, handleSavedImagePath, handleImagePaste } from './helpers/images'; import { createGitClient } from './helpers/gitClient'; import { createOutlineController } from './helpers/outline'; @@ -150,6 +150,7 @@ let vimKeybindingsState: VimKeybinding[] = []; let vimLeaderState = '\\'; let lineNumbersVisible = true; +let liveReadOnlyEnabled = false; let gitChangesGutterVisible = true; let gitDiffLineHighlightsEnabled = true; let spellCheckEnabled = true; @@ -242,6 +243,43 @@ const setLineNumbersVisible = (visible, { post = true } = {}) => { } }; +const isReadingLive = () => currentMode === 'live' && liveReadOnlyEnabled; + +const updateLiveReadOnlyUI = () => { + const reading = isReadingLive(); + liveReadOnlyBtn.classList.toggle('is-active', liveReadOnlyEnabled); + liveReadOnlyBtn.setAttribute('aria-pressed', liveReadOnlyEnabled ? 'true' : 'false'); + liveReadOnlyBtn.title = liveReadOnlyEnabled + ? 'Live read-only on (reading view). Click to allow Live editing.' + : 'Live read-only off. Click for a reading view without edit chrome.'; + liveButton.textContent = liveReadOnlyEnabled ? 'Live ยท Read' : 'Live'; + liveButton.title = liveReadOnlyEnabled ? 'Live (read-only reading view)' : 'Live'; + root.classList.toggle('meo-live-reading', reading); + root.dataset.liveReadonly = liveReadOnlyEnabled ? 'true' : 'false'; + formatGroup.classList.toggle('is-disabled', reading); + formatGroup.setAttribute('aria-disabled', reading ? 'true' : 'false'); + for (const button of formatGroup.querySelectorAll('button')) { + if (button instanceof HTMLButtonElement) { + button.disabled = reading; + } + } + selectionMenuController?.hide?.(); +}; + +const setLiveReadOnlyEnabled = (enabled: boolean, { post = true } = {}) => { + const nextEnabled = enabled === true; + const changed = nextEnabled !== liveReadOnlyEnabled; + liveReadOnlyEnabled = nextEnabled; + if (changed || editor) { + editor?.setLiveReadOnly?.(liveReadOnlyEnabled); + } + updateLiveReadOnlyUI(); + updateModeUI(); + if (post && changed) { + vscode.postMessage({ type: 'setLiveReadOnly', enabled: liveReadOnlyEnabled }); + } +}; + const setGitChangesGutterVisible = (visible, { post = true } = {}) => { const nextVisible = visible !== false; const changed = nextVisible !== gitChangesGutterVisible; @@ -439,7 +477,7 @@ tableGrid.addEventListener('mouseleave', () => { tableGrid.addEventListener('click', (event) => { const cell = (event.target as Element).closest('.table-grid-cell') as HTMLElement | null; - if (!cell || !editor) return; + if (!cell || !editor || isReadingLive()) return; editor.insertFormat('table', { cols: selectedTableCols, rows: selectedTableRows }); editor.focus(); }); @@ -516,13 +554,33 @@ sourceButton.textContent = 'Source'; sourceButton.setAttribute('role', 'tab'); sourceButton.title = 'Source'; -modeGroup.append(liveButton, sourceButton); +const liveReadOnlyBtn = document.createElement('button'); +liveReadOnlyBtn.type = 'button'; +liveReadOnlyBtn.className = 'format-button toggle-button mode-readonly-button'; +liveReadOnlyBtn.dataset.action = 'liveReadOnly'; +liveReadOnlyBtn.title = 'Toggle Live read-only (reading view)'; +liveReadOnlyBtn.setAttribute('aria-label', 'Toggle Live read-only reading view'); +liveReadOnlyBtn.setAttribute('aria-pressed', 'false'); +liveReadOnlyBtn.appendChild(createElement(BookOpen, { width: 18, height: 18 })); + +modeGroup.append(liveButton, sourceButton, liveReadOnlyBtn); +liveReadOnlyBtn.addEventListener('click', () => { + setLiveReadOnlyEnabled(!liveReadOnlyEnabled, { post: true }); +}); const findPanelElements = createFindPanel(findToggleBtn); const findPanelController = createFindPanelController(findPanelElements, () => editor, toolbar, modeGroup); const selectionMenuElements = createSelectionMenu(); const selectionMenuController = createSelectionMenuController(selectionMenuElements, () => editor); +const originalSelectionMenuUpdate = selectionMenuController.update.bind(selectionMenuController); +selectionMenuController.update = (state: any) => { + if (isReadingLive()) { + selectionMenuController.hide(); + return; + } + originalSelectionMenuUpdate(state); +}; const editorNoticeBanner = document.createElement('div'); editorNoticeBanner.className = 'editor-notice'; @@ -932,6 +990,10 @@ const applyRevealSelectionFromHost = (revealMessage: any) => { }; const focusEditorFromHost = () => { + if (isReadingLive()) { + pendingEditorFocus = false; + return; + } if (!editor) { pendingEditorFocus = true; return; @@ -1117,6 +1179,9 @@ const shortcutHandlerContext: ShortcutHandlerContext = { }; const queueChanges = (nextText: string) => { + if (isReadingLive()) { + return; + } bumpLocalEditGeneration(); pendingText = nextText; syncPendingDraftState(); @@ -1147,6 +1212,7 @@ const updateModeUI = () => { button.setAttribute('aria-selected', selected ? 'true' : 'false'); button.tabIndex = selected ? 0 : -1; } + updateLiveReadOnlyUI(); }; const applyMode = (mode: 'live' | 'source', { post = true, persist = true, userTriggered = false, reason = 'user' } = {}): boolean => { @@ -1169,7 +1235,7 @@ const applyMode = (mode: 'live' | 'source', { post = true, persist = true, userT try { editor.setMode(mode); syncGitDiffLineHighlights(); - if (shouldRestoreEditorFocus) { + if (shouldRestoreEditorFocus && !(mode === 'live' && liveReadOnlyEnabled)) { editor.focus(); } if (mode === 'live') { @@ -1246,6 +1312,7 @@ const mountInitialEditor = async () => { initialTopLine, initialTopLineOffset, initialLineNumbers: lineNumbersVisible, + initialLiveReadOnly: liveReadOnlyEnabled, initialGitGutter: gitChangesGutterVisible, initialVimMode: vimModeEnabled, initialVimKeybindings: vimKeybindingsState, @@ -1368,6 +1435,9 @@ const handleInit = (message: any) => { if (typeof message.lineNumbers === 'boolean') { setLineNumbersVisible(message.lineNumbers, { post: false }); } + if (typeof message.liveReadOnly === 'boolean') { + setLiveReadOnlyEnabled(message.liveReadOnly, { post: false }); + } if (typeof message.gitChangesGutter === 'boolean') { setGitChangesGutterVisible(message.gitChangesGutter, { post: false }); } @@ -1505,6 +1575,16 @@ window.addEventListener('message', (event) => { return; } + if (message.type === 'toggleLiveReadOnly') { + setLiveReadOnlyEnabled(!liveReadOnlyEnabled, { post: true }); + return; + } + + if (message.type === 'liveReadOnlyChanged') { + setLiveReadOnlyEnabled(message.enabled === true, { post: false }); + return; + } + if (message.type === 'docChanged' && !editor && pendingInitialText !== null) { clearGitBlameCache({ hideTooltip: false }); documentVersion = message.version; @@ -1859,6 +1939,7 @@ modeGroup.addEventListener('pointerdown', preserveEditorFocusOnModePointerToggle const handleFormatAction = (action: string) => { if (!editor) return; + if (isReadingLive()) return; editor.insertFormat(action); editor.focus(); }; @@ -1956,6 +2037,7 @@ headingDropdown.addEventListener('click', (event) => { const option = (event.target as Element).closest('.heading-dropdown-option') as HTMLElement | null; if (!option || !editor) return; const level = parseInt(option.dataset.level ?? '', 10); + if (isReadingLive()) return; editor.insertFormat('heading', level); editor.focus(); }); diff --git a/webview/src/liveMode.ts b/webview/src/liveMode.ts index 7412b99..2f6ed3b 100644 --- a/webview/src/liveMode.ts +++ b/webview/src/liveMode.ts @@ -1,4 +1,4 @@ -import { RangeSetBuilder, StateField, EditorState } from '@codemirror/state'; +import { RangeSetBuilder, StateField, EditorState, Facet } from '@codemirror/state'; import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { syntaxHighlighting } from '@codemirror/language'; import { Decoration, EditorView, GutterMarker, WidgetType, gutterLineClass } from '@codemirror/view'; @@ -885,7 +885,18 @@ function addSingleTildeStrikeDecorations(builder, state, activeLines, existingSt } } + +/** When true, Live mode stays fully rendered (no active-line source reveal) and is non-editable. */ +export const liveReadingFacet = Facet.define({ + combine: (values) => values.some(Boolean) +}); + +export const isLiveReadingMode = (state: EditorState): boolean => state.facet(liveReadingFacet); + function collectActiveLines(state: EditorState): Set { + if (isLiveReadingMode(state)) { + return new Set(); + } const lines = new Set(); for (const range of state.selection.ranges) { // In live mode, only reveal markdown markers on the focused line. diff --git a/webview/src/styles.css b/webview/src/styles.css index e517ecf..73c78af 100644 --- a/webview/src/styles.css +++ b/webview/src/styles.css @@ -2977,3 +2977,37 @@ body { .cm-editor.meo-mode-live .cm-line.meo-md-alert .meo-md-alert-label-active { color: var(--meo-color-base05) !important; } + + +/* Live read-only reading view */ +.cm-editor.meo-active-line-highlight-hidden .cm-activeLine, +.cm-editor.meo-active-line-highlight-hidden .cm-activeLine.meo-md-code-block, +.cm-editor.meo-mode-live.meo-active-line-highlight-hidden .cm-activeLine.meo-md-code-block { + background-color: transparent; +} + +.cm-editor.meo-active-line-highlight-hidden .cm-gutterElement.cm-activeLineGutter { + background: transparent !important; +} + +.editor-root.meo-live-reading .format-group.is-disabled, +.editor-root.meo-live-reading .format-group[aria-disabled='true'] { + opacity: 0.45; + pointer-events: none; +} + +.editor-root.meo-live-reading .selection-inline-menu { + display: none !important; +} + +.editor-root.meo-live-reading .cm-editor.meo-live-reading .meo-table-add-row-btn, +.editor-root.meo-live-reading .meo-table-add-col-btn, +.editor-root.meo-live-reading .meo-md-html-table-cell-content textarea, +.cm-editor.meo-live-reading .meo-table-add-row-btn, +.cm-editor.meo-live-reading .meo-table-add-col-btn { + pointer-events: none !important; +} + +.mode-group .mode-readonly-button.is-active { + color: var(--vscode-button-foreground, inherit); +} diff --git a/webview/src/types.d.ts b/webview/src/types.d.ts index bb3bcbc..77d8e65 100644 --- a/webview/src/types.d.ts +++ b/webview/src/types.d.ts @@ -15,6 +15,7 @@ type WebviewMessage = | { type: 'setGitChangesGutter'; visible: boolean } | { type: 'setSpellCheck'; enabled: boolean } | { type: 'setContentMaxWidth'; enabled: boolean } + | { type: 'setLiveReadOnly'; enabled: boolean } | { type: 'setOutlineVisible'; visible: boolean } | { type: 'setFindOptions'; findOptions: { wholeWord: boolean; caseSensitive: boolean } } | { type: 'viewPositionChanged'; topLine: number; topLineOffset?: number } @@ -37,7 +38,7 @@ type VimKeybinding = { }; type ExtensionMessage = - | { type: 'init'; text: string; version: number; diagnostics: EditorDiagnostic[]; theme: ThemeSettings; mode: 'live' | 'source'; outlinePosition: 'left' | 'right'; outlineVisible: boolean; lineNumbers: boolean; gitChangesGutter: boolean; gitDiffLineHighlights: boolean; spellCheckEnabled: boolean; contentMaxWidthEnabled: boolean; vimMode: boolean; vimKeybindings: VimKeybinding[]; vimLeader: string; findOptions: { wholeWord: boolean; caseSensitive: boolean }; restoreTopLine?: number; restoreTopLineOffset?: number } + | { type: 'init'; text: string; version: number; diagnostics: EditorDiagnostic[]; theme: ThemeSettings; mode: 'live' | 'source'; outlinePosition: 'left' | 'right'; outlineVisible: boolean; lineNumbers: boolean; liveReadOnly: boolean; gitChangesGutter: boolean; gitDiffLineHighlights: boolean; spellCheckEnabled: boolean; contentMaxWidthEnabled: boolean; vimMode: boolean; vimKeybindings: VimKeybinding[]; vimLeader: string; findOptions: { wholeWord: boolean; caseSensitive: boolean }; restoreTopLine?: number; restoreTopLineOffset?: number } | { type: 'docChanged'; text: string; version: number } | { type: 'applied'; version: number } | { type: 'focusEditor' } @@ -51,6 +52,8 @@ type ExtensionMessage = | { type: 'gitDiffLineHighlightsChanged'; enabled: boolean } | { type: 'spellCheckChanged'; enabled: boolean } | { type: 'contentMaxWidthChanged'; enabled: boolean } + | { type: 'liveReadOnlyChanged'; enabled: boolean } + | { type: 'toggleLiveReadOnly' } | { type: 'vimModeChanged'; enabled: boolean } | { type: 'vimKeybindingsChanged'; keybindings: VimKeybinding[]; leaderKey: string } | { type: 'findOptionsChanged'; findOptions: { wholeWord: boolean; caseSensitive: boolean } } From e254dfa5e942e30010459262e8a096647df9bb66 Mon Sep 17 00:00:00 2001 From: Apostol Apostolov Date: Sat, 5 Sep 2026 13:51:32 -0700 Subject: [PATCH 02/10] feat: configurable CodeMirror keymap via settings (#77) Adds configurable CodeMirror and MEO key bindings with live reload, a curated command list, and VS Code passthrough support. Includes maintainer fixes for shortcut precedence, platform-specific modifiers, and heading collapse targeting. Co-authored-by: Apostol Apostolov Closes #53 --- CHANGELOG.md | 3 + README.md | 1 + package.json | 23 ++ src/extension.ts | 6 + src/extension/panelSession.ts | 4 + src/shared/extensionConfig.ts | 12 ++ src/shared/keymapConfig.ts | 283 +++++++++++++++++++++++++ webview/src/editor.ts | 33 ++- webview/src/helpers/headingCollapse.ts | 2 +- webview/src/helpers/shortcuts.ts | 15 +- webview/src/helpers/userKeymap.ts | 251 ++++++++++++++++++++++ webview/src/index.ts | 36 +++- webview/src/types.d.ts | 3 +- 13 files changed, 664 insertions(+), 8 deletions(-) create mode 100644 src/shared/keymapConfig.ts create mode 100644 webview/src/helpers/userKeymap.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 91ce234..3aeb84d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Markdown Editor Optimized (MEO) --- +## Unreleased +- Added configurable CodeMirror keymap via `markdownEditorOptimized.keymap` (whitelist commands + `passthrough`) + ## 0.1.26 - Improved dark Mermaid diagram line contrast - Fixed live find matches and preserve active highlight diff --git a/README.md b/README.md index f072e05..ede9a20 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ An optimized markdown editor with live editing mode for VS Code. ### Writing & Editing - **Live/Source modes** - Switch between clean writing and raw markdown in a single tab +- **Configurable keymap** - Override CodeMirror chords from settings (`passthrough` releases keys to VS Code) - **Toolbar formatting** - Insert headings, lists, tasks, tables, code blocks, links, images, and quotes in one click - **Floating selection menu** - Instantly apply bold, italic, strikethrough, inline code, or links on any text selection - **Spellcheck** - Fix issues with built-in spelling suggestions diff --git a/package.json b/package.json index c504a16..8b2e7a3 100644 --- a/package.json +++ b/package.json @@ -233,6 +233,29 @@ "order": 6, "description": "Controls Vim emulation in Source and Live mode." }, + "markdownEditorOptimized.keymap": { + "type": "array", + "default": [], + "order": 6, + "markdownDescription": "Override MEO's CodeMirror key bindings. Each entry maps a key chord to a whitelisted command. Use `passthrough` to stop MEO from handling a key so VS Code `keybindings.json` can receive it (e.g. release Ctrl+Shift+F for Find in Files).\n\n**Key format:** `alt+up`, `Mod-Shift-f`, `Ctrl-Enter`. `Mod` means Cmd on macOS and Ctrl on Windows/Linux; `Cmd` and `Ctrl` stay platform-specific. Arrow aliases: `up`/`down`/`left`/`right`.\n\n**Commands (subset):** `passthrough`, `undo`, `redo`, `cursorLineUp`, `cursorLineDown`, `moveLineUp`, `moveLineDown`, `deleteLine`, `indentMore`, `indentLess`, `selectAll`, `foldCode`, `unfoldCode`, `toggleFold`, `foldAll`, `unfoldAll`, `toggleHeadingCollapse`, `openFind`, `openReplace`, `toggleMode`.", + "items": { + "type": "object", + "required": [ + "key", + "command" + ], + "properties": { + "key": { + "type": "string", + "description": "Key chord, e.g. alt+up or Mod-Shift-f." + }, + "command": { + "type": "string", + "description": "Whitelisted command name, or passthrough." + } + } + } + }, "markdownEditorOptimized.codeBlocks.useVscodeTheme": { "type": "boolean", "default": false, diff --git a/src/extension.ts b/src/extension.ts index 327ae98..efa97fe 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -58,6 +58,8 @@ import { getVimKeybindings, getVimLeaderKey, getVimModeEnabled, + getKeymapBindings, + KEYMAP_SETTING_KEY, isMarkdownDocumentPath, migrateLegacyToggleSettings, resetThemeSettingsToDefault @@ -565,6 +567,10 @@ class MarkdownWebviewProvider implements vscode.CustomTextEditorProvider { this.broadcast({ type: 'vimModeChanged', enabled: getVimModeEnabled(this.context) }); } + if (event.affectsConfiguration(`${EXTENSION_CONFIG_SECTION}.${KEYMAP_SETTING_KEY}`)) { + this.broadcast({ type: 'keymapChanged', keymap: getKeymapBindings() }); + } + if ( event.affectsConfiguration('vim.normalModeKeyBindings') || event.affectsConfiguration('vim.normalModeKeyBindingsNonRecursive') || diff --git a/src/extension/panelSession.ts b/src/extension/panelSession.ts index 08f2f6e..d995ba2 100644 --- a/src/extension/panelSession.ts +++ b/src/extension/panelSession.ts @@ -19,10 +19,12 @@ import { getVimKeybindings, getVimLeaderKey, getVimModeEnabled, + getKeymapBindings, getUseVscodeThemeForCodeBlocks, getCodeBlockVscodeTheme, type VimKeybinding } from '../shared/extensionConfig'; +import type { NormalizedKeymapBinding } from '../shared/keymapConfig'; import { openLink, resolveLocalLinkTargets, resolveWebviewImageSrc, resolveWikiLinkTargets } from '../shared/documentLinks'; import { GitDocumentState, hashGitBaselinePayload } from '../git/documentState'; import { openGitRevisionForLine, openGitWorktreeForLine, resolveGitBlameForRequest } from '../git/blameActions'; @@ -60,6 +62,7 @@ type InitMessage = { vimMode: boolean; vimKeybindings: VimKeybinding[]; vimLeader: string; + keymap: NormalizedKeymapBinding[]; findOptions: FindOptions; outlinePosition: OutlinePosition; outlineVisible: boolean; @@ -553,6 +556,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam vimMode: getVimModeEnabled(context), vimKeybindings: getVimKeybindings(), vimLeader: getVimLeaderKey(), + keymap: getKeymapBindings(), findOptions: getFindOptions(), outlinePosition: getOutlinePosition(), outlineVisible: getOutlineVisible(context), diff --git a/src/shared/extensionConfig.ts b/src/shared/extensionConfig.ts index f0c1f95..fb79bb9 100644 --- a/src/shared/extensionConfig.ts +++ b/src/shared/extensionConfig.ts @@ -1,4 +1,9 @@ import * as vscode from 'vscode'; +import { + KEYMAP_SETTING_KEY, + parseKeymapBindings, + type NormalizedKeymapBinding +} from './keymapConfig'; import { defaultThemeSettings, resolveTheme, @@ -75,6 +80,13 @@ export function getSpellCheckEnabled(): boolean { return vscode.workspace.getConfiguration(EXTENSION_CONFIG_SECTION).get(SPELL_CHECK_SETTING_KEY, true); } +export function getKeymapBindings(): NormalizedKeymapBinding[] { + const raw = vscode.workspace.getConfiguration(EXTENSION_CONFIG_SECTION).get(KEYMAP_SETTING_KEY, []); + return parseKeymapBindings(raw); +} + +export { KEYMAP_SETTING_KEY }; + export function getVimModeEnabled(context: vscode.ExtensionContext): boolean { const config = vscode.workspace.getConfiguration(EXTENSION_CONFIG_SECTION); const behavior = getVimModeBehavior(config); diff --git a/src/shared/keymapConfig.ts b/src/shared/keymapConfig.ts new file mode 100644 index 0000000..9878449 --- /dev/null +++ b/src/shared/keymapConfig.ts @@ -0,0 +1,283 @@ +/** + * Shared keymap config for markdownEditorOptimized.keymap. + * Used by the extension host (settings parse) and the webview (bindings). + */ + +export const KEYMAP_SETTING_KEY = 'keymap'; + +export const KEYMAP_COMMAND_WHITELIST = [ + 'passthrough', + // History + 'undo', + 'redo', + 'undoSelection', + 'redoSelection', + // Cursor and selection + 'cursorCharLeft', + 'cursorCharRight', + 'cursorCharForward', + 'cursorCharBackward', + 'cursorGroupLeft', + 'cursorGroupRight', + 'cursorGroupForward', + 'cursorGroupBackward', + 'cursorLineUp', + 'cursorLineDown', + 'cursorPageUp', + 'cursorPageDown', + 'cursorLineStart', + 'cursorLineEnd', + 'cursorDocStart', + 'cursorDocEnd', + 'selectCharLeft', + 'selectCharRight', + 'selectGroupLeft', + 'selectGroupRight', + 'selectLineUp', + 'selectLineDown', + 'selectPageUp', + 'selectPageDown', + 'selectLineStart', + 'selectLineEnd', + 'selectDocStart', + 'selectDocEnd', + 'selectAll', + 'selectLine', + // Editing + 'deleteCharBackward', + 'deleteCharForward', + 'deleteGroupBackward', + 'deleteGroupForward', + 'deleteLine', + 'deleteToLineStart', + 'deleteToLineEnd', + 'indentMore', + 'indentLess', + 'indentSelection', + 'insertNewlineAndIndent', + 'insertBlankLine', + 'transposeChars', + 'moveLineUp', + 'moveLineDown', + 'copyLineUp', + 'copyLineDown', + // CodeMirror language folding + 'foldCode', + 'unfoldCode', + 'toggleFold', + 'foldAll', + 'unfoldAll', + // MEO commands + 'toggleHeadingCollapse', + 'openFind', + 'openReplace', + 'toggleMode' +] as const; + +export type KeymapCommandName = typeof KEYMAP_COMMAND_WHITELIST[number]; + +/** Canonical CodeMirror key, such as Alt-ArrowUp or Mod-Shift-f. */ +export type NormalizedKeymapBinding = { + key: string; + command: KeymapCommandName; +}; + +const commandSet = new Set(KEYMAP_COMMAND_WHITELIST); + +const SPECIAL_KEY_ALIASES: Record = { + up: 'ArrowUp', + down: 'ArrowDown', + left: 'ArrowLeft', + right: 'ArrowRight', + arrowup: 'ArrowUp', + arrowdown: 'ArrowDown', + arrowleft: 'ArrowLeft', + arrowright: 'ArrowRight', + enter: 'Enter', + return: 'Enter', + escape: 'Escape', + esc: 'Escape', + space: 'Space', + tab: 'Tab', + backspace: 'Backspace', + delete: 'Delete', + del: 'Delete', + home: 'Home', + end: 'End', + pageup: 'PageUp', + pagedown: 'PageDown', + pgup: 'PageUp', + pgdn: 'PageDown' +}; + +const MODIFIER_ALIASES: Record = { + mod: 'Mod', + cmd: 'Cmd', + command: 'Cmd', + meta: 'Cmd', + ctrl: 'Ctrl', + control: 'Ctrl', + alt: 'Alt', + option: 'Alt', + shift: 'Shift' +}; + +/** + * Normalize user key strings ("alt+up", "Ctrl-Shift-F", "mod+shift+f") to CodeMirror form ("Alt-ArrowUp"). + */ +export function normalizeKeymapKey(raw: string): string | null { + if (typeof raw !== 'string') { + return null; + } + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + + const parts = trimmed + .split(/[+\-\s]+/) + .map((part) => part.trim()) + .filter(Boolean); + + if (!parts.length) { + return null; + } + + const modifiers: string[] = []; + let mainKey: string | null = null; + + for (const part of parts) { + const lower = part.toLowerCase(); + if (MODIFIER_ALIASES[lower]) { + const mod = MODIFIER_ALIASES[lower]; + if (!modifiers.includes(mod)) { + modifiers.push(mod); + } + continue; + } + if (mainKey) { + // Multiple main keys are invalid. + return null; + } + if (SPECIAL_KEY_ALIASES[lower]) { + mainKey = SPECIAL_KEY_ALIASES[lower]; + continue; + } + // Single character โ†’ keep letter lowercase for CM (f not F), except when length > 1 + if (part.length === 1) { + mainKey = part.toLowerCase(); + } else if (/^f\d{1,2}$/i.test(part)) { + mainKey = part.toUpperCase(); + } else { + // Pascal-case unknowns (ArrowUp already handled) + mainKey = part[0].toUpperCase() + part.slice(1); + } + } + + if (!mainKey) { + return null; + } + + const order = ['Mod', 'Ctrl', 'Cmd', 'Alt', 'Shift']; + modifiers.sort((a, b) => order.indexOf(a) - order.indexOf(b)); + + return [...modifiers, mainKey].join('-'); +} + +export function normalizeKeymapCommand(raw: unknown): KeymapCommandName | null { + if (typeof raw !== 'string') { + return null; + } + const command = raw.trim(); + if (!commandSet.has(command)) { + return null; + } + return command as KeymapCommandName; +} + +export function parseKeymapBindings(raw: unknown): NormalizedKeymapBinding[] { + if (!Array.isArray(raw)) { + return []; + } + + const seenKeys = new Set(); + const result: NormalizedKeymapBinding[] = []; + + for (const entry of raw) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + continue; + } + const record = entry as Record; + const key = normalizeKeymapKey(typeof record.key === 'string' ? record.key : ''); + const command = normalizeKeymapCommand(record.command); + if (!key || !command) { + continue; + } + // Last binding for a key wins. + if (seenKeys.has(key)) { + const index = result.findIndex((item) => item.key === key); + if (index >= 0) { + result.splice(index, 1); + } + } + seenKeys.add(key); + result.push({ key, command }); + } + + return result; +} + +export function resolveKeymapKeyForPlatform(key: string, isMac: boolean): string { + const parts = key.split('-'); + if (parts.length === 1) { + return key; + } + + const main = parts.pop() as string; + const order = ['Ctrl', 'Cmd', 'Alt', 'Shift']; + const modifiers = parts + .map((modifier) => modifier === 'Mod' ? (isMac ? 'Cmd' : 'Ctrl') : modifier) + .sort((a, b) => order.indexOf(a) - order.indexOf(b)); + return [...new Set(modifiers), main].join('-'); +} + +/** Build a normalized physical key string from a browser KeyboardEvent. */ +export function keyEventToNormalizedKey(event: KeyboardEvent): string { + const modifiers: string[] = []; + if (event.ctrlKey) { + modifiers.push('Ctrl'); + } + if (event.metaKey) { + modifiers.push('Cmd'); + } + if (event.altKey) { + modifiers.push('Alt'); + } + if (event.shiftKey) { + modifiers.push('Shift'); + } + + let main: string; + const code = event.code || ''; + const key = event.key || ''; + + if (code.startsWith('Arrow') || ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)) { + main = key.startsWith('Arrow') ? key : code; + } else if (code.startsWith('Key') && code.length === 4) { + main = code.slice(3).toLowerCase(); + } else if (code.startsWith('Digit') && code.length === 6) { + main = code.slice(5); + } else if (/^F\d{1,2}$/.test(code)) { + main = code; + } else if (key === ' ') { + main = 'Space'; + } else if (key.length === 1) { + main = key.toLowerCase(); + } else { + main = key.length ? key[0].toUpperCase() + key.slice(1) : code; + } + + const order = ['Ctrl', 'Cmd', 'Alt', 'Shift']; + const uniqueMods = [...new Set(modifiers)].sort((a, b) => order.indexOf(a) - order.indexOf(b)); + return [...uniqueMods, main].join('-'); +} diff --git a/webview/src/editor.ts b/webview/src/editor.ts index 94fd0bd..dc408a0 100644 --- a/webview/src/editor.ts +++ b/webview/src/editor.ts @@ -1,13 +1,15 @@ -import { EditorState, Compartment, Transaction, StateEffect, StateField, RangeSetBuilder, type ChangeSpec } from '@codemirror/state'; +import { EditorState, Compartment, Prec, Transaction, StateEffect, StateField, RangeSetBuilder, type ChangeSpec } from '@codemirror/state'; import { EditorView, keymap, highlightActiveLine, lineNumbers, highlightActiveLineGutter, scrollPastEnd, Decoration, type ViewUpdate } from '@codemirror/view'; import { defaultKeymap, history, historyKeymap, indentMore, indentLess, undo, redo } from '@codemirror/commands'; import { markdown, markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown'; -import { indentUnit, syntaxHighlighting, syntaxTree, forceParsing } from '@codemirror/language'; +import { indentUnit, syntaxHighlighting, syntaxTree, forceParsing, codeFolding } from '@codemirror/language'; import { vim, Vim } from '@replit/codemirror-vim'; import { highlightStyle } from './theme'; import { shikiCodeHighlight } from './helpers/shikiDecorations'; import { liveModeExtensions } from './liveMode'; import { headingCollapseSharedExtensions, headingCollapseSourceSpacerExtensions } from './helpers/headingCollapse'; +import { buildUserKeymapBindings, type KeymapCommandHandlers } from './helpers/userKeymap'; +import type { NormalizedKeymapBinding } from '../../src/shared/keymapConfig'; import { resolveCodeLanguage, insertCodeBlock, sourceCodeBlockField } from './helpers/codeBlocks'; import { sourceStrikeMarkerField } from './helpers/strikeMarkers'; import { sourceWikiMarkerField } from './helpers/wikiLinks'; @@ -170,7 +172,9 @@ export function createEditor({ initialVimMode = false, initialVimKeybindings = [], initialVimLeader = '\\', - initialDiagnostics = [] + initialDiagnostics = [], + initialKeymap = [], + keymapHandlers = {} }) { // VS Code webviews can hit cross-origin window access issues in the EditContext path. // Disable it explicitly for stability in embedded Chromium. @@ -179,12 +183,15 @@ export function createEditor({ const modeCompartment = new Compartment(); const gitGutterCompartment = new Compartment(); const vimCompartment = new Compartment(); + const userKeymapCompartment = new Compartment(); const startMode = initialMode === 'live' ? 'live' : 'source'; let lineNumbersVisible = initialLineNumbers !== false; let gitGutterVisible = initialGitGutter !== false; let vimModeEnabled = initialVimMode === true; let vimKeybindings = initialVimKeybindings; let vimLeader = initialVimLeader; + let userKeymapBindings: NormalizedKeymapBinding[] = Array.isArray(initialKeymap) ? [...initialKeymap] : []; + let userKeymapHandlers: KeymapCommandHandlers = keymapHandlers ?? {}; let appliedVimKeybindings: Array<{ before: string; mode: string }> = []; let currentDiagnostics: EditorDiagnostic[] = Array.isArray(initialDiagnostics) ? initialDiagnostics : []; let lastDiagnosticClick: { key: string; from: number; to: number } | null = null; @@ -267,6 +274,14 @@ export function createEditor({ return Math.max(0, Number(value)); }; const vimExtensionsForState = () => (vimModeEnabled ? vim() : []); + const userKeymapExtensions = () => { + const bindings = buildUserKeymapBindings(userKeymapBindings, userKeymapHandlers); + if (!bindings.length) { + return []; + } + // Highest precedence so user bindings override defaultKeymap / markdownKeymap. + return [Prec.highest(keymap.of(bindings))]; + }; const getLineStartOffset = (docText, targetLineNumber) => { const targetLine = Math.max(1, Math.floor(targetLineNumber)); if (targetLine === 1) { @@ -1453,6 +1468,9 @@ export function createEditor({ EditorState.tabSize.of(4), indentUnit.of(' '), vimCompartment.of(vimExtensionsForState()), + userKeymapCompartment.of(userKeymapExtensions()), + // Enable CM fold service so foldCode/toggleFold keymap commands work on foldable ranges. + codeFolding(), keymap.of([ { key: 'Tab', run: (view) => indentListByTwoSpaces(view) || indentMore(view) }, { key: 'Shift-Tab', run: (view) => outdentListByTwoSpaces(view) || indentLess(view) }, @@ -1984,6 +2002,15 @@ export function createEditor({ applyVimKeybindings(vimKeybindings, vimLeader); } }, + setKeymap(bindings: NormalizedKeymapBinding[], handlers?: KeymapCommandHandlers) { + userKeymapBindings = Array.isArray(bindings) ? [...bindings] : []; + if (handlers) { + userKeymapHandlers = handlers; + } + view.dispatch({ + effects: userKeymapCompartment.reconfigure(userKeymapExtensions()) + }); + }, insertFormat(action, level) { const activeTableInput = getActiveTableInput(); if (activeTableInput) { diff --git a/webview/src/helpers/headingCollapse.ts b/webview/src/helpers/headingCollapse.ts index 1e987ae..b147b04 100644 --- a/webview/src/helpers/headingCollapse.ts +++ b/webview/src/helpers/headingCollapse.ts @@ -55,7 +55,7 @@ function createDetailsCollapsibleSection(detailsBlock: DetailsBlockInfo): Collap }; } -function getCollapsibleHeadingSections(state: EditorState): HeadingSection[] { +export function getCollapsibleHeadingSections(state: EditorState): HeadingSection[] { return extractHeadingSections(state).filter((section) => isHeadingSectionCollapsible(state, section)); } diff --git a/webview/src/helpers/shortcuts.ts b/webview/src/helpers/shortcuts.ts index 2018546..35f57f0 100644 --- a/webview/src/helpers/shortcuts.ts +++ b/webview/src/helpers/shortcuts.ts @@ -20,6 +20,9 @@ export interface ShortcutHandlerContext { openFindPanel: (target: 'find' | 'replace') => void; applyMode: (mode: 'live' | 'source', options?: { userTriggered?: boolean; reason?: string }) => boolean; flushPendingChangesNow: () => void; + /** Normalized keys (e.g. Mod-Shift-f) owned by the configurable keymap. */ + userKeymapKeys?: Set; + keyEventToNormalizedKey?: (event: KeyboardEvent) => string; } export const handleEditorShortcut = ( @@ -27,11 +30,19 @@ export const handleEditorShortcut = ( context: ShortcutHandlerContext ): boolean => { const { editor, currentMode, vimModeEnabled, pendingText, syncedText } = context; - + if (!editor || event.isComposing) { return false; } - + + if (context.userKeymapKeys?.size && context.keyEventToNormalizedKey) { + const normalized = context.keyEventToNormalizedKey(event); + if (context.userKeymapKeys.has(normalized)) { + // Let the configured CodeMirror binding decide whether to handle or pass through the chord. + return false; + } + } + const hasPrimaryModifier = isPrimaryModifier(event); const editorFocused = editor.hasFocus(); const vimEditorFocused = vimModeEnabled && editorFocused; diff --git a/webview/src/helpers/userKeymap.ts b/webview/src/helpers/userKeymap.ts new file mode 100644 index 0000000..ed68193 --- /dev/null +++ b/webview/src/helpers/userKeymap.ts @@ -0,0 +1,251 @@ +import type { Command, KeyBinding } from '@codemirror/view'; +import { + undo, + redo, + undoSelection, + redoSelection, + cursorCharLeft, + cursorCharRight, + cursorCharForward, + cursorCharBackward, + cursorGroupLeft, + cursorGroupRight, + cursorGroupForward, + cursorGroupBackward, + cursorLineUp, + cursorLineDown, + cursorPageUp, + cursorPageDown, + cursorLineStart, + cursorLineEnd, + cursorDocStart, + cursorDocEnd, + selectCharLeft, + selectCharRight, + selectGroupLeft, + selectGroupRight, + selectLineUp, + selectLineDown, + selectPageUp, + selectPageDown, + selectLineStart, + selectLineEnd, + selectDocStart, + selectDocEnd, + selectAll, + selectLine, + deleteCharBackward, + deleteCharForward, + deleteGroupBackward, + deleteGroupForward, + deleteLine, + deleteToLineStart, + deleteToLineEnd, + indentMore, + indentLess, + indentSelection, + insertNewlineAndIndent, + insertBlankLine, + transposeChars, + moveLineUp, + moveLineDown, + copyLineUp, + copyLineDown +} from '@codemirror/commands'; +import { foldCode, unfoldCode, toggleFold, foldAll, unfoldAll } from '@codemirror/language'; +import { + getCollapsibleHeadingSections, + toggleCollapsibleSection +} from './headingCollapse'; +import { + resolveKeymapKeyForPlatform, + type KeymapCommandName, + type NormalizedKeymapBinding +} from '../../../src/shared/keymapConfig'; + +export type KeymapCommandHandlers = { + openFind?: () => void; + openReplace?: () => void; + toggleMode?: () => void; +}; + +const toggleHeadingCollapseCommand: Command = (view) => { + const head = view.state.selection.main.head; + const sections = getCollapsibleHeadingSections(view.state); + if (!sections.length) { + return false; + } + + // Prefer the heading line itself, then the deepest section containing the cursor. + let target = sections.find((section) => { + const line = view.state.doc.lineAt(section.lineFrom); + return head >= line.from && head <= line.to; + }); + if (!target) { + target = [...sections].reverse().find( + (section) => head > section.collapseFrom && head < section.collapseTo + ); + } + if (!target) { + // Nearest heading above the cursor. + for (let i = sections.length - 1; i >= 0; i -= 1) { + if (sections[i].lineFrom <= head) { + target = sections[i]; + break; + } + } + } + if (!target) { + return false; + } + return toggleCollapsibleSection(view, target.lineFrom); +}; + +const BUILTIN_COMMANDS: Partial> = { + undo, + redo, + undoSelection, + redoSelection, + cursorCharLeft, + cursorCharRight, + cursorCharForward, + cursorCharBackward, + cursorGroupLeft, + cursorGroupRight, + cursorGroupForward, + cursorGroupBackward, + cursorLineUp, + cursorLineDown, + cursorPageUp, + cursorPageDown, + cursorLineStart, + cursorLineEnd, + cursorDocStart, + cursorDocEnd, + selectCharLeft, + selectCharRight, + selectGroupLeft, + selectGroupRight, + selectLineUp, + selectLineDown, + selectPageUp, + selectPageDown, + selectLineStart, + selectLineEnd, + selectDocStart, + selectDocEnd, + selectAll, + selectLine, + deleteCharBackward, + deleteCharForward, + deleteGroupBackward, + deleteGroupForward, + deleteLine, + deleteToLineStart, + deleteToLineEnd, + indentMore, + indentLess, + indentSelection, + insertNewlineAndIndent, + insertBlankLine, + transposeChars, + moveLineUp, + moveLineDown, + copyLineUp, + copyLineDown, + foldCode, + unfoldCode, + toggleFold, + foldAll, + unfoldAll, + toggleHeadingCollapse: toggleHeadingCollapseCommand +}; + +/** + * Claim the key in CodeMirror so lower keymaps do not handle it, but allow the + * event to bubble to VS Code's webview keybinding forwarder. + */ +const passthroughBinding = (key: string): KeyBinding => ({ + key, + run: () => true, + preventDefault: false, + stopPropagation: false +}); + +const overridingBinding = (key: string, command: Command): KeyBinding => ({ + key, + run: (view) => { + command(view); + // A configured binding owns its chord even when the command has no effect in + // the current editor state. Do not fall through to the default keymaps. + return true; + }, + stopPropagation: true +}); + +export function buildUserKeymapBindings( + bindings: readonly NormalizedKeymapBinding[], + handlers: KeymapCommandHandlers = {} +): KeyBinding[] { + if (!bindings.length) { + return []; + } + + const result: KeyBinding[] = []; + + for (const binding of bindings) { + if (binding.command === 'passthrough') { + result.push(passthroughBinding(binding.key)); + continue; + } + + if (binding.command === 'openFind') { + if (handlers.openFind) { + result.push(overridingBinding(binding.key, () => { + handlers.openFind?.(); + return true; + })); + } + continue; + } + + if (binding.command === 'openReplace') { + if (handlers.openReplace) { + result.push(overridingBinding(binding.key, () => { + handlers.openReplace?.(); + return true; + })); + } + continue; + } + + if (binding.command === 'toggleMode') { + if (handlers.toggleMode) { + result.push(overridingBinding(binding.key, () => { + handlers.toggleMode?.(); + return true; + })); + } + continue; + } + + const command = BUILTIN_COMMANDS[binding.command]; + if (!command) { + continue; + } + result.push(overridingBinding(binding.key, command)); + } + + return result; +} + +export function collectUserKeymapKeys( + bindings: readonly NormalizedKeymapBinding[], + isMac: boolean +): Set { + const keys = new Set(); + for (const binding of bindings) { + keys.add(resolveKeymapKeyForPlatform(binding.key, isMac)); + } + return keys; +} diff --git a/webview/src/index.ts b/webview/src/index.ts index a488612..9a09178 100644 --- a/webview/src/index.ts +++ b/webview/src/index.ts @@ -9,6 +9,8 @@ import { applyThemeSettings } from './helpers/theme'; import { setShikiTheme, setShikiEnabled } from './helpers/shikiHighlighter'; import { createFailureNoticeManager, getErrorMessage, isTransientMermaidRuntimeError, shouldAutoFallbackToSourceForLiveError, logWebviewRenderError, type EditorNotice, type FailureNoticeManager } from './helpers/errors'; import { isPrimaryModifier, isShortcutKey, normalizeEol, handleEditorShortcut, type ShortcutHandlerContext } from './helpers/shortcuts'; +import { collectUserKeymapKeys } from './helpers/userKeymap'; +import { keyEventToNormalizedKey, type NormalizedKeymapBinding } from '../../src/shared/keymapConfig'; import { createFindPanel, createFindPanelController, type FindPanelController } from './helpers/findPanel'; import { createSelectionMenu, createSelectionMenuController, type SelectionMenuController } from './helpers/selectionMenu'; import { createExportHandler, type ExportHandlerContext } from './helpers/export'; @@ -148,6 +150,8 @@ taskBtn.appendChild(createElement(ListTodo, { width: 18, height: 18 })); let vimModeEnabled = false; let vimKeybindingsState: VimKeybinding[] = []; let vimLeaderState = '\\'; +let keymapBindings: NormalizedKeymapBinding[] = []; +let userKeymapKeys = new Set(); let lineNumbersVisible = true; let gitChangesGutterVisible = true; @@ -311,6 +315,22 @@ const setVimModeEnabled = (enabled) => { editor?.setVimMode(vimModeEnabled); }; +const isMacPlatform = /Mac|iPhone|iPad|iPod/.test(navigator.platform); + +const getKeymapHandlers = () => ({ + openFind: () => findPanelController.open('find'), + openReplace: () => findPanelController.open('replace'), + toggleMode: () => { + applyMode(currentMode === 'live' ? 'source' : 'live', { userTriggered: true, reason: 'keymap' }); + } +}); + +const syncKeymapBindings = (bindings: NormalizedKeymapBinding[]) => { + keymapBindings = Array.isArray(bindings) ? [...bindings] : []; + userKeymapKeys = collectUserKeymapKeys(keymapBindings, isMacPlatform); + editor?.setKeymap?.(keymapBindings, getKeymapHandlers()); +}; + const toggleLineNumbers = () => { setLineNumbersVisible(!lineNumbersVisible); }; @@ -1113,7 +1133,9 @@ const shortcutHandlerContext: ShortcutHandlerContext = { requestSave, openFindPanel: (target) => findPanelController.open(target), applyMode: (mode, options) => applyMode(mode, options), - flushPendingChangesNow + flushPendingChangesNow, + get userKeymapKeys() { return userKeymapKeys; }, + keyEventToNormalizedKey }; const queueChanges = (nextText: string) => { @@ -1250,6 +1272,8 @@ const mountInitialEditor = async () => { initialVimMode: vimModeEnabled, initialVimKeybindings: vimKeybindingsState, initialVimLeader: vimLeaderState, + initialKeymap: keymapBindings, + keymapHandlers: getKeymapHandlers(), initialDiagnostics: pendingDiagnostics, onApplyChanges: queueChanges, onOpenLink: (href: string) => { @@ -1381,6 +1405,9 @@ const handleInit = (message: any) => { if (typeof message.vimMode === 'boolean') { setVimModeEnabled(message.vimMode); } + if (Array.isArray(message.keymap)) { + syncKeymapBindings(message.keymap); + } if (Array.isArray(message.vimKeybindings)) { vimKeybindingsState = message.vimKeybindings; vimLeaderState = typeof message.vimLeader === 'string' ? message.vimLeader : '\\'; @@ -1655,6 +1682,13 @@ window.addEventListener('message', (event) => { return; } + if (message.type === 'keymapChanged') { + if (Array.isArray(message.keymap)) { + syncKeymapBindings(message.keymap); + } + return; + } + if (message.type === 'findOptionsChanged') { if (message.findOptions && typeof message.findOptions === 'object') { findPanelController.setSearchOptions(message.findOptions); diff --git a/webview/src/types.d.ts b/webview/src/types.d.ts index bb3bcbc..a12d90d 100644 --- a/webview/src/types.d.ts +++ b/webview/src/types.d.ts @@ -37,7 +37,7 @@ type VimKeybinding = { }; type ExtensionMessage = - | { type: 'init'; text: string; version: number; diagnostics: EditorDiagnostic[]; theme: ThemeSettings; mode: 'live' | 'source'; outlinePosition: 'left' | 'right'; outlineVisible: boolean; lineNumbers: boolean; gitChangesGutter: boolean; gitDiffLineHighlights: boolean; spellCheckEnabled: boolean; contentMaxWidthEnabled: boolean; vimMode: boolean; vimKeybindings: VimKeybinding[]; vimLeader: string; findOptions: { wholeWord: boolean; caseSensitive: boolean }; restoreTopLine?: number; restoreTopLineOffset?: number } + | { type: 'init'; text: string; version: number; diagnostics: EditorDiagnostic[]; theme: ThemeSettings; mode: 'live' | 'source'; outlinePosition: 'left' | 'right'; outlineVisible: boolean; lineNumbers: boolean; gitChangesGutter: boolean; gitDiffLineHighlights: boolean; spellCheckEnabled: boolean; contentMaxWidthEnabled: boolean; vimMode: boolean; vimKeybindings: VimKeybinding[]; vimLeader: string; keymap?: Array<{ key: string; command: string }>; findOptions: { wholeWord: boolean; caseSensitive: boolean }; restoreTopLine?: number; restoreTopLineOffset?: number } | { type: 'docChanged'; text: string; version: number } | { type: 'applied'; version: number } | { type: 'focusEditor' } @@ -53,6 +53,7 @@ type ExtensionMessage = | { type: 'contentMaxWidthChanged'; enabled: boolean } | { type: 'vimModeChanged'; enabled: boolean } | { type: 'vimKeybindingsChanged'; keybindings: VimKeybinding[]; leaderKey: string } + | { type: 'keymapChanged'; keymap: Array<{ key: string; command: string }> } | { type: 'findOptionsChanged'; findOptions: { wholeWord: boolean; caseSensitive: boolean } } | { type: 'resolvedImageSrc'; requestId: string; resolvedUrl: string } | { type: 'resolvedWikiLinks'; requestId: string; results: Array<{ target: string; exists: boolean }> } From 9bbe16704506f0631be806880922ffeb7c8cef8a Mon Sep 17 00:00:00 2001 From: Vadim Melnicuk Date: Sat, 5 Sep 2026 21:16:30 +0100 Subject: [PATCH 03/10] fix: optimize selection change handling in editor --- webview/src/editor.ts | 50 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/webview/src/editor.ts b/webview/src/editor.ts index dc408a0..6f21776 100644 --- a/webview/src/editor.ts +++ b/webview/src/editor.ts @@ -247,6 +247,8 @@ export function createEditor({ } let applyingExternal = false; let capturedPointerId = null; + let selectionPointerId: number | null = null; + let pendingSelectionEmitFrame: number | null = null; let inlineCodeClick = null; let checkboxClick = null; let frontmatterBoundaryClick = null; @@ -542,6 +544,16 @@ export function createEditor({ } }; + const scheduleSelectionChangeEmit = (): void => { + if (pendingSelectionEmitFrame !== null) { + window.cancelAnimationFrame(pendingSelectionEmitFrame); + } + pendingSelectionEmitFrame = window.requestAnimationFrame(() => { + pendingSelectionEmitFrame = null; + emitSelectionChange(); + }); + }; + const syncSelectionClass = () => { if (!view) { return; @@ -977,6 +989,9 @@ export function createEditor({ const selection = view.state.selection.main; if (selection.empty) { + if (selectionPointerId !== null) { + return; + } onSelectionChange({ visible: false }); return; } @@ -1506,10 +1521,12 @@ export function createEditor({ pointerdown(event, view) { if (event.button !== 0) { frontmatterBoundaryClick = null; + selectionPointerId = null; return false; } if (openLinkIfModifierClick(event, view)) { frontmatterBoundaryClick = null; + selectionPointerId = null; return true; } @@ -1517,6 +1534,7 @@ export function createEditor({ const targetElement = targetElementFrom(target); if (!(target instanceof Node) || !view.contentDOM.contains(target)) { clearDiagnosticSuggestionState(); + selectionPointerId = null; return false; } @@ -1541,6 +1559,8 @@ export function createEditor({ return false; } + selectionPointerId = event.pointerId; + onSelectionChange?.({ visible: false }); inlineCodeClick = { pointerId: event.pointerId, inInlineCode: @@ -1562,9 +1582,17 @@ export function createEditor({ return false; }, pointerup(event, view) { + const shouldEmitSelectionAfterPointerUp = selectionPointerId === event.pointerId; + if (shouldEmitSelectionAfterPointerUp) { + selectionPointerId = null; + } + if (checkboxClick?.pointerId === event.pointerId) { frontmatterBoundaryClick = null; checkboxClick = null; + if (shouldEmitSelectionAfterPointerUp) { + scheduleSelectionChangeEmit(); + } return false; } @@ -1572,6 +1600,9 @@ export function createEditor({ if (frontmatterBoundaryClick?.pointerId === event.pointerId) { frontmatterBoundaryClick = null; } + if (shouldEmitSelectionAfterPointerUp) { + scheduleSelectionChangeEmit(); + } return false; } @@ -1627,13 +1658,24 @@ export function createEditor({ } inlineCodeClick = null; + if (shouldEmitSelectionAfterPointerUp) { + scheduleSelectionChangeEmit(); + } return false; }, pointercancel(event, _view) { + const shouldEmitSelectionAfterPointerCancel = selectionPointerId === event.pointerId; + if (shouldEmitSelectionAfterPointerCancel) { + selectionPointerId = null; + } + if (capturedPointerId !== event.pointerId) { if (frontmatterBoundaryClick?.pointerId === event.pointerId) { frontmatterBoundaryClick = null; } + if (shouldEmitSelectionAfterPointerCancel) { + scheduleSelectionChangeEmit(); + } return false; } @@ -1642,6 +1684,9 @@ export function createEditor({ frontmatterBoundaryClick = null; inlineCodeClick = null; checkboxClick = null; + if (shouldEmitSelectionAfterPointerCancel) { + scheduleSelectionChangeEmit(); + } return false; }, pointermove(event, view) { @@ -1899,6 +1944,11 @@ export function createEditor({ releasePointerCaptureIfHeld(capturedPointerId); capturedPointerId = null; } + selectionPointerId = null; + if (pendingSelectionEmitFrame !== null) { + window.cancelAnimationFrame(pendingSelectionEmitFrame); + pendingSelectionEmitFrame = null; + } pendingLiveSearchRevealToken += 1; if (pendingLiveSearchRevealFrame !== null) { window.cancelAnimationFrame(pendingLiveSearchRevealFrame); From 7d6de86cfa786043522231f385efc5ffd418494b Mon Sep 17 00:00:00 2001 From: Vadim Melnicuk Date: Sat, 5 Sep 2026 21:59:45 +0100 Subject: [PATCH 04/10] fix: move keymap setting and shorten its documentation --- README.md | 2 +- package.json | 46 +++++++++++++++++++++++----------------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index ede9a20..b09053d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ An optimized markdown editor with live editing mode for VS Code. ### Writing & Editing - **Live/Source modes** - Switch between clean writing and raw markdown in a single tab -- **Configurable keymap** - Override CodeMirror chords from settings (`passthrough` releases keys to VS Code) - **Toolbar formatting** - Insert headings, lists, tasks, tables, code blocks, links, images, and quotes in one click - **Floating selection menu** - Instantly apply bold, italic, strikethrough, inline code, or links on any text selection - **Spellcheck** - Fix issues with built-in spelling suggestions @@ -34,6 +33,7 @@ An optimized markdown editor with live editing mode for VS Code. - **Theming** - [Guide](./docs/theming.md) - Customise syntax colors, background, fonts, and line height to match your style - **Default themes** - One Monokai, One Dark Pro, Dracula, Gruvbox, Nord, Solarized Dark, Catppuccin Mocha, Tokyo Night, GitHub Dark, and GitHub Light +- **Configurable keymap** - Remap editor shortcuts or pass them through to VS Code from settings - **Export** - Save your document as HTML or PDF ## Getting Started diff --git a/package.json b/package.json index 8b2e7a3..fadd741 100644 --- a/package.json +++ b/package.json @@ -233,29 +233,6 @@ "order": 6, "description": "Controls Vim emulation in Source and Live mode." }, - "markdownEditorOptimized.keymap": { - "type": "array", - "default": [], - "order": 6, - "markdownDescription": "Override MEO's CodeMirror key bindings. Each entry maps a key chord to a whitelisted command. Use `passthrough` to stop MEO from handling a key so VS Code `keybindings.json` can receive it (e.g. release Ctrl+Shift+F for Find in Files).\n\n**Key format:** `alt+up`, `Mod-Shift-f`, `Ctrl-Enter`. `Mod` means Cmd on macOS and Ctrl on Windows/Linux; `Cmd` and `Ctrl` stay platform-specific. Arrow aliases: `up`/`down`/`left`/`right`.\n\n**Commands (subset):** `passthrough`, `undo`, `redo`, `cursorLineUp`, `cursorLineDown`, `moveLineUp`, `moveLineDown`, `deleteLine`, `indentMore`, `indentLess`, `selectAll`, `foldCode`, `unfoldCode`, `toggleFold`, `foldAll`, `unfoldAll`, `toggleHeadingCollapse`, `openFind`, `openReplace`, `toggleMode`.", - "items": { - "type": "object", - "required": [ - "key", - "command" - ], - "properties": { - "key": { - "type": "string", - "description": "Key chord, e.g. alt+up or Mod-Shift-f." - }, - "command": { - "type": "string", - "description": "Whitelisted command name, or passthrough." - } - } - } - }, "markdownEditorOptimized.codeBlocks.useVscodeTheme": { "type": "boolean", "default": false, @@ -303,6 +280,29 @@ "order": 9, "description": "Folder name to save pasted images (relative to workspace root)." }, + "markdownEditorOptimized.keymap": { + "type": "array", + "default": [], + "order": 10, + "markdownDescription": "Remap editor shortcuts to supported commands. Keys use CodeMirror syntax, such as `Mod-Shift-f`; `Mod` is Cmd on macOS and Ctrl elsewhere. Use `passthrough` to let VS Code handle a key.", + "items": { + "type": "object", + "required": [ + "key", + "command" + ], + "properties": { + "key": { + "type": "string", + "description": "Key chord, e.g. alt+up or Mod-Shift-f." + }, + "command": { + "type": "string", + "description": "Whitelisted command name, or passthrough." + } + } + } + }, "markdownEditorOptimized.theme": { "type": "object", "default": { From de3cddc7b99eef5926ab87774a96e871c8878ac6 Mon Sep 17 00:00:00 2001 From: Apostol Apostolov Date: Sat, 5 Sep 2026 14:11:25 -0700 Subject: [PATCH 05/10] fix: harden external document sync and recovery Adopt external host changes safely, preserve local drafts until adoption succeeds, keep recovery actions available, and recover cleanly when document edits fail.\n\nCloses #72 --- CHANGELOG.md | 2 + src/extension/panelSession.ts | 84 ++++++++++++++++++-- webview/src/editor.ts | 19 +++-- webview/src/helpers/errors.ts | 25 ++++-- webview/src/index.ts | 139 +++++++++++++++++++++++++++------- webview/src/styles.css | 27 ++++++- webview/src/types.d.ts | 2 + 7 files changed, 250 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aeb84d..59739f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Markdown Editor Optimized (MEO) --- ## Unreleased +- Hardened external document sync (git/LLM/outside editors): prefer host content on conflict, fix stuck applyingExternal, Reload recovery +- Avoid silent local-draft overwrite of external writes; surface appliedFailed + requestReload paths - Added configurable CodeMirror keymap via `markdownEditorOptimized.keymap` (whitelist commands + `passthrough`) ## 0.1.26 diff --git a/src/extension/panelSession.ts b/src/extension/panelSession.ts index d995ba2..74c0da6 100644 --- a/src/extension/panelSession.ts +++ b/src/extension/panelSession.ts @@ -84,6 +84,12 @@ type AppliedMessage = { version: number; }; +type AppliedFailedMessage = { + type: 'appliedFailed'; + text: string; + version: number; +}; + type RevealSelectionMessage = { type: 'revealSelection'; anchor: number; @@ -143,6 +149,10 @@ type SaveDocumentMessage = { type: 'saveDocument'; }; +type RequestReloadMessage = { + type: 'requestReload'; +}; + type ExportDocumentMessage = { type: 'exportDocument'; format: ExportFormat; @@ -321,6 +331,7 @@ type WebviewMessage = | ResolveWikiLinksMessage | ResolveLocalLinksMessage | SaveDocumentMessage + | RequestReloadMessage | ExportDocumentMessage | ExportSnapshotMessage | ExportSnapshotErrorMessage @@ -421,6 +432,9 @@ export function createPanelSessionController(params: PanelSessionControllerParam let webviewReady = false; let initDelivered = false; let isApplyingOwnChange = false; + let applyGeneration = 0; + let lastAppliedNormalizedText: string | null = null; + let lastAppliedAtMs = 0; let gitRefreshRunning = false; let gitRefreshPending = false; let gitRefreshPendingForcePost = false; @@ -622,6 +636,31 @@ export function createPanelSessionController(params: PanelSessionControllerParam return postToWebview(message); }; + const sendAppliedFailed = async (): Promise => { + const message: AppliedFailedMessage = { + type: 'appliedFailed', + text: document.getText(), + version: document.version + }; + return postToWebview(message); + }; + + const noteOwnAppliedText = (): void => { + lastAppliedNormalizedText = document.getText().replace(/\r\n/g, '\n'); + lastAppliedAtMs = Date.now(); + }; + + const isLikelyEchoOfOwnApply = (eventDocument: vscode.TextDocument): boolean => { + if (!lastAppliedNormalizedText) { + return false; + } + if (Date.now() - lastAppliedAtMs > 750) { + return false; + } + const incoming = eventDocument.getText().replace(/\r\n/g, '\n'); + return incoming === lastAppliedNormalizedText; + }; + const sendGitBaselineChanged = async (options: RefreshGitBaselineOptions = {}): Promise => { if (!initDelivered) { return false; @@ -1081,12 +1120,23 @@ export function createPanelSessionController(params: PanelSessionControllerParam case 'applyChanges': agentReviewHandoff.noteRecentMEOOwnedFileChangeForUri(document.uri); isApplyingOwnChange = true; + applyGeneration += 1; + const applyGen = applyGeneration; try { await enqueue(async () => { - await applyDocumentChanges(document, raw, sendDocChanged, sendApplied); + await applyDocumentChanges( + document, + raw, + sendDocChanged, + sendApplied, + sendAppliedFailed, + noteOwnAppliedText + ); }); } finally { - isApplyingOwnChange = false; + if (applyGen === applyGeneration) { + isApplyingOwnChange = false; + } } return; case 'draftChanged': @@ -1094,10 +1144,13 @@ export function createPanelSessionController(params: PanelSessionControllerParam return; case 'saveDocument': isApplyingOwnChange = true; + applyGeneration += 1; + const saveGen = applyGeneration; try { await enqueue(async () => { const appliedDraft = await applyPendingDraftIfNeeded(); if (appliedDraft) { + noteOwnAppliedText(); await sendDocChanged(); } else if (pendingDraftText !== null) { await sendDocChanged(); @@ -1106,9 +1159,22 @@ export function createPanelSessionController(params: PanelSessionControllerParam await document.save(); }); } finally { - isApplyingOwnChange = false; + if (saveGen === applyGeneration) { + isApplyingOwnChange = false; + } } return; + case 'requestReload': + await enqueue(async () => { + // Force a full re-init payload so the webview can hard-recover. + initDelivered = false; + webviewReady = true; + await ensureInitDelivered(); + if (initDelivered) { + await sendDocChanged(); + } + }); + return; case 'saveImageFromClipboard': { const response = await handleSaveImageFromClipboard(raw, documentUri); await postToWebview(response); @@ -1142,6 +1208,11 @@ export function createPanelSessionController(params: PanelSessionControllerParam return; } + // Drop short-lived echoes of our own successful applyEdit (race after flag cleared). + if (isLikelyEchoOfOwnApply(event.document)) { + return; + } + runBackground(enqueue(async () => { await sendDocChanged(); }), 'sendDocChanged'); @@ -1326,7 +1397,9 @@ async function applyDocumentChanges( document: vscode.TextDocument, message: ApplyChangesMessage, sendDocChanged: () => Promise, - sendApplied: (version: number) => Promise + sendApplied: (version: number) => Promise, + sendAppliedFailed: () => Promise, + noteOwnAppliedText: () => void ): Promise { if (message.baseVersion !== document.version) { await sendDocChanged(); @@ -1363,10 +1436,11 @@ async function applyDocumentChanges( const applied = await vscode.workspace.applyEdit(edit); if (!applied) { - await sendDocChanged(); + await sendAppliedFailed(); return; } + noteOwnAppliedText(); await sendApplied(document.version); } diff --git a/webview/src/editor.ts b/webview/src/editor.ts index 6f21776..20ea0c8 100644 --- a/webview/src/editor.ts +++ b/webview/src/editor.ts @@ -1960,6 +1960,8 @@ export function createEditor({ setText(textValue) { gitBlameHover?.hide(); clearDiagnosticSuggestionState(); + // Commit widget editors (tables) before replacing the underlying doc. + commitActiveTableInput(); const currentText = view.state.doc.toString(); const syncChange = findSyncChange(currentText, textValue); if (!syncChange) { @@ -1971,12 +1973,17 @@ export function createEditor({ const mappedAnchor = Math.min(mapPositionThroughChange(anchor, syncChange), newLength); const mappedHead = Math.min(mapPositionThroughChange(head, syncChange), newLength); applyingExternal = true; - view.dispatch({ - changes: syncChange, - selection: { anchor: mappedAnchor, head: mappedHead } - }); - applyingExternal = false; - pendingExternalUndoSelectionPreserve = true; + try { + view.dispatch({ + changes: syncChange, + selection: { anchor: mappedAnchor, head: mappedHead } + }); + pendingExternalUndoSelectionPreserve = true; + } finally { + // If dispatch throws (live decorations/plugins), never leave this stuck true โ€” + // otherwise user edits stop calling onApplyChanges and silently never reach the host. + applyingExternal = false; + } syncSelectionClass(); emitSelectionChange(); }, diff --git a/webview/src/helpers/errors.ts b/webview/src/helpers/errors.ts index d1b5635..53581b9 100644 --- a/webview/src/helpers/errors.ts +++ b/webview/src/helpers/errors.ts @@ -1,8 +1,10 @@ const liveModeFailureNoticeMessage = 'Live mode failed to render this document. Switched to Source mode.'; const editorUpdateFailureNoticeMessage = 'Editor failed to update this document. Try reopening the file.'; +const externalSyncConflictNoticeMessage = 'Document changed outside the editor. Showing host content. Your unsaved MEO buffer was discarded from the view (use Undo if needed).'; +const externalSyncAdoptFailureNoticeMessage = 'Could not apply an external document update. Reload from the host document or reopen the file.'; export interface EditorNotice { - setEditorNotice: (message: string, kind?: string) => void; + setEditorNotice: (message: string, kind?: string, options?: { showReload?: boolean }) => void; clearEditorNotice: () => void; } @@ -46,21 +48,28 @@ export const logWebviewRenderError = (context: string, error: unknown, extra: Re export interface FailureNoticeState { message: string; kind: string; + showReload: boolean; } export const createFailureNoticeManager = (notice: EditorNotice) => { - let failureNotice: FailureNoticeState = { message: '', kind: 'error' }; + let failureNotice: FailureNoticeState = { message: '', kind: 'error', showReload: false }; const updateEditorNotice = () => { if (failureNotice.message) { - notice.setEditorNotice(failureNotice.message, failureNotice.kind); + notice.setEditorNotice(failureNotice.message, failureNotice.kind, { + showReload: failureNotice.showReload + }); return; } notice.clearEditorNotice(); }; - const setFailureNotice = (message: string, kind: 'error' | 'warning' = 'error'): void => { - failureNotice = { message, kind }; + const setFailureNotice = ( + message: string, + kind: 'error' | 'warning' = 'error', + options?: { showReload?: boolean } + ): void => { + failureNotice = { message, kind, showReload: options?.showReload === true }; updateEditorNotice(); }; @@ -68,7 +77,7 @@ export const createFailureNoticeManager = (notice: EditorNotice) => { if (!failureNotice.message) { return; } - failureNotice = { message: '', kind: 'error' }; + failureNotice = { message: '', kind: 'error', showReload: false }; updateEditorNotice(); }; @@ -80,7 +89,9 @@ export const createFailureNoticeManager = (notice: EditorNotice) => { hasFailureNotice, updateEditorNotice, get liveModeFailureMessage() { return liveModeFailureNoticeMessage; }, - get editorUpdateFailureMessage() { return editorUpdateFailureNoticeMessage; } + get editorUpdateFailureMessage() { return editorUpdateFailureNoticeMessage; }, + get externalSyncConflictMessage() { return externalSyncConflictNoticeMessage; }, + get externalSyncAdoptFailureMessage() { return externalSyncAdoptFailureNoticeMessage; } }; }; diff --git a/webview/src/index.ts b/webview/src/index.ts index 9a09178..222c5a0 100644 --- a/webview/src/index.ts +++ b/webview/src/index.ts @@ -580,6 +580,9 @@ let syncedText = ''; let inFlight = false; let inFlightText: string | null = null; let saveAfterSync = false; +/** Last host text/version we know about when setText adopt fails (for Reload). */ +let hostAuthoritativeText: string | null = null; +let hostAuthoritativeVersion: number | null = null; let currentMode: 'live' | 'source' = 'live'; let hasLocalModePreference = false; let pendingInitialText: string | null = null; @@ -647,23 +650,44 @@ toolbarAlignmentResizeObserver.observe(editorWrapper); toolbarAlignmentResizeObserver.observe(editorHost); window.addEventListener('resize', scheduleSingleToolbarTextAlignment); -const setEditorNotice = (message: string, kind = 'info') => { +const setEditorNotice = (message: string, kind = 'info', options?: { showReload?: boolean }) => { const normalizedMessage = `${message ?? ''}`.trim(); if (!normalizedMessage) { clearEditorNotice(); return; } - editorNoticeBanner.textContent = normalizedMessage; + editorNoticeBanner.replaceChildren(); + const messageEl = document.createElement('span'); + messageEl.className = 'editor-notice-message'; + messageEl.textContent = normalizedMessage; + editorNoticeBanner.appendChild(messageEl); + if (options?.showReload) { + const reloadBtn = document.createElement('button'); + reloadBtn.type = 'button'; + reloadBtn.className = 'editor-notice-action'; + reloadBtn.textContent = 'Reload'; + reloadBtn.title = 'Reload editor content from the VS Code document'; + reloadBtn.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + void reloadEditorFromHost('notice-reload'); + }); + editorNoticeBanner.appendChild(reloadBtn); + } editorNoticeBanner.dataset.kind = kind; editorNoticeBanner.hidden = false; editorNoticeBanner.classList.add('is-visible'); + // Allow clicking Reload; keep the banner non-blocking for the rest of the UI. + editorNoticeBanner.style.pointerEvents = options?.showReload ? 'auto' : 'none'; }; const clearEditorNotice = () => { + editorNoticeBanner.replaceChildren(); editorNoticeBanner.textContent = ''; delete editorNoticeBanner.dataset.kind; editorNoticeBanner.hidden = true; editorNoticeBanner.classList.remove('is-visible'); + editorNoticeBanner.style.pointerEvents = 'none'; }; const editorNotice: EditorNotice = { @@ -1114,16 +1138,71 @@ const setEditorTextSafely = (text: string, context: string): boolean => { return true; } catch (retryError) { logWebviewRenderError('setText.retryInSource', retryError, { context }); - failureNotice.setFailureNotice(failureNotice.editorUpdateFailureMessage, 'error'); + failureNotice.setFailureNotice(failureNotice.externalSyncAdoptFailureMessage, 'error', { showReload: true }); return false; } } - failureNotice.setFailureNotice(failureNotice.editorUpdateFailureMessage, 'error'); + failureNotice.setFailureNotice(failureNotice.externalSyncAdoptFailureMessage, 'error', { showReload: true }); return false; } }; +const rememberHostAuthoritative = (text: string, version: number): void => { + hostAuthoritativeText = text; + hostAuthoritativeVersion = version; +}; + +const clearHostAuthoritative = (): void => { + hostAuthoritativeText = null; + hostAuthoritativeVersion = null; +}; + +const adoptHostText = (rawText: string, version: number, context: string): boolean => { + commitEditorTransientEdits(); + rememberHostAuthoritative(rawText, version); + + if (pendingDebounce !== null) { + window.clearTimeout(pendingDebounce); + pendingDebounce = null; + } + + // Do not advance syncedText / clear drafts until the editor successfully shows host text. + const adopted = setEditorTextSafely(rawText, context); + if (!adopted) { + failureNotice.setFailureNotice(failureNotice.externalSyncAdoptFailureMessage, 'error', { showReload: true }); + return false; + } + + documentVersion = version; + syncedText = normalizeEol(rawText); + pendingText = null; + inFlight = false; + inFlightText = null; + saveAfterSync = false; + clearHostAuthoritative(); + failureNotice.clearFailureNotice(); + syncPendingDraftState(); + scheduleWikiLinkStatusRefresh(rawText); + scheduleLocalLinkStatusRefresh(rawText); + findPanelController.updateFindStatusSummary(); + return true; +}; + +const reloadEditorFromHost = async (reason: string): Promise => { + const snapshotText = hostAuthoritativeText; + const snapshotVersion = hostAuthoritativeVersion; + + if (typeof snapshotText === 'string' && typeof snapshotVersion === 'number') { + if (adoptHostText(snapshotText, snapshotVersion, `reload.${reason}`)) { + return; + } + } + + // Ask host for a fresh init/doc snapshot. + vscode.postMessage({ type: 'requestReload' }); +}; + const shortcutHandlerContext: ShortcutHandlerContext = { get editor() { return editor; }, get currentMode() { return currentMode; }, @@ -1469,6 +1548,7 @@ window.addEventListener('message', (event) => { setShikiTheme(message.codeTheme); initialMountRecoveryAttempted = false; failureNotice.clearFailureNotice(); + clearHostAuthoritative(); gitClient?.resetForInit({ hideTooltip: false }); const nextMode = hasLocalModePreference ? currentMode : message.mode; documentVersion = message.version; @@ -1548,10 +1628,11 @@ window.addEventListener('message', (event) => { const inFlightNormalized = inFlightText === null ? null : normalizeEol(inFlightText); const localDraftText = pendingText ?? inFlightText; const localDraftNormalized = localDraftText === null ? null : normalizeEol(localDraftText); + const hostVersion = typeof message.version === 'number' ? message.version : documentVersion; - documentVersion = message.version; - + // Echo of our own write (or editor already matches host). if (incomingText === currentText) { + documentVersion = hostVersion; syncedText = currentText; if (pendingNormalized === incomingText) { @@ -1563,16 +1644,20 @@ window.addEventListener('message', (event) => { inFlightText = null; } + clearHostAuthoritative(); flushChanges(); maybeSaveAfterSync(); syncPendingDraftState(); return; } + // Host confirmed the in-flight full replace we just sent. if (inFlight && inFlightNormalized === incomingText) { + documentVersion = hostVersion; syncedText = incomingText; inFlight = false; inFlightText = null; + clearHostAuthoritative(); flushChanges(); maybeSaveAfterSync(); syncPendingDraftState(); @@ -1580,51 +1665,47 @@ window.addEventListener('message', (event) => { } if (pendingNormalized === incomingText) { + documentVersion = hostVersion; syncedText = incomingText; pendingText = null; inFlight = false; inFlightText = null; + clearHostAuthoritative(); flushChanges(); maybeSaveAfterSync(); syncPendingDraftState(); return; } + // External host write while we still have a local draft that differs. + // Prefer host content so agents/git/outside editors win; do not silently + // force the local draft back over the host without painting the new text. if (localDraftText !== null && localDraftNormalized !== incomingText) { - syncedText = incomingText; - pendingText = localDraftText; - inFlight = false; - inFlightText = null; - if (pendingDebounce !== null) { window.clearTimeout(pendingDebounce); pendingDebounce = null; } - - flushChanges(); - maybeSaveAfterSync(); - syncPendingDraftState(); + const adopted = adoptHostText(message.text, hostVersion, 'docChanged.external-over-local'); + if (adopted) { + failureNotice.setFailureNotice(failureNotice.externalSyncConflictMessage, 'warning'); + } return; } - syncedText = incomingText; - pendingText = null; + // Pure external update (no conflicting local draft). + adoptHostText(message.text, hostVersion, 'docChanged.external'); + return; + } + + if (message.type === 'appliedFailed') { + // Host rejected applyEdit โ€” drop inFlight and re-sync from host payload if provided. inFlight = false; inFlightText = null; - saveAfterSync = false; - - if (pendingDebounce !== null) { - window.clearTimeout(pendingDebounce); - pendingDebounce = null; - } - - syncPendingDraftState(); - if (!setEditorTextSafely(message.text, 'docChanged')) { + if (typeof message.text === 'string' && typeof message.version === 'number') { + adoptHostText(message.text, message.version, 'appliedFailed'); return; } - scheduleWikiLinkStatusRefresh(message.text); - scheduleLocalLinkStatusRefresh(message.text); - findPanelController.updateFindStatusSummary(); + vscode.postMessage({ type: 'requestReload' }); return; } diff --git a/webview/src/styles.css b/webview/src/styles.css index e517ecf..9a4ed4c 100644 --- a/webview/src/styles.css +++ b/webview/src/styles.css @@ -230,12 +230,37 @@ body { line-height: 1.4; z-index: 275; pointer-events: none; + align-items: center; + gap: 10px; } .editor-notice.is-visible { - display: block; + display: flex; } +.editor-notice-message { + flex: 1 1 auto; + min-width: 0; +} + +.editor-notice-action { + flex: 0 0 auto; + appearance: none; + border: 1px solid currentColor; + border-radius: 4px; + background: transparent; + color: inherit; + font: inherit; + font-weight: 600; + padding: 2px 8px; + cursor: pointer; +} + +.editor-notice-action:hover { + filter: brightness(1.08); +} + + .editor-notice[data-kind='warning'] { border-color: var(--vscode-inputValidation-warningBorder, #b89500); color: var(--vscode-inputValidation-warningForeground, var(--vscode-editor-foreground)); diff --git a/webview/src/types.d.ts b/webview/src/types.d.ts index a12d90d..caa3f8d 100644 --- a/webview/src/types.d.ts +++ b/webview/src/types.d.ts @@ -24,6 +24,7 @@ type WebviewMessage = | { type: 'resolveLocalLinks'; requestId: string; targets: string[] } | { type: 'requestDiagnosticSuggestions'; requestId: string; from: number; to: number; message: string; source?: string; code?: string } | { type: 'saveDocument' } + | { type: 'requestReload' } | { type: 'exportDocument'; format: 'html' | 'pdf' } | { type: 'exportSnapshot'; requestId: string; text: string; environment?: Record } | { type: 'exportSnapshotError'; requestId: string; error: string; message?: string } @@ -40,6 +41,7 @@ type ExtensionMessage = | { type: 'init'; text: string; version: number; diagnostics: EditorDiagnostic[]; theme: ThemeSettings; mode: 'live' | 'source'; outlinePosition: 'left' | 'right'; outlineVisible: boolean; lineNumbers: boolean; gitChangesGutter: boolean; gitDiffLineHighlights: boolean; spellCheckEnabled: boolean; contentMaxWidthEnabled: boolean; vimMode: boolean; vimKeybindings: VimKeybinding[]; vimLeader: string; keymap?: Array<{ key: string; command: string }>; findOptions: { wholeWord: boolean; caseSensitive: boolean }; restoreTopLine?: number; restoreTopLineOffset?: number } | { type: 'docChanged'; text: string; version: number } | { type: 'applied'; version: number } + | { type: 'appliedFailed'; text?: string; version?: number } | { type: 'focusEditor' } | { type: 'revealSelection'; anchor: number; head: number; focus?: boolean } | { type: 'diagnosticsChanged'; diagnostics: EditorDiagnostic[] } From eaf072b0effd5ba3ba6f464a8eee0866bc574a94 Mon Sep 17 00:00:00 2001 From: Vadim Melnicuk Date: Sat, 5 Sep 2026 22:45:34 +0100 Subject: [PATCH 06/10] feat: enhance markdown export with visible link-reference definitions and improve copy functionality in editor --- CHANGELOG.md | 4 +- package.json | 2 +- src/export/renderMarkdown.ts | 110 ++++++++++++++++++++++++++++++++++- webview/src/editor.ts | 20 +++++++ webview/src/liveMode.ts | 3 +- 5 files changed, 134 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59739f1..4c843ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,9 @@ # Markdown Editor Optimized (MEO) --- ## Unreleased +- 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` - Hardened external document sync (git/LLM/outside editors): prefer host content on conflict, fix stuck applyingExternal, Reload recovery -- Avoid silent local-draft overwrite of external writes; surface appliedFailed + requestReload paths -- Added configurable CodeMirror keymap via `markdownEditorOptimized.keymap` (whitelist commands + `passthrough`) ## 0.1.26 - Improved dark Mermaid diagram line contrast diff --git a/package.json b/package.json index fadd741..4b9b9e0 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "customEditors": [ { "viewType": "markdownEditorOptimized.editor", - "displayName": "Markdown Editor Optimized", + "displayName": "MEO", "selector": [ { "filenamePattern": "*.md" diff --git a/src/export/renderMarkdown.ts b/src/export/renderMarkdown.ts index 7363bce..c287afb 100644 --- a/src/export/renderMarkdown.ts +++ b/src/export/renderMarkdown.ts @@ -220,7 +220,115 @@ export function renderMarkdownToHtml(options: RenderMarkdownOptions): RenderMark } function normalizeMarkdownForExport(markdownText: string): string { - return ensureBlankLinesAroundTableBlocks(normalizeMermaidColonFences(markdownText)); + return ensureVisibleReferenceSections( + ensureBlankLinesAroundTableBlocks(normalizeMermaidColonFences(markdownText)) + ); +} + +type LinkReferenceDefinition = { + label: string; + title: string; +}; + +const referenceSectionHeadingPattern = /^([ \t]{0,3})(#{1,6})[ \t]+(?:references|sources|citations)[ \t]*#*[ \t]*$/i; +const linkReferenceDefinitionPattern = /^[ \t]{0,3}\[([^\]^][^\]]*)\]:[ \t]*(?:<[^>\r\n]+>|\S+)(?:[ \t]+(?:"([^"]*)"|'([^']*)'|\(([^)]*)\)))?[ \t]*$/; + +function ensureVisibleReferenceSections(markdownText: string): string { + const lines = String(markdownText ?? '').split(/\r?\n/); + const out: string[] = []; + const fenceState = { inFence: false, char: '', length: 0 }; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ''; + updateExportFenceState(fenceState, line); + out.push(line); + + if (fenceState.inFence) { + continue; + } + + const headingMatch = referenceSectionHeadingPattern.exec(line); + if (!headingMatch) { + continue; + } + + const definitions = collectReferenceDefinitions(lines, index + 1, headingMatch[2].length); + if (!definitions.length) { + continue; + } + + out.push('', ...definitions.map(renderVisibleReferenceDefinition), ''); + } + + return out.join('\n'); +} + +function collectReferenceDefinitions( + lines: string[], + startIndex: number, + sectionHeadingLevel: number +): LinkReferenceDefinition[] { + const definitions: LinkReferenceDefinition[] = []; + + for (let index = startIndex; index < lines.length; index += 1) { + const line = lines[index] ?? ''; + if (!line.trim()) { + continue; + } + + const heading = /^[ \t]{0,3}(#{1,6})[ \t]+/.exec(line); + if (heading && heading[1].length <= sectionHeadingLevel) { + break; + } + + const definitionMatch = linkReferenceDefinitionPattern.exec(line); + if (!definitionMatch) { + return []; + } + + definitions.push({ + label: definitionMatch[1].trim(), + title: (definitionMatch[2] ?? definitionMatch[3] ?? definitionMatch[4] ?? '').trim() + }); + } + + return definitions; +} + +function renderVisibleReferenceDefinition(definition: LinkReferenceDefinition): string { + const text = escapeMarkdownLinkText(definition.title || definition.label); + const label = definition.label.replace(/\\/g, '\\\\').replace(/\]/g, '\\]'); + return /^\d+$/.test(definition.label) + ? `${definition.label}. [${text}][${label}]` + : `- [${text}][${label}]`; +} + +function escapeMarkdownLinkText(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/([\[\]])/g, '\\$1'); +} + +function updateExportFenceState( + state: { inFence: boolean; char: string; length: number }, + line: string +): void { + const fence = /^[ \t]{0,3}([`~]{3,})/.exec(line); + if (!fence) { + return; + } + + const marker = fence[1]; + if (!state.inFence) { + state.inFence = true; + state.char = marker[0]; + state.length = marker.length; + return; + } + + if (marker[0] === state.char && marker.length >= state.length) { + state.inFence = false; + state.char = ''; + state.length = 0; + } } function normalizeMermaidColonFences(markdownText: string): string { diff --git a/webview/src/editor.ts b/webview/src/editor.ts index 20ea0c8..2b14dcd 100644 --- a/webview/src/editor.ts +++ b/webview/src/editor.ts @@ -1518,6 +1518,26 @@ export function createEditor({ EditorView.lineWrapping, scrollPastEnd(), EditorView.domEventHandlers({ + copy(event, view) { + const selectedRanges = view.state.selection.ranges.filter((range) => !range.empty); + if (!selectedRanges.length) { + return false; + } + + if (!event.clipboardData) { + return false; + } + + const selectedMarkdown = selectedRanges + .map((range) => view.state.doc.sliceString( + Math.min(range.from, range.to), + Math.max(range.from, range.to) + )) + .join(view.state.lineBreak); + event.clipboardData.setData('text/plain', selectedMarkdown); + event.preventDefault(); + return true; + }, pointerdown(event, view) { if (event.button !== 0) { frontmatterBoundaryClick = null; diff --git a/webview/src/liveMode.ts b/webview/src/liveMode.ts index 7412b99..89d3b79 100644 --- a/webview/src/liveMode.ts +++ b/webview/src/liveMode.ts @@ -944,6 +944,7 @@ function addDetailsBlockDecorations(builder, state, detailsBlocks, activeLines) const openingActive = rangeTouchesActiveLine(state, detailsBlock.anchorFrom, detailsBlock.anchorTo, activeLines); const closingActive = rangeTouchesActiveLine(state, detailsBlock.closingFrom, detailsBlock.closingTo, activeLines); const editingBoundary = openingActive || closingActive; + const selectingBlock = overlapsSelection(state, detailsBlock.sectionFrom, detailsBlock.sectionTo); if (!editingBoundary) { addLineClass(builder, state, detailsBlock.lineFrom, detailsBlock.lineTo, lineStyleDecos.detailsSummary); @@ -976,7 +977,7 @@ function addDetailsBlockDecorations(builder, state, detailsBlocks, activeLines) builder.push(collapsedHeadingBodyDeco.range(detailsBlock.closingFrom, detailsBlock.closingTo)); } - if (detailsBlock.collapsed && detailsBlock.bodyTo > detailsBlock.bodyFrom) { + if (detailsBlock.collapsed && !selectingBlock && detailsBlock.bodyTo > detailsBlock.bodyFrom) { builder.push(collapsedHeadingBodyDeco.range(detailsBlock.bodyFrom, detailsBlock.bodyTo)); } } From 5cd4356528f5328a3d89ed02ff7e4897c68da629 Mon Sep 17 00:00:00 2001 From: Apostol Apostolov Date: Sat, 5 Sep 2026 15:02:58 -0700 Subject: [PATCH 07/10] fix: stop selection toolbox flicker when highlighting text (#76) Closes #63. --- CHANGELOG.md | 1 + webview/src/editor.ts | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c843ab..9dc4815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Markdown Editor Optimized (MEO) --- ## Unreleased +- Fixed the selection formatting toolbox flickering while highlighting text - 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` - Hardened external document sync (git/LLM/outside editors): prefer host content on conflict, fix stuck applyingExternal, Reload recovery diff --git a/webview/src/editor.ts b/webview/src/editor.ts index 2b14dcd..054c480 100644 --- a/webview/src/editor.ts +++ b/webview/src/editor.ts @@ -987,11 +987,12 @@ export function createEditor({ return; } + if (selectionPointerId !== null) { + return; + } + const selection = view.state.selection.main; if (selection.empty) { - if (selectionPointerId !== null) { - return; - } onSelectionChange({ visible: false }); return; } @@ -1786,6 +1787,17 @@ export function createEditor({ parent, scrollTo: initialScrollTo }); + + const finishSelectionOutsideEditor = (event: PointerEvent): void => { + if (selectionPointerId !== event.pointerId) { + return; + } + selectionPointerId = null; + scheduleSelectionChangeEmit(); + }; + window.addEventListener('pointerup', finishSelectionOutsideEditor); + window.addEventListener('pointercancel', finishSelectionOutsideEditor); + if (typeof initialTopLine === 'number' && Number.isFinite(initialTopLine)) { restoreTopVisibleLine(initialTopLine, initialTopLineOffset, { syncCursor: true }); } @@ -1940,6 +1952,8 @@ export function createEditor({ view.focus(); }, destroy() { + window.removeEventListener('pointerup', finishSelectionOutsideEditor); + window.removeEventListener('pointercancel', finishSelectionOutsideEditor); gitBlameHover?.destroy(); gitBlameHover = null; gitDiffOverviewRuler?.destroy(); From e483f9a52a4b3a685dc8e3c945ab21b5d83345b1 Mon Sep 17 00:00:00 2001 From: Vadim Melnicuk Date: Sat, 5 Sep 2026 23:40:40 +0100 Subject: [PATCH 08/10] feat: add visual multiline editing for table cells and improve markdown handling --- CHANGELOG.md | 3 +- webview/src/editor.ts | 22 ++++-- webview/src/helpers/tables.ts | 137 ++++++++++++++++++++++++++++++++-- 3 files changed, 151 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dc4815..cb67cd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,10 @@ # Markdown Editor Optimized (MEO) --- ## Unreleased -- Fixed the selection formatting toolbox flickering while highlighting text +- 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 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 ## 0.1.26 diff --git a/webview/src/editor.ts b/webview/src/editor.ts index 054c480..43d65e7 100644 --- a/webview/src/editor.ts +++ b/webview/src/editor.ts @@ -43,7 +43,13 @@ import { indentListByTwoSpaces, outdentListByTwoSpaces } from './helpers/listMarkers'; -import { insertTable, sourceTableHeaderLineField } from './helpers/tables'; +import { + insertTable, + sourceTableHeaderLineField, + tableCellEditorOffsetToMarkdownOffset, + tableCellEditorTextToMarkdown, + tableCellMarkdownOffsetToEditorOffset +} from './helpers/tables'; import { parseFrontmatter, sourceFrontmatterField } from './helpers/frontmatter'; import { collectLatexMathRanges } from './helpers/math'; import { diagnosticDataField, diagnosticField, setDiagnosticsEffect, type EditorDiagnostic } from './helpers/diagnostics'; @@ -612,6 +618,10 @@ export function createEditor({ return { from, to }; }; + const getTableInputMarkdown = (input: HTMLTextAreaElement): string => { + return input.dataset.tableCellMarkdown ?? tableCellEditorTextToMarkdown(input.value); + }; + const getTableInputDocumentSelection = ( input: HTMLTextAreaElement ): { from: number; to: number; anchorX: number; anchorY: number; anchorBottomY: number } | null => { @@ -628,9 +638,10 @@ export function createEditor({ const selectionEnd = Math.max(rawStart, rawEnd); const coords = measureTextareaSelectionStart(input, selectionStart); const lineHeight = parseFloat(getComputedStyle(input).lineHeight); + const markdown = getTableInputMarkdown(input); return { - from: sourceRange.from + selectionStart, - to: sourceRange.from + selectionEnd, + from: sourceRange.from + tableCellEditorOffsetToMarkdownOffset(markdown, selectionStart), + to: sourceRange.from + tableCellEditorOffsetToMarkdownOffset(markdown, selectionEnd), anchorX: coords.left, anchorY: coords.top, anchorBottomY: coords.top + (Number.isFinite(lineHeight) ? lineHeight : 20) @@ -2306,8 +2317,9 @@ export function createEditor({ from >= tableSourceRange.from && to <= tableSourceRange.to ) { - const localFrom = from - tableSourceRange.from; - const localTo = to - tableSourceRange.from; + const markdown = getTableInputMarkdown(activeTableInput); + const localFrom = tableCellMarkdownOffsetToEditorOffset(markdown, from - tableSourceRange.from); + const localTo = tableCellMarkdownOffsetToEditorOffset(markdown, to - tableSourceRange.from); clearDiagnosticSuggestionState(); updateActiveTableInput( activeTableInput, diff --git a/webview/src/helpers/tables.ts b/webview/src/helpers/tables.ts index 74a139a..7718c26 100644 --- a/webview/src/helpers/tables.ts +++ b/webview/src/helpers/tables.ts @@ -269,6 +269,7 @@ function isRedoShortcut(event) { const tableInlineSchemeRe = /^[a-z][a-z0-9+.-]*:/i; const tableInlineRawUrlRe = /^(?:[a-z][a-z0-9+.-]*:\/\/|mailto:|file:|www\.)[^\s<]+/i; const tableInlineEmojiShortcodeRe = /^:([a-zA-Z0-9_+-]+):/; +const tableInlineLineBreakRe = /^/i; const tableInlineEscapableChars = new Set(['\\', '*', '_', '~', '`', '[', ']', '(', ')', '!', '|', '<', '>']); const tableSearchStateEventName = 'meo-search-state-change'; const tableDiagnosticSeverityClasses = [ @@ -512,6 +513,96 @@ function parseTableInlineCodeSpan(text, index) { }; } +interface TableCellEditorProjection { + text: string; + editorToMarkdown: number[]; + markdownToEditor: number[]; +} + +function projectTableCellMarkdownToEditor(markdown: string): TableCellEditorProjection { + let text = ''; + const editorToMarkdown = [0]; + const markdownToEditor = new Array(markdown.length + 1).fill(0); + + const appendSourceText = (from: number, to: number) => { + for (let offset = from; offset < to; offset += 1) { + markdownToEditor[offset] = text.length; + text += markdown[offset]; + editorToMarkdown[text.length] = offset + 1; + } + markdownToEditor[to] = text.length; + }; + + for (let index = 0; index < markdown.length;) { + if (markdown[index] === '\\' && index + 1 < markdown.length) { + appendSourceText(index, index + 2); + index += 2; + continue; + } + + const code = parseTableInlineCodeSpan(markdown, index); + if (code) { + appendSourceText(index, code.nextIndex); + index = code.nextIndex; + continue; + } + + const kbd = markdown[index] === '<' ? parseKbdTagAt(markdown, index) : null; + if (kbd) { + appendSourceText(index, kbd.nextIndex); + index = kbd.nextIndex; + continue; + } + + const math = parseLatexMathAt(markdown, index); + if (math) { + appendSourceText(index, math.to); + index = math.to; + continue; + } + + const lineBreak = markdown[index] === '<' + ? tableInlineLineBreakRe.exec(markdown.slice(index)) + : null; + if (lineBreak) { + const nextIndex = index + lineBreak[0].length; + markdownToEditor[index] = text.length; + text += '\n'; + editorToMarkdown[text.length] = nextIndex; + for (let offset = index + 1; offset <= nextIndex; offset += 1) { + markdownToEditor[offset] = text.length; + } + index = nextIndex; + continue; + } + + appendSourceText(index, index + 1); + index += 1; + } + + return { text, editorToMarkdown, markdownToEditor }; +} + +export function tableCellMarkdownToEditorText(markdown: string): string { + return projectTableCellMarkdownToEditor(markdown).text; +} + +export function tableCellEditorTextToMarkdown(text: string): string { + return text.replace(/\r\n?|\n/g, '
'); +} + +export function tableCellEditorOffsetToMarkdownOffset(markdown: string, editorOffset: number): number { + const projection = projectTableCellMarkdownToEditor(markdown); + const safeOffset = Math.min(Math.max(editorOffset, 0), projection.text.length); + return projection.editorToMarkdown[safeOffset] ?? markdown.length; +} + +export function tableCellMarkdownOffsetToEditorOffset(markdown: string, markdownOffset: number): number { + const projection = projectTableCellMarkdownToEditor(markdown); + const safeOffset = Math.min(Math.max(markdownOffset, 0), markdown.length); + return projection.markdownToEditor[safeOffset] ?? projection.text.length; +} + function consumeTableInlineAngleSection(text, index) { if (text[index] !== '<' || isTableInlineEscaped(text, index)) return null; const close = text.indexOf('>', index + 1); @@ -852,6 +943,16 @@ function appendTableInlinePreviewNodes(parent: HTMLElement, text: string, option continue; } + const lineBreak = text[i] === '<' && !isTableInlineEscaped(text, i) + ? tableInlineLineBreakRe.exec(text.slice(i)) + : null; + if (lineBreak) { + flushBuffer(); + parent.appendChild(document.createElement('br')); + i += lineBreak[0].length; + continue; + } + const math = parseLatexMathAt(text, i); if (math) { const mathElement = createLatexMathElement(math.content, math.mode); @@ -1341,9 +1442,15 @@ class HtmlTableWidget extends WidgetType { readCellMatrix(): CellMatrix { if (!this.domRefs) return { headerCells: [], rows: [], alignments: [] }; const { headerInputs, rowInputs } = this.domRefs; - const headerCells = normalizeRow(headerInputs.map((input) => input.value.trim()), this.tableData.colCount); + const headerCells = normalizeRow( + headerInputs.map((input) => tableCellEditorTextToMarkdown(input.value).trim()), + this.tableData.colCount + ); - const rows = rowInputs.map((inputs) => normalizeRow(inputs.map((input) => input.value.trim()), this.tableData.colCount)); + const rows = rowInputs.map((inputs) => normalizeRow( + inputs.map((input) => tableCellEditorTextToMarkdown(input.value).trim()), + this.tableData.colCount + )); return { headerCells, rows, alignments: this.tableData.alignments }; } @@ -2064,7 +2171,7 @@ class HtmlTableWidget extends WidgetType { const refreshPreview = () => { this.renderCellPreview( preview, - input.value, + tableCellEditorTextToMarkdown(input.value), this.cellDiagnostics(rowIndex, colIndex), this.cellSourceRange(rowIndex, colIndex) ); @@ -2112,6 +2219,7 @@ class HtmlTableWidget extends WidgetType { input.addEventListener('input', () => { this.hasPendingCellEdits = true; + input.dataset.tableCellMarkdown = tableCellEditorTextToMarkdown(input.value); // The preview layer is hidden while editing. Rebuilding it on each keystroke // recreates inline image DOM and resets image load opacity, which causes flicker. this.resizeRow(rowEl, rowInputs); @@ -2122,6 +2230,22 @@ class HtmlTableWidget extends WidgetType { input.addEventListener('keyup', notifySelectionChange); input.addEventListener('pointerup', notifySelectionChange); input.addEventListener('keydown', (event) => { + if ( + event.key === 'Enter' && + !event.isComposing && + !event.altKey && + !event.ctrlKey && + !event.metaKey + ) { + event.preventDefault(); + event.stopPropagation(); + const start = input.selectionStart ?? input.value.length; + const end = input.selectionEnd ?? start; + input.setRangeText('\n', start, end, 'end'); + input.dispatchEvent(new Event('input', { bubbles: true })); + return; + } + const direction = event.key === 'ArrowUp' ? 'up' : event.key === 'ArrowDown' ? 'down' : null; if (direction) onArrowVertical(event, direction); }); @@ -2250,9 +2374,11 @@ class HtmlTableWidget extends WidgetType { if (!(input instanceof HTMLTextAreaElement)) return; const preview = input.parentElement?.querySelector('.meo-md-html-table-cell-preview'); const coords = this.parseCellCoords(input.dataset.tableRow, input.dataset.tableCol); + const markdown = tableCellEditorTextToMarkdown(input.value); + input.dataset.tableCellMarkdown = markdown; this.renderCellPreview( preview, - input.value, + markdown, coords ? this.cellDiagnostics(coords.row, coords.col) : [], coords ? this.cellSourceRange(coords.row, coords.col) : null ); @@ -2300,7 +2426,8 @@ class HtmlTableWidget extends WidgetType { const input = document.createElement('textarea'); input.rows = 1; input.spellcheck = true; - input.value = value; + input.value = tableCellMarkdownToEditorText(value); + input.dataset.tableCellMarkdown = value; input.dataset.tableRow = String(rowIndex); input.dataset.tableCol = String(colIndex); const sourceRange = this.cellSourceRange(rowIndex, colIndex); From e6a2f861a259320f38432c7ff50684b42f665d44 Mon Sep 17 00:00:00 2001 From: Vadim Melnicuk Date: Sun, 6 Sep 2026 06:51:06 +0100 Subject: [PATCH 09/10] fix: correct caret placement in live table cell editing --- CHANGELOG.md | 1 + webview/src/helpers/tables.ts | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb67cd3..5a113c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - 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 - 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 diff --git a/webview/src/helpers/tables.ts b/webview/src/helpers/tables.ts index 7718c26..dd49ea5 100644 --- a/webview/src/helpers/tables.ts +++ b/webview/src/helpers/tables.ts @@ -1701,6 +1701,18 @@ class HtmlTableWidget extends WidgetType { return true; } + focusCellInputAtPoint(cell, clientX, clientY) { + const input = cell.querySelector('textarea'); + if (!(input instanceof HTMLTextAreaElement)) return false; + + // Reveal the textarea before hit-testing so Chromium resolves the pointer + // position against the editable text rather than the rendered preview. + this.setCellEditingState(input, true); + const caretPosition = document.caretPositionFromPoint?.(clientX, clientY); + const caret = caretPosition?.offsetNode === input ? caretPosition.offset : null; + return this.focusTableInput(input, caret); + } + focusCellInputAt(row, col, caret = null) { const input = this.domRefs?.allRowInputs?.[row]?.[col]; return this.focusTableInput(input, caret); @@ -1895,7 +1907,7 @@ class HtmlTableWidget extends WidgetType { if (!(event.target instanceof HTMLTextAreaElement)) { event.preventDefault(); - this.focusCellInput(cell); + this.focusCellInputAtPoint(cell, event.clientX, event.clientY); } }; From e00f1b7046af285612814e7a0a41e39726a4cc55 Mon Sep 17 00:00:00 2001 From: Vadim Melnicuk Date: Sun, 6 Sep 2026 07:10:18 +0100 Subject: [PATCH 10/10] fix: Persist read-only mode across all documents --- CHANGELOG.md | 2 +- README.md | 2 +- package.json | 7 +++++++ src/extension.ts | 12 ++++++++---- src/extension/panelSession.ts | 11 +++++------ src/shared/extensionConfig.ts | 10 ++++++++++ 6 files changed, 32 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35900a2..cc35498 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Markdown Editor Optimized (MEO) --- ## Unreleased -- Added a per-document Read Only toggle beside Spell Check that blocks editing in Live and Source while preserving reading, selection, copying, and external updates +- 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` diff --git a/README.md b/README.md index d9368ec..67ddebf 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ An optimized markdown editor with live editing mode for VS Code. ### Writing & Editing - **Live/Source modes** - Switch between clean writing and raw markdown in a single tab -- **Read Only** - Lock the current document in Live and Source using the button beside Spell Check. Live stays rendered while selecting and copying text. Toggle off to edit. +- **Read Only** - Lock all documents in Live and Source using the button beside Spell Check. Read Only defaults to off and is remembered across editor sessions and workspaces. Live stays rendered while selecting and copying text. Toggle off to edit. - **Toolbar formatting** - Insert headings, lists, tasks, tables, code blocks, links, images, and quotes in one click - **Floating selection menu** - Instantly apply bold, italic, strikethrough, inline code, or links on any text selection - **Spellcheck** - Fix issues with built-in spelling suggestions diff --git a/package.json b/package.json index 127bfad..775c7d5 100644 --- a/package.json +++ b/package.json @@ -184,6 +184,13 @@ "order": 2, "description": "Position of the contents outline sidebar." }, + "markdownEditorOptimized.readOnly": { + "type": "boolean", + "default": false, + "scope": "application", + "order": 3, + "description": "Make all Markdown Editor Optimized documents read-only in Live and Source. Remembered across editor sessions and workspaces." + }, "markdownEditorOptimized.contentMaxWidth.visible": { "type": "boolean", "default": false, diff --git a/src/extension.ts b/src/extension.ts index c4b18fd..531a93f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -40,6 +40,9 @@ import { CODE_BLOCKS_VSCODE_THEME_SETTING_KEY, CONTENT_MAX_WIDTH_SETTING_KEY, SPELL_CHECK_SETTING_KEY, + READ_ONLY_SETTING_KEY, + getReadOnlyEnabled, + setReadOnlyEnabled, getUseVscodeThemeForCodeBlocks, getCodeBlockVscodeTheme, syncEditorAssociations, @@ -533,6 +536,10 @@ class MarkdownWebviewProvider implements vscode.CustomTextEditorProvider { } async handleConfigurationChanged(event: vscode.ConfigurationChangeEvent): Promise { + if (event.affectsConfiguration(`${EXTENSION_CONFIG_SECTION}.${READ_ONLY_SETTING_KEY}`)) { + this.broadcast({ type: 'readOnlyChanged', enabled: getReadOnlyEnabled() }); + } + if ( event.affectsConfiguration(`${EXTENSION_CONFIG_SECTION}.${LINE_NUMBERS_SETTING_KEY}`) || event.affectsConfiguration(`${EXTENSION_CONFIG_SECTION}.${LINE_NUMBERS_LEGACY_SETTING_KEY}`) || @@ -633,10 +640,7 @@ class MarkdownWebviewProvider implements vscode.CustomTextEditorProvider { } async toggleReadOnly(): Promise { - const session = this.getActiveSession(); - if (!session) return; - await session.ensureInitDelivered(); - await session.panel.webview.postMessage({ type: 'toggleReadOnly' }); + await setReadOnlyEnabled(!getReadOnlyEnabled()); } async resolveCustomTextEditor( diff --git a/src/extension/panelSession.ts b/src/extension/panelSession.ts index 9b56c32..527b71a 100644 --- a/src/extension/panelSession.ts +++ b/src/extension/panelSession.ts @@ -7,6 +7,8 @@ import { GIT_CHANGES_GUTTER_SETTING_KEY, CONTENT_MAX_WIDTH_SETTING_KEY, SPELL_CHECK_SETTING_KEY, + getReadOnlyEnabled, + setReadOnlyEnabled, getContentMaxWidthEnabled, getLineNumbersEnabled, getGitChangesGutterEnabled, @@ -434,8 +436,6 @@ export function createPanelSessionController(params: PanelSessionControllerParam } = params; const documentKey = document.uri.toString(); - const readOnlyStateKey = `readOnly:${documentKey}`; - let readOnly = context.workspaceState.get(readOnlyStateKey, false); let mode: EditorMode = 'live'; let applyQueue: Promise = Promise.resolve(); let webviewReady = false; @@ -572,7 +572,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam diagnostics: serializeDiagnostics(document), mode, lineNumbers: getLineNumbersEnabled(context), - readOnly, + readOnly: getReadOnlyEnabled(), gitChangesGutter: getGitChangesGutterEnabled(context), gitDiffLineHighlights: getGitDiffLineHighlightsEnabled(), spellCheckEnabled: getSpellCheckEnabled(), @@ -904,7 +904,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam return; } // Avoid stealing keyboard focus from chat/agent inputs while the document is read-only. - if (readOnly) { + if (getReadOnlyEnabled()) { return; } await ensureInitDelivered(); @@ -1056,8 +1056,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam .update(CONTENT_MAX_WIDTH_SETTING_KEY, raw.enabled === true, vscode.ConfigurationTarget.Global); return; case 'setReadOnly': { - readOnly = raw.enabled === true; - await context.workspaceState.update(readOnlyStateKey, readOnly || undefined); + await setReadOnlyEnabled(raw.enabled === true); return; } diff --git a/src/shared/extensionConfig.ts b/src/shared/extensionConfig.ts index fb79bb9..92dae00 100644 --- a/src/shared/extensionConfig.ts +++ b/src/shared/extensionConfig.ts @@ -17,6 +17,7 @@ export const EXTENSION_CONFIG_SECTION = 'markdownEditorOptimized'; export const LINE_NUMBERS_SETTING_KEY = 'lineNumbers.visible'; export const GIT_CHANGES_GUTTER_SETTING_KEY = 'gitChanges.visible'; export const GIT_DIFF_LINE_HIGHLIGHTS_SETTING_KEY = 'gitChanges.lineHighlights'; +export const READ_ONLY_SETTING_KEY = 'readOnly'; export const SPELL_CHECK_SETTING_KEY = 'spellCheck.enabled'; export const VIM_MODE_BEHAVIOR_SETTING_KEY = 'vimMode.behavior'; export const VIM_MODE_SETTING_KEY = 'vimMode.enabled'; @@ -76,6 +77,15 @@ export function getGitDiffLineHighlightsEnabled(): boolean { return vscode.workspace.getConfiguration(EXTENSION_CONFIG_SECTION).get(GIT_DIFF_LINE_HIGHLIGHTS_SETTING_KEY, true); } +export function getReadOnlyEnabled(): boolean { + return vscode.workspace.getConfiguration(EXTENSION_CONFIG_SECTION).get(READ_ONLY_SETTING_KEY, false); +} + +export async function setReadOnlyEnabled(enabled: boolean): Promise { + await vscode.workspace.getConfiguration(EXTENSION_CONFIG_SECTION) + .update(READ_ONLY_SETTING_KEY, enabled, vscode.ConfigurationTarget.Global); +} + export function getSpellCheckEnabled(): boolean { return vscode.workspace.getConfiguration(EXTENSION_CONFIG_SECTION).get(SPELL_CHECK_SETTING_KEY, true); }