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 */} -
+