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