Revamp key bindings

This is a work in progress revamp of the key binding system. It implements a system, built on top of CodeMirror's key binding system, for defining key bindings. 

The system uses a dumb "KeyShortcut" -> "Command" mapping with a set of default keys (which will be different if Heynote's Emacs mode is used) that can be overridden by user key bindings.

The key bindings are *displayed* in the Settings, and it's possible to set user defined key bindings in Heynote's config file, but it's not yet possible to define custom key bindings in the UI.

Previously we Heynote on a bunch of default key bindings from CodeMirror (some of which was not "block aware"). This is no longer the case, and because of this, it's quite likely that there are key bindings that was previously working that is now missing (if so, these can easily be added later).
This commit is contained in:
Jonatan Heyman
2025-04-10 20:04:44 +02:00
parent 9be328cbe4
commit 94f5534611
15 changed files with 615 additions and 225 deletions

View File

@ -24,6 +24,16 @@ const schema = {
properties: {
"keymap": { "enum": ["default", "emacs"], default:"default" },
"emacsMetaKey": { "enum": [null, "alt", "meta"], default: null },
"keyBindings": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"showLineNumberGutter": {type: "boolean", default:true},
"showFoldGutter": {type: "boolean", default:true},
"autoUpdate": {type: "boolean", default: true},
@ -61,6 +71,7 @@ const defaults = {
settings: {
keymap: "default",
emacsMetaKey: isMac ? "meta" : "alt",
keyBindings: {},
showLineNumberGutter: true,
showFoldGutter: true,
autoUpdate: true,

View File

@ -0,0 +1,85 @@
<script>
export default {
props: [
"keys",
"command",
"isDefault",
"isOverridden",
],
computed: {
formattedKeys() {
return this.keys.replaceAll(
"Mod",
window.heynote.platform.isMac ? "⌘" : "Ctrl",
)
},
},
}
</script>
<template>
<tr :class="{overridden:isOverridden}">
<td class="source">
{{ isDefault ? "Heynote" : "User" }}
</td>
<td class="key">
<template v-if="keys">
{{ formattedKeys }}
</template>
</td>
<td class="command">
<span v-if="!command" class="unbound">Unbound</span>
<span class="command-name">{{ command }}</span>
</td>
<td class="actions">
<template v-if="!isDefault">
<button class="delete">Delete</button>
</template>
</td>
</tr>
</template>
<style lang="sass" scoped>
tr
&.overridden
text-decoration: line-through
color: rgba(0,0,0, 0.4)
+dark-mode
color: rgba(255,255,255, 0.4)
td
&.key
//letter-spacing: 1px
&.command
.command-name
font-family: monospace
margin-right: 10px
.unbound
font-style: italic
color: #999
button
padding: 0 10px
height: 22px
font-size: 12px
background: none
border: none
border-radius: 2px
margin-right: 2px
cursor: pointer
background: #ddd
&:hover
background: #ccc
+dark-mode
background: #555
&:hover
background: #666
//&.delete
// background: #e95050
// &:hover
// background: #ce4848
// +dark-mode
// &.delete
// background: #ae1e1e
// &:hover
// background: #bf2222
</style>

View File

@ -0,0 +1,126 @@
<script>
import { mapState} from 'pinia'
import { DEFAULT_KEYMAP, EMACS_KEYMAP } from "@/src/editor/keymap"
import { useSettingsStore } from "@/src/stores/settings-store"
import KeyBindRow from "./KeyBindRow.vue"
export default {
props: [
"userKeys"
],
components: {
KeyBindRow,
},
data() {
return {
}
},
computed: {
...mapState(useSettingsStore, [
"settings",
]),
keymapOld() {
const userKeys = [
{key: "Mod-Enter", command: null},
]
// merge default keymap with user keymap
const defaultKeys = Object.fromEntries(DEFAULT_KEYMAP.map(km => [km.key, km.command]))
let mergedKeys = {...defaultKeys, ...Object.fromEntries(userKeys.map(km => [km.key, km.command]))}
//console.log("defaultKeys:", defaultKeys)
return Object.entries(mergedKeys).map(([key, command]) => {
return {
key: key,
command: command,
isDefault: defaultKeys[key] !== undefined && defaultKeys[key] === command,
}
})
},
keymap() {
//const userKeys = {
// "Mod-Enter": null,
// "Mod-Shift-A": "test",
//}
const keymap = this.settings.keymap === "emacs" ? EMACS_KEYMAP : DEFAULT_KEYMAP
return [
...Object.entries(this.userKeys).map(([key, command]) => {
return {key, command}
}),
...keymap.map((km) => {
return {
key: km.key,
command: km.command,
isDefault: true,
isOverridden: km.key in this.userKeys,
}
}),
]
}
},
methods: {
},
}
</script>
<template>
<div class="container">
<h2>Keyboard Bindings</h2>
<table>
<tr>
<th>Source</th>
<th>Key</th>
<th>Command</th>
<th></th>
</tr>
<KeyBindRow
v-for="key in keymap"
:key="key.key"
:keys="key.key"
:command="key.command"
:isOverridden="key.isOverridden"
:isDefault="key.isDefault"
/>
</table>
</div>
</template>
<style lang="sass" scoped>
h2
font-weight: 600
margin-bottom: 20px
font-size: 14px
table
width: 100%
background: #f1f1f1
border: 2px solid #f1f1f1
+dark-mode
background: #3c3c3c
border: 2px solid #3c3c3c
::v-deep(tr)
&:nth-child(2n)
background: #fff
+dark-mode
background: #333
th
text-align: left
font-weight: 600
th, td
padding: 8px
</style>

View File

@ -1,9 +1,11 @@
<script>
import { toRaw} from 'vue';
import { LANGUAGES } from '../../editor/languages.js'
import KeyboardHotkey from "./KeyboardHotkey.vue"
import TabListItem from "./TabListItem.vue"
import TabContent from "./TabContent.vue"
import KeyboardBindings from './KeyboardBindings.vue'
const defaultFontFamily = window.heynote.defaultFontFamily
const defaultFontSize = window.heynote.defaultFontSize
@ -20,15 +22,18 @@
KeyboardHotkey,
TabListItem,
TabContent,
KeyboardBindings,
},
data() {
console.log("settings:", this.initialSettings)
return {
keymaps: [
{ name: "Default", value: "default" },
{ name: "Emacs", value: "emacs" },
],
keymap: this.initialSettings.keymap,
keyBindings: this.initialSettings.keyBindings,
metaKey: this.initialSettings.emacsMetaKey,
isMac: window.heynote.platform.isMac,
showLineNumberGutter: this.initialSettings.showLineNumberGutter,
@ -93,6 +98,7 @@
showLineNumberGutter: this.showLineNumberGutter,
showFoldGutter: this.showFoldGutter,
keymap: this.keymap,
keyBindings: toRaw(this.keyBindings),
emacsMetaKey: window.heynote.platform.isMac ? this.metaKey : "alt",
allowBetaVersions: this.allowBetaVersions,
enableGlobalHotkey: this.enableGlobalHotkey,
@ -159,6 +165,12 @@
:activeTab="activeTab"
@click="activeTab = 'appearance'"
/>
<TabListItem
name="Key Bindings"
tab="keyboard-bindings"
:activeTab="activeTab"
@click="activeTab = 'keyboard-bindings'"
/>
<TabListItem
:name="isWebApp ? 'Version' : 'Updates'"
tab="updates"
@ -169,23 +181,6 @@
</nav>
<div class="settings-content">
<TabContent tab="general" :activeTab="activeTab">
<div class="row">
<div class="entry">
<h2>Keymap</h2>
<select ref="keymapSelector" v-model="keymap" @change="updateSettings" class="keymap">
<template v-for="km in keymaps" :key="km.value">
<option :selected="km.value === keymap" :value="km.value">{{ km.name }}</option>
</template>
</select>
</div>
<div class="entry" v-if="keymap === 'emacs' && isMac">
<h2>Meta Key</h2>
<select v-model="metaKey" @change="updateSettings" class="metaKey">
<option :selected="metaKey === 'meta'" value="meta">Command</option>
<option :selected="metaKey === 'alt'" value="alt">Option</option>
</select>
</div>
</div>
<div class="row" v-if="!isWebApp">
<div class="entry">
<h2>Global Keyboard Shortcut</h2>
@ -354,6 +349,29 @@
</div>
</TabContent>
<TabContent tab="keyboard-bindings" :activeTab="activeTab">
<div class="row">
<div class="entry">
<h2>Keymap</h2>
<select ref="keymapSelector" v-model="keymap" @change="updateSettings" class="keymap">
<template v-for="km in keymaps" :key="km.value">
<option :selected="km.value === keymap" :value="km.value">{{ km.name }}</option>
</template>
</select>
</div>
<div class="entry" v-if="keymap === 'emacs' && isMac">
<h2>Meta Key</h2>
<select v-model="metaKey" @change="updateSettings" class="metaKey">
<option :selected="metaKey === 'meta'" value="meta">Command</option>
<option :selected="metaKey === 'alt'" value="alt">Option</option>
</select>
</div>
</div>
<KeyboardBindings
:userKeys="keyBindings ? keyBindings : {}"
/>
</TabContent>
<TabContent tab="updates" :activeTab="activeTab">
<div class="row">
<div class="entry">
@ -421,6 +439,8 @@
background: rgba(0, 0, 0, 0.5)
.dialog
--dialog-height: 560px
--bottom-bar-height: 48px
box-sizing: border-box
z-index: 2
position: absolute
@ -428,7 +448,7 @@
top: 50%
transform: translate(-50%, -50%)
width: 700px
height: 560px
height: var(--dialog-height)
max-width: 100%
max-height: 100%
display: flex
@ -448,6 +468,7 @@
.dialog-content
flex-grow: 1
display: flex
height: calc(var(--dialog-height) - var(--bottom-bar-height))
.sidebar
box-sizing: border-box
width: 140px
@ -521,6 +542,8 @@
background: #222
color: #aaa
.bottom-bar
box-sizing: border-box
height: var(--bottom-bar-height)
border-radius: 0 0 5px 5px
background: #eee
text-align: right

View File

@ -18,6 +18,7 @@
li
padding: 9px 20px
font-size: 13px
line-height: 1.3
user-select: none
cursor: pointer
&:hover

View File

@ -0,0 +1,12 @@
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'
import { Prec } from "@codemirror/state"
import { keymap } from "@codemirror/view"
export function getCloseBracketsExtensions() {
return [
closeBrackets(),
Prec.highest(keymap.of([
...closeBracketsKeymap,
])),
]
}

122
src/editor/commands.js Normal file
View File

@ -0,0 +1,122 @@
import * as codeMirrorCommands from "@codemirror/commands"
import {
undo, redo,
indentMore, indentLess,
deleteCharBackward, deleteCharForward,
deleteGroupBackward, deleteGroupForward,
deleteLineBoundaryBackward, deleteLineBoundaryForward,
deleteToLineEnd, deleteToLineStart,
simplifySelection,
splitLine,
} from "@codemirror/commands"
import { foldCode, unfoldCode } from "@codemirror/language"
import { selectNextOccurrence } from "@codemirror/search"
import {
addNewBlockAfterCurrent, addNewBlockBeforeCurrent, addNewBlockAfterLast, addNewBlockBeforeFirst, insertNewBlockAtCursor,
gotoPreviousBlock, gotoNextBlock, selectNextBlock, selectPreviousBlock,
gotoPreviousParagraph, gotoNextParagraph, selectNextParagraph, selectPreviousParagraph,
moveLineUp, moveLineDown,
selectAll,
deleteBlock, deleteBlockSetCursorPreviousBlock,
newCursorBelow, newCursorAbove,
} from "./block/commands.js"
import { deleteLine } from "./block/delete-line.js"
import { formatBlockContent } from "./block/format-code.js"
import { transposeChars } from "./block/transpose-chars.js"
import { cutCommand, copyCommand, pasteCommand } from "./copy-paste.js"
import { emacsMoveCommand, toggleEmacsMarkMode, emacsCancel } from "./emacs-mode.js"
const cursorPreviousBlock = emacsMoveCommand(gotoPreviousBlock, selectPreviousBlock)
const cursorNextBlock = emacsMoveCommand(gotoNextBlock, selectNextBlock)
const cursorPreviousParagraph = emacsMoveCommand(gotoPreviousParagraph, selectPreviousParagraph)
const cursorNextParagraph = emacsMoveCommand(gotoNextParagraph, selectNextParagraph)
const openLanguageSelector = (editor) => () => {
editor.openLanguageSelector()
return true
}
const openBufferSelector = (editor) => () => {
editor.openBufferSelector()
return true
}
const openMoveToBuffer = (editor) => () => {
editor.openMoveToBufferSelector()
return true
}
const openCreateNewBuffer = (editor) => () => {
editor.openCreateBuffer("new")
return true
}
const HEYNOTE_COMMANDS = {
//undo,
//redo,
addNewBlockAfterCurrent, addNewBlockBeforeCurrent, addNewBlockAfterLast, addNewBlockBeforeFirst, insertNewBlockAtCursor,
cursorPreviousBlock, cursorNextBlock,
cursorPreviousParagraph, cursorNextParagraph,
deleteBlock, deleteBlockSetCursorPreviousBlock,
toggleEmacsMarkMode,
emacsCancel,
openLanguageSelector,
openBufferSelector,
openMoveToBuffer,
openCreateNewBuffer,
cut: cutCommand,
copy: copyCommand,
}
// emacs-mode:ify all cursor/select commands from CodeMirror
for (let commandSuffix of [
"CharLeft", "CharRight",
"CharBackward", "CharForward",
"LineUp", "LineDown",
"LineStart", "LineEnd",
"GroupLeft", "GroupRight",
"GroupForward", "GroupBackward",
"PageUp", "PageDown",
"SyntaxLeft", "SyntaxRight",
"SubwordBackward", "SubwordForward",
"LineBoundaryBackward", "LineBoundaryForward",
]) {
HEYNOTE_COMMANDS[`cursor${commandSuffix}`] = emacsMoveCommand(codeMirrorCommands[`cursor${commandSuffix}`], codeMirrorCommands[`select${commandSuffix}`])
HEYNOTE_COMMANDS[`select${commandSuffix}`] = (editor) => codeMirrorCommands[`select${commandSuffix}`]
}
const NON_EDITOR_CONTEXT_COMMANDS = {
selectAll,
moveLineUp, moveLineDown,
deleteLine,
formatBlockContent,
newCursorAbove, newCursorBelow,
selectPreviousParagraph, selectNextParagraph,
selectPreviousBlock, selectNextBlock,
paste: pasteCommand,
// directly from CodeMirror
undo, redo,
indentMore, indentLess,
foldCode, unfoldCode,
selectNextOccurrence,
deleteCharBackward, deleteCharForward,
deleteGroupBackward, deleteGroupForward,
deleteLineBoundaryBackward, deleteLineBoundaryForward,
deleteToLineEnd, deleteToLineStart,
simplifySelection,
splitLine,
transposeChars,
}
for (const [key, cmCommand] of Object.entries(NON_EDITOR_CONTEXT_COMMANDS)) {
HEYNOTE_COMMANDS[key] = (editor) => cmCommand
}
export { HEYNOTE_COMMANDS }

View File

@ -2,7 +2,6 @@ import { EditorState, EditorSelection } from "@codemirror/state"
import { EditorView } from "@codemirror/view"
import { LANGUAGES } from './languages.js';
import { setEmacsMarkMode } from "./emacs.js"
const languageTokensMatcher = LANGUAGES.map(l => l.token).join("|")
@ -61,7 +60,7 @@ export const heynoteCopyCut = (editor) => {
}
// if we're in Emacs mode, we want to exit mark mode in case we're in it
setEmacsMarkMode(false)
editor.emacsMarkMode = false
// if Editor.deselectOnCopy is set (e.g. we're in Emacs mode), we want to remove the selection after we've copied the text
if (editor.deselectOnCopy && event.type == "copy") {
@ -95,7 +94,7 @@ const copyCut = (view, cut, editor) => {
}
// if we're in Emacs mode, we want to exit mark mode in case we're in it
setEmacsMarkMode(false)
editor.emacsMarkMode = false
// if Editor.deselectOnCopy is set (e.g. we're in Emacs mode), we want to remove the selection after we've copied the text
if (editor.deselectOnCopy && !cut) {
@ -107,6 +106,7 @@ const copyCut = (view, cut, editor) => {
selection: newSelection,
}))
}
return true
}

View File

@ -1,4 +1,4 @@
import { Annotation, EditorState, Compartment, Facet, EditorSelection, Transaction } from "@codemirror/state"
import { Annotation, EditorState, Compartment, Facet, EditorSelection, Transaction, Prec } from "@codemirror/state"
import { EditorView, keymap, drawSelection, ViewPlugin, lineNumbers } from "@codemirror/view"
import { indentUnit, forceParsing, foldGutter, ensureSyntaxTree } from "@codemirror/language"
import { markdown } from "@codemirror/lang-markdown"
@ -11,12 +11,12 @@ import { heynoteBase } from "./theme/base.js"
import { getFontTheme } from "./theme/font-theme.js";
import { customSetup } from "./setup.js"
import { heynoteLang } from "./lang-heynote/heynote.js"
import { getCloseBracketsExtensions } from "./close-brackets.js"
import { noteBlockExtension, blockLineNumbers, blockState, getActiveNoteBlock, triggerCursorChange } from "./block/block.js"
import { heynoteEvent, SET_CONTENT, DELETE_BLOCK, APPEND_BLOCK, SET_FONT } from "./annotation.js";
import { changeCurrentBlockLanguage, triggerCurrenciesLoaded, getBlockDelimiter, deleteBlock, selectAll } from "./block/commands.js"
import { formatBlockContent } from "./block/format-code.js"
import { heynoteKeymap } from "./keymap.js"
import { emacsKeymap } from "./emacs.js"
import { heynoteKeymap, DEFAULT_KEYMAP, EMACS_KEYMAP } from "./keymap.js"
import { heynoteCopyCut } from "./copy-paste"
import { languageDetection } from "./language-detection/autodetect.js"
import { autoSaveContent } from "./save.js"
@ -28,12 +28,12 @@ import { useHeynoteStore } from "../stores/heynote-store.js";
import { useErrorStore } from "../stores/error-store.js";
function getKeymapExtensions(editor, keymap) {
if (keymap === "emacs") {
return emacsKeymap(editor)
} else {
return heynoteKeymap(editor)
}
function getKeymapExtensions(editor, keymap, keyBindings) {
return heynoteKeymap(
editor,
keymap === "emacs" ? EMACS_KEYMAP : DEFAULT_KEYMAP,
keyBindings,
)
}
@ -53,6 +53,7 @@ export class HeynoteEditor {
fontSize,
defaultBlockToken,
defaultBlockAutoDetect,
keyBindings,
}) {
this.element = element
this.path = path
@ -71,20 +72,20 @@ export class HeynoteEditor {
this.notesStore = useHeynoteStore()
this.errorStore = useErrorStore()
this.name = ""
this.emacsMarkMode = false
const state = EditorState.create({
doc: "",
extensions: [
this.keymapCompartment.of(getKeymapExtensions(this, keymap)),
this.keymapCompartment.of(getKeymapExtensions(this, keymap, keyBindings)),
heynoteCopyCut(this),
//minimalSetup,
this.lineNumberCompartment.of(showLineNumberGutter ? [lineNumbers(), blockLineNumbers] : []),
customSetup,
this.foldGutterCompartment.of(showFoldGutter ? [foldGutter()] : []),
this.closeBracketsCompartment.of(bracketClosing ? [closeBrackets()] : []),
this.closeBracketsCompartment.of(bracketClosing ? [getCloseBracketsExtensions()] : []),
this.readOnlyCompartment.of([]),
@ -273,11 +274,11 @@ export class HeynoteEditor {
})
}
setKeymap(keymap, emacsMetaKey) {
setKeymap(keymap, emacsMetaKey, keyBindings) {
this.deselectOnCopy = keymap === "emacs"
this.emacsMetaKey = emacsMetaKey
this.view.dispatch({
effects: this.keymapCompartment.reconfigure(getKeymapExtensions(this, keymap)),
effects: this.keymapCompartment.reconfigure(getKeymapExtensions(this, keymap, keyBindings)),
})
}
@ -371,7 +372,7 @@ export class HeynoteEditor {
setBracketClosing(value) {
this.view.dispatch({
effects: this.closeBracketsCompartment.reconfigure(value ? [closeBrackets()] : []),
effects: this.closeBracketsCompartment.reconfigure(value ? [getCloseBracketsExtensions()] : []),
})
}

37
src/editor/emacs-mode.js Normal file
View File

@ -0,0 +1,37 @@
import {
simplifySelection,
} from "@codemirror/commands"
/**
* Takes a command that moves the cursor and a command that marks the selection, and returns a new command that
* will run the mark command if we're in Emacs mark mode, or the move command otherwise.
*/
export function emacsMoveCommand(defaultCmd, markModeCmd) {
return (editor) => {
if (editor.emacsMarkMode) {
return (view) => {
markModeCmd(view)
// we need to return true here instead of returning what the default command returns, since the default
// codemirror select commands will return false if the selection doesn't change, which in turn will
// make the default *move* command run which will kill the selection if we're in mark mode
return true
}
} else {
return (view) => defaultCmd(view)
}
}
}
export function toggleEmacsMarkMode(editor) {
return (view) => {
editor.emacsMarkMode = !editor.emacsMarkMode
}
}
export function emacsCancel(editor) {
return (view) => {
simplifySelection(view)
editor.emacsMarkMode = false
}
}

View File

@ -1,119 +0,0 @@
import { Direction} from "@codemirror/view"
import { EditorSelection, EditorState, Prec } from "@codemirror/state"
import {
undo, redo,
cursorGroupLeft, cursorGroupRight, selectGroupLeft, selectGroupRight,
simplifySelection,
deleteCharForward, deleteCharBackward, deleteToLineEnd,
splitLine,
transposeChars,
cursorPageDown,
cursorCharLeft, selectCharLeft,
cursorCharRight, selectCharRight,
cursorLineUp, selectLineUp,
cursorLineDown, selectLineDown,
cursorLineStart, selectLineStart,
cursorLineEnd, selectLineEnd,
} from "@codemirror/commands"
import { heynoteKeymap, keymapFromSpec } from "./keymap.js"
import {
gotoPreviousBlock, gotoNextBlock,
selectNextBlock, selectPreviousBlock,
gotoPreviousParagraph, gotoNextParagraph,
selectNextParagraph, selectPreviousParagraph,
selectAll,
} from "./block/commands.js"
import { pasteCommand, copyCommand, cutCommand } from "./copy-paste.js"
// if set to true, all keybindings for moving around is changed to their corresponding select commands
let emacsMarkMode = false
export function setEmacsMarkMode(value) {
emacsMarkMode = value
}
/**
* Return a command that will conditionally execute either the default command or the mark mode command
*
* @param defaultCmd Default command to execute
* @param {*} markModeCmd Command to execute if mark mode is active
*/
function emacsMoveCommand(defaultCmd, markModeCmd) {
return (view) => emacsMarkMode ? markModeCmd(view) : defaultCmd(view)
}
/**
* C-g command that exits mark mode and simplifies selection
*/
function emacsCancel(view) {
simplifySelection(view)
setEmacsMarkMode(false)
}
/**
* Exit mark mode before executing selectAll command
*/
function emacsSelectAll(view) {
setEmacsMarkMode(false)
return selectAll(view)
}
function emacsMetaKeyCommand(key, editor, command) {
const handler = (view, event) => {
if (editor.emacsMetaKey === "meta" && event.metaKey || editor.emacsMetaKey === "alt" && event.altKey) {
event.preventDefault()
return command(view)
} else {
return false
}
}
return [
{key, run:handler, preventDefault:false},
{key:key.replace("Meta", "Alt"), run:handler, preventDefault:false},
]
}
export function emacsKeymap(editor) {
return [
heynoteKeymap(editor),
Prec.highest(keymapFromSpec([
["Ctrl-Shift--", undo],
["Ctrl-.", redo],
["Ctrl-g", emacsCancel],
["ArrowLeft", emacsMoveCommand(cursorCharLeft, selectCharLeft)],
["ArrowRight", emacsMoveCommand(cursorCharRight, selectCharRight)],
["ArrowUp", emacsMoveCommand(cursorLineUp, selectLineUp)],
["ArrowDown", emacsMoveCommand(cursorLineDown, selectLineDown)],
{key: "Ctrl-ArrowLeft", run: emacsMoveCommand(cursorGroupLeft, selectGroupLeft), shift: selectGroupLeft},
{key: "Ctrl-ArrowRight", run: emacsMoveCommand(cursorGroupRight, selectGroupRight), shift: selectGroupRight},
["Ctrl-d", deleteCharForward],
["Ctrl-h", deleteCharBackward],
["Ctrl-k", deleteToLineEnd],
["Ctrl-o", splitLine],
["Ctrl-t", transposeChars],
["Ctrl-v", cursorPageDown],
["Ctrl-y", pasteCommand],
["Ctrl-w", cutCommand(editor)],
...emacsMetaKeyCommand("Meta-w", editor, copyCommand(editor)),
{ key: "Ctrl-b", run: emacsMoveCommand(cursorCharLeft, selectCharLeft), shift: selectCharLeft },
{ key: "Ctrl-f", run: emacsMoveCommand(cursorCharRight, selectCharRight), shift: selectCharRight },
{ key: "Ctrl-a", run: emacsMoveCommand(cursorLineStart, selectLineStart), shift: selectLineStart },
{ key: "Ctrl-e", run: emacsMoveCommand(cursorLineEnd, selectLineEnd), shift: selectLineEnd },
])),
Prec.highest(keymapFromSpec([
["Ctrl-Space", (view) => { emacsMarkMode = !emacsMarkMode }],
["Mod-a", emacsSelectAll],
{key:"Mod-ArrowUp", run:emacsMoveCommand(gotoPreviousBlock, selectPreviousBlock), shift:selectPreviousBlock},
{key:"Mod-ArrowDown", run:emacsMoveCommand(gotoNextBlock, selectNextBlock), shift:selectNextBlock},
{key:"Ctrl-ArrowUp", run:emacsMoveCommand(gotoPreviousParagraph, selectPreviousParagraph), shift:selectPreviousParagraph},
{key:"Ctrl-ArrowDown", run:emacsMoveCommand(gotoNextParagraph, selectNextParagraph), shift:selectNextParagraph},
])),
]
}

View File

@ -1,75 +1,162 @@
import { keymap } from "@codemirror/view"
//import { EditorSelection, EditorState } from "@codemirror/state"
import {
indentLess, indentMore, redo,
} from "@codemirror/commands"
import { Prec } from "@codemirror/state"
import {
insertNewBlockAtCursor,
addNewBlockBeforeCurrent, addNewBlockAfterCurrent,
addNewBlockBeforeFirst, addNewBlockAfterLast,
moveLineUp, moveLineDown,
selectAll,
gotoPreviousBlock, gotoNextBlock,
selectNextBlock, selectPreviousBlock,
gotoPreviousParagraph, gotoNextParagraph,
selectNextParagraph, selectPreviousParagraph,
newCursorBelow, newCursorAbove,
deleteBlock,
} from "./block/commands.js"
import { pasteCommand, copyCommand, cutCommand } from "./copy-paste.js"
import { formatBlockContent } from "./block/format-code.js"
import { deleteLine } from "./block/delete-line.js"
import { HEYNOTE_COMMANDS } from "./commands.js"
export function keymapFromSpec(specs) {
function keymapFromSpec(specs, editor) {
return keymap.of(specs.map((spec) => {
if (spec.run) {
if ("preventDefault" in spec) {
return spec
} else {
return {...spec, preventDefault: true}
}
} else {
const [key, run] = spec
return {
key,
run,
preventDefault: true,
}
let key = spec.key
if (key.indexOf("EmacsMeta") != -1) {
key = key.replace("EmacsMeta", editor.emacsMetaKey === "alt" ? "Alt" : "Meta")
}
return {
key: key,
//preventDefault: true,
preventDefault: false,
run: (view) => {
//console.log("run()", spec.key, spec.command)
const command = HEYNOTE_COMMANDS[spec.command]
if (!command) {
console.error(`Command not found: ${spec.command} (${spec.key})`)
return false
}
return command(editor)(view)
},
}
}))
}
export function heynoteKeymap(editor) {
return keymapFromSpec([
["Mod-c", copyCommand(editor)],
["Mod-v", pasteCommand],
["Mod-x", cutCommand(editor)],
["Tab", indentMore],
["Shift-Tab", indentLess],
["Alt-Shift-Enter", addNewBlockBeforeFirst(editor)],
["Mod-Shift-Enter", addNewBlockAfterLast(editor)],
["Alt-Enter", addNewBlockBeforeCurrent(editor)],
["Mod-Enter", addNewBlockAfterCurrent(editor)],
["Mod-Alt-Enter", insertNewBlockAtCursor(editor)],
["Mod-a", selectAll],
["Alt-ArrowUp", moveLineUp],
["Alt-ArrowDown", moveLineDown],
["Mod-l", () => editor.openLanguageSelector()],
["Mod-p", () => editor.openBufferSelector()],
["Mod-s", () => editor.openMoveToBufferSelector()],
["Mod-n", () => editor.openCreateBuffer("new")],
["Mod-Shift-d", deleteBlock(editor)],
["Alt-Shift-f", formatBlockContent],
["Mod-Alt-ArrowDown", newCursorBelow],
["Mod-Alt-ArrowUp", newCursorAbove],
["Mod-Shift-k", deleteLine],
["Mod-Shift-z", redo],
{key:"Mod-ArrowUp", run:gotoPreviousBlock, shift:selectPreviousBlock},
{key:"Mod-ArrowDown", run:gotoNextBlock, shift:selectNextBlock},
{key:"Ctrl-ArrowUp", run:gotoPreviousParagraph, shift:selectPreviousParagraph},
{key:"Ctrl-ArrowDown", run:gotoNextParagraph, shift:selectNextParagraph},
])
const cmd = (key, command) => ({key, command})
const cmdShift = (key, command, shiftCommand) => {
return [
cmd(key, command),
cmd(`Shift-${key}`, shiftCommand),
]
}
const isMac = window.heynote.platform.isMac
const isLinux = window.heynote.platform.isLinux
const isWindows = window.heynote.platform.isWindows
export const DEFAULT_KEYMAP = [
cmd("Mod-a", "selectAll"),
cmd("Mod-Enter", "addNewBlockAfterCurrent"),
cmd("Mod-Shift-Enter", "addNewBlockAfterLast"),
cmd("Alt-Enter", "addNewBlockBeforeCurrent"),
cmd("Alt-Shift-Enter", "addNewBlockBeforeFirst"),
cmd("Mod-Alt-Enter", "insertNewBlockAtCursor"),
...cmdShift("ArrowLeft", "cursorCharLeft", "selectCharLeft"),
...cmdShift("ArrowRight", "cursorCharRight", "selectCharRight"),
...cmdShift("ArrowUp", "cursorLineUp", "selectLineUp"),
...cmdShift("ArrowDown", "cursorLineDown", "selectLineDown"),
...cmdShift("Ctrl-ArrowLeft", "cursorGroupLeft", "selectGroupLeft"),
...cmdShift("Ctrl-ArrowRight", "cursorGroupRight", "selectGroupRight"),
...cmdShift("Alt-ArrowLeft", "cursorGroupLeft", "selectGroupLeft"),
...cmdShift("Alt-ArrowRight", "cursorGroupRight", "selectGroupRight"),
...cmdShift("Mod-ArrowUp", "cursorPreviousBlock", "selectPreviousBlock"),
...cmdShift("Mod-ArrowDown", "cursorNextBlock", "selectNextBlock"),
...cmdShift("Ctrl-ArrowUp", "cursorPreviousParagraph", "selectPreviousParagraph"),
...cmdShift("Ctrl-ArrowDown", "cursorNextParagraph", "selectNextParagraph"),
...cmdShift("PageUp", "cursorPageUp", "selectPageUp"),
...cmdShift("PageDown", "cursorPageDown", "selectPageDown"),
...cmdShift("Home", "cursorLineBoundaryBackward", "selectLineBoundaryBackward"),
...cmdShift("End", "cursorLineBoundaryForward", "selectLineBoundaryForward"),
cmd("Backspace", "deleteCharBackward"),
cmd("Delete", "deleteCharForward"),
cmd("Escape", "simplifySelection"),
cmd("Ctrl-Backspace", "deleteGroupBackward"),
cmd("Ctrl-Delete", "deleteGroupForward"),
...(isMac ? [
cmd("Alt-Backspace", "deleteGroupBackward"),
cmd("Alt-Delete", "deleteGroupForward"),
cmd("Mod-Backspace", "deleteLineBoundaryBackward"),
cmd("Mod-Delete", "deleteLineBoundaryForward"),
] : []),
cmd("Alt-ArrowUp", "moveLineUp"),
cmd("Alt-ArrowDown", "moveLineDown"),
cmd("Mod-Shift-k", "deleteLine"),
cmd("Mod-Alt-ArrowDown", "newCursorBelow"),
cmd("Mod-Alt-ArrowUp", "newCursorAbove"),
cmd("Mod-Shift-d", "deleteBlock"),
cmd("Mod-d", "selectNextOccurrence"),
cmd(isMac ? "Cmd-Alt-[" : "Ctrl-Shift-[", "foldCode"),
cmd(isMac ? "Cmd-Alt-]" : "Ctrl-Shift-]", "unfoldCode"),
cmd("Mod-c", "copy"),
cmd("Mod-v", "paste"),
cmd("Mod-x", "cut"),
cmd("Mod-z", "undo"),
cmd("Mod-Shift-z", "redo"),
...(isWindows || isLinux ? [
cmd("Mod-y", "redo"),
] : []),
cmd("Tab", "indentMore"),
cmd("Shift-Tab", "indentLess"),
//cmd("Alt-ArrowLeft", "cursorSubwordBackward"),
//cmd("Alt-ArrowRight", "cursorSubwordForward"),
cmd("Ctrl-Space", "toggleEmacsMarkMode"),
cmd("Ctrl-g", "emacsCancel"),
cmd("Mod-l", "openLanguageSelector"),
cmd("Mod-p", "openBufferSelector"),
cmd("Mod-s", "openMoveToBuffer"),
cmd("Mod-n", "openCreateNewBuffer"),
cmd("Alt-Shift-f", "formatBlockContent"),
// search
//cmd("Mod-f", "openSearchPanel"),
//cmd("F3", "findNext"),
//cmd("Mod-g", "findNext"),
//cmd("Shift-F3", "findPrevious"),
//cmd("Shift-Mod-g", "findPrevious"),
//cmd("Mod-Alt-g", "gotoLine"),
//cmd("Mod-d", "selectNextOccurrence"),
/*
- Mod-f: [`openSearchPanel`](https://codemirror.net/6/docs/ref/#search.openSearchPanel)
- F3, Mod-g: [`findNext`](https://codemirror.net/6/docs/ref/#search.findNext)
- Shift-F3, Shift-Mod-g: [`findPrevious`](https://codemirror.net/6/docs/ref/#search.findPrevious)
- Mod-Alt-g: [`gotoLine`](https://codemirror.net/6/docs/ref/#search.gotoLine)
- Mod-d: [`selectNextOccurrence`](https://codemirror.net/6/docs/ref/#search.selectNextOccurrence)
*/
]
export const EMACS_KEYMAP = [
cmd("Ctrl-w", "cut"),
cmd("Ctrl-y", "paste"),
cmd("EmacsMeta-w", "copy"),
cmd("Ctrl-Space", "toggleEmacsMarkMode"),
cmd("Ctrl-g", "emacsCancel"),
cmd("Escape", "emacsCancel"),
cmd("Ctrl-o", "splitLine"),
cmd("Ctrl-d", "deleteCharForward"),
cmd("Ctrl-h", "deleteCharBackward"),
cmd("Ctrl-k", "deleteToLineEnd"),
cmd("Ctrl-t", "transposeChars"),
cmd("Ctrl-Shift--", "undo"),
cmd("Ctrl-.", "redo"),
...cmdShift("Ctrl-v", "cursorPageDown", "selectPageDown"),
...cmdShift("Ctrl-b", "cursorCharLeft", "selectCharLeft"),
...cmdShift("Ctrl-f", "cursorCharRight", "selectCharRight"),
...cmdShift("Ctrl-a", "cursorLineStart", "selectLineStart"),
...cmdShift("Ctrl-e", "cursorLineEnd", "selectLineEnd"),
...DEFAULT_KEYMAP,
]
export function heynoteKeymap(editor, keymap, userKeymap) {
// merge the default keymap with the custom keymap
const defaultKeys = Object.fromEntries(keymap.map(km => [km.key, km.command]))
//let mergedKeys = Object.entries({...defaultKeys, ...Object.fromEntries(userKeymap.map(km => [km.key, km.command]))}).map(([key, command]) => cmd(key, command))
let mergedKeys = Object.entries({...defaultKeys, ...userKeymap}).map(([key, command]) => cmd(key, command))
//console.log("userKeys:", userKeymap)
//console.log("mergedKeys:", mergedKeys)
return [
Prec.high(keymapFromSpec(mergedKeys, editor)),
]
}

View File

@ -68,11 +68,12 @@ const customSetup = /*@__PURE__*/(() => [
EditorView.lineWrapping,
scrollPastEnd(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
//...closeBracketsKeymap,
//...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
//...historyKeymap,
//...foldKeymap,
//...completionKeymap,
//...lintKeymap
])

View File

@ -38,6 +38,7 @@ export const useEditorCacheStore = defineStore("editorCache", {
fontSize: settingsStore.settings.fontSize,
defaultBlockToken: settingsStore.settings.defaultBlockLanguage,
defaultBlockAutoDetect: settingsStore.settings.defaultBlockLanguageAutoDetect,
keyBindings: settingsStore.settings.keyBindings,
})
} catch (e) {
errorStore.addError("Error! " + e.message)
@ -122,7 +123,8 @@ export const useEditorCacheStore = defineStore("editorCache", {
switch (key) {
case "keymap":
case "emacsMetaKey":
editor.setKeymap(newSettings.keymap, newSettings.emacsMetaKey)
case "keyBindings":
editor.setKeymap(newSettings.keymap, newSettings.emacsMetaKey, newSettings.keyBindings)
break
case "showLineNumberGutter":
editor.setLineNumberGutter(newSettings.showLineNumberGutter)

View File

@ -13,6 +13,7 @@ test.beforeEach(async ({ page, browserName }) => {
test.skip()
}
await page.locator("css=.status-block.settings").click()
await page.locator("css=li.tab-keyboard-bindings").click()
//await page.locator("css=li.tab-editing").click()
await page.locator("css=select.keymap").selectOption("emacs")
if (heynotePage.isMac) {