diff --git a/README.md b/README.md index 92214de..0539f64 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/electron/detect-platform.ts b/electron/detect-platform.ts index 1f677e0..6d54738 100644 --- a/electron/detect-platform.ts +++ b/electron/detect-platform.ts @@ -1,4 +1,4 @@ -const os = require('os'); +import os from 'os'; export const isDev = !!process.env.VITE_DEV_SERVER_URL diff --git a/electron/initial-content.ts b/electron/initial-content.ts index 4c4a8d8..c20ea51 100644 --- a/electron/initial-content.ts +++ b/electron/initial-content.ts @@ -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: [ diff --git a/electron/main/index.ts b/electron/main/index.ts index 121126b..b764c72 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -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() } diff --git a/shared-utils/key-helper.ts b/shared-utils/key-helper.ts new file mode 100644 index 0000000..c8260ca --- /dev/null +++ b/shared-utils/key-helper.ts @@ -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") +} \ No newline at end of file diff --git a/src/editor/annotation.js b/src/editor/annotation.js index e7f72f2..6b4e83c 100644 --- a/src/editor/annotation.js +++ b/src/editor/annotation.js @@ -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" diff --git a/src/editor/block/block.js b/src/editor/block/block.js index 8e3cc07..bb86ecb 100644 --- a/src/editor/block/block.js +++ b/src/editor/block/block.js @@ -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 => { diff --git a/src/editor/block/commands.js b/src/editor/block/commands.js index c52d5fa..7265b37 100644 --- a/src/editor/block/commands.js +++ b/src/editor/block/commands.js @@ -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({ diff --git a/src/editor/editor.js b/src/editor/editor.js index 2b21208..1ea0078 100644 --- a/src/editor/editor.js +++ b/src/editor/editor.js @@ -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() } diff --git a/src/editor/keymap.js b/src/editor/keymap.js index bf26410..05fc7a5 100644 --- a/src/editor/keymap.js +++ b/src/editor/keymap.js @@ -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], diff --git a/tests/block-creation.spec.js b/tests/block-creation.spec.js new file mode 100644 index 0000000..046ed01 --- /dev/null +++ b/tests/block-creation.spec.js @@ -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) +} + diff --git a/tests/test-utils.js b/tests/test-utils.js index d249170..9e841c4 100644 --- a/tests/test-utils.js +++ b/tests/test-utils.js @@ -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() diff --git a/tsconfig.json b/tsconfig.json index cbef20b..1dfdfd1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,7 @@ "noEmit": true, "allowJs": true }, - "include": ["src"], + "include": ["src"," shared-utils"], "references": [ { "path": "./tsconfig.node.json" } ] diff --git a/tsconfig.node.json b/tsconfig.node.json index ed1b586..9d3c6d6 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -6,5 +6,5 @@ "resolveJsonModule": true, "allowSyntheticDefaultImports": true }, - "include": ["vite.config.ts", "package.json", "electron"] + "include": ["vite.config.ts", "package.json", "electron", "shared-utils"] } diff --git a/vite.config.ts b/vite.config.ts index fc1a0db..2af59d5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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.