Add optional Vim keybindings and editor status #106

Merged
eleith merged 1 commits from feat/editor-vim-keybindings into main 2026-10-05 13:27:59 +00:00
11 changed files with 557 additions and 2 deletions

No files matched your search

+5
View File
@@ -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.
+16
View File
@@ -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
+1
View File
@@ -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"
@@ -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%;
}
@@ -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<ReturnType<typeof getCM>>,
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('')
})
@@ -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<typeof getCM> = 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)
}
}
}
@@ -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<typeof EditorView.domEventHandlers>[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> = {}): 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<ReturnType<typeof getCM>>
vi.mocked(getCM).mockReturnValue(editor)
finishVimInput(view)
expect(getCM).toHaveBeenCalledExactlyOnceWith(view)
expect(Vim.handleKey).toHaveBeenCalledExactlyOnceWith(editor, '<Esc>', 'api')
})
test('finishing Vim input is a no-op in Standard mode', () => {
const { view } = setup()
finishVimInput(view)
expect(Vim.handleKey).not.toHaveBeenCalled()
})
@@ -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, '<Esc>', 'api')
}
@@ -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));
@@ -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(),
+26
View File
@@ -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)