Refactor the way we handle copy/cut/paste in Emacs mode

Previously we listened for the key bindings for copy, cut and paste in the Electron main process, and triggered the event using copy(), paste() and cut() methods on win.webContent. Now this is fully handled within the renderer process using the window.navigator.clipboard API.

This will make it simpler to implement fully customizable key bindings.
This commit is contained in:
Jonatan Heyman 2024-01-05 16:29:26 +01:00
parent 1b0b2d55b1
commit 957b22c70e
10 changed files with 160 additions and 44 deletions

View File

@ -1,28 +0,0 @@
import CONFIG from "./config"
import { isMac } from "./detect-platform"
export function onBeforeInputEvent({win, event, input, currentKeymap}) {
//console.log("keyboard event", input)
let metaKey = "alt"
if (isMac) {
metaKey = CONFIG.get("settings.emacsMetaKey", "meta")
}
if (currentKeymap === "emacs") {
/**
* When using Emacs keymap, we can't bind shortcuts for copy, cut and paste in the the renderer process
* using Codemirror's bind function. Therefore we have to bind them in electron land, and send
* cut, paste and copy to window.webContents
*/
if (input.code === "KeyY" && input.control) {
event.preventDefault()
win.webContents.paste()
} else if (input.code === "KeyW" && input.control) {
event.preventDefault()
win.webContents.cut()
} else if (input.code === "KeyW" && input[metaKey]) {
event.preventDefault()
win.webContents.copy()
}
}
}

View File

