Replace block separators with "\n\n" when copying text.

Add Settings dialog.
Started implementing Emacs-like keymap.
This commit is contained in:
Jonatan Heyman 2023-01-20 15:33:26 +01:00
parent 6c1e89c5b0
commit 924fd4b226
12 changed files with 362 additions and 67 deletions

21
electron/config.js Normal file
View File

@ -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})

View File

@ -1 +1,3 @@
export const WINDOW_CLOSE_EVENT = "window-close"
export const KEYMAP_CHANGE_EVENT = "keymap-change"
export const OPEN_SETTINGS_EVENT = "open-settings"

27
electron/keymap.js Normal file
View File

@ -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()
}
}
}

View File

@ -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
@ -115,6 +117,11 @@ 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())
@ -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)
})

View File

@ -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' },

View File

@ -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))
},
},
})

View File

@ -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"
/>
<Settings
v-if="showSettings"
:keymap="keymap"
@closeSettings="closeSettings"
/>
</div>
</div>
</template>

View File

@ -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: {

View File

@ -0,0 +1,113 @@
<script>
export default {
props: {
keymap: String,
},
mounted() {
window.addEventListener("keydown", this.onKeyDown);
this.$refs.keymapSelector.focus()
},
beforeUnmount() {
window.removeEventListener("keydown", this.onKeyDown);
},
methods: {
onKeymapChange(event) {
window.heynote.keymap.set(event.target.value)
},
onKeyDown(event) {
if (event.key === "Escape") {
this.$emit("closeSettings")
}
},
}
}
</script>
<template>
<div class="settings">
<div class="dialog">
<div>
<h1>Settings</h1>
<div class="entry">
<h2>Keymap:</h2>
<select ref="keymapSelector" @change="onKeymapChange">
<option :selected="keymap==='default'" value="default">Default</option>
<option :selected="keymap==='emacs'" value="emacs">Emacs</option>
</select>
</div>
</div>
<button
@click="$emit('closeSettings')"
class="close"
>Close</button>
</div>
<div class="shader"></div>
</div>
</template>
<style lang="sass">
=dark-mode()
@media (prefers-color-scheme: dark)
@content
.settings
position: fixed
top: 0
left: 0
bottom: 0
right: 0
.shader
z-index: 1
position: absolute
top: 0
left: 0
bottom: 0
right: 0
background: rgba(0, 0, 0, 0.5)
.dialog
box-sizing: border-box
z-index: 2
position: absolute
left: 50%
top: 50%
transform: translate(-50%, -50%)
width: 100%
height: 100%
max-width: 700px
max-height: 500px
border-radius: 5px
padding: 40px
background: #fff
color: #333
&:active, &:selected, &:focus, &:focus-visible
border: none
outline: none
+dark-mode
background: #333
color: #eee
h1
font-size: 20px
font-weight: 600
margin-bottom: 20px
.entry
margin-bottom: 20px
h2
font-weight: 600
margin-bottom: 10px
select
width: 200px
&:focus
outline: none
.close
height: 32px
position: absolute
bottom: 30px
right: 30px
</style>

57
src/editor/copy-paste.js Normal file
View File

@ -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,
})
}

View File

@ -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)),
})
}

View File

@ -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(),
},
])
}
}))
}
export function emacsKeymap(editor) {
return [
heynoteKeymap(editor),
keymap.of([
["Ctrl-Shift--", undo],
["Ctrl-.", redo],
].map(([key, run]) => {
return {
key,
run,
preventDefault: true,
}
})),
]
}