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** **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 ⌘ + 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 ⌘ + L Change block language
⌘ + Down Goto next block ⌘ + Down Goto next block
⌘ + Up Goto previous 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** **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 + 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 + L Change block language
Ctrl + Down Goto next block Ctrl + Down Goto next block
Ctrl + Up Goto previous 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 export const isDev = !!process.env.VITE_DEV_SERVER_URL

View File

@ -1,31 +1,13 @@
import { isLinux, isMac, isWindows } from "./detect-platform.js" import os from "os";
import { keyHelpStr } from "../shared-utils/key-helper";
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")
export const eraseInitialContent = !!process.env.ERASE_INITIAL_CONTENT
export const initialContent = ` export const initialContent = `
text markdown
Welcome to Heynote! 👋 Welcome to Heynote! 👋
${keyHelpStr} ${keyHelpStr(os.platform())}
math math
This is a Math block. Here, rows are evaluated as math expressions. This is a Math block. Here, rows are evaluated as math expressions.
@ -54,13 +36,13 @@ export const initialDevContent = initialContent + `
def my_func(): def my_func():
print("hejsan") print("hejsan")
javascript-a
import {basicSetup} from "codemirror" import {basicSetup} from "codemirror"
import {EditorView, keymap} from "@codemirror/view" import {EditorView, keymap} from "@codemirror/view"
import {javascript} from "@codemirror/lang-javascript" import {javascript} from "@codemirror/lang-javascript"
import {indentWithTab, insertTab, indentLess, indentMore} from "@codemirror/commands" import {indentWithTab, insertTab, indentLess, indentMore} from "@codemirror/commands"
import {nord} from "./nord.mjs" import {nord} from "./nord.mjs"
javascript-a
let editor = new EditorView({ let editor = new EditorView({
//extensions: [basicSetup, javascript()], //extensions: [basicSetup, javascript()],
extensions: [ extensions: [

View File

@ -1,10 +1,9 @@
import { app, BrowserWindow, Tray, shell, ipcMain, Menu, nativeTheme, globalShortcut, nativeImage } from 'electron' import { app, BrowserWindow, Tray, shell, ipcMain, Menu, nativeTheme, globalShortcut, nativeImage } from 'electron'
import { release } from 'node:os' import { release } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import * as jetpack from "fs-jetpack";
import { menu, getTrayMenu } from './menu' 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 { WINDOW_CLOSE_EVENT, SETTINGS_CHANGE_EVENT } from '../constants';
import CONFIG from "../config" import CONFIG from "../config"
import { onBeforeInputEvent } from "../keymap" import { onBeforeInputEvent } from "../keymap"
@ -139,11 +138,11 @@ async function createWindow() {
}) })
// Make all links open with the browser, not with the application // 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:')) { if (url.startsWith('https:') || url.startsWith('http:')) {
shell.openExternal(url) shell.openExternal(url)
} }
return { action: 'deny' } return {action: 'deny'}
}) })
fixElectronCors(win) fixElectronCors(win)
@ -260,10 +259,10 @@ const buffer = new Buffer({
}) })
ipcMain.handle('buffer-content:load', async () => { ipcMain.handle('buffer-content:load', async () => {
if (buffer.exists()) { if (buffer.exists() && !(eraseInitialContent && isDev)) {
return await buffer.load() return await buffer.load()
} else { } else {
return isDev? initialDevContent : initialContent return isDev ? initialDevContent : initialContent
} }
}); });
@ -271,7 +270,7 @@ async function save(content) {
return await buffer.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) return await save(content)
}); });

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 LANGUAGE_CHANGE = "heynote-change"
export const CURRENCIES_LOADED = "heynote-currencies-loaded" export const CURRENCIES_LOADED = "heynote-currencies-loaded"
export const SET_CONTENT = "heynote-set-content" 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) 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) { export function getNoteBlockFromPos(state, pos) {
return state.facet(blockState).find(block => block.range.from <= pos && block.range.to >= 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 this.isFirst = isFirst
} }
eq(other) { eq(other) {
//return other.checked == this.checked return this.isFirst === other.isFirst
return true
} }
toDOM() { toDOM() {
let wrap = document.createElement("div") 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 * Transaction filter to prevent the selection from being before the first block
*/ */
const preventSelectionBeforeFirstBlock = EditorState.transactionFilter.of((tr) => { const preventSelectionBeforeFirstBlock = EditorState.transactionFilter.of((tr) => {
if (!firstBlockDelimiterSize) { if (!firstBlockDelimiterSize || tr.annotations.some(a => a.type === heynoteEvent)) {
return tr return tr
} }
tr?.selection?.ranges.forEach(range => { tr?.selection?.ranges.forEach(range => {

View File

@ -1,6 +1,6 @@
import { EditorSelection } from "@codemirror/state" import { EditorSelection } from "@codemirror/state"
import { heynoteEvent, LANGUAGE_CHANGE, CURRENCIES_LOADED } from "../annotation.js"; import { heynoteEvent, LANGUAGE_CHANGE, CURRENCIES_LOADED, ADD_NEW_BLOCK } from "../annotation.js";
import { blockState, getActiveNoteBlock, getNoteBlockFromPos } from "./block" import { blockState, getActiveNoteBlock, getFirstNoteBlock, getLastNoteBlock, getNoteBlockFromPos } from "./block"
import { moveLineDown, moveLineUp } from "./move-lines.js"; import { moveLineDown, moveLineUp } from "./move-lines.js";
import { selectAll } from "./select-all.js"; import { selectAll } from "./select-all.js";
@ -28,9 +28,32 @@ export const insertNewBlockAtCursor = ({ state, dispatch }) => {
return true; 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 }) => { export const addNewBlockAfterCurrent = ({ state, dispatch }) => {
if (state.readOnly) if (state.readOnly)
return false return false
const block = getActiveNoteBlock(state) const block = getActiveNoteBlock(state)
const delimText = "\n∞∞∞text-a\n" const delimText = "\n∞∞∞text-a\n"
@ -47,10 +70,50 @@ export const addNewBlockAfterCurrent = ({ state, dispatch }) => {
return true; 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;
}
export function changeLanguageTo(state, dispatch, block, language, auto) { export function changeLanguageTo(state, dispatch, block, language, auto) {
if (state.readOnly) if (state.readOnly)
return false 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)) { if (state.doc.sliceString(block.delimiter.from, block.delimiter.to).match(delimRegex)) {
//console.log("changing language to", language) //console.log("changing language to", language)
dispatch(state.update({ dispatch(state.update({

View File

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

View File

@ -6,7 +6,8 @@ import {
import { import {
insertNewBlockAtCursor, insertNewBlockAtCursor,
addNewBlockAfterCurrent, addNewBlockBeforeCurrent, addNewBlockAfterCurrent,
addNewBlockBeforeFirst, addNewBlockAfterLast,
moveLineUp, moveLineDown, moveLineUp, moveLineDown,
selectAll, selectAll,
gotoPreviousBlock, gotoNextBlock, gotoPreviousBlock, gotoNextBlock,
@ -38,8 +39,11 @@ export function heynoteKeymap(editor) {
return keymapFromSpec([ return keymapFromSpec([
["Tab", indentMore], ["Tab", indentMore],
["Shift-Tab", indentLess], ["Shift-Tab", indentLess],
["Alt-Shift-Enter", addNewBlockBeforeFirst],
["Mod-Shift-Enter", addNewBlockAfterLast],
["Alt-Enter", addNewBlockBeforeCurrent],
["Mod-Enter", addNewBlockAfterCurrent], ["Mod-Enter", addNewBlockAfterCurrent],
["Mod-Shift-Enter", insertNewBlockAtCursor], ["Mod-Alt-Enter", insertNewBlockAtCursor],
["Mod-a", selectAll], ["Mod-a", selectAll],
["Alt-ArrowUp", moveLineUp], ["Alt-ArrowUp", moveLineUp],
["Alt-ArrowDown", moveLineDown], ["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) 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) { async getBlockContent(blockIndex) {
const blocks = await this.getBlocks() const blocks = await this.getBlocks()
const content = await this.getContent() const content = await this.getContent()

View File

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

View File

@ -6,5 +6,5 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"allowSyntheticDefaultImports": 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 pkg from './package.json'
import path from 'path' import path from 'path'
import { keyHelpStr } from "./shared-utils/key-helper";
rmSync('dist-electron', { recursive: true, force: true }) rmSync('dist-electron', { recursive: true, force: true })
const isDevelopment = process.env.NODE_ENV === "development" || !!process.env.VSCODE_DEBUG const isDevelopment = process.env.NODE_ENV === "development" || !!process.env.VSCODE_DEBUG
const isProduction = process.env.NODE_ENV === "production" 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/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
resolve: { resolve: {
@ -23,6 +47,7 @@ export default defineConfig({
plugins: [ plugins: [
vue(), vue(),
updateReadmeKeybinds(),
electron([ electron([
{ {
// Main-Process entry file of the Electron App. // Main-Process entry file of the Electron App.