From 663998aceb0fd9b0b3b83015026e1d62b2d500e6 Mon Sep 17 00:00:00 2001 From: Horacio Herrera Date: Thu, 26 Feb 2026 23:50:43 +0100 Subject: [PATCH] feat(editor): add button to insert new blocks at document end Add AddBlockAtEndButton component that enables users to quickly create new blocks at the end of the document with the slash menu pre-activated. The button includes intelligent handling of edge cases: - Cleans up leftover "/" blocks from dismissed slash menus - Reuses empty trailing blocks instead of creating duplicates - Inserts "/" to trigger slash menu with inline decoration Updates document content areas with bottom padding (pb-60) to provide space for the floating button and improve UX. Includes comprehensive test suite validating block detection, insertion, and edge case handling at the ProseMirror level. --- .../components/add-block-at-end-button.tsx | 85 ++++++ .../apps/desktop/src/components/editor.tsx | 32 +- frontend/apps/desktop/src/pages/draft.tsx | 3 +- .../__tests__/addBlockAtEnd.test.ts | 274 ++++++++++++++++++ .../packages/ui/src/resource-page-common.tsx | 2 +- 5 files changed, 379 insertions(+), 17 deletions(-) create mode 100644 frontend/apps/desktop/src/components/add-block-at-end-button.tsx create mode 100644 frontend/packages/editor/src/blocknote/core/api/blockManipulation/__tests__/addBlockAtEnd.test.ts diff --git a/frontend/apps/desktop/src/components/add-block-at-end-button.tsx b/frontend/apps/desktop/src/components/add-block-at-end-button.tsx new file mode 100644 index 000000000..3c5621529 --- /dev/null +++ b/frontend/apps/desktop/src/components/add-block-at-end-button.tsx @@ -0,0 +1,85 @@ +import {getBlockInfoFromPos, slashMenuPluginKey} from '@shm/editor/blocknote/core' +import type {HyperMediaEditor} from '@shm/editor/types' +import {Button} from '@shm/ui/button' +import {Plus} from 'lucide-react' + +export function AddBlockAtEndButton({editor}: {editor: HyperMediaEditor}) { + return ( + + ) +} + +function addBlockAtEnd(editor: HyperMediaEditor) { + const ttEditor = editor._tiptapEditor + const view = ttEditor.view + const state = view.state + const doc = state.doc + + // The TrailingNode extension always keeps an empty block at the end. + // After a previous click + dismiss, the doc may look like: + // [...blocks, block with "/", empty trailing block] + // We delete the leftover "/" block so the normal flow reuses the trailing one. + const topGroup = doc.firstChild + if (topGroup && topGroup.childCount >= 2) { + const lastInfo = getBlockInfoFromPos(state, doc.content.size - 2) + const prevPos = lastInfo.block.beforePos - 1 + if (prevPos > 0) { + const prevInfo = getBlockInfoFromPos(state, prevPos) + if ( + prevInfo.block.node !== lastInfo.block.node && + prevInfo.blockContent.node.textContent === '/' && + lastInfo.blockContent.node.textContent.length === 0 + ) { + // Delete the entire "/" block + view.dispatch(state.tr.delete(prevInfo.block.beforePos, prevInfo.block.afterPos)) + } + } + } + + // Re-read state after potential cleanup above + const currentState = view.state + const currentDoc = currentState.doc + + // Position at the end of the document to find the last block. + // doc.content.size - 2 resolves inside the last blockChildren, near the last blockNode. + const lastBlockPos = currentDoc.content.size - 2 + const blockInfo = getBlockInfoFromPos(currentState, lastBlockPos) + + const {blockContent: contentNode, block} = blockInfo + + if (contentNode.node.textContent.length !== 0) { + // Last block has content — create a new empty paragraph block after it + const newBlockInsertionPos = block.afterPos + const newBlockContentPos = newBlockInsertionPos + 2 + ttEditor.chain().BNCreateBlock(newBlockInsertionPos).setTextSelection(newBlockContentPos).run() + } else { + // Last block is already empty — just move cursor there + ttEditor.commands.setTextSelection(block.afterPos - 1) + } + + // Focus and insert "/" to trigger the slash menu with an inline decoration. + // Using insertText + triggerCharacter creates a tight inline decoration at the + // cursor position, so the menu appears left-aligned near the "+" button. + // The programmatic {activate: true} path creates a node-level decoration that + // spans the full block width, causing the menu to appear centered. + view.focus() + view.dispatch( + view.state.tr.insertText('/').scrollIntoView().setMeta(slashMenuPluginKey, { + activate: true, + triggerCharacter: '/', + }), + ) +} diff --git a/frontend/apps/desktop/src/components/editor.tsx b/frontend/apps/desktop/src/components/editor.tsx index 1e8542599..293269a1f 100644 --- a/frontend/apps/desktop/src/components/editor.tsx +++ b/frontend/apps/desktop/src/components/editor.tsx @@ -12,6 +12,7 @@ import {HMFormattingToolbar} from '@shm/editor/hm-formatting-toolbar' import {HypermediaLinkPreview} from '@shm/editor/hm-link-preview' import type {HyperMediaEditor} from '@shm/editor/types' import {useEffect} from 'react' +import {AddBlockAtEndButton} from './add-block-at-end-button' export function HyperMediaEditorView({ editor, @@ -39,19 +40,22 @@ export function HyperMediaEditorView({ }, [editor]) return ( - - - - - {comment ? null : } - - + <> + + + + + {comment ? null : } + + + {comment ? null : } + ) } diff --git a/frontend/apps/desktop/src/pages/draft.tsx b/frontend/apps/desktop/src/pages/draft.tsx index 4979f9cd9..147bef010 100644 --- a/frontend/apps/desktop/src/pages/draft.tsx +++ b/frontend/apps/desktop/src/pages/draft.tsx @@ -696,11 +696,10 @@ function DocumentEditor({ ) : null} -
+
) => { e.stopPropagation() }} diff --git a/frontend/packages/editor/src/blocknote/core/api/blockManipulation/__tests__/addBlockAtEnd.test.ts b/frontend/packages/editor/src/blocknote/core/api/blockManipulation/__tests__/addBlockAtEnd.test.ts new file mode 100644 index 000000000..c0e413d11 --- /dev/null +++ b/frontend/packages/editor/src/blocknote/core/api/blockManipulation/__tests__/addBlockAtEnd.test.ts @@ -0,0 +1,274 @@ +import {EditorState, TextSelection} from 'prosemirror-state' +import {beforeEach, describe, expect, it} from 'vitest' +import {getBlockInfoFromPos} from '../../../extensions/Blocks/helpers/getBlockInfoFromPos' +import {buildDoc, createMinimalSchema, findPosInBlock} from './test-helpers-prosemirror' +import type {Schema} from 'prosemirror-model' + +/** + * Tests for the "add block at end" logic used by the AddBlockAtEndButton. + * + * The button's core behavior: + * 1. Find the last block via getBlockInfoFromPos(state, doc.content.size - 2) + * 2. If the last block has content → insert a new empty block after it + * 3. If the last block is empty → reuse it (just move cursor there) + * 4. If the second-to-last block has "/" and last is empty (previous dismissed + * slash menu), reuse the "/" block instead of creating a new one + * + * These tests validate steps 1-4 at the ProseMirror level without + * needing a full Tiptap/React environment. + */ +describe('addBlockAtEnd — last block detection', () => { + let schema: Schema + + beforeEach(() => { + schema = createMinimalSchema() + }) + + it('finds the last block in a single-block document', () => { + const doc = buildDoc(schema, [{id: 'block-1', text: 'Hello'}]) + const state = EditorState.create({doc, schema}) + + const lastBlockPos = doc.content.size - 2 + const blockInfo = getBlockInfoFromPos(state, lastBlockPos) + + expect(blockInfo.block.node.attrs.id).toBe('block-1') + expect(blockInfo.blockContent.node.textContent).toBe('Hello') + }) + + it('finds the last block in a multi-block document', () => { + const doc = buildDoc(schema, [ + {id: 'block-1', text: 'First'}, + {id: 'block-2', text: 'Second'}, + {id: 'block-3', text: 'Third'}, + ]) + const state = EditorState.create({doc, schema}) + + const lastBlockPos = doc.content.size - 2 + const blockInfo = getBlockInfoFromPos(state, lastBlockPos) + + expect(blockInfo.block.node.attrs.id).toBe('block-3') + expect(blockInfo.blockContent.node.textContent).toBe('Third') + }) + + it('detects last block has content (should create new block)', () => { + const doc = buildDoc(schema, [{id: 'block-1', text: 'Some content'}]) + const state = EditorState.create({doc, schema}) + + const lastBlockPos = doc.content.size - 2 + const blockInfo = getBlockInfoFromPos(state, lastBlockPos) + + expect(blockInfo.blockContent.node.textContent.length).toBeGreaterThan(0) + }) + + it('detects last block is empty (should reuse it)', () => { + const doc = buildDoc(schema, [ + {id: 'block-1', text: 'Some content'}, + {id: 'block-2', text: ''}, + ]) + const state = EditorState.create({doc, schema}) + + const lastBlockPos = doc.content.size - 2 + const blockInfo = getBlockInfoFromPos(state, lastBlockPos) + + expect(blockInfo.block.node.attrs.id).toBe('block-2') + expect(blockInfo.blockContent.node.textContent.length).toBe(0) + }) +}) + +describe('addBlockAtEnd — block insertion', () => { + let schema: Schema + + beforeEach(() => { + schema = createMinimalSchema() + }) + + it('inserts a new empty block after the last block with content', () => { + const doc = buildDoc(schema, [{id: 'block-1', text: 'Hello'}]) + const state = EditorState.create({doc, schema}) + + const lastBlockPos = doc.content.size - 2 + const blockInfo = getBlockInfoFromPos(state, lastBlockPos) + + // Simulate inserting a new block at block.afterPos + const newBlockInsertionPos = blockInfo.block.afterPos + const newBlock = schema.nodes['blockNode']!.createAndFill()! + const tr = state.tr.insert(newBlockInsertionPos, newBlock) + const newState = state.apply(tr) + + // Document should now have 2 blocks + const topGroup = newState.doc.firstChild! + expect(topGroup.childCount).toBe(2) + + // First block unchanged + expect(topGroup.child(0).attrs.id).toBe('block-1') + expect(topGroup.child(0).firstChild!.textContent).toBe('Hello') + + // New block is empty + expect(topGroup.child(1).firstChild!.textContent).toBe('') + }) + + it('cursor can be placed in the newly created block', () => { + const doc = buildDoc(schema, [{id: 'block-1', text: 'Hello'}]) + const state = EditorState.create({doc, schema}) + + const lastBlockPos = doc.content.size - 2 + const blockInfo = getBlockInfoFromPos(state, lastBlockPos) + + const newBlockInsertionPos = blockInfo.block.afterPos + const newBlockContentPos = newBlockInsertionPos + 2 + const newBlock = schema.nodes['blockNode']!.createAndFill()! + const tr = state.tr.insert(newBlockInsertionPos, newBlock) + const newState = state.apply(tr) + + // Set cursor in the new block + const sel = TextSelection.create(newState.doc, newBlockContentPos) + const stateWithCursor = newState.apply(newState.tr.setSelection(sel)) + + // Cursor should be inside the new (second) block's paragraph + const $pos = stateWithCursor.selection.$from + expect($pos.parent.type.name).toBe('paragraph') + expect($pos.parent.textContent).toBe('') + }) + + it('does not insert a block when last block is already empty', () => { + const doc = buildDoc(schema, [ + {id: 'block-1', text: 'Hello'}, + {id: 'block-2', text: ''}, + ]) + const state = EditorState.create({doc, schema}) + + const lastBlockPos = doc.content.size - 2 + const blockInfo = getBlockInfoFromPos(state, lastBlockPos) + + // Last block is empty — no insertion needed + expect(blockInfo.blockContent.node.textContent.length).toBe(0) + + // Just move cursor to the empty block's paragraph content. + // In the actual code, tiptap's setTextSelection resolves to the nearest + // valid text position. Here we use blockContent.beforePos + 1 directly. + const cursorPos = blockInfo.blockContent.beforePos + 1 + const sel = TextSelection.create(state.doc, cursorPos) + const newState = state.apply(state.tr.setSelection(sel)) + + // Document still has 2 blocks (no new one created) + const topGroup = newState.doc.firstChild! + expect(topGroup.childCount).toBe(2) + + // Cursor is inside the empty block's paragraph + const $pos = newState.selection.$from + expect($pos.parent.type.name).toBe('paragraph') + expect($pos.parent.textContent).toBe('') + }) + + it('works with nested blocks (finds top-level last block)', () => { + const doc = buildDoc(schema, [ + { + id: 'block-1', + text: 'Parent', + children: { + blocks: [{id: 'child-1', text: 'Nested child'}], + }, + }, + {id: 'block-2', text: 'Last top-level'}, + ]) + const state = EditorState.create({doc, schema}) + + const lastBlockPos = doc.content.size - 2 + const blockInfo = getBlockInfoFromPos(state, lastBlockPos) + + expect(blockInfo.block.node.attrs.id).toBe('block-2') + expect(blockInfo.blockContent.node.textContent).toBe('Last top-level') + }) +}) + +describe('addBlockAtEnd — leftover "/" detection', () => { + let schema: Schema + + beforeEach(() => { + schema = createMinimalSchema() + }) + + // After clicking "+" and dismissing the slash menu, the doc looks like: + // [content..., block with "/", empty trailing block] + // The button should detect this and reuse the "/" block. + + it('detects second-to-last block with "/" when last is empty', () => { + const doc = buildDoc(schema, [ + {id: 'block-1', text: 'Some content'}, + {id: 'block-slash', text: '/'}, + {id: 'block-trailing', text: ''}, + ]) + const state = EditorState.create({doc, schema}) + + const topGroup = doc.firstChild! + expect(topGroup.childCount).toBe(3) + + // Find last and second-to-last blocks + const lastInfo = getBlockInfoFromPos(state, doc.content.size - 2) + expect(lastInfo.block.node.attrs.id).toBe('block-trailing') + expect(lastInfo.blockContent.node.textContent).toBe('') + + const prevPos = lastInfo.block.beforePos - 1 + const prevInfo = getBlockInfoFromPos(state, prevPos) + expect(prevInfo.block.node.attrs.id).toBe('block-slash') + expect(prevInfo.blockContent.node.textContent).toBe('/') + + // Condition matches: prevInfo has "/" and lastInfo is empty + expect(prevInfo.blockContent.node.textContent === '/').toBe(true) + expect(lastInfo.blockContent.node.textContent.length === 0).toBe(true) + // Confirm they're different blocks + expect(prevInfo.block.node).not.toBe(lastInfo.block.node) + }) + + it('deletes the "/" block so the trailing block can be reused', () => { + const doc = buildDoc(schema, [ + {id: 'block-1', text: 'Some content'}, + {id: 'block-slash', text: '/'}, + {id: 'block-trailing', text: ''}, + ]) + const state = EditorState.create({doc, schema}) + + const lastInfo = getBlockInfoFromPos(state, doc.content.size - 2) + const prevInfo = getBlockInfoFromPos(state, lastInfo.block.beforePos - 1) + + // Delete the entire "/" block + const tr = state.tr.delete(prevInfo.block.beforePos, prevInfo.block.afterPos) + const newState = state.apply(tr) + + // Document now has 2 blocks (the "/" block was removed) + const topGroup = newState.doc.firstChild! + expect(topGroup.childCount).toBe(2) + + // First block unchanged + expect(topGroup.child(0).attrs.id).toBe('block-1') + expect(topGroup.child(0).firstChild!.textContent).toBe('Some content') + + // The trailing empty block is now the last block — ready for the normal flow + expect(topGroup.child(1).firstChild!.textContent).toBe('') + }) + + it('does not match when second-to-last has other content', () => { + const doc = buildDoc(schema, [ + {id: 'block-1', text: 'Some content'}, + {id: 'block-2', text: 'Not a slash'}, + {id: 'block-trailing', text: ''}, + ]) + const state = EditorState.create({doc, schema}) + + const lastInfo = getBlockInfoFromPos(state, doc.content.size - 2) + const prevInfo = getBlockInfoFromPos(state, lastInfo.block.beforePos - 1) + + // Should NOT match the "/" reuse condition + expect(prevInfo.blockContent.node.textContent).toBe('Not a slash') + expect(prevInfo.blockContent.node.textContent === '/').toBe(false) + }) + + it('does not match when only one block exists', () => { + const doc = buildDoc(schema, [{id: 'block-1', text: 'Only block'}]) + const state = EditorState.create({doc, schema}) + + const topGroup = doc.firstChild! + // Only 1 block — the reuse logic requires >= 2 + expect(topGroup.childCount).toBe(1) + }) +}) diff --git a/frontend/packages/ui/src/resource-page-common.tsx b/frontend/packages/ui/src/resource-page-common.tsx index 9cfc971d2..1e20f5b64 100644 --- a/frontend/packages/ui/src/resource-page-common.tsx +++ b/frontend/packages/ui/src/resource-page-common.tsx @@ -1085,7 +1085,7 @@ function DocumentBody({
{/* Main content based on activeView */} -
+