Add key bindings for inserting new blocks at the end/top of the buffer, as well as before the current block (#85)

* Add functionality to insert new block after the last block

- Update key bindings in `initial-content.ts` to include `Alt + Enter` for adding a new block after the last block.
- Implement `getLastNoteBlock` function in `block.js` to retrieve the last block in the note.
- Add `addNewBlockAfterLast` command in `commands.js` to handle the insertion of a new block after the last one.
- Integrate `addNewBlockAfterLast` command into the keymap in `keymap.js`.

* Add block insertion before/after current, before first and after last. Also, tests.

- Added `getFirstNoteBlock` in `block.js` for accessing the first text block.
- Implemented new functions in `commands.js` like `addNewBlockBeforeCurrent` and `addNewBlockBeforeFirst`.
- Updated `keymap.js` with new key bindings to facilitate block creation.
- Introduced `block-creation.spec.js` for testing the new block manipulation features.

* Fix visual bug when inserting new block at the top

* Update help text and Readme

* Fix wrong cursor position after inserting new blocks at the top of the buffer, when the previous first block's delimiter is long (e.g. Markdown)

* Make RegEx more generic

* Fix import

* Auto-generate the README.md and initial-content documentation

- Add a documentation generator
- Add an option to force the initial content to be erased with an env variable

* Add more specific tests

* Fix Mod key on Mac in test

---------

Co-authored-by: Jonatan Heyman <jonatan@heyman.info>
This commit is contained in:
Florian Labarre 2024-01-04 16:11:26 +01:00 committed by GitHub
parent b80230454d
commit d0d8f872a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 290 additions and 54 deletions

View File

@ -97,8 +97,11 @@ I can totally see the usefulness of such a feature, and it's definitely somethin
**On Mac**
```
⌥ + Shift + Enter Add new block at the start of the buffer
⌘ + Shift + Enter Add new block at the end of the buffer
⌥ + Enter Add new block before the current block
⌘ + Enter Add new block below the current block
⌘ + Shift + Enter Split the current block at cursor position
⌘ + ⌥ + Enter Split the current block at cursor position
⌘ + L Change block language
⌘ + Down Goto next block
⌘ + Up Goto previous block
@ -110,8 +113,11 @@ I can totally see the usefulness of such a feature, and it's definitely somethin
**On Windows and Linux**
```
Alt + Shift + Enter Add new block at the start of the buffer
Ctrl + Shift + Enter Add new block at the end of the buffer
Alt + Enter Add new block before the current block
Ctrl + Enter Add new block below the current block
Ctrl + Shift + Enter Split the current block at cursor position
Ctrl + Alt + Enter Split the current block at cursor position
Ctrl + L Change block language
Ctrl + Down Goto next block
Ctrl + Up Goto previous block

View File

@ -1,4 +1,4 @@
const os = require('os');
import os from 'os';
export const isDev = !!process.env.VITE_DEV_SERVER_URL

View File

@ -1,31 +1,13 @@
import { isLinux, isMac, isWindows } from "./detect-platform.js"
const modChar = isMac ? "⌘" : "Ctrl"
const altChar = isMac ? "⌥" : "Alt"
const keyHelp = [
[`${modChar} + Enter`, "Add new block below the current block"],
[`${modChar} + Shift + Enter`, "Split the current block at cursor position"],
[`${modChar} + L`, "Change block language"],
[`${modChar} + Down`, "Goto next block"],
[`${modChar} + Up`, "Goto previous block"],
[`${modChar} + A`, "Select all text in a note block. Press again to select the whole buffer"],
[`${modChar} + ${altChar} + Up/Down`, "Add additional cursor above/below"],
[`${altChar} + Shift + F`, "Format block content (works for JSON, JavaScript, HTML, CSS and Markdown)"],
]
if (isWindows || isLinux) {
keyHelp.push([altChar, "Show menu"])
}
const keyMaxLength = keyHelp.map(([key, help]) => key.length).reduce((a, b) => Math.max(a, b))
const keyHelpStr = keyHelp.map(([key, help]) => `${key.padEnd(keyMaxLength)} ${help}`).join("\n")
import os from "os";
import { keyHelpStr } from "../shared-utils/key-helper";
export const eraseInitialContent = !!process.env.ERASE_INITIAL_CONTENT
export const initialContent = `
text
markdown
Welcome to Heynote! 👋
${keyHelpStr}
${keyHelpStr(os.platform())}
math
This is a Math block. Here, rows are evaluated as math expressions.
@ -54,13 +36,13 @@ export const initialDevContent = initialContent + `
def my_func():
print("hejsan")
javascript-a
import {basicSetup} from "codemirror"
import {EditorView, keymap} from "@codemirror/view"
import {javascript} from "@codemirror/lang-javascript"
import {indentWithTab, insertTab, indentLess, indentMore} from "@codemirror/commands"
import {nord} from "./nord.mjs"
javascript-a
let editor = new EditorView({
//extensions: [basicSetup, javascript()],
extensions: [

View File

@ -1,10 +1,9 @@
import { app, BrowserWindow, Tray, shell, ipcMain, Menu, nativeTheme, globalShortcut, nativeImage } from 'electron'
import { release } from 'node:os'
import { join } from 'node:path'
import * as jetpack from "fs-jetpack";
import { menu, getTrayMenu } from './menu'
import { initialContent, initialDevContent } from '../initial-content'
import { eraseInitialContent, initialContent, initialDevContent } from '../initial-content'
import { WINDOW_CLOSE_EVENT, SETTINGS_CHANGE_EVENT } from '../constants';
import CONFIG from "../config"
import { onBeforeInputEvent } from "../keymap"
@ -127,7 +126,7 @@ 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})
@ -139,11 +138,11 @@ async function createWindow() {
})
// Make all links open with the browser, not with the application
win.webContents.setWindowOpenHandler(({ url }) => {
win.webContents.setWindowOpenHandler(({url}) => {
if (url.startsWith('https:') || url.startsWith('http:')) {
shell.openExternal(url)
}
return { action: 'deny' }
return {action: 'deny'}
})
fixElectronCors(win)
@ -253,17 +252,17 @@ ipcMain.handle('dark-mode:get', () => nativeTheme.themeSource)
const buffer = new Buffer({
filePath: getBufferFilePath(),
filePath: getBufferFilePath(),
onChange: (eventData) => {
win?.webContents.send("buffer-content:change", eventData)
},
})
ipcMain.handle('buffer-content:load', async () => {
if (buffer.exists()) {
if (buffer.exists() && !(eraseInitialContent && isDev)) {
return await buffer.load()
} else {
return isDev? initialDevContent : initialContent
return isDev ? initialDevContent : initialContent
}
});
@ -271,7 +270,7 @@ async function save(content) {
return await buffer.save(content)
}
ipcMain.handle('buffer-content:save', async (event, content) =>  {
ipcMain.handle('buffer-content:save', async (event, content) => {
return await save(content)
});
@ -281,7 +280,7 @@ ipcMain.handle('buffer-content:saveAndQuit', async (event, content) => {
app.quit()
})
ipcMain.handle('settings:set', (event, settings) => {
ipcMain.handle('settings:set', (event, settings) => {
if (settings.keymap !== CONFIG.get("settings.keymap")) {
currentKeymap = settings.keymap
}
@ -291,7 +290,7 @@ ipcMain.handle('settings:set', (event, settings) => {
CONFIG.set("settings", settings)
win?.webContents.send(SETTINGS_CHANGE_EVENT, settings)
if (globalHotkeyChanged) {
registerGlobalHotkey()
}

View File

@ -0,0 +1,25 @@
export const keyHelpStr = (platform: string) => {
const modChar = platform === "darwin" ? "⌘" : "Ctrl"
const altChar = platform === "darwin" ? "⌥" : "Alt"
const keyHelp = [
[`${altChar} + Shift + Enter`, "Add new block at the start of the buffer"],
[`${modChar} + Shift + Enter`, "Add new block at the end of the buffer"],
[`${altChar} + Enter`, "Add new block before the current block"],
[`${modChar} + Enter`, "Add new block below the current block"],
[`${modChar} + ${altChar} + Enter`, "Split the current block at cursor position"],
[`${modChar} + L`, "Change block language"],
[`${modChar} + Down`, "Goto next block"],
[`${modChar} + Up`, "Goto previous block"],
[`${modChar} + A`, "Select all text in a note block. Press again to select the whole buffer"],
[`${modChar} + ${altChar} + Up/Down`, "Add additional cursor above/below"],
[`${altChar} + Shift + F`, "Format block content (works for JSON, JavaScript, HTML, CSS and Markdown)"],
]
if (platform === "win32" || platform === "linux") {
keyHelp.push([altChar, "Show menu"])
}
const keyMaxLength = keyHelp.map(([key]) => key.length).reduce((a, b) => Math.max(a, b))
return keyHelp.map(([key, help]) => `${key.padEnd(keyMaxLength)} ${help}`).join("\n")
}

View File

@ -4,4 +4,4 @@ export const heynoteEvent = Annotation.define()
export const LANGUAGE_CHANGE = "heynote-change"
export const CURRENCIES_LOADED = "heynote-currencies-loaded"
export const SET_CONTENT = "heynote-set-content"
export const ADD_NEW_BLOCK = "heynote-add-new-block"

View File

@ -75,6 +75,14 @@ export function getActiveNoteBlock(state) {
return state.facet(blockState).find(block => block.range.from <= range.head && block.range.to >= range.head)
}
export function getFirstNoteBlock(state) {
return state.facet(blockState)[0]
}
export function getLastNoteBlock(state) {
return state.facet(blockState)[state.facet(blockState).length - 1]
}
export function getNoteBlockFromPos(state, pos) {
return state.facet(blockState).find(block => block.range.from <= pos && block.range.to >= pos)
}
@ -86,8 +94,7 @@ class NoteBlockStart extends WidgetType {
this.isFirst = isFirst
}
eq(other) {
//return other.checked == this.checked
return true
return this.isFirst === other.isFirst
}
toDOM() {
let wrap = document.createElement("div")
@ -249,7 +256,7 @@ const preventFirstBlockFromBeingDeleted = EditorState.changeFilter.of((tr) => {
* Transaction filter to prevent the selection from being before the first block
*/
const preventSelectionBeforeFirstBlock = EditorState.transactionFilter.of((tr) => {
if (!firstBlockDelimiterSize) {
if (!firstBlockDelimiterSize || tr.annotations.some(a => a.type === heynoteEvent)) {
return tr
}
tr?.selection?.ranges.forEach(range => {

View File

@ -1,6 +1,6 @@
import { EditorSelection } from "@codemirror/state"
import { heynoteEvent, LANGUAGE_CHANGE, CURRENCIES_LOADED } from "../annotation.js";
import { blockState, getActiveNoteBlock, getNoteBlockFromPos } from "./block"
import { heynoteEvent, LANGUAGE_CHANGE, CURRENCIES_LOADED, ADD_NEW_BLOCK } from "../annotation.js";
import { blockState, getActiveNoteBlock, getFirstNoteBlock, getLastNoteBlock, getNoteBlockFromPos } from "./block"
import { moveLineDown, moveLineUp } from "./move-lines.js";
import { selectAll } from "./select-all.js";
@ -10,7 +10,7 @@ export { moveLineDown, moveLineUp, selectAll }
export const insertNewBlockAtCursor = ({ state, dispatch }) => {
if (state.readOnly)
return false
const currentBlock = getActiveNoteBlock(state)
let delimText;
if (currentBlock) {
@ -18,9 +18,9 @@ export const insertNewBlockAtCursor = ({ state, dispatch }) => {
} else {
delimText = "\n∞∞∞text-a\n"
}
dispatch(state.replaceSelection(delimText),
dispatch(state.replaceSelection(delimText),
{
scrollIntoView: true,
scrollIntoView: true,
userEvent: "input",
}
)
@ -28,9 +28,32 @@ export const insertNewBlockAtCursor = ({ state, dispatch }) => {
return true;
}
export const addNewBlockBeforeCurrent = ({ state, dispatch }) => {
console.log("addNewBlockBeforeCurrent")
if (state.readOnly)
return false
const block = getActiveNoteBlock(state)
const delimText = "\n∞∞∞text-a\n"
dispatch(state.update({
changes: {
from: block.delimiter.from,
insert: delimText,
},
selection: EditorSelection.cursor(block.delimiter.from + delimText.length),
annotations: [heynoteEvent.of(ADD_NEW_BLOCK)],
}, {
scrollIntoView: true,
userEvent: "input",
}))
return true;
}
export const addNewBlockAfterCurrent = ({ state, dispatch }) => {
if (state.readOnly)
return false
const block = getActiveNoteBlock(state)
const delimText = "\n∞∞∞text-a\n"
@ -41,7 +64,47 @@ export const addNewBlockAfterCurrent = ({ state, dispatch }) => {
},
selection: EditorSelection.cursor(block.content.to + delimText.length)
}, {
scrollIntoView: true,
scrollIntoView: true,
userEvent: "input",
}))
return true;
}
export const addNewBlockBeforeFirst = ({ state, dispatch }) => {
if (state.readOnly)
return false
const block = getFirstNoteBlock(state)
const delimText = "\n∞∞∞text-a\n"
dispatch(state.update({
changes: {
from: block.delimiter.from,
insert: delimText,
},
selection: EditorSelection.cursor(delimText.length),
annotations: [heynoteEvent.of(ADD_NEW_BLOCK)],
}, {
scrollIntoView: true,
userEvent: "input",
}))
return true;
}
export const addNewBlockAfterLast = ({ state, dispatch }) => {
if (state.readOnly)
return false
const block = getLastNoteBlock(state)
const delimText = "\n∞∞∞text-a\n"
dispatch(state.update({
changes: {
from: block.content.to,
insert: delimText,
},
selection: EditorSelection.cursor(block.content.to + delimText.length)
}, {
scrollIntoView: true,
userEvent: "input",
}))
return true;
@ -50,7 +113,7 @@ export const addNewBlockAfterCurrent = ({ state, dispatch }) => {
export function changeLanguageTo(state, dispatch, block, language, auto) {
if (state.readOnly)
return false
const delimRegex = /^\n∞∞∞[a-z]{0,16}(-a)?\n/g
const delimRegex = /^\n∞∞∞[a-z]+?(-a)?\n/g
if (state.doc.sliceString(block.delimiter.from, block.delimiter.to).match(delimRegex)) {
//console.log("changing language to", language)
dispatch(state.update({

View File

@ -139,6 +139,10 @@ export class HeynoteEditor {
return this.view.state.facet(blockState)
}
getCursorPosition() {
return this.view.state.selection.main.head
}
focus() {
this.view.focus()
}

View File

@ -6,7 +6,8 @@ import {
import {
insertNewBlockAtCursor,
addNewBlockAfterCurrent,
addNewBlockBeforeCurrent, addNewBlockAfterCurrent,
addNewBlockBeforeFirst, addNewBlockAfterLast,
moveLineUp, moveLineDown,
selectAll,
gotoPreviousBlock, gotoNextBlock,
@ -38,8 +39,11 @@ export function heynoteKeymap(editor) {
return keymapFromSpec([
["Tab", indentMore],
["Shift-Tab", indentLess],
["Alt-Shift-Enter", addNewBlockBeforeFirst],
["Mod-Shift-Enter", addNewBlockAfterLast],
["Alt-Enter", addNewBlockBeforeCurrent],
["Mod-Enter", addNewBlockAfterCurrent],
["Mod-Shift-Enter", insertNewBlockAtCursor],
["Mod-Alt-Enter", insertNewBlockAtCursor],
["Mod-a", selectAll],
["Alt-ArrowUp", moveLineUp],
["Alt-ArrowDown", moveLineDown],

View File

@ -0,0 +1,117 @@
import {expect, test} from "@playwright/test";
import {HeynotePage} from "./test-utils.js";
let heynotePage
test.beforeEach(async ({page}) => {
console.log("beforeEach")
heynotePage = new HeynotePage(page)
await heynotePage.goto()
expect((await heynotePage.getBlocks()).length).toBe(1)
heynotePage.setContent(`
text
Block A
text
Block B
text
Block C`)
await page.waitForTimeout(100);
// check that blocks are created
expect((await heynotePage.getBlocks()).length).toBe(3)
// check that visual block layers are created
await expect(page.locator("css=.heynote-blocks-layer > div")).toHaveCount(3)
});
/* from A */
test("create block before current (A)", async ({page}) => {
// select the first block
await page.locator("body").press("ArrowUp")
await page.locator("body").press("ArrowUp")
await runTest(page, "Alt+Enter", ['D', 'A', 'B', 'C'])
})
test("create block after current (A)", async ({page}) => {
// select the first block
await page.locator("body").press("ArrowUp")
await page.locator("body").press("ArrowUp")
await runTest(page, "Mod+Enter", ['A', 'D', 'B', 'C'])
})
/* from B */
test("create block before current (B)", async ({page}) => {
// select the second block
await page.locator("body").press("ArrowUp")
await runTest(page, "Alt+Enter", ['A', 'D', 'B', 'C'])
})
test("create block after current (B)", async ({page}) => {
// select the second block
await page.locator("body").press("ArrowUp")
await runTest(page, "Mod+Enter", ['A', 'B', 'D', 'C'])
})
/* from C */
test("create block before current (C)", async ({page}) => {
await runTest(page, "Alt+Enter", ['A', 'B', 'D', 'C'])
})
test("create block after current (C)", async ({page}) => {
await runTest(page, "Mod+Enter", ['A', 'B', 'C', 'D'])
})
test("create block before first", async ({page}) => {
await runTest(page, "Alt+Shift+Enter", ['D', 'A', 'B', 'C'])
})
test("create block after last", async ({page}) => {
for (let i = 0; i < 3; i++) {
await page.locator("body").press("ArrowUp")
}
await runTest(page, "Mod+Shift+Enter", ['A', 'B', 'C', 'D'])
})
test("create block before Markdown block", async ({page}) => {
await heynotePage.setContent(`
markdown
# Markdown!
`)
await page.locator("body").press("Alt+Enter")
await page.waitForTimeout(100);
expect(await heynotePage.getCursorPosition()).toBe(11)
})
test("create block before first Markdown block", async ({page}) => {
await heynotePage.setContent(`
markdown
# Markdown!
text
`)
for (let i = 0; i < 5; i++) {
await page.locator("body").press("ArrowDown")
}
await page.locator("body").press("Alt+Shift+Enter")
await page.waitForTimeout(100);
expect(await heynotePage.getCursorPosition()).toBe(11)
})
const runTest = async (page, key, expectedBlocks) => {
// create a new block
await page.locator("body").press(key.replace("Mod", heynotePage.isMac ? "Meta" : "Control"))
await page.waitForTimeout(100);
await page.locator("body").pressSequentially("Block D")
// check that blocks are created
expect((await heynotePage.getBlocks()).length).toBe(4)
// check that the content of each block is correct
for (const expectedBlock of expectedBlocks) {
const index = expectedBlocks.indexOf(expectedBlock);
expect(await heynotePage.getBlockContent(index)).toBe(`Block ${expectedBlock}`)
}
// check that only one block delimiter widget has the class first
await expect(await page.locator("css=.heynote-block-start.first")).toHaveCount(1)
}

View File

@ -35,6 +35,10 @@ export class HeynotePage {
await this.page.evaluate((content) => window._heynote_editor.setContent(content), content)
}
async getCursorPosition() {
return await this.page.evaluate(() => window._heynote_editor.getCursorPosition())
}
async getBlockContent(blockIndex) {
const blocks = await this.getBlocks()
const content = await this.getContent()

View File

@ -14,7 +14,7 @@
"noEmit": true,
"allowJs": true
},
"include": ["src"],
"include": ["src"," shared-utils"],
"references": [
{ "path": "./tsconfig.node.json" }
]

View File

@ -6,5 +6,5 @@
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts", "package.json", "electron"]
"include": ["vite.config.ts", "package.json", "electron", "shared-utils"]
}

View File

@ -8,11 +8,35 @@ import license from 'rollup-plugin-license'
import pkg from './package.json'
import path from 'path'
import { keyHelpStr } from "./shared-utils/key-helper";
rmSync('dist-electron', { recursive: true, force: true })
const isDevelopment = process.env.NODE_ENV === "development" || !!process.env.VSCODE_DEBUG
const isProduction = process.env.NODE_ENV === "production"
const updateReadmeKeybinds = async () => {
const fs = require('fs')
const path = require('path')
const readmePath = path.resolve(__dirname, 'README.md')
let readme = fs.readFileSync(readmePath, 'utf-8')
const keybindsRegex = /^(### What are the default keyboard shortcuts\?\s*).*?^(```\s+#)/gms
const shortcuts = `$1**On Mac**
\`\`\`
${keyHelpStr('darwin')}
\`\`\`
**On Windows and Linux**
\`\`\`
${keyHelpStr('win32')}
$2`
readme = readme.replace(keybindsRegex, shortcuts)
fs.writeFileSync(readmePath, readme)
}
// https://vitejs.dev/config/
export default defineConfig({
resolve: {
@ -23,6 +47,7 @@ export default defineConfig({
plugins: [
vue(),
updateReadmeKeybinds(),
electron([
{
// Main-Process entry file of the Electron App.