@ -6,7 +6,6 @@ import fs from "fs"
import { menu, getTrayMenu } from './menu'
import { WINDOW_CLOSE_EVENT, SETTINGS_CHANGE_EVENT } from '../constants';
import CONFIG from "../config"
import { onBeforeInputEvent } from "../keymap"
import { isDev, isMac, isWindows } from '../detect-platform';
import { initializeAutoUpdate, checkForUpdates } from './auto-update';
import { fixElectronCors } from './cors';
@ -126,11 +125,6 @@ async function createWindow() {
//win.webContents.openDevTools()
}
// custom keyboard shortcuts for Emacs keybindings
win.webContents.on("before-input-event", function (event, input) {
onBeforeInputEvent({event, input, win, currentKeymap})
})
// Test actively push message to the Electron-Renderer
win.webContents.on('did-finish-load', () => {
win?.webContents.send('main-process-message', new Date().toLocaleString())

View File

@ -34,7 +34,12 @@ export default defineConfig({
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
use: {
...devices['Desktop Chrome'],
contextOptions: {
permissions: ['clipboard-read','clipboard-write'],
},
},
},
{

View File

@ -116,6 +116,7 @@
:development="development"
:debugSyntaxTree="false"
:keymap="settings.keymap"
:emacsMetaKey="settings.emacsMetaKey"
:showLineNumberGutter="settings.showLineNumberGutter"
:showFoldGutter="settings.showFoldGutter"
:bracketClosing="settings.bracketClosing"

View File

@ -11,6 +11,10 @@
type: String,
default: "default",
},
emacsMetaKey: {
type: String,
default: "Meta",
},
showLineNumberGutter: {
type: Boolean,
default: true,
@ -63,6 +67,7 @@
window.heynote.buffer.save(content)
},
keymap: this.keymap,
emacsMetaKey: this.emacsMetaKey,
showLineNumberGutter: this.showLineNumberGutter,
showFoldGutter: this.showFoldGutter,
bracketClosing: this.bracketClosing,
@ -110,8 +115,12 @@
this.editor.setTheme(newTheme)
},
keymap(keymap) {
this.editor.setKeymap(keymap)
keymap() {
this.editor.setKeymap(this.keymap, this.emacsMetaKey)
},
emacsMetaKey() {
this.editor.setKeymap(this.keymap, this.emacsMetaKey)
},
showLineNumberGutter(show) {

View File

@ -129,7 +129,7 @@
<div class="row">
<div class="entry">
<h2>Keymap</h2>
<select ref="keymapSelector" v-model="keymap" @change="updateSettings">
<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>
@ -137,7 +137,7 @@
</div>
<div class="entry" v-if="keymap === 'emacs' && isMac">
<h2>Meta Key</h2>
<select v-model="metaKey" @change="updateSettings">
<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>

View File

@ -21,7 +21,10 @@ function copiedRange(state) {
export const heynoteCopyPaste = (editor) => {
/**
* Set up event handlers for the browser's copy & cut events, that will replace block separators with newlines
*/
export const heynoteCopyCut = (editor) => {
let copy, cut
copy = cut = (event, view) => {
let { text, ranges } = copiedRange(view.state)
@ -60,3 +63,76 @@ export const heynoteCopyPaste = (editor) => {
cut,
})
}
const copyCut = (view, cut, editor) => {
let { text, ranges } = copiedRange(view.state)
text = text.replaceAll(blockSeparatorRegex, "\n\n")
navigator.clipboard.writeText(text)
if (cut && !view.state.readOnly) {
view.dispatch({
changes: ranges,
scrollIntoView: true,
userEvent: "delete.cut"
})
}
// if we're in Emacs mode, we want to exit mark mode in case we're in it
setEmacsMarkMode(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) {
const newSelection = EditorSelection.create(
view.state.selection.ranges.map(r => EditorSelection.cursor(r.head)),
view.state.selection.mainIndex,
)
view.dispatch(view.state.update({
selection: newSelection,
}))
}
}
function doPaste(view, input) {
let { state } = view, changes, i = 1, text = state.toText(input)
let byLine = text.lines == state.selection.ranges.length
if (byLine) {
changes = state.changeByRange(range => {
let line = text.line(i++)
return {
changes: { from: range.from, to: range.to, insert: line.text },
range: EditorSelection.cursor(range.from + line.length)
}
})
} else {
changes = state.replaceSelection(text)
}
view.dispatch(changes, {
userEvent: "input.paste",
scrollIntoView: true
})
}
/**
* @param editor Editor instance
* @returns CodeMirror command that copies the current selection to the clipboard
*/
export function copyCommand(editor) {
return (view) => copyCut(view, false, editor)
}
/**
* @param editor Editor instance
* @returns CodeMirror command that cuts the current selection to the clipboard
*/
export function cutCommand(editor) {
return (view) => copyCut(view, true, editor)
}
/**
* CodeMirror command that pastes the clipboard content into the editor
*/
export async function pasteCommand(view) {
return doPaste(view, await navigator.clipboard.readText())
}

View File

@ -15,7 +15,7 @@ import { changeCurrentBlockLanguage, triggerCurrenciesLoaded } from "./block/com
import { formatBlockContent } from "./block/format-code.js"
import { heynoteKeymap } from "./keymap.js"
import { emacsKeymap } from "./emacs.js"
import { heynoteCopyPaste } from "./copy-paste"
import { heynoteCopyCut } from "./copy-paste"
import { languageDetection } from "./language-detection/autodetect.js"
import { autoSaveContent } from "./save.js"
import { todoCheckboxPlugin} from "./todo-checkbox.ts"
@ -40,6 +40,7 @@ export class HeynoteEditor {
theme="light",
saveFunction=null,
keymap="default",
emacsMetaKey="Meta",
showLineNumberGutter=true,
showFoldGutter=true,
bracketClosing=false,
@ -53,12 +54,13 @@ export class HeynoteEditor {
this.readOnlyCompartment = new Compartment
this.closeBracketsCompartment = new Compartment
this.deselectOnCopy = keymap === "emacs"
this.emacsMetaKey = emacsMetaKey
const state = EditorState.create({
doc: content || "",
extensions: [
this.keymapCompartment.of(getKeymapExtensions(this, keymap)),
heynoteCopyPaste(this),
heynoteCopyCut(this),
//minimalSetup,
this.lineNumberCompartment.of(showLineNumberGutter ? [lineNumbers(), blockLineNumbers] : []),
@ -159,8 +161,9 @@ export class HeynoteEditor {
})
}
setKeymap(keymap) {
setKeymap(keymap, emacsMetaKey) {
this.deselectOnCopy = keymap === "emacs"
this.emacsMetaKey = emacsMetaKey
this.view.dispatch({
effects: this.keymapCompartment.reconfigure(getKeymapExtensions(this, keymap)),
})

View File

@ -24,6 +24,7 @@ import {
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
@ -60,6 +61,21 @@ function emacsSelectAll(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),
@ -81,7 +97,11 @@ export function emacsKeymap(editor) {
["Ctrl-t", transposeChars],
["Ctrl-v", cursorPageDown],
{ key: "Ctrl-b", run: emacsMoveCommand(cursorCharLeft, selectCharLeft), shift: selectCharLeft, preventDefault: true },
["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-p", run: emacsMoveCommand(cursorLineUp, selectLineUp), shift: selectLineUp },
{ key: "Ctrl-n", run: emacsMoveCommand(cursorLineDown, selectLineDown), shift: selectLineDown },

View File

@ -0,0 +1,36 @@
import { test, expect } from "@playwright/test";
import { HeynotePage } from "./test-utils.js";
let heynotePage
test.beforeEach(async ({ page }) => {
heynotePage = new HeynotePage(page)
await heynotePage.goto()
});
test("test emacs copy/pase/cut key bindings", async ({ page, browserName }) => {
if (browserName !== "chromium") {
// This test only works in Chromium due to accessing the clipboard
test.skip()
}
await page.locator("css=.status-block.settings").click()
//await page.locator("css=li.tab-editing").click()
await page.locator("css=select.keymap").selectOption("emacs")
await page.locator("css=select.metaKey").selectOption("alt")
await page.locator("body").press("Escape")
await page.locator("body").pressSequentially("test")
await page.locator("body").press(heynotePage.isMac ? "Meta+A" : "Control+A")
await page.locator("body").press("Alt+W")
expect(await heynotePage.getBlockContent(0)).toBe("test")
await page.locator("body").press("Control+Y")
expect(await heynotePage.getBlockContent(0)).toBe("testtest")
await page.locator("body").press(heynotePage.isMac ? "Meta+A" : "Control+A")
await page.locator("body").press("Control+W")
expect(await heynotePage.getBlockContent(0)).toBe("")
await page.locator("body").press("Control+Y")
await page.locator("body").press("Control+Y")
expect(await heynotePage.getBlockContent(0)).toBe("testtesttesttest")
})