Add optional Vim keybindings and editor status #106
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Generated
+26
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user