diff --git a/docs/changelog.md b/docs/changelog.md index 090c4fa..a6bad58 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -9,6 +9,7 @@ Here are the most notable changes in each release. For a more detailed list of c - Added support for custom key bindings. See [the documentation](https://heynote.com/docs/#user-content-custom-key-bindings) for more info. - Added a "command palette" that can be accessed by pressing `Ctrl/Cmd+Shift+P`, or just typing `>` in the buffer selector. The command palette allows you to discover all available commands in the app, and to quickly execute them. - Added support for configuring the tab size. +- Added functionality for moving blocks up and down. Default key bindings are `Ctrl/Cmd+Alt+Shift+Up` and `Ctrl/Cmd+Alt+Shift+Down`. ### Other changes diff --git a/src/editor/annotation.js b/src/editor/annotation.js index 5e46be7..ee8c07e 100644 --- a/src/editor/annotation.js +++ b/src/editor/annotation.js @@ -5,6 +5,7 @@ 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" +export const MOVE_BLOCK = "heynote-move-block" export const DELETE_BLOCK = "heynote-delete-block" export const CURSOR_CHANGE = "heynote-cursor-change" export const APPEND_BLOCK = "heynote-append-block" diff --git a/src/editor/block/commands.js b/src/editor/block/commands.js index 0126356..9fa9212 100644 --- a/src/editor/block/commands.js +++ b/src/editor/block/commands.js @@ -1,6 +1,6 @@ import { EditorSelection, Transaction } from "@codemirror/state" -import { heynoteEvent, LANGUAGE_CHANGE, CURRENCIES_LOADED, ADD_NEW_BLOCK, DELETE_BLOCK } from "../annotation.js"; +import { heynoteEvent, LANGUAGE_CHANGE, CURRENCIES_LOADED, ADD_NEW_BLOCK, MOVE_BLOCK, DELETE_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"; @@ -322,6 +322,62 @@ export function triggerCurrenciesLoaded(state, dispatch) { })) } +export function moveCurrentBlockUp({state, dispatch}) { + return moveCurrentBlock(state, dispatch, true) +} + +export function moveCurrentBlockDown({state, dispatch}) { + return moveCurrentBlock(state, dispatch, false) +} + +function moveCurrentBlock(state, dispatch, up) { + if (state.readOnly) { + return false + } + + const blocks = state.facet(blockState) + const currentBlock = getActiveNoteBlock(state) + const blockIndex = blocks.indexOf(currentBlock) + if ((up && blockIndex === 0) || (!up && blockIndex === blocks.length - 1)) { + return false + } + + const dir = up ? -1 : 1 + const neighborBlock = blocks[blockIndex + dir] + + const currentBlockContent = state.sliceDoc(currentBlock.delimiter.from, currentBlock.content.to) + const neighborBlockContent = state.sliceDoc(neighborBlock.delimiter.from, neighborBlock.content.to) + const newContent = up ? currentBlockContent + neighborBlockContent : neighborBlockContent + currentBlockContent + + const selectionRange = state.selection.asSingle().ranges[0] + let newSelectionRange + if (up) { + newSelectionRange = EditorSelection.range( + selectionRange.anchor - currentBlock.delimiter.from + neighborBlock.delimiter.from, + selectionRange.head - currentBlock.delimiter.from + neighborBlock.delimiter.from, + ) + } else { + newSelectionRange = EditorSelection.range( + selectionRange.anchor + neighborBlock.content.to - neighborBlock.delimiter.from, + selectionRange.head + neighborBlock.content.to - neighborBlock.delimiter.from, + ) + } + + dispatch(state.update({ + changes: { + from: up ? neighborBlock.delimiter.from : currentBlock.delimiter.from, + to: up ? currentBlock.content.to : neighborBlock.content.to, + insert: newContent, + }, + selection: newSelectionRange, + annotations: [heynoteEvent.of(MOVE_BLOCK)], + }, { + scrollIntoView: true, + userEvent: "input", + })) + return true +} + export const deleteBlock = (editor) => ({state, dispatch}) => { const range = state.selection.asSingle().ranges[0] const blocks = state.facet(blockState) diff --git a/src/editor/commands.js b/src/editor/commands.js index 8842ae6..3693f27 100644 --- a/src/editor/commands.js +++ b/src/editor/commands.js @@ -22,6 +22,7 @@ import { selectAll, deleteBlock, deleteBlockSetCursorPreviousBlock, newCursorBelow, newCursorAbove, + moveCurrentBlockUp, moveCurrentBlockDown, } from "./block/commands.js" import { deleteLine } from "./block/delete-line.js" import { formatBlockContent } from "./block/format-code.js" @@ -85,6 +86,8 @@ const HEYNOTE_COMMANDS = { insertNewBlockAtCursor: cmd(insertNewBlockAtCursor, "Block", "Insert new block at cursor"), deleteBlock: cmd(deleteBlock, "Block", "Delete block"), deleteBlockSetCursorPreviousBlock: cmd(deleteBlockSetCursorPreviousBlock, "Block", "Delete block and set cursor to previous block"), + moveCurrentBlockUp: cmdLessContext(moveCurrentBlockUp, "Block", "Move current block up"), + moveCurrentBlockDown: cmdLessContext(moveCurrentBlockDown, "Block", "Move current block down"), cursorPreviousBlock: cmd(cursorPreviousBlock, "Cursor", "Move cursor to previous block"), cursorNextBlock: cmd(cursorNextBlock, "Cursor", "Move cursor to next block"), cursorPreviousParagraph: cmd(cursorPreviousParagraph, "Cursor", "Move cursor to previous paragraph"), diff --git a/src/editor/keymap.js b/src/editor/keymap.js index 233d616..a396dc9 100644 --- a/src/editor/keymap.js +++ b/src/editor/keymap.js @@ -40,6 +40,8 @@ export const DEFAULT_KEYMAP = [ ...cmdShift("PageDown", "cursorPageDown", "selectPageDown"), ...cmdShift("Home", "cursorLineBoundaryBackward", "selectLineBoundaryBackward"), ...cmdShift("End", "cursorLineBoundaryForward", "selectLineBoundaryForward"), + cmd("Alt-Mod-Shift-ArrowUp", "moveCurrentBlockUp"), + cmd("Alt-Mod-Shift-ArrowDown", "moveCurrentBlockDown"), cmd("Backspace", "deleteCharBackward"), cmd("Delete", "deleteCharForward"), cmd("Escape", "simplifySelection"), diff --git a/tests/move-block-between-buffers.spec.js b/tests/move-block-between-buffers.spec.js new file mode 100644 index 0000000..20e84e5 --- /dev/null +++ b/tests/move-block-between-buffers.spec.js @@ -0,0 +1,125 @@ +import {expect, test} from "@playwright/test"; +import {HeynotePage} from "./test-utils.js"; + +import { AUTO_SAVE_INTERVAL } from "../src/common/constants.js" +import { NoteFormat } from "../src/common/note-format.js" + + +let heynotePage + +test.beforeEach(async ({page}) => { + heynotePage = new HeynotePage(page) + await heynotePage.goto() + + expect((await heynotePage.getBlocks()).length).toBe(1) + await 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) + + // create secondary buffer + await heynotePage.saveBuffer("other.txt", ` +∞∞∞text-a +First block +∞∞∞math +Second block`) +}); + + +test("move block to other buffer", async ({page}) => { + await page.locator("body").press(heynotePage.agnosticKey("Mod+S")) + await page.waitForTimeout(50) + await page.locator("body").press("Enter") + await page.waitForTimeout(AUTO_SAVE_INTERVAL + 50); + + const buffers = Object.keys(await heynotePage.getStoredBufferList()) + expect(buffers).toContain("other.txt") + + const otherBuffer = NoteFormat.load(await heynotePage.getStoredBuffer("other.txt")) + + expect(await heynotePage.getContent()).toBe(` +∞∞∞text +Block A +∞∞∞text +Block B`) + + expect(otherBuffer.content).toBe(` +∞∞∞text-a +First block +∞∞∞math +Second block +∞∞∞text +Block C`) + +}) + + +test("move block to other open/cached buffer", async ({page}) => { + await page.locator("body").press(heynotePage.agnosticKey("Mod+P")) + await page.locator("body").press("Enter") + await page.waitForTimeout(50) + await page.locator("body").press(heynotePage.agnosticKey("Mod+P")) + await page.locator("body").press("Enter") + await page.waitForTimeout(50) + await page.locator("body").press(heynotePage.agnosticKey("Mod+S")) + await page.waitForTimeout(50) + await page.locator("body").press("Enter") + await page.waitForTimeout(AUTO_SAVE_INTERVAL + 50); + + const buffers = Object.keys(await heynotePage.getStoredBufferList()) + expect(buffers).toContain("other.txt") + + const otherBuffer = NoteFormat.load(await heynotePage.getStoredBuffer("other.txt")) + + expect(await heynotePage.getContent()).toBe(` +∞∞∞text +Block A +∞∞∞text +Block B`) + + expect(otherBuffer.content).toBe(` +∞∞∞text-a +First block +∞∞∞math +Second block +∞∞∞text +Block C`) + +}) + +test("cursor position after moving first block", async ({page}) => { + await heynotePage.setCursorPosition(10) + expect(await heynotePage.getCursorPosition()).toBe(10) + await page.locator("body").press(heynotePage.agnosticKey("Mod+S")) + await page.waitForTimeout(50) + await page.locator("body").press("Enter") + await page.waitForTimeout(50) + expect(await heynotePage.getCursorPosition()).toBe(9) +}) + +test("cursor position after moving middle block", async ({page}) => { + await heynotePage.setCursorPosition(28) + await page.locator("body").press(heynotePage.agnosticKey("Mod+S")) + await page.waitForTimeout(50) + await page.locator("body").press("Enter") + await page.waitForTimeout(50) + expect(await heynotePage.getCursorPosition()).toBe(25) +}) + +test("cursor position after moving last block", async ({page}) => { + await heynotePage.setCursorPosition(48) + await page.locator("body").press(heynotePage.agnosticKey("Mod+S")) + await page.waitForTimeout(50) + await page.locator("body").press("Enter") + await page.waitForTimeout(50) + expect(await heynotePage.getCursorPosition()).toBe(32) +}) diff --git a/tests/move-block.spec.js b/tests/move-block.spec.js index 20e84e5..5f11079 100644 --- a/tests/move-block.spec.js +++ b/tests/move-block.spec.js @@ -1,13 +1,9 @@ -import {expect, test} from "@playwright/test"; -import {HeynotePage} from "./test-utils.js"; - -import { AUTO_SAVE_INTERVAL } from "../src/common/constants.js" -import { NoteFormat } from "../src/common/note-format.js" - +import { expect, test } from "@playwright/test" +import { HeynotePage } from "./test-utils.js" let heynotePage -test.beforeEach(async ({page}) => { +test.beforeEach(async ({ page }) => { heynotePage = new HeynotePage(page) await heynotePage.goto() @@ -19,107 +15,97 @@ Block A 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) - - // create secondary buffer - await heynotePage.saveBuffer("other.txt", ` -∞∞∞text-a -First block -∞∞∞math -Second block`) -}); - - -test("move block to other buffer", async ({page}) => { - await page.locator("body").press(heynotePage.agnosticKey("Mod+S")) - await page.waitForTimeout(50) - await page.locator("body").press("Enter") - await page.waitForTimeout(AUTO_SAVE_INTERVAL + 50); - - const buffers = Object.keys(await heynotePage.getStoredBufferList()) - expect(buffers).toContain("other.txt") - - const otherBuffer = NoteFormat.load(await heynotePage.getStoredBuffer("other.txt")) - - expect(await heynotePage.getContent()).toBe(` -∞∞∞text -Block A -∞∞∞text -Block B`) - - expect(otherBuffer.content).toBe(` -∞∞∞text-a -First block -∞∞∞math -Second block -∞∞∞text -Block C`) - }) +test("move the first block up", async ({ page }) => { + // select the first block, cursor position: "Block A|" + await page.locator("body").press("ArrowUp") + await page.locator("body").press("ArrowUp") -test("move block to other open/cached buffer", async ({page}) => { - await page.locator("body").press(heynotePage.agnosticKey("Mod+P")) - await page.locator("body").press("Enter") - await page.waitForTimeout(50) - await page.locator("body").press(heynotePage.agnosticKey("Mod+P")) - await page.locator("body").press("Enter") - await page.waitForTimeout(50) - await page.locator("body").press(heynotePage.agnosticKey("Mod+S")) - await page.waitForTimeout(50) - await page.locator("body").press("Enter") - await page.waitForTimeout(AUTO_SAVE_INTERVAL + 50); - - const buffers = Object.keys(await heynotePage.getStoredBufferList()) - expect(buffers).toContain("other.txt") - - const otherBuffer = NoteFormat.load(await heynotePage.getStoredBuffer("other.txt")) - - expect(await heynotePage.getContent()).toBe(` -∞∞∞text -Block A -∞∞∞text -Block B`) - - expect(otherBuffer.content).toBe(` -∞∞∞text-a -First block -∞∞∞math -Second block -∞∞∞text -Block C`) + await page.locator("body").press(`${heynotePage.isMac ? "Meta" : "Control"}+Shift+Alt+ArrowUp`) + const cursorPosition = await heynotePage.getCursorPosition() + const content = await heynotePage.getContent() + expect((await heynotePage.getBlocks()).length).toBe(3) + expect(await heynotePage.getBlockContent(0)).toBe("Block A") + expect(await heynotePage.getBlockContent(1)).toBe("Block B") + expect(await heynotePage.getBlockContent(2)).toBe("Block C") + expect(content.slice(cursorPosition - 1, cursorPosition)).toBe("A") }) -test("cursor position after moving first block", async ({page}) => { - await heynotePage.setCursorPosition(10) - expect(await heynotePage.getCursorPosition()).toBe(10) - await page.locator("body").press(heynotePage.agnosticKey("Mod+S")) - await page.waitForTimeout(50) - await page.locator("body").press("Enter") - await page.waitForTimeout(50) - expect(await heynotePage.getCursorPosition()).toBe(9) +test("move the middle block up", async ({ page }) => { + // select the second block, cursor position: "Block B|" + await page.locator("body").press("ArrowUp") + + await page.locator("body").press(`${heynotePage.isMac ? "Meta" : "Control"}+Shift+Alt+ArrowUp`) + const cursorPosition = await heynotePage.getCursorPosition() + const content = await heynotePage.getContent() + + expect((await heynotePage.getBlocks()).length).toBe(3) + expect(await heynotePage.getBlockContent(0)).toBe("Block B") + expect(await heynotePage.getBlockContent(1)).toBe("Block A") + expect(await heynotePage.getBlockContent(2)).toBe("Block C") + expect(content.slice(cursorPosition - 1, cursorPosition)).toBe("B") }) -test("cursor position after moving middle block", async ({page}) => { - await heynotePage.setCursorPosition(28) - await page.locator("body").press(heynotePage.agnosticKey("Mod+S")) - await page.waitForTimeout(50) - await page.locator("body").press("Enter") - await page.waitForTimeout(50) - expect(await heynotePage.getCursorPosition()).toBe(25) +test("move the last block up", async ({ page }) => { + // cursor position: "Block C|" + await page.locator("body").press(`${heynotePage.isMac ? "Meta" : "Control"}+Shift+Alt+ArrowUp`) + const cursorPosition = await heynotePage.getCursorPosition() + const content = await heynotePage.getContent() + + expect((await heynotePage.getBlocks()).length).toBe(3) + expect(await heynotePage.getBlockContent(0)).toBe("Block A") + expect(await heynotePage.getBlockContent(1)).toBe("Block C") + expect(await heynotePage.getBlockContent(2)).toBe("Block B") + expect(content.slice(cursorPosition - 1, cursorPosition)).toBe("C") }) -test("cursor position after moving last block", async ({page}) => { - await heynotePage.setCursorPosition(48) - await page.locator("body").press(heynotePage.agnosticKey("Mod+S")) - await page.waitForTimeout(50) - await page.locator("body").press("Enter") - await page.waitForTimeout(50) - expect(await heynotePage.getCursorPosition()).toBe(32) +test("move the first block down", async ({ page }) => { + // select the first block, cursor position: "Block A|" + await page.locator("body").press("ArrowUp") + await page.locator("body").press("ArrowUp") + + await page.locator("body").press(`${heynotePage.isMac ? "Meta" : "Control"}+Shift+Alt+ArrowDown`) + const cursorPosition = await heynotePage.getCursorPosition() + const content = await heynotePage.getContent() + + expect((await heynotePage.getBlocks()).length).toBe(3) + expect(await heynotePage.getBlockContent(0)).toBe("Block B") + expect(await heynotePage.getBlockContent(1)).toBe("Block A") + expect(await heynotePage.getBlockContent(2)).toBe("Block C") + expect(content.slice(cursorPosition - 1, cursorPosition)).toBe("A") +}) + +test("move the middle block down", async ({ page }) => { + // select the second block, cursor position: "Block B|" + await page.locator("body").press("ArrowUp") + + await page.locator("body").press(`${heynotePage.isMac ? "Meta" : "Control"}+Shift+Alt+ArrowDown`) + const cursorPosition = await heynotePage.getCursorPosition() + const content = await heynotePage.getContent() + + expect((await heynotePage.getBlocks()).length).toBe(3) + expect(await heynotePage.getBlockContent(0)).toBe("Block A") + expect(await heynotePage.getBlockContent(1)).toBe("Block C") + expect(await heynotePage.getBlockContent(2)).toBe("Block B") + expect(content.slice(cursorPosition - 1, cursorPosition)).toBe("B") +}) + +test("move the last block down", async ({ page }) => { + // cursor position: "Block C|" + await page.locator("body").press(`${heynotePage.isMac ? "Meta" : "Control"}+Shift+Alt+ArrowDown`) + const cursorPosition = await heynotePage.getCursorPosition() + const content = await heynotePage.getContent() + + expect((await heynotePage.getBlocks()).length).toBe(3) + expect(await heynotePage.getBlockContent(0)).toBe("Block A") + expect(await heynotePage.getBlockContent(1)).toBe("Block B") + expect(await heynotePage.getBlockContent(2)).toBe("Block C") + expect(content.slice(cursorPosition - 1, cursorPosition)).toBe("C") })