diff --git a/CHANGELOG.md b/CHANGELOG.md index d675eb5..c0aa836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,14 @@ # Markdown Editor Optimized (MEO) --- ## Unreleased -- Added setting to toggle active line highlight (`markdownEditorOptimized.activeLineHighlight.visible`) -- Added theme slot `activeLineBackground` to restyle or clear the active line highlight +- Added the theme color `activeLineBackground` to customize the active-line highlight in Live and Source +- Added a shared Read Only toggle beside Spell Check that blocks editing in Live and Source while preserving reading, selection, copying, and external updates; the preference defaults to off and persists across documents, sessions, and workspaces +- Added visual multiline editing to live table cells while storing line breaks as `
` in Markdown +- Added link-reference definitions as visible linked lists in HTML and PDF exports, and restored copying selections across rendered blocks +- Added configurable CodeMirror keymap via `markdownEditorOptimized.keymap` +- Fixed live table cell clicks placing the caret at the end instead of the clicked position +- 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 - Improved dark Mermaid diagram line contrast diff --git a/README.md b/README.md index f072e05..67ddebf 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 +- **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 @@ -33,6 +34,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/docs/theming.md b/docs/theming.md index 129dc65..bfc32fd 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -64,7 +64,8 @@ Guide on how to customise the editor background, colors, syntax highlighting, fo - Optional. Leave empty (or omit) to use the built-in mix derived from `base03`. - Set to a color (`#hex`, `rgb()`, `hsl()`, `var(--...)`) to restyle the highlight for your theme. - Set to `transparent` to remove the active-line wash via the theme (without turning off CodeMirror's active-line machinery globally). -- There is also a workspace setting `markdownEditorOptimized.activeLineHighlight.visible` (default `true`) that fully disables the active-line highlight in both modes when set to `false`. + +Read Only hides the active-line highlight regardless of this color. ## colors diff --git a/package.json b/package.json index d354bdc..4b00e68 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,11 @@ "title": "Markdown Editor Optimized: Toggle Live/Source Mode", "icon": "$(sync)" }, + { + "command": "markdownEditorOptimized.toggleReadOnly", + "title": "Markdown Editor Optimized: Toggle Read Only", + "icon": "$(lock)" + }, { "command": "markdownEditorOptimized.exportHtml", "title": "Markdown Editor Optimized: Export as HTML", @@ -98,7 +103,7 @@ "customEditors": [ { "viewType": "markdownEditorOptimized.editor", - "displayName": "Markdown Editor Optimized", + "displayName": "MEO", "selector": [ { "filenamePattern": "*.md" @@ -179,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, @@ -191,12 +203,6 @@ "order": 4, "description": "Show line numbers." }, - "markdownEditorOptimized.activeLineHighlight.visible": { - "type": "boolean", - "default": true, - "order": 4, - "description": "Show the cursor/active line background highlight in Live and Source modes." - }, "markdownEditorOptimized.gitChanges.visible": { "type": "boolean", "default": true, @@ -286,6 +292,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": { @@ -354,11 +383,6 @@ "default": "var(--vscode-editor-background)", "description": "Editor document background color (hex/rgb/hsl/var)." }, - "activeLineBackground": { - "type": "string", - "default": "", - "description": "Optional cursor/active line background color (hex/rgb/hsl/var/transparent). Leave empty to use the built-in mix from base03. Set to \"transparent\" to remove the highlight via theme." - }, "colors": { "type": "object", "default": {}, @@ -742,6 +766,11 @@ "sourceLineHeight" ], "additionalProperties": false + }, + "activeLineBackground": { + "type": "string", + "default": "", + "description": "Active-line background in Live and Source. Empty uses the default color; accepts theme colors or transparent. Read Only hides the highlight." } }, "required": [ 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/src/extension.ts b/src/extension.ts index 4d86a2c..531a93f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -34,13 +34,15 @@ import { LINE_NUMBERS_LEGACY_SETTING_KEY, LINE_NUMBERS_LEGACY_VISIBLE_SETTING_KEY, LINE_NUMBERS_SETTING_KEY, - ACTIVE_LINE_HIGHLIGHT_SETTING_KEY, OUTLINE_VISIBLE_KEY, VIM_MODE_BEHAVIOR_SETTING_KEY, VIM_MODE_SETTING_KEY, CODE_BLOCKS_VSCODE_THEME_SETTING_KEY, CONTENT_MAX_WIDTH_SETTING_KEY, SPELL_CHECK_SETTING_KEY, + READ_ONLY_SETTING_KEY, + getReadOnlyEnabled, + setReadOnlyEnabled, getUseVscodeThemeForCodeBlocks, getCodeBlockVscodeTheme, syncEditorAssociations, @@ -51,7 +53,6 @@ import { getGitChangesGutterEnabled, getGitDiffLineHighlightsEnabled, getLineNumbersEnabled, - getActiveLineHighlightEnabled, getOutlinePosition, getOutlineVisible, getContentMaxWidthEnabled, @@ -60,6 +61,8 @@ import { getVimKeybindings, getVimLeaderKey, getVimModeEnabled, + getKeymapBindings, + KEYMAP_SETTING_KEY, isMarkdownDocumentPath, migrateLegacyToggleSettings, resetThemeSettingsToDefault @@ -437,6 +440,9 @@ export function activate(context: vscode.ExtensionContext): void { ); context.subscriptions.push( + vscode.commands.registerCommand('markdownEditorOptimized.toggleReadOnly', async () => { + await provider.toggleReadOnly(); + }), vscode.commands.registerCommand('markdownEditorOptimized.toggleMode', async () => { await provider.toggleActiveEditorMode(); }) @@ -530,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}`) || @@ -538,10 +548,6 @@ class MarkdownWebviewProvider implements vscode.CustomTextEditorProvider { this.broadcast({ type: 'lineNumbersChanged', enabled: getLineNumbersEnabled(this.context) }); } - if (event.affectsConfiguration(`${EXTENSION_CONFIG_SECTION}.${ACTIVE_LINE_HIGHLIGHT_SETTING_KEY}`)) { - this.broadcast({ type: 'activeLineHighlightChanged', enabled: getActiveLineHighlightEnabled() }); - } - if ( event.affectsConfiguration(`${EXTENSION_CONFIG_SECTION}.${GIT_CHANGES_GUTTER_SETTING_KEY}`) || event.affectsConfiguration(`${EXTENSION_CONFIG_SECTION}.${GIT_CHANGES_GUTTER_LEGACY_VISIBLE_SETTING_KEY}`) || @@ -571,6 +577,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') || @@ -629,6 +639,10 @@ class MarkdownWebviewProvider implements vscode.CustomTextEditorProvider { await session.panel.webview.postMessage({ type: 'toggleMode' }); } + async toggleReadOnly(): Promise { + await setReadOnlyEnabled(!getReadOnlyEnabled()); + } + async resolveCustomTextEditor( document: vscode.TextDocument, panel: vscode.WebviewPanel, diff --git a/src/extension/panelSession.ts b/src/extension/panelSession.ts index 38248ce..527b71a 100644 --- a/src/extension/panelSession.ts +++ b/src/extension/panelSession.ts @@ -7,9 +7,10 @@ import { GIT_CHANGES_GUTTER_SETTING_KEY, CONTENT_MAX_WIDTH_SETTING_KEY, SPELL_CHECK_SETTING_KEY, + getReadOnlyEnabled, + setReadOnlyEnabled, getContentMaxWidthEnabled, getLineNumbersEnabled, - getActiveLineHighlightEnabled, getGitChangesGutterEnabled, getGitDiffLineHighlightsEnabled, getSpellCheckEnabled, @@ -20,10 +21,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'; @@ -54,7 +57,7 @@ type InitMessage = { diagnostics: SerializedDiagnostic[]; mode: EditorMode; lineNumbers: boolean; - activeLineHighlight: boolean; + readOnly: boolean; gitChangesGutter: boolean; gitDiffLineHighlights: boolean; spellCheckEnabled: boolean; @@ -62,6 +65,7 @@ type InitMessage = { vimMode: boolean; vimKeybindings: VimKeybinding[]; vimLeader: string; + keymap: NormalizedKeymapBinding[]; findOptions: FindOptions; outlinePosition: OutlinePosition; outlineVisible: boolean; @@ -83,6 +87,12 @@ type AppliedMessage = { version: number; }; +type AppliedFailedMessage = { + type: 'appliedFailed'; + text: string; + version: number; +}; + type RevealSelectionMessage = { type: 'revealSelection'; anchor: number; @@ -142,6 +152,10 @@ type SaveDocumentMessage = { type: 'saveDocument'; }; +type RequestReloadMessage = { + type: 'requestReload'; +}; + type ExportDocumentMessage = { type: 'exportDocument'; format: ExportFormat; @@ -187,6 +201,11 @@ type SetContentMaxWidthMessage = { enabled: boolean; }; +type SetReadOnlyMessage = { + type: 'setReadOnly'; + enabled: boolean; +}; + type SetFindOptionsMessage = { type: 'setFindOptions'; wholeWord?: boolean; @@ -313,6 +332,7 @@ type WebviewMessage = | SetSpellCheckMessage | SetOutlineVisibleMessage | SetContentMaxWidthMessage + | SetReadOnlyMessage | SetFindOptionsMessage | ViewPositionChangedMessage | OpenLinkMessage @@ -320,6 +340,7 @@ type WebviewMessage = | ResolveWikiLinksMessage | ResolveLocalLinksMessage | SaveDocumentMessage + | RequestReloadMessage | ExportDocumentMessage | ExportSnapshotMessage | ExportSnapshotErrorMessage @@ -420,6 +441,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; @@ -548,7 +572,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam diagnostics: serializeDiagnostics(document), mode, lineNumbers: getLineNumbersEnabled(context), - activeLineHighlight: getActiveLineHighlightEnabled(), + readOnly: getReadOnlyEnabled(), gitChangesGutter: getGitChangesGutterEnabled(context), gitDiffLineHighlights: getGitDiffLineHighlightsEnabled(), spellCheckEnabled: getSpellCheckEnabled(), @@ -556,6 +580,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam vimMode: getVimModeEnabled(context), vimKeybindings: getVimKeybindings(), vimLeader: getVimLeaderKey(), + keymap: getKeymapBindings(), findOptions: getFindOptions(), outlinePosition: getOutlinePosition(), outlineVisible: getOutlineVisible(context), @@ -621,6 +646,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; @@ -853,6 +903,10 @@ export function createPanelSessionController(params: PanelSessionControllerParam if (!webviewReady) { return; } + // Avoid stealing keyboard focus from chat/agent inputs while the document is read-only. + if (getReadOnlyEnabled()) { + return; + } await ensureInitDelivered(); if (!initDelivered) { return; @@ -1001,6 +1055,11 @@ export function createPanelSessionController(params: PanelSessionControllerParam .getConfiguration(EXTENSION_CONFIG_SECTION) .update(CONTENT_MAX_WIDTH_SETTING_KEY, raw.enabled === true, vscode.ConfigurationTarget.Global); return; + case 'setReadOnly': { + await setReadOnlyEnabled(raw.enabled === true); + return; + } + case 'setFindOptions': { const wholeWord = raw.findOptions?.wholeWord ?? raw.wholeWord; const caseSensitive = raw.findOptions?.caseSensitive ?? raw.caseSensitive; @@ -1080,12 +1139,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': @@ -1093,10 +1163,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(); @@ -1105,9 +1178,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); @@ -1141,6 +1227,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'); @@ -1325,7 +1416,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(); @@ -1362,10 +1455,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/src/shared/extensionConfig.ts b/src/shared/extensionConfig.ts index 06851bb..92dae00 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, @@ -12,13 +17,13 @@ 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'; 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 ACTIVE_LINE_HIGHLIGHT_SETTING_KEY = 'activeLineHighlight.visible'; 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'; @@ -72,14 +77,26 @@ 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); } -export function getActiveLineHighlightEnabled(): boolean { - return vscode.workspace.getConfiguration(EXTENSION_CONFIG_SECTION).get(ACTIVE_LINE_HIGHLIGHT_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/src/shared/themeDefaults.ts b/src/shared/themeDefaults.ts index 450fe2c..89ebccd 100644 --- a/src/shared/themeDefaults.ts +++ b/src/shared/themeDefaults.ts @@ -335,11 +335,7 @@ export type ThemeSettings = { id: string; name: string; backgroundColor: string; - /** - * Cursor/active line background in Live and Source modes. - * Empty string uses the built-in mix from base03. - * Set to "transparent" (or any valid color) to override. - */ + /** Empty uses the built-in mix from base03. */ activeLineBackground: string; colors: ThemeColors; syntaxTokens: ThemeSyntaxTokens; @@ -584,7 +580,7 @@ export const defaultThemeSettings: ThemeSettings = themePresets[0] as ThemeSetti const hexColorRegex = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; const rgbColorRegex = /^rgba?\(\s*(?:\d{1,3}\s*,\s*){2}\d{1,3}(?:\s*,\s*(?:0(?:\.\d+)?|1(?:\.0+)?|\d*\.?\d+))?\s*\)$/; -const hslColorRegex = /^hsla?\(\s*(?:[+\-]?\d+(?:\.\d+)?(?:deg|rad|grad|turn)?\s*,\s*){2}\d{1,3}%?(?:\s*,\s*(?:0(?:\.\d+)?|1(?:\.0+)?|\d*\.?\d+))?\s*\)$/; +const hslColorRegex = /^hsla?\(\s*[+\-]?\d+(?:\.\d+)?(?:deg|rad|grad|turn)?\s*,\s*\d+(?:\.\d+)?%\s*,\s*\d+(?:\.\d+)?%(?:\s*,\s*(?:0(?:\.\d+)?|1(?:\.0+)?|\d*\.?\d+))?\s*\)$/; const cssVarColorRegex = /^var\(\s*--[A-Za-z0-9_-]+\s*(?:,\s*[^)]+)?\)$/; const isRecord = (value: unknown): value is Record => @@ -604,13 +600,14 @@ const isValidThemeColor = (value: string): boolean => { return false; } const candidate = value.trim(); - if (candidate.toLowerCase() === 'transparent') { - return true; - } return hexColorRegex.test(candidate) || rgbColorRegex.test(candidate) || hslColorRegex.test(candidate) || cssVarColorRegex.test(candidate); }; -/** Empty string means "use built-in mix from base03". */ +const isValidActiveLineColor = (value: string): boolean => ( + value.trim().toLowerCase() === 'transparent' || isValidThemeColor(value) +); + +/** Empty string uses the built-in mix from base03. */ const resolveOptionalThemeColor = (value: unknown): string => { if (typeof value !== 'string') { return ''; @@ -619,7 +616,7 @@ const resolveOptionalThemeColor = (value: unknown): string => { if (!trimmed) { return ''; } - return isValidThemeColor(trimmed) ? trimmed : ''; + return isValidActiveLineColor(trimmed) ? trimmed : ''; }; const sanitizeThemeColor = (value: unknown, fallback: string): string => { @@ -821,7 +818,7 @@ export const validateThemePayload = (value: unknown): ThemeValidationResult => { if (value.activeLineBackground !== undefined) { if (typeof value.activeLineBackground !== 'string') { errors.push('Theme "activeLineBackground" must be a string.'); - } else if (value.activeLineBackground.trim() && !isValidThemeColor(value.activeLineBackground)) { + } else if (value.activeLineBackground.trim() && !isValidActiveLineColor(value.activeLineBackground)) { errors.push('Theme "activeLineBackground" must be empty (default), transparent, or a valid hex, rgb, hsl, or var(--...) color string.'); } } diff --git a/webview/src/editor.ts b/webview/src/editor.ts index a0e3c5f..f4a55ad 100644 --- a/webview/src/editor.ts +++ b/webview/src/editor.ts @@ -1,13 +1,16 @@ -import { EditorState, Compartment, Transaction, StateEffect, StateField, RangeSetBuilder, type ChangeSpec } from '@codemirror/state'; -import { EditorView, keymap, highlightActiveLine, lineNumbers, highlightActiveLineGutter, scrollPastEnd, Decoration, type ViewUpdate } from '@codemirror/view'; +import { EditorState, Compartment, Prec, Transaction, StateEffect, StateField, RangeSetBuilder, type ChangeSpec } from '@codemirror/state'; +import { EditorView, keymap, lineNumbers, 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 { readOnlyExtensions, activeLineHighlightExtensions, externalSyncAnnotation, copyReadOnlySelection } from './helpers/readOnly'; 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'; @@ -41,7 +44,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'; @@ -166,12 +175,14 @@ export function createEditor({ initialTopLine = null, initialTopLineOffset = 0, initialLineNumbers = true, - initialActiveLineHighlight = true, + initialReadOnly = false, initialGitGutter = true, 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. @@ -180,14 +191,18 @@ export function createEditor({ const modeCompartment = new Compartment(); const gitGutterCompartment = new Compartment(); const vimCompartment = new Compartment(); + const readingCompartment = new Compartment(); const activeLineHighlightCompartment = new Compartment(); + const userKeymapCompartment = new Compartment(); const startMode = initialMode === 'live' ? 'live' : 'source'; let lineNumbersVisible = initialLineNumbers !== false; - let activeLineHighlightVisible = initialActiveLineHighlight !== false; + let readOnlyEnabled = initialReadOnly === true; 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; @@ -243,12 +258,37 @@ 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; let view = null; let currentMode = startMode; let applyingRenumber = false; + + const isReadOnly = () => readOnlyEnabled; + const syncReadingPresentation = () => { + if (!view) { + return; + } + const reading = isReadOnly(); + view.dom.classList.toggle('meo-read-only', reading); + view.dom.classList.toggle('meo-active-line-highlight-hidden', reading); + }; + const reconfigureReadingState = () => { + if (!view) { + return; + } + const reading = isReadOnly(); + view.dispatch({ + effects: [ + readingCompartment.reconfigure(readOnlyExtensions(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. @@ -270,6 +310,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) { @@ -530,6 +578,16 @@ export function createEditor({ } }; + const scheduleSelectionChangeEmit = (): void => { + if (pendingSelectionEmitFrame !== null) { + window.cancelAnimationFrame(pendingSelectionEmitFrame); + } + pendingSelectionEmitFrame = window.requestAnimationFrame(() => { + pendingSelectionEmitFrame = null; + emitSelectionChange(); + }); + }; + const syncSelectionClass = () => { if (!view) { return; @@ -588,6 +646,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 => { @@ -604,9 +666,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) @@ -963,6 +1026,10 @@ export function createEditor({ return; } + if (selectionPointerId !== null) { + return; + } + const selection = view.state.selection.main; if (selection.empty) { onSelectionChange({ visible: false }); @@ -1422,7 +1489,7 @@ export function createEditor({ }; const replaceCurrentMatch = (query, replacement, options: SearchOptions = {}) => { - if (!query) { + if (isReadOnly() || !query) { return { replaced: false, found: false, current: 0, total: 0 }; } @@ -1456,6 +1523,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) }, @@ -1482,21 +1552,42 @@ export function createEditor({ lineNumbers(), ...gitDiffGutterBaselineExtensions(), gitGutterCompartment.of(startMode === 'live' ? gitDiffGutterLiveRenderExtensions() : gitDiffGutterRenderExtensions()), - activeLineHighlightCompartment.of(activeLineHighlightVisible ? [ - highlightActiveLineGutter(), - highlightActiveLine() - ] : []), + readingCompartment.of(readOnlyExtensions(readOnlyEnabled)), + activeLineHighlightCompartment.of(activeLineHighlightExtensions(!readOnlyEnabled)), shikiCodeHighlight, EditorView.lineWrapping, scrollPastEnd(), EditorView.domEventHandlers({ + copy(event, view) { + if (copyReadOnlySelection(event, view)) return true; + 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; + selectionPointerId = null; return false; } if (openLinkIfModifierClick(event, view)) { frontmatterBoundaryClick = null; + selectionPointerId = null; return true; } @@ -1504,6 +1595,7 @@ export function createEditor({ const targetElement = targetElementFrom(target); if (!(target instanceof Node) || !view.contentDOM.contains(target)) { clearDiagnosticSuggestionState(); + selectionPointerId = null; return false; } @@ -1528,6 +1620,8 @@ export function createEditor({ return false; } + selectionPointerId = event.pointerId; + onSelectionChange?.({ visible: false }); inlineCodeClick = { pointerId: event.pointerId, inInlineCode: @@ -1549,9 +1643,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; } @@ -1559,6 +1661,9 @@ export function createEditor({ if (frontmatterBoundaryClick?.pointerId === event.pointerId) { frontmatterBoundaryClick = null; } + if (shouldEmitSelectionAfterPointerUp) { + scheduleSelectionChangeEmit(); + } return false; } @@ -1614,13 +1719,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; } @@ -1629,6 +1745,9 @@ export function createEditor({ frontmatterBoundaryClick = null; inlineCodeClick = null; checkboxClick = null; + if (shouldEmitSelectionAfterPointerCancel) { + scheduleSelectionChangeEmit(); + } return false; }, pointermove(event, view) { @@ -1708,6 +1827,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 }); } @@ -1752,7 +1882,7 @@ export function createEditor({ syncLineNumbersVisibility(); syncGitGutterVisibility(); syncSelectionClass(); - view.dom.classList.toggle('meo-active-line-highlight-hidden', !activeLineHighlightVisible); + syncReadingPresentation(); view.dispatch({ effects: setDiagnosticsEffect.of(currentDiagnostics) }); emitSelectionChange(); @@ -1813,6 +1943,7 @@ export function createEditor({ return replaceCurrentMatch(query, replacement, options); }, replaceAll(query, replacement, options: SearchOptions = {}) { + if (isReadOnly()) return { replaced: 0, total: 0 }; if (!query) { return { replaced: 0, total: 0 }; } @@ -1863,6 +1994,8 @@ export function createEditor({ view.focus(); }, destroy() { + window.removeEventListener('pointerup', finishSelectionOutsideEditor); + window.removeEventListener('pointercancel', finishSelectionOutsideEditor); gitBlameHover?.destroy(); gitBlameHover = null; gitDiffOverviewRuler?.destroy(); @@ -1887,6 +2020,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); @@ -1898,6 +2036,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) { @@ -1909,12 +2049,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 }, + annotations: externalSyncAnnotation.of(true) + }); + pendingExternalUndoSelectionPreserve = true; + } finally { + // Always restore outgoing edits, even when decoration updates throw. + applyingExternal = false; + } syncSelectionClass(); emitSelectionChange(); }, @@ -1932,13 +2077,16 @@ export function createEditor({ const previousMode = currentMode; currentMode = nextMode; try { + const reading = readOnlyEnabled; view.dispatch({ effects: [ modeCompartment.reconfigure(nextMode === 'live' ? liveModeExtensions() : sourceMode()), gitGutterCompartment.reconfigure( nextMode === 'live' ? gitDiffGutterLiveRenderExtensions() : gitDiffGutterRenderExtensions() ), - vimCompartment.reconfigure(vimExtensionsForState()) + vimCompartment.reconfigure(vimExtensionsForState()), + readingCompartment.reconfigure(readOnlyExtensions(reading)), + activeLineHighlightCompartment.reconfigure(activeLineHighlightExtensions(!reading)) ] }); forceParsing(view, view.state.doc.length, 500); @@ -1949,9 +2097,25 @@ export function createEditor({ } syncModeClasses(); syncGitGutterVisibility(); + syncReadingPresentation(); restoreTopVisibleLine(topPosition.lineNumber, topPosition.lineOffset, { syncCursor: false }); }, + setReadOnly(enabled) { + const nextEnabled = enabled === true; + if (nextEnabled === readOnlyEnabled) { + syncReadingPresentation(); + return; + } + const topPosition = computeTopVisiblePosition(); + if (nextEnabled) commitActiveTableInput(); + readOnlyEnabled = nextEnabled; + reconfigureReadingState(); + restoreTopVisibleLine(topPosition.lineNumber, topPosition.lineOffset, { syncCursor: false }); + }, + isReadOnly() { + return isReadOnly(); + }, setLineNumbers(visible) { const nextVisible = visible !== false; if (nextVisible === lineNumbersVisible) { @@ -1960,21 +2124,6 @@ export function createEditor({ lineNumbersVisible = nextVisible; syncLineNumbersVisibility(); }, - setActiveLineHighlight(visible) { - const nextVisible = visible !== false; - if (nextVisible === activeLineHighlightVisible) { - return; - } - activeLineHighlightVisible = nextVisible; - view.dispatch({ - effects: activeLineHighlightCompartment.reconfigure( - activeLineHighlightVisible - ? [highlightActiveLineGutter(), highlightActiveLine()] - : [] - ) - }); - view.dom.classList.toggle('meo-active-line-highlight-hidden', !activeLineHighlightVisible); - }, setGitGutterVisible(visible) { const nextVisible = visible !== false; if (nextVisible === gitGutterVisible) { @@ -2005,7 +2154,19 @@ 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) { + if (isReadOnly()) { + return; + } const activeTableInput = getActiveTableInput(); if (activeTableInput) { return insertFormatInActiveTableInput(activeTableInput, action); @@ -2090,6 +2251,7 @@ export function createEditor({ return extractHeadings(view.state); }, moveHeadingSection(sourceHeadingFrom, targetHeadingFrom, placement) { + if (isReadOnly()) return false; if (placement !== 'before' && placement !== 'after') { return false; } @@ -2209,8 +2371,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/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/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/outline.ts b/webview/src/helpers/outline.ts index 3675627..98b850d 100644 --- a/webview/src/helpers/outline.ts +++ b/webview/src/helpers/outline.ts @@ -6,6 +6,7 @@ interface OutlineHeading { } interface EditorApi { + isReadOnly(): boolean; getHeadings(): OutlineHeading[]; scrollToLine(line: number, position: string): void; moveHeadingSection(sourceFrom: number, targetFrom: number, placement: 'before' | 'after'): boolean; @@ -198,7 +199,7 @@ export function createOutlineController({ root, editorWrapper, outlineButton, ge item.type = 'button'; item.className = `outline-item outline-level-${heading.level}`; item.textContent = heading.text; - item.draggable = true; + item.draggable = !editor.isReadOnly(); item.dataset.headingFrom = String(heading.from); item.dataset.headingLine = String(heading.line); outlineContent.appendChild(item); @@ -256,7 +257,7 @@ export function createOutlineController({ root, editorWrapper, outlineButton, ge const sourceFrom = Number.parseInt((item as HTMLElement).dataset.headingFrom ?? '', 10); const sourceIndex = currentOutlineHeadingIndexByFrom.get(sourceFrom); const editor = getEditor(); - if (!editor || typeof sourceIndex !== 'number') { + if (!editor || editor.isReadOnly() || typeof sourceIndex !== 'number') { event.preventDefault(); return; } diff --git a/webview/src/helpers/readOnly.ts b/webview/src/helpers/readOnly.ts new file mode 100644 index 0000000..2681dc3 --- /dev/null +++ b/webview/src/helpers/readOnly.ts @@ -0,0 +1,34 @@ +import { Annotation, EditorState, type Extension } from '@codemirror/state'; +import { EditorView, highlightActiveLine, highlightActiveLineGutter } from '@codemirror/view'; + +export const externalSyncAnnotation = Annotation.define(); + +export function readOnlyExtensions(enabled: boolean): Extension[] { + if (!enabled) return []; + return [ + EditorState.readOnly.of(true), + EditorView.editable.of(false), + EditorView.contentAttributes.of({ tabindex: '0' }), + EditorState.transactionFilter.of((transaction) => { + if (transaction.annotation(externalSyncAnnotation)) return transaction; + return transaction.docChanged ? [] : transaction; + }) + ]; +} + +export function activeLineHighlightExtensions(enabled: boolean): Extension[] { + return enabled ? [highlightActiveLineGutter(), highlightActiveLine()] : []; +} + +export function copyReadOnlySelection(event: ClipboardEvent, view: EditorView): boolean { + if (!view.state.readOnly || !event.clipboardData) return false; + const selection = view.dom.ownerDocument.getSelection(); + if (!selection || selection.isCollapsed || + !view.contentDOM.contains(selection.anchorNode) || + !view.contentDOM.contains(selection.focusNode)) return false; + + // Read-only selections can span rendered widgets outside CodeMirror's selection. + event.clipboardData.setData('text/plain', selection.toString()); + event.preventDefault(); + return true; +} 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/tables.ts b/webview/src/helpers/tables.ts index 74a139a..4b7732a 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 }; } @@ -1570,6 +1677,7 @@ class HtmlTableWidget extends WidgetType { } focusTableInput(input, caret = null) { + if (this.view?.state.readOnly) return false; if (!(input instanceof HTMLTextAreaElement)) return false; this.setCellEditingState(input, true); input.focus({ preventScroll: true }); @@ -1584,6 +1692,10 @@ class HtmlTableWidget extends WidgetType { } focusCellInput(cell, { updateSelection = false } = {}) { + if (this.view?.state.readOnly) { + return false; + } + const input = cell.querySelector('textarea'); if (!this.focusTableInput(input)) return false; if (!updateSelection) return true; @@ -1594,6 +1706,19 @@ class HtmlTableWidget extends WidgetType { return true; } + focusCellInputAtPoint(cell, clientX, clientY) { + if (this.view?.state.readOnly) return false; + 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); @@ -1788,7 +1913,7 @@ class HtmlTableWidget extends WidgetType { if (!(event.target instanceof HTMLTextAreaElement)) { event.preventDefault(); - this.focusCellInput(cell); + this.focusCellInputAtPoint(cell, event.clientX, event.clientY); } }; @@ -1927,6 +2052,7 @@ class HtmlTableWidget extends WidgetType { } commit(dom) { + if (this.view?.state.readOnly) return; if (!this.hasPendingCellEdits) return; this.commitMatrix(this.readCellMatrix(), dom); } @@ -2064,7 +2190,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 +2238,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 +2249,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 +2393,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 +2445,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); 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 bc9e0a3..f127970 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, LockKeyhole } from 'lucide'; import { setImageSrcResolver, initializeImageHandling, resolveImageSrc, settleImageSrcRequest, handleSavedImagePath, handleImagePaste } from './helpers/images'; import { createGitClient } from './helpers/gitClient'; import { createOutlineController } from './helpers/outline'; @@ -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,9 +150,11 @@ 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 activeLineHighlightVisible = true; +let readOnlyEnabled = false; let gitChangesGutterVisible = true; let gitDiffLineHighlightsEnabled = true; let spellCheckEnabled = true; @@ -243,14 +247,42 @@ const setLineNumbersVisible = (visible, { post = true } = {}) => { } }; -const setActiveLineHighlightVisible = (visible: boolean) => { - const nextVisible = visible !== false; - const changed = nextVisible !== activeLineHighlightVisible; - activeLineHighlightVisible = nextVisible; - if (changed) { - editor?.setActiveLineHighlight?.(activeLineHighlightVisible); +const isReadOnly = () => readOnlyEnabled; + +const updateReadOnlyUI = () => { + const reading = isReadOnly(); + readOnlyBtn.classList.toggle('is-active', readOnlyEnabled); + readOnlyBtn.setAttribute('aria-pressed', readOnlyEnabled ? 'true' : 'false'); + readOnlyBtn.title = readOnlyEnabled ? 'Disable Read Only' : 'Enable Read Only'; + readOnlyBtn.setAttribute('aria-label', readOnlyBtn.title); + root.classList.toggle('meo-read-only', reading); + root.dataset.readOnly = readOnlyEnabled ? '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?.(); + findPanelElements.replaceBtn.disabled = reading; + findPanelElements.replaceAllBtn.disabled = reading; + findPanelElements.replaceInput.disabled = reading; +}; + +const setReadOnlyEnabled = (enabled: boolean, { post = true } = {}) => { + const nextEnabled = enabled === true; + const changed = nextEnabled !== readOnlyEnabled; + if (changed || editor) { + editor?.setReadOnly?.(nextEnabled); + } + readOnlyEnabled = nextEnabled; + updateReadOnlyUI(); + outlineController.refresh(); + updateModeUI(); + if (post && changed) { + vscode.postMessage({ type: 'setReadOnly', enabled: readOnlyEnabled }); } - root.classList.toggle('meo-active-line-highlight-hidden', !activeLineHighlightVisible); }; const setGitChangesGutterVisible = (visible, { post = true } = {}) => { @@ -322,6 +354,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); }; @@ -450,7 +498,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 || isReadOnly()) return; editor.insertFormat('table', { cols: selectedTableCols, rows: selectedTableRows }); editor.focus(); }); @@ -527,13 +575,34 @@ sourceButton.textContent = 'Source'; sourceButton.setAttribute('role', 'tab'); sourceButton.title = 'Source'; +const readOnlyBtn = document.createElement('button'); +readOnlyBtn.type = 'button'; +readOnlyBtn.className = 'format-button toggle-button'; +readOnlyBtn.dataset.action = 'readOnly'; +readOnlyBtn.title = 'Enable Read Only'; +readOnlyBtn.setAttribute('aria-label', 'Enable Read Only'); +readOnlyBtn.setAttribute('aria-pressed', 'false'); +readOnlyBtn.appendChild(createElement(LockKeyhole, { width: 18, height: 18 })); + modeGroup.append(liveButton, sourceButton); +rightGroup.insertBefore(readOnlyBtn, exportWrapper); +readOnlyBtn.addEventListener('click', () => { + setReadOnlyEnabled(!readOnlyEnabled, { 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 (isReadOnly()) { + selectionMenuController.hide(); + return; + } + originalSelectionMenuUpdate(state); +}; const editorNoticeBanner = document.createElement('div'); editorNoticeBanner.className = 'editor-notice'; @@ -571,6 +640,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; @@ -638,23 +710,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 = { @@ -943,6 +1036,10 @@ const applyRevealSelectionFromHost = (revealMessage: any) => { }; const focusEditorFromHost = () => { + if (isReadOnly()) { + pendingEditorFocus = false; + return; + } if (!editor) { pendingEditorFocus = true; return; @@ -1105,16 +1202,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; }, @@ -1124,10 +1276,15 @@ 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) => { + if (isReadOnly()) { + return; + } bumpLocalEditGeneration(); pendingText = nextText; syncPendingDraftState(); @@ -1158,6 +1315,7 @@ const updateModeUI = () => { button.setAttribute('aria-selected', selected ? 'true' : 'false'); button.tabIndex = selected ? 0 : -1; } + updateReadOnlyUI(); }; const applyMode = (mode: 'live' | 'source', { post = true, persist = true, userTriggered = false, reason = 'user' } = {}): boolean => { @@ -1180,7 +1338,7 @@ const applyMode = (mode: 'live' | 'source', { post = true, persist = true, userT try { editor.setMode(mode); syncGitDiffLineHighlights(); - if (shouldRestoreEditorFocus) { + if (shouldRestoreEditorFocus && !(readOnlyEnabled)) { editor.focus(); } if (mode === 'live') { @@ -1257,11 +1415,13 @@ const mountInitialEditor = async () => { initialTopLine, initialTopLineOffset, initialLineNumbers: lineNumbersVisible, - initialActiveLineHighlight: activeLineHighlightVisible, + initialReadOnly: readOnlyEnabled, initialGitGutter: gitChangesGutterVisible, initialVimMode: vimModeEnabled, initialVimKeybindings: vimKeybindingsState, initialVimLeader: vimLeaderState, + initialKeymap: keymapBindings, + keymapHandlers: getKeymapHandlers(), initialDiagnostics: pendingDiagnostics, onApplyChanges: queueChanges, onOpenLink: (href: string) => { @@ -1380,8 +1540,8 @@ const handleInit = (message: any) => { if (typeof message.lineNumbers === 'boolean') { setLineNumbersVisible(message.lineNumbers, { post: false }); } - if (typeof message.activeLineHighlight === 'boolean') { - setActiveLineHighlightVisible(message.activeLineHighlight); + if (typeof message.readOnly === 'boolean') { + setReadOnlyEnabled(message.readOnly, { post: false }); } if (typeof message.gitChangesGutter === 'boolean') { setGitChangesGutterVisible(message.gitChangesGutter, { post: false }); @@ -1396,6 +1556,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 : '\\'; @@ -1457,6 +1620,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; @@ -1520,6 +1684,16 @@ window.addEventListener('message', (event) => { return; } + if (message.type === 'toggleReadOnly') { + setReadOnlyEnabled(!readOnlyEnabled, { post: true }); + return; + } + + if (message.type === 'readOnlyChanged') { + setReadOnlyEnabled(message.enabled === true, { post: false }); + return; + } + if (message.type === 'docChanged' && !editor && pendingInitialText !== null) { clearGitBlameCache({ hideTooltip: false }); documentVersion = message.version; @@ -1536,10 +1710,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) { @@ -1551,16 +1726,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(); @@ -1568,51 +1747,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; } @@ -1637,11 +1812,6 @@ window.addEventListener('message', (event) => { return; } - if (message.type === 'activeLineHighlightChanged') { - setActiveLineHighlightVisible(message.enabled === true); - return; - } - if (message.type === 'gitChangesGutterChanged') { setGitChangesGutterVisible(message.enabled, { post: false }); return; @@ -1675,6 +1845,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); @@ -1879,6 +2056,7 @@ modeGroup.addEventListener('pointerdown', preserveEditorFocusOnModePointerToggle const handleFormatAction = (action: string) => { if (!editor) return; + if (isReadOnly()) return; editor.insertFormat(action); editor.focus(); }; @@ -1976,6 +2154,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 (isReadOnly()) return; editor.insertFormat('heading', level); editor.focus(); }); diff --git a/webview/src/liveMode.ts b/webview/src/liveMode.ts index 7412b99..203feb7 100644 --- a/webview/src/liveMode.ts +++ b/webview/src/liveMode.ts @@ -885,7 +885,11 @@ function addSingleTildeStrikeDecorations(builder, state, activeLines, existingSt } } + function collectActiveLines(state: EditorState): Set { + if (state.readOnly) { + 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. @@ -944,6 +948,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 +981,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)); } } diff --git a/webview/src/styles.css b/webview/src/styles.css index 4124ae6..775281b 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)); @@ -1002,16 +1027,6 @@ body { background-color: var(--meo-active-line-bg); } -.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; -} - .cm-editor.meo-table-interaction-active .cm-activeLine { background-color: transparent; } @@ -2987,3 +3002,38 @@ body { .cm-editor.meo-mode-live .cm-line.meo-md-alert .meo-md-alert-label-active { color: var(--meo-color-base05) !important; } + + +/* Read-only document presentation */ +.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-read-only .format-group.is-disabled, +.editor-root.meo-read-only .format-group[aria-disabled='true'] { + opacity: 0.45; + pointer-events: none; +} + +.editor-root.meo-read-only .selection-inline-menu { + display: none !important; +} + +.editor-root.meo-read-only .cm-editor.meo-read-only .meo-table-add-row-btn, +.editor-root.meo-read-only .meo-table-add-col-btn, +.editor-root.meo-read-only .meo-md-html-table-cell-content textarea, +.cm-editor.meo-read-only .meo-table-add-row-btn, +.cm-editor.meo-read-only .meo-table-add-col-btn { + pointer-events: none !important; +} + +.cm-editor.meo-read-only .cm-cursor, +.cm-editor.meo-read-only .cm-dropCursor { + display: none !important; +} diff --git a/webview/src/types.d.ts b/webview/src/types.d.ts index 1726e99..42c430b 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: 'setReadOnly'; enabled: boolean } | { type: 'setOutlineVisible'; visible: boolean } | { type: 'setFindOptions'; findOptions: { wholeWord: boolean; caseSensitive: boolean } } | { type: 'viewPositionChanged'; topLine: number; topLineOffset?: number } @@ -24,6 +25,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 } @@ -37,9 +39,10 @@ type VimKeybinding = { }; type ExtensionMessage = - | { type: 'init'; text: string; version: number; diagnostics: EditorDiagnostic[]; theme: ThemeSettings; mode: 'live' | 'source'; outlinePosition: 'left' | 'right'; outlineVisible: boolean; lineNumbers: boolean; activeLineHighlight: 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; readOnly: 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[] } @@ -47,13 +50,15 @@ type ExtensionMessage = | { type: 'outlinePositionChanged'; position: 'left' | 'right' } | { type: 'outlineVisibilityChanged'; visible: boolean } | { type: 'lineNumbersChanged'; enabled: boolean } - | { type: 'activeLineHighlightChanged'; enabled: boolean } | { type: 'gitChangesGutterChanged'; enabled: boolean } | { type: 'gitDiffLineHighlightsChanged'; enabled: boolean } | { type: 'spellCheckChanged'; enabled: boolean } | { type: 'contentMaxWidthChanged'; enabled: boolean } + | { type: 'readOnlyChanged'; enabled: boolean } + | { type: 'toggleReadOnly' } | { 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 }> }