diff --git a/.changeset/editor-vim-keybindings.md b/.changeset/editor-vim-keybindings.md new file mode 100644 index 00000000..56d81052 --- /dev/null +++ b/.changeset/editor-vim-keybindings.md @@ -0,0 +1,5 @@ +--- +"@luzzle/web": patch +--- + +Add opt-in Vim keybindings to source and text-asset editors, toggled with Ctrl+Alt+V and remembered in the browser. Keep Standard as the default, show the current keybinding mode in a quiet editor footer, retain configured editor colors, and finish Vim input before applying generated content. Saving and navigation continue to use the existing application controls. diff --git a/apps/web/README.md b/apps/web/README.md index 999baf95..0e5c3185 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -3,6 +3,22 @@ The SvelteKit application for browsing, searching, and editing your Luzzle archives in a responsive web manager interface. +## Editor Keybindings + +Source and text-asset editors default to Standard keybindings. Press +**Ctrl+Alt+V** while the editor is focused to toggle Vim keybindings. The choice +is remembered in this browser and reused when opening another editor. + +A small editor footer shows Standard or the current Vim mode, alongside the +shortcut hint. It is informational; Ctrl+Alt+V remains the only toggle. Save +continues to use the existing Save button; Vim Save/quit commands are not connected +to application actions. Before applying generated content, the editor finishes Vim's current +insertion and returns to Normal mode, keeping generation outside dot-repeat. + +If Vimium is installed, exclude the editor URLs in its settings and leave the +excluded-keys field empty. Vimium otherwise intercepts Escape before the editor +can return to Normal mode. + ## Centralized Web Documentation 📖 All documentation, including deployment references, configuration setups, and diff --git a/apps/web/package.json b/apps/web/package.json index 800ede94..265755ba 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -87,6 +87,7 @@ "@codemirror/language-data": "6.5.2", "@luzzle/web.db": "workspace:*", "@luzzle/web.pieces": "workspace:*", + "@replit/codemirror-vim": "6.4.0", "eventsource-parser": "4.1.1", "kysely": "0.28.17", "openworkflow": "0.9.0" diff --git a/apps/web/src/lib/components/editor/MarkdownEditor.svelte b/apps/web/src/lib/components/editor/MarkdownEditor.svelte index 15a50b54..df759650 100644 --- a/apps/web/src/lib/components/editor/MarkdownEditor.svelte +++ b/apps/web/src/lib/components/editor/MarkdownEditor.svelte @@ -8,6 +8,7 @@ import { luzzleFieldEditor } from './extensions/luzzleFieldEditor' import { luzzleAssetEditor } from './extensions/luzzleAssetEditor' import { replaceDocument } from './replace-document' + import { editorKeybindings, finishVimInput } from './keybindings' function getAppliedTheme(): 'dark' | 'light' { const preference = window.localStorage.getItem('theme') || 'system' @@ -63,6 +64,7 @@ returnTo }) + extensions.unshift(editorKeybindings()) extensions.push(lspConfig.of([])) if (file && returnTo) { @@ -149,6 +151,7 @@ export function replaceContent(content: string, expectedSource: string) { if (!view) throw new Error('The editor is not ready. Please try again.') + if (view.state.doc.toString() === expectedSource) finishVimInput(view) view.dispatch(replaceDocument(view.state, content, expectedSource)) } @@ -173,7 +176,6 @@ width: 100%; border-radius: var(--radius-medium); overflow: hidden; - min-height: 500px; position: relative; transition: background-color 0.2s ease; } @@ -188,6 +190,11 @@ border-radius: inherit; } + .editor-wrapper :global(.cm-content), + .editor-wrapper :global(.cm-gutter) { + min-height: 500px; + } + :global(.cm-editor) { height: 100%; } diff --git a/apps/web/src/lib/components/editor/keybindings-status.test.ts b/apps/web/src/lib/components/editor/keybindings-status.test.ts new file mode 100644 index 00000000..e10a2d92 --- /dev/null +++ b/apps/web/src/lib/components/editor/keybindings-status.test.ts @@ -0,0 +1,173 @@ +import { afterEach, expect, test, vi } from 'vitest' +import type { EditorView, ViewUpdate } from '@codemirror/view' +import { getCM } from '@replit/codemirror-vim' +import { keybindingsStatus } from './keybindings-status' + +vi.mock('@replit/codemirror-vim', () => ({ getCM: vi.fn() })) + +afterEach(() => vi.resetAllMocks()) + +function element() { + let text = '' + const writes = vi.fn() + return { + className: '', + setAttribute: vi.fn(), + append: vi.fn(), + writes, + get textContent() { + return text + }, + set textContent(value: string) { + text = value + writes(value) + } + } +} + +function setup() { + const dom = element() + const label = element() + const modeLabel = element() + const createElement = vi + .fn() + .mockReturnValueOnce(dom) + .mockReturnValueOnce(label) + .mockReturnValueOnce(modeLabel) + const focus = vi.fn() + const view = { + dom: { ownerDocument: { createElement } }, + hasFocus: false, + focus + } as unknown as EditorView + const panel = keybindingsStatus(view) + return { panel, dom, label, modeLabel, createElement, focus } +} + +function vimEditor() { + const events = new EventTarget() + const state = { + vim: { insertMode: false, visualMode: false, visualLine: false, visualBlock: false }, + overwrite: false + } + const adapter = { + state, + on: vi.fn((event: string, handler: () => void) => events.addEventListener(event, handler)), + off: vi.fn((event: string, handler: () => void) => events.removeEventListener(event, handler)) + } + return { + adapter: adapter as unknown as NonNullable>, + state, + on: adapter.on, + off: adapter.off, + modeChanged: () => events.dispatchEvent(new Event('vim-mode-change')) + } +} + +test('renders a bottom informational footer in Standard mode with no focusable controls', () => { + vi.mocked(getCM).mockReturnValue(null) + const { panel, dom, label, modeLabel, createElement, focus } = setup() + expect(panel.top).toBe(false) + expect(panel.dom).toBe(dom) + expect(dom.className).toBe('editor-status') + expect(dom.setAttribute).toHaveBeenCalledWith('aria-label', 'Editor keybindings') + expect(label.setAttribute).toHaveBeenCalledWith('role', 'status') + expect(label.textContent).toBe('Standard (Ctrl+Alt+V)') + expect(modeLabel.textContent).toBe('') + expect(modeLabel.setAttribute).toHaveBeenCalledWith('role', 'status') + expect(createElement.mock.calls).toEqual([['div'], ['span'], ['span']]) + expect(dom.append).toHaveBeenCalledWith(label, modeLabel) + panel.update?.({ focusChanged: true } as ViewUpdate) + panel.destroy?.() + expect(focus).not.toHaveBeenCalled() +}) + +test.each([ + ['Normal', {}], + ['Insert', { insertMode: true }], + ['Visual', { visualMode: true }], + ['Visual line', { visualMode: true, visualLine: true }], + ['Visual block', { visualMode: true, visualBlock: true }] +])('shows the actual initial Vim mode: %s', (mode, flags) => { + const editor = vimEditor() + Object.assign(editor.state.vim, flags) + vi.mocked(getCM).mockReturnValue(editor.adapter) + const { dom, label, modeLabel } = setup() + expect(label.textContent).toBe('Vim (Ctrl+Alt+V)') + expect(modeLabel.textContent).toBe(mode) + expect(dom.append).toHaveBeenCalledWith(label, modeLabel) +}) + +test('updates from mode events even when there is no document transaction', () => { + const editor = vimEditor() + vi.mocked(getCM).mockReturnValue(editor.adapter) + const { panel, modeLabel, focus } = setup() + const listener = editor.on.mock.calls[0][1] + expect(editor.on).toHaveBeenCalledExactlyOnceWith('vim-mode-change', listener) + editor.state.vim.insertMode = true + editor.modeChanged() + expect(modeLabel.textContent).toBe('Insert') + editor.state.overwrite = true + editor.modeChanged() + expect(modeLabel.textContent).toBe('Replace') + editor.state.vim.insertMode = false + editor.state.overwrite = false + editor.modeChanged() + expect(modeLabel.textContent).toBe('Normal') + panel.update?.({ focusChanged: true } as ViewUpdate) + expect(modeLabel.textContent).toBe('Normal') + expect(focus).not.toHaveBeenCalled() +}) + +test('tracks enable/disable and a replacement Vim instance without leaking listeners', () => { + vi.mocked(getCM).mockReturnValue(null) + const { panel, label, modeLabel } = setup() + const first = vimEditor() + vi.mocked(getCM).mockReturnValue(first.adapter) + panel.update?.({} as ViewUpdate) + expect(label.textContent).toBe('Vim (Ctrl+Alt+V)') + expect(modeLabel.textContent).toBe('Normal') + const firstListener = first.on.mock.calls[0][1] + + vi.mocked(getCM).mockReturnValue(null) + panel.update?.({} as ViewUpdate) + expect(first.off).toHaveBeenCalledExactlyOnceWith('vim-mode-change', firstListener) + expect(label.textContent).toBe('Standard (Ctrl+Alt+V)') + expect(modeLabel.textContent).toBe('') + first.state.vim.insertMode = true + first.modeChanged() + expect(label.textContent).toBe('Standard (Ctrl+Alt+V)') + expect(modeLabel.textContent).toBe('') + + const second = vimEditor() + vi.mocked(getCM).mockReturnValue(second.adapter) + panel.update?.({} as ViewUpdate) + const secondListener = second.on.mock.calls[0][1] + panel.destroy?.() + expect(second.off).toHaveBeenCalledExactlyOnceWith('vim-mode-change', secondListener) + second.state.vim.insertMode = true + second.modeChanged() + expect(label.textContent).toBe('Vim (Ctrl+Alt+V)') + expect(modeLabel.textContent).toBe('Normal') +}) + +test('does not rewrite the live status for every cursor or focus update', () => { + const editor = vimEditor() + vi.mocked(getCM).mockReturnValue(editor.adapter) + const { panel, label, modeLabel } = setup() + panel.update?.({ selectionSet: true } as ViewUpdate) + panel.update?.({ focusChanged: true } as ViewUpdate) + editor.modeChanged() + expect(label.writes).toHaveBeenCalledOnce() + expect(modeLabel.writes).toHaveBeenCalledOnce() + expect(editor.on).toHaveBeenCalledOnce() +}) + +test('does not report Vim from a stale adapter without active Vim state', () => { + const editor = vimEditor() + delete (editor.state as { vim?: unknown }).vim + vi.mocked(getCM).mockReturnValue(editor.adapter) + const { label, modeLabel } = setup() + expect(label.textContent).toBe('Standard (Ctrl+Alt+V)') + expect(modeLabel.textContent).toBe('') +}) diff --git a/apps/web/src/lib/components/editor/keybindings-status.ts b/apps/web/src/lib/components/editor/keybindings-status.ts new file mode 100644 index 00000000..56b98024 --- /dev/null +++ b/apps/web/src/lib/components/editor/keybindings-status.ts @@ -0,0 +1,57 @@ +import type { EditorView, Panel } from '@codemirror/view' +import { getCM } from '@replit/codemirror-vim' + +/** An informational footer; keybinding changes still belong to the shortcut. */ +export function keybindingsStatus(view: EditorView): Panel { + const document = view.dom.ownerDocument + const dom = document.createElement('div') + dom.className = 'editor-status' + dom.setAttribute('role', 'group') + dom.setAttribute('aria-label', 'Editor keybindings') + const label = document.createElement('span') + label.setAttribute('role', 'status') + const modeLabel = document.createElement('span') + modeLabel.setAttribute('role', 'status') + dom.append(label, modeLabel) + + let editor: ReturnType = null + + function render() { + const state = editor?.state.vim + const text = `${state ? 'Vim' : 'Standard'} (Ctrl+Alt+V)` + let mode = '' + if (state) { + mode = 'Normal' + if (state.visualMode) { + mode = 'Visual' + if (state.visualBlock) mode = 'Visual block' + else if (state.visualLine) mode = 'Visual line' + } else if (state.insertMode) { + mode = editor?.state.overwrite ? 'Replace' : 'Insert' + } + } + // Cursor movements and focus updates must not repeatedly announce the same mode. + if (label.textContent !== text) label.textContent = text + if (modeLabel.textContent !== mode) modeLabel.textContent = mode + } + + function update() { + const current = getCM(view) + if (current !== editor) { + editor?.off('vim-mode-change', render) + editor = current + editor?.on('vim-mode-change', render) + } + render() + } + + update() + return { + dom, + top: false, + update, + destroy() { + editor?.off('vim-mode-change', render) + } + } +} diff --git a/apps/web/src/lib/components/editor/keybindings.test.ts b/apps/web/src/lib/components/editor/keybindings.test.ts new file mode 100644 index 00000000..255e2ca2 --- /dev/null +++ b/apps/web/src/lib/components/editor/keybindings.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import { EditorState, StateField, type TransactionSpec } from '@codemirror/state' +import { EditorView, showPanel } from '@codemirror/view' +import { history, undo } from '@codemirror/commands' +import { getCM, Vim, vim } from '@replit/codemirror-vim' +import { editorKeybindings, finishVimInput } from './keybindings' +import { keybindingsStatus } from './keybindings-status' + +vi.mock('@replit/codemirror-vim', () => ({ + vim: vi.fn(), + getCM: vi.fn(), + Vim: { handleKey: vi.fn() } +})) + +const activeVim = StateField.define({ create: () => true, update: (value) => value }) +const storage = { getItem: vi.fn(), setItem: vi.fn() } +let handlers: Parameters[0] + +beforeEach(() => { + vi.stubGlobal('window', { localStorage: storage }) + storage.getItem.mockReturnValue(null) + vi.mocked(vim).mockReturnValue(activeVim) + vi.mocked(getCM).mockReturnValue(null) + vi.spyOn(EditorView, 'domEventHandlers').mockImplementation((value) => { + handlers = value + return [] + }) +}) + +afterEach(() => { + vi.resetAllMocks() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +function setup() { + const bindings = editorKeybindings() + const keydown = handlers.keydown! + let state = EditorState.create({ + doc: 'Notes', + selection: { anchor: 1, head: 3 }, + extensions: [bindings, history()] + }) + const view = { + get state() { + return state + }, + dispatch(spec: TransactionSpec) { + state = state.update(spec).state + } + } as EditorView + return { view, keydown: (event: KeyboardEvent) => keydown.call(view, event, view) } +} + +function shortcut(overrides: Partial = {}): KeyboardEvent { + return { + key: 'v', + ctrlKey: true, + altKey: true, + shiftKey: false, + metaKey: false, + isComposing: false, + repeat: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + ...overrides + } as unknown as KeyboardEvent +} + +test.each([null, 'standard', 'invalid'])('defaults to Standard for preference %j', (preference) => { + storage.getItem.mockReturnValue(preference) + const { view } = setup() + expect(storage.getItem).toHaveBeenCalledWith('editor.keybindings') + expect(view.state.facet(showPanel)).toContain(keybindingsStatus) + expect(view.state.field(activeVim, false)).toBeUndefined() + expect(vim).not.toHaveBeenCalled() +}) + +test('restores Vim without the plugin’s separate status panel', () => { + storage.getItem.mockReturnValue('vim') + const { view } = setup() + expect(view.state.field(activeVim)).toBe(true) + expect(vim).toHaveBeenCalledExactlyOnceWith({ status: false }) +}) + +test('Ctrl+Alt+V toggles and remembers the preference without changing text or selection', () => { + const { view, keydown } = setup() + const before = view.state.selection + const event = shortcut() + expect(keydown(event)).toBe(true) + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(event.stopPropagation).toHaveBeenCalledOnce() + expect(view.state.field(activeVim)).toBe(true) + expect(storage.setItem).toHaveBeenLastCalledWith('editor.keybindings', 'vim') + expect(view.state.doc.toString()).toBe('Notes') + expect(view.state.selection.eq(before)).toBe(true) + + expect(keydown(shortcut())).toBe(true) + expect(view.state.field(activeVim, false)).toBeUndefined() + expect(storage.setItem).toHaveBeenLastCalledWith('editor.keybindings', 'standard') + expect(view.state.facet(showPanel)).toContain(keybindingsStatus) + expect(view.state.doc.toString()).toBe('Notes') + expect(view.state.selection.eq(before)).toBe(true) +}) + +test('does not add history entries or discard prior editing history when toggled', () => { + const { view, keydown } = setup() + view.dispatch({ changes: { from: 5, insert: ' edited' } }) + keydown(shortcut()) + keydown(shortcut()) + expect(undo(view)).toBe(true) + expect(view.state.doc.toString()).toBe('Notes') +}) + +test('restores the shared preference when opening another editor', () => { + storage.setItem.mockImplementation((_key, value) => storage.getItem.mockReturnValue(value)) + setup().keydown(shortcut()) + expect(setup().view.state.field(activeVim)).toBe(true) +}) + +test.each([ + { ctrlKey: false }, + { altKey: false }, + { shiftKey: true }, + { metaKey: true }, + { key: 'x' }, + { isComposing: true } +])('leaves other key events alone: %j', (overrides) => { + const { view, keydown } = setup() + const event = shortcut(overrides) + expect(keydown(event)).toBe(false) + expect(event.preventDefault).not.toHaveBeenCalled() + expect(view.state.field(activeVim, false)).toBeUndefined() + expect(storage.setItem).not.toHaveBeenCalled() +}) + +test('does not toggle repeatedly while the shortcut is held down', () => { + const { view, keydown } = setup() + keydown(shortcut()) + expect(keydown(shortcut({ repeat: true }))).toBe(true) + expect(view.state.field(activeVim)).toBe(true) + expect(storage.setItem).toHaveBeenCalledTimes(1) +}) + +test('accepts Caps Lock without requiring Shift', () => { + const { view, keydown } = setup() + keydown(shortcut({ key: 'V' })) + expect(view.state.field(activeVim)).toBe(true) +}) + +test('still edits and toggles when browser storage is unavailable', () => { + storage.getItem.mockImplementation(() => { + throw new Error('Storage unavailable') + }) + storage.setItem.mockImplementation(() => { + throw new Error('Storage unavailable') + }) + const { view, keydown } = setup() + expect(view.state.field(activeVim, false)).toBeUndefined() + expect(() => keydown(shortcut())).not.toThrow() + expect(view.state.field(activeVim)).toBe(true) +}) + +test('finishes the active Vim input operation before an external document replacement', () => { + const { view } = setup() + const editor = {} as NonNullable> + vi.mocked(getCM).mockReturnValue(editor) + finishVimInput(view) + expect(getCM).toHaveBeenCalledExactlyOnceWith(view) + expect(Vim.handleKey).toHaveBeenCalledExactlyOnceWith(editor, '', 'api') +}) + +test('finishing Vim input is a no-op in Standard mode', () => { + const { view } = setup() + finishVimInput(view) + expect(Vim.handleKey).not.toHaveBeenCalled() +}) diff --git a/apps/web/src/lib/components/editor/keybindings.ts b/apps/web/src/lib/components/editor/keybindings.ts new file mode 100644 index 00000000..d534e797 --- /dev/null +++ b/apps/web/src/lib/components/editor/keybindings.ts @@ -0,0 +1,62 @@ +import { Compartment, Prec, type Extension } from '@codemirror/state' +import { EditorView, showPanel } from '@codemirror/view' +import { getCM, Vim, vim } from '@replit/codemirror-vim' +import { keybindingsStatus } from './keybindings-status' + +const preferenceKey = 'editor.keybindings' + +function readPreference(): boolean { + try { + return window.localStorage.getItem(preferenceKey) === 'vim' + } catch { + return false + } +} + +function savePreference(enabled: boolean) { + try { + window.localStorage.setItem(preferenceKey, enabled ? 'vim' : 'standard') + } catch { + // The current editor still switches if browser storage is unavailable. + } +} + +/** Create once per editor, before its ordinary keymaps. */ +export function editorKeybindings(): Extension { + const keybindings = new Compartment() + let enabled = readPreference() + return [ + Prec.highest( + EditorView.domEventHandlers({ + keydown(event, view) { + if ( + !event.ctrlKey || + !event.altKey || + event.shiftKey || + event.metaKey || + event.isComposing || + event.key.toLowerCase() !== 'v' + ) + return false + event.preventDefault() + event.stopPropagation() + if (event.repeat) return true + enabled = !enabled + view.dispatch({ + effects: keybindings.reconfigure(enabled ? vim({ status: false }) : []) + }) + savePreference(enabled) + return true + } + }) + ), + keybindings.of(enabled ? vim({ status: false }) : []), + showPanel.of(keybindingsStatus) + ] +} + +/** Finish Vim's insertion before applying a generated document, outside dot-repeat. */ +export function finishVimInput(view: EditorView) { + const editor = getCM(view) + if (editor) Vim.handleKey(editor, '', 'api') +} diff --git a/apps/web/src/lib/components/editor/themes/configured.css b/apps/web/src/lib/components/editor/themes/configured.css index 680bbe92..65a58b34 100644 --- a/apps/web/src/lib/components/editor/themes/configured.css +++ b/apps/web/src/lib/components/editor/themes/configured.css @@ -35,7 +35,7 @@ background-color: var(--editor-selection, var(--color-surface-container-highest)); } -.cm-editor .cm-selectionMatch { +.cm-editor :is(.cm-selectionMatch, .cm-searchMatch) { background-color: var(--editor-selectionMatch, var(--color-surface-container-high)); } @@ -50,6 +50,35 @@ color: var(--editor-foreground, var(--color-on-surface)); } +.cm-editor .cm-fat-cursor { + background-color: var(--editor-selection, var(--color-surface-container-highest)); + outline: 1px solid var(--editor-caret, var(--color-on-surface)); +} + +.cm-editor:not(.cm-focused) .cm-fat-cursor { + outline-color: var(--editor-caret, var(--color-on-surface)); +} + +.cm-editor .cm-vim-panel input { + color: inherit; + font: inherit; +} + +.cm-editor .editor-status { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + box-sizing: border-box; + block-size: calc(var(--space-4) + var(--space-2)); + padding: var(--space-1) var(--space-3); + color: var(--editor-panelForeground, var(--color-on-surface-variant)); + font-family: var(--font-mono-name), monospace; + font-size: var(--font-size-xxs); + line-height: 1; + white-space: nowrap; +} + /* Keep the spike's role colors and Markdown formatting, now on stable classes. */ .cm-editor .tok-heading { color: var(--editor-heading, var(--color-on-surface)); diff --git a/apps/web/src/routes/admin/asset/editor/[...asset]/+page.svelte b/apps/web/src/routes/admin/asset/editor/[...asset]/+page.svelte index b2d00666..8ff36dd6 100644 --- a/apps/web/src/routes/admin/asset/editor/[...asset]/+page.svelte +++ b/apps/web/src/routes/admin/asset/editor/[...asset]/+page.svelte @@ -9,6 +9,7 @@ import { EditorView } from 'codemirror' import { EditorState, Compartment } from '@codemirror/state' import { configuredTheme } from '$lib/components/editor/themes/configured' + import { editorKeybindings } from '$lib/components/editor/keybindings' import { highlightSpecialChars, drawSelection, @@ -136,6 +137,7 @@ const initialTheme = configuredTheme(isDark ? 'dark' : 'light') const extensions = [ + editorKeybindings(), highlightSpecialChars(), history(), drawSelection(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3083586..fb2c9767 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -272,6 +272,9 @@ importers: '@luzzle/web.pieces': specifier: workspace:* version: link:../../packages/web/pieces + '@replit/codemirror-vim': + specifier: 6.4.0 + version: 6.4.0(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0) eventsource-parser: specifier: 4.1.1 version: 4.1.1 @@ -1817,6 +1820,18 @@ packages: '@protobufjs/utf8@1.1.2': resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@replit/codemirror-vim-core@0.1.0': + resolution: {integrity: sha512-1i6EBKpcNfDKvTmTh6N6g9lL6udD5t+uFNh4JCqozRnVlvUGOps7h/QzS2ne4zcvPUjvApKpmcP7Grc3fNbZiQ==} + + '@replit/codemirror-vim@6.4.0': + resolution: {integrity: sha512-t9UMDNhkmeAkl0uRbiJVotv97bGD6mf4GyJJoEbrjUqa/Pov0s9eL+4AaI0Xto3OGri17i9qwIpT0JbVlVXt8A==} + peerDependencies: + '@codemirror/commands': 6.x.x + '@codemirror/language': 6.x.x + '@codemirror/search': 6.x.x + '@codemirror/state': 6.x.x + '@codemirror/view': 6.x.x + '@rollup/plugin-commonjs@28.0.9': resolution: {integrity: sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==} engines: {node: '>=16.0.0 || 14 >= 14.17'} @@ -5484,6 +5499,17 @@ snapshots: '@protobufjs/utf8@1.1.2': {} + '@replit/codemirror-vim-core@0.1.0': {} + + '@replit/codemirror-vim@6.4.0(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)': + dependencies: + '@codemirror/commands': 6.10.3 + '@codemirror/language': 6.12.3 + '@codemirror/search': 6.7.0 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@replit/codemirror-vim-core': 0.1.0 + '@rollup/plugin-commonjs@28.0.9(rollup@4.60.4)': dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.60.4)