From 924fd4b2267144cb2099f327bd739e7196c3751e Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Fri, 20 Jan 2023 15:33:26 +0100 Subject: [PATCH] Replace block separators with "\n\n" when copying text. Add Settings dialog. Started implementing Emacs-like keymap. --- electron/config.js | 21 +++++ electron/constants.ts | 2 + electron/keymap.js | 27 +++++++ electron/main/index.ts | 23 ++++-- electron/main/menu.ts | 9 +++ electron/preload/index.ts | 19 ++++- src/components/App.vue | 24 ++++++ src/components/Editor.vue | 19 +++-- src/components/settings/Settings.vue | 113 +++++++++++++++++++++++++++ src/editor/copy-paste.js | 57 ++++++++++++++ src/editor/editor.js | 31 ++++++-- src/editor/keymap.js | 84 +++++++++----------- 12 files changed, 362 insertions(+), 67 deletions(-) create mode 100644 electron/config.js create mode 100644 electron/keymap.js create mode 100644 src/components/settings/Settings.vue create mode 100644 src/editor/copy-paste.js diff --git a/electron/config.js b/electron/config.js new file mode 100644 index 0000000..05b411c --- /dev/null +++ b/electron/config.js @@ -0,0 +1,21 @@ +import Store from "electron-store" + +const schema = { + additionalProperties: false, + + windowConfig: { + type: "object", + properties: { + width: {type: "number"}, + height: {type: "number"}, + x: {type: "number"}, + y: {type: "number"}, + isMaximized: {type: "boolean"}, + isFullScreen: {type: "boolean"}, + }, + additionalProperties: false, + }, + keymap: { "enum": ["default", "emacs"] }, +} + +export default new Store({schema}) diff --git a/electron/constants.ts b/electron/constants.ts index 2af85cd..488a2d7 100644 --- a/electron/constants.ts +++ b/electron/constants.ts @@ -1 +1,3 @@ export const WINDOW_CLOSE_EVENT = "window-close" +export const KEYMAP_CHANGE_EVENT = "keymap-change" +export const OPEN_SETTINGS_EVENT = "open-settings" diff --git a/electron/keymap.js b/electron/keymap.js new file mode 100644 index 0000000..89269e4 --- /dev/null +++ b/electron/keymap.js @@ -0,0 +1,27 @@ +import { isMac } from "./detect-platform" + + +export function onBeforeInputEvent({win, event, input, currentKeymap}) { + //console.log("keyboard event", input) + let metaKey = "alt" + if (isMac) { + metaKey = "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.key === "y" && input.control) { + event.preventDefault() + win.webContents.paste() + } else if (input.key === "w" && input.control) { + event.preventDefault() + win.webContents.cut() + } else if (input.key === "w" && input[metaKey]) { + event.preventDefault() + win.webContents.copy() + } + } +} diff --git a/electron/main/index.ts b/electron/main/index.ts index f2d14be..7b46eb8 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -1,11 +1,13 @@ -import { app, BrowserWindow, shell, ipcMain, Menu, nativeTheme } from 'electron' +import { app, BrowserWindow, shell, ipcMain, Menu, nativeTheme, globalShortcut } from 'electron' import { release } from 'node:os' import { join } from 'node:path' import * as jetpack from "fs-jetpack"; -import Store from "electron-store" import menu from './menu' import { initialContent, initialDevContent } from '../initial-content' -import { WINDOW_CLOSE_EVENT } from '../constants'; +import { WINDOW_CLOSE_EVENT, KEYMAP_CHANGE_EVENT } from '../constants'; +import CONFIG from "../config" +import { onBeforeInputEvent } from "../keymap" +import { isMac } from '../detect-platform'; // The built directory structure // @@ -37,8 +39,6 @@ if (!process.env.VITE_DEV_SERVER_URL && !app.requestSingleInstanceLock()) { // Set custom application menu Menu.setApplicationMenu(menu) -const CONFIG = new Store() - // Remove electron security warnings // This warning only shows in development mode @@ -51,6 +51,8 @@ const preload = join(__dirname, '../preload/index.js') const url = process.env.VITE_DEV_SERVER_URL const indexHtml = join(process.env.DIST, 'index.html') const isDev = !!process.env.VITE_DEV_SERVER_URL + +let currentKeymap = CONFIG.get("keymap", "default") let contentSaved = false @@ -114,6 +116,11 @@ async function createWindow() { win.loadFile(indexHtml) //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', () => { @@ -183,3 +190,9 @@ ipcMain.handle('buffer-content:saveAndQuit', async (event, content) => { contentSaved = true app.quit() }) + +ipcMain.handle('keymap:set', (event, keymap) => { + currentKeymap = keymap + win?.webContents.send(KEYMAP_CHANGE_EVENT, keymap) + CONFIG.set("keymap", keymap) +}) diff --git a/electron/main/menu.ts b/electron/main/menu.ts index e62dcaa..0a70f57 100644 --- a/electron/main/menu.ts +++ b/electron/main/menu.ts @@ -1,4 +1,5 @@ const { app, Menu } = require('electron') +import { WINDOW_CLOSE_EVENT, KEYMAP_CHANGE_EVENT, OPEN_SETTINGS_EVENT } from '../constants'; const isMac = process.platform === 'darwin' @@ -9,6 +10,14 @@ const template = [ submenu: [ { role: 'about' }, { type: 'separator' }, + { + label: 'Preferences', + click: (menuItem, window, event) => { + window?.webContents.send(OPEN_SETTINGS_EVENT) + }, + accelerator: isMac ? 'Command+,': null, + }, + { type: 'separator' }, { role: 'services' }, { type: 'separator' }, { role: 'hide' }, diff --git a/electron/preload/index.ts b/electron/preload/index.ts index bdc7f69..3dadf81 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -2,7 +2,8 @@ const { contextBridge } = require('electron') import darkMode from "./theme-mode" import { isMac, isWindows, isLinux } from "../detect-platform" import { ipcRenderer } from "electron" -import { WINDOW_CLOSE_EVENT } from "../constants" +import { WINDOW_CLOSE_EVENT, KEYMAP_CHANGE_EVENT, OPEN_SETTINGS_EVENT } from "../constants" +import CONFIG from "../config" contextBridge.exposeInMainWorld("platform", { isMac, @@ -21,6 +22,10 @@ contextBridge.exposeInMainWorld("heynote", { ipcRenderer.on(WINDOW_CLOSE_EVENT, callback) }, + onOpenSettings(callback) { + ipcRenderer.on(OPEN_SETTINGS_EVENT, callback) + }, + buffer: { async load() { return await ipcRenderer.invoke("buffer-content:load") @@ -33,7 +38,17 @@ contextBridge.exposeInMainWorld("heynote", { async saveAndQuit(content) { return await ipcRenderer.invoke("buffer-content:saveAndQuit", content) }, - } + }, + + keymap: { + set(keymap) { + ipcRenderer.invoke("keymap:set", keymap); + }, + initial: CONFIG.get("keymap", "default"), + onKeymapChange(callback) { + ipcRenderer.on(KEYMAP_CHANGE_EVENT, (event, keymap) => callback(keymap)) + }, + }, }) diff --git a/src/components/App.vue b/src/components/App.vue index b9feee3..940faa4 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -2,12 +2,14 @@ import StatusBar from './StatusBar.vue' import Editor from './Editor.vue' import LanguageSelector from './LanguageSelector.vue' + import Settings from './settings/Settings.vue' export default { components: { Editor, StatusBar, LanguageSelector, + Settings, }, data() { @@ -21,6 +23,8 @@ systemTheme: 'system', development: window.location.href.indexOf("dev=1") !== -1, showLanguageSelector: false, + showSettings: false, + keymap: window.heynote.keymap.initial, } }, @@ -32,6 +36,12 @@ window.darkMode.onChange((theme) => { this.theme = theme }) + window.heynote.keymap.onKeymapChange((keymap) => { + this.keymap = keymap + }) + window.heynote.onOpenSettings(() => { + this.showSettings = true + }) }, beforeUnmount() { @@ -39,6 +49,14 @@ }, methods: { + openSettings() { + this.showSettings = true + }, + closeSettings() { + this.showSettings = false + this.$refs.editor.focus() + }, + toggleTheme() { let newTheme // when the "system" theme is used, make sure that the first click always results in amn actual theme change @@ -85,6 +103,7 @@ :theme="theme" :development="development" :debugSyntaxTree="false" + :keymap="keymap" class="editor" ref="editor" @openLanguageSelector="openLanguageSelector" @@ -106,6 +125,11 @@ @selectLanguage="onSelectLanguage" @close="closeLanguageSelector" /> + diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 6890201..f8f8d4e 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -3,11 +3,15 @@ import { syntaxTree } from "@codemirror/language" export default { - props: [ - "theme", - "development", - "debugSyntaxTree", - ], + props: { + theme: String, + development: Boolean, + debugSyntaxTree: Boolean, + keymap: { + type: String, + default: "default", + }, + }, data() { return { @@ -38,6 +42,7 @@ saveFunction: (content) => { window.heynote.buffer.save(content) }, + keymap: this.keymap, }) }) // set up window close handler that will save the buffer and quit @@ -69,6 +74,10 @@ theme(newTheme) { this.editor.setTheme(newTheme) }, + + keymap(keymap) { + this.editor.setKeymap(keymap) + } }, methods: { diff --git a/src/components/settings/Settings.vue b/src/components/settings/Settings.vue new file mode 100644 index 0000000..f428e41 --- /dev/null +++ b/src/components/settings/Settings.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/src/editor/copy-paste.js b/src/editor/copy-paste.js new file mode 100644 index 0000000..e82c243 --- /dev/null +++ b/src/editor/copy-paste.js @@ -0,0 +1,57 @@ +import { EditorState, EditorSelection } from "@codemirror/state" +import { EditorView } from "@codemirror/view" + +import { LANGUAGES } from './languages.js'; + + +const languageTokensMatcher = LANGUAGES.map(l => l.token).join("|") +const blockSeparatorRegex = new RegExp(`\\n∞∞∞(${languageTokensMatcher})(-a)?\\n`, "g") + + +function copiedRange(state) { + let content = [], ranges = [] + for (let range of state.selection.ranges) if (!range.empty) { + content.push(state.sliceDoc(range.from, range.to)) + ranges.push(range) + } + return { text: content.join(state.lineBreak), ranges } +} + + + + +export const heynoteCopyPaste = (editor) => { + let copy, cut + copy = cut = (event, view) => { + let { text, ranges } = copiedRange(view.state) + text = text.replaceAll(blockSeparatorRegex, "\n\n") + let data = event.clipboardData + if (data) { + event.preventDefault() + data.clearData() + data.setData("text/plain", text) + } + if (event.type == "cut" && !view.state.readOnly) { + view.dispatch({ + changes: ranges, + scrollIntoView: true, + userEvent: "delete.cut" + }) + } + // 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") { + 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, + })) + } + } + + return EditorView.domEventHandlers({ + copy, + cut, + }) +} diff --git a/src/editor/editor.js b/src/editor/editor.js index dfe50d8..0311a13 100644 --- a/src/editor/editor.js +++ b/src/editor/editor.js @@ -9,27 +9,39 @@ import { customSetup } from "./setup.js" import { heynoteLang } from "./lang-heynote/heynote.js" import { noteBlockExtension } from "./block/block.js" import { changeCurrentBlockLanguage } from "./block/commands.js" -import { heynoteKeymap } from "./keymap.js" +import { heynoteKeymap, emacsKeymap } from "./keymap.js" +import { heynoteCopyPaste } from "./copy-paste" import { languageDetection } from "./language-detection/autodetect.js" import { autoSaveContent } from "./save.js" export const LANGUAGE_SELECTOR_EVENT = "openLanguageSelector" +function getKeymapExtensions(editor, keymap) { + if (keymap === "emacs") { + return emacsKeymap(editor) + } else { + return heynoteKeymap(editor) + } +} + export class HeynoteEditor { - constructor({element, content, focus=true, theme="light", saveFunction=null}) { + constructor({element, content, focus=true, theme="light", saveFunction=null, keymap="default"}) { this.element = element - this.theme = new Compartment + this.themeCompartment = new Compartment + this.keymapCompartment = new Compartment + this.deselectOnCopy = keymap === "emacs" const state = EditorState.create({ doc: content || "", extensions: [ - heynoteKeymap(this), + this.keymapCompartment.of(getKeymapExtensions(this, keymap)), + heynoteCopyPaste(this), //minimalSetup, customSetup, - this.theme.of(theme === "dark" ? heynoteDark : heynoteLight), + this.themeCompartment.of(theme === "dark" ? heynoteDark : heynoteLight), heynoteBase, indentUnit.of(" "), EditorView.scrollMargins.of(f => { @@ -71,7 +83,14 @@ export class HeynoteEditor { setTheme(theme) { this.view.dispatch({ - effects: this.theme.reconfigure(theme === "dark" ? heynoteDark : heynoteLight), + effects: this.themeCompartment.reconfigure(theme === "dark" ? heynoteDark : heynoteLight), + }) + } + + setKeymap(keymap) { + this.deselectOnCopy = keymap === "emacs" + this.view.dispatch({ + effects: this.keymapCompartment.reconfigure(getKeymapExtensions(this, keymap)), }) } diff --git a/src/editor/keymap.js b/src/editor/keymap.js index 46369d6..5718bc5 100644 --- a/src/editor/keymap.js +++ b/src/editor/keymap.js @@ -1,54 +1,40 @@ -import { keymap } from "@codemirror/view" -import { indentWithTab, insertTab, indentLess, indentMore } from "@codemirror/commands" +import { EditorView, keymap } from "@codemirror/view" +import { EditorSelection } from "@codemirror/state" +import { indentWithTab, insertTab, indentLess, indentMore, undo, redo } from "@codemirror/commands" import { insertNewBlockAtCursor, addNewBlockAfterCurrent, moveLineUp, selectAll, gotoPreviousBlock, gotoNextBlock } from "./block/commands.js"; export function heynoteKeymap(editor) { return keymap.of([ - { - key: "Tab", + ["Tab", indentMore], + ["Shift-Tab", indentLess], + ["Mod-Enter", addNewBlockAfterCurrent], + ["Mod-Shift-Enter", insertNewBlockAtCursor], + ["Mod-a", selectAll], + ["Alt-ArrowUp", moveLineUp], + ["Mod-ArrowUp", gotoPreviousBlock], + ["Mod-ArrowDown", gotoNextBlock], + ["Mod-l", () => editor.openLanguageSelector()], + ].map(([key, run]) => { + return { + key, + run, preventDefault: true, - //run: insertTab, - run: indentMore, - }, - { - key: 'Shift-Tab', - preventDefault: true, - run: indentLess, - }, - { - key: "Mod-Enter", - preventDefault: true, - run: addNewBlockAfterCurrent, - }, - { - key: "Mod-Shift-Enter", - preventDefault: true, - run: insertNewBlockAtCursor, - }, - { - key: "Mod-a", - preventDefault: true, - run: selectAll, - }, - { - key: "Alt-ArrowUp", - preventDefault: true, - run: moveLineUp, - }, - { - key: "Mod-ArrowUp", - preventDefault: true, - run: gotoPreviousBlock, - }, - { - key: "Mod-ArrowDown", - preventDefault: true, - run: gotoNextBlock, - }, - { - key: "Mod-l", - preventDefault: true, - run: () => editor.openLanguageSelector(), - }, - ]) -} \ No newline at end of file + } + })) +} + +export function emacsKeymap(editor) { + return [ + heynoteKeymap(editor), + keymap.of([ + ["Ctrl-Shift--", undo], + ["Ctrl-.", redo], + ].map(([key, run]) => { + return { + key, + run, + preventDefault: true, + } + })), + ] +}