Paragraph
Bullet List Item
Numbered List Item
Paragraph
Bullet List Item
Numbered List Item
BoldItalicUnderlineStrikethroughTextColorBackgroundColorMultiple
Paragraph
Paragraph
Paragraph
Bullet List Item
Bullet List Item
Bullet List Item
Bullet List Item
Paragraph
Numbered List Item
Numbered List Item
Numbered List Item
Numbered List Item
Bullet List Item
Bullet List Item
Bullet List Item
Paragraph
Bullet List Item
Numbered List Item
Paragraph
Bullet List Item
Numbered List Item
BoldItalicUnderlineStrikethroughTextColorBackgroundColorMultiple
Paragraph
Paragraph
Paragraph
Bullet List Item
Bullet List Item
Bullet List Item
Bullet List Item
Paragraph
Numbered List Item
Numbered List Item
Numbered List Item
Numbered List Item
Bullet List Item
Bullet List Item
Bullet List Item
`.
+ /** @type {Element} */
+ let result: any = {
+ type: 'element',
+ tagName: 'code',
+ properties,
+ children: [{type: 'text', value}],
+ }
+
+ if (node.meta) {
+ result.data = {meta: node.meta}
+ }
+
+ state.patch(node, result)
+ result = state.applyData(node, result)
+
+ // Create ``.
+ result = {
+ type: 'element',
+ tagName: 'pre',
+ properties: {},
+ children: [result],
+ }
+ state.patch(node, result)
+ return result
+}
+
export async function markdownToBlocks(
markdown: string,
blockSchema: BSchema,
@@ -81,7 +120,12 @@ export async function markdownToBlocks(
const htmlString = await unified()
.use(remarkParse)
.use(remarkGfm)
- .use(remarkRehype)
+ .use(remarkRehype, {
+ handlers: {
+ ...(defaultHandlers as any),
+ code,
+ },
+ })
.use(rehypeStringify)
.process(markdown)
diff --git a/frontend/packages/editor/src/blocknote/core/api/formatConversions/removeUnderlinesRehypePlugin.ts b/frontend/packages/editor/src/blocknote/core/api/formatConversions/removeUnderlinesRehypePlugin.ts
index bdc025df89..1215497d31 100644
--- a/frontend/packages/editor/src/blocknote/core/api/formatConversions/removeUnderlinesRehypePlugin.ts
+++ b/frontend/packages/editor/src/blocknote/core/api/formatConversions/removeUnderlinesRehypePlugin.ts
@@ -1,4 +1,4 @@
-import {Element as HASTElement, Parent as HASTParent} from 'rehype'
+import {Element as HASTElement, Parent as HASTParent} from 'hast'
/**
* Rehype plugin which removes tags. Used to remove underlines before converting HTML to markdown, as Markdown
diff --git a/frontend/packages/editor/src/blocknote/core/api/formatConversions/simplifyBlocksRehypePlugin.ts b/frontend/packages/editor/src/blocknote/core/api/formatConversions/simplifyBlocksRehypePlugin.ts
index a1c7b95bb5..97029d8b1a 100644
--- a/frontend/packages/editor/src/blocknote/core/api/formatConversions/simplifyBlocksRehypePlugin.ts
+++ b/frontend/packages/editor/src/blocknote/core/api/formatConversions/simplifyBlocksRehypePlugin.ts
@@ -1,4 +1,4 @@
-import {Element as HASTElement, Parent as HASTParent} from 'rehype'
+import {Element as HASTElement, Parent as HASTParent} from 'hast'
import {fromDom} from 'hast-util-from-dom'
type SimplifyBlocksOptions = {
diff --git a/frontend/packages/editor/src/blocknote/core/api/nodeConversions/nodeConversions._test.ts b/frontend/packages/editor/src/blocknote/core/api/nodeConversions/nodeConversions._test.ts
index c0e4bd197f..37cf9b9e86 100644
--- a/frontend/packages/editor/src/blocknote/core/api/nodeConversions/nodeConversions._test.ts
+++ b/frontend/packages/editor/src/blocknote/core/api/nodeConversions/nodeConversions._test.ts
@@ -1,136 +1,49 @@
import {Editor} from '@tiptap/core'
-import {Node} from 'prosemirror-model'
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
import {BlockNoteEditor, PartialBlock} from '../..'
-import UniqueID from '../../extensions/UniqueID/UniqueID'
-import {blockToNode, nodeToBlock} from './nodeConversions'
-import {partialBlockToBlockForTesting} from './testUtil'
import {
- defaultBlockSchema,
DefaultBlockSchema,
+ defaultBlockSchema,
} from '../../extensions/Blocks/api/defaultBlocks'
+import UniqueID from '../../extensions/UniqueID/UniqueID'
+import {blockToNode, nodeToBlock} from './nodeConversions'
+import {partialBlockToBlockForTesting} from './testUtil'
let editor: BlockNoteEditor
let tt: Editor
-let simpleBlock: PartialBlock
-let simpleNode: Node
-
-let complexBlock: PartialBlock
-let complexNode: Node
-
beforeEach(() => {
- ;(window as Window & {__TEST_OPTIONS?: {}}).__TEST_OPTIONS = {}
+ ;(window as Window & {__TEST_OPTIONS?: any}).__TEST_OPTIONS = {}
editor = new BlockNoteEditor()
tt = editor._tiptapEditor
- console.log(
- '=========== 🚀 ~ file: nodeConversions.test.ts:27 ~ beforeEach ~ tt:',
- tt,
- )
-
- simpleBlock = {
- type: 'paragraph',
- }
- simpleNode = tt.schema.nodes['blockContainer'].create(
- {id: UniqueID.options.generateID()},
- tt.schema.nodes['paragraph'].create(),
- )
-
- complexBlock = {
- type: 'heading',
- props: {
- backgroundColor: 'blue',
- textColor: 'yellow',
- textAlignment: 'right',
- level: '2',
- },
- content: [
- {
- type: 'text',
- text: 'Heading ',
- styles: {
- bold: true,
- underline: true,
- },
- },
- {
- type: 'text',
- text: '2',
- styles: {
- italic: true,
- strike: true,
- },
- },
- ],
- children: [
- {
- type: 'paragraph',
- props: {
- backgroundColor: 'red',
- },
- content: 'Paragraph',
- children: [],
- },
- {
- type: 'bulletListItem',
- },
- ],
- }
- complexNode = tt.schema.nodes['blockContainer'].create(
- {
- id: UniqueID.options.generateID(),
- backgroundColor: 'blue',
- textColor: 'yellow',
- },
- [
- tt.schema.nodes['heading'].create({textAlignment: 'right', level: '2'}, [
- tt.schema.text('Heading ', [
- tt.schema.mark('bold'),
- tt.schema.mark('underline'),
- ]),
- tt.schema.text('2', [
- tt.schema.mark('italic'),
- tt.schema.mark('strike'),
- ]),
- ]),
- tt.schema.nodes['blockGroup'].create({}, [
- tt.schema.nodes['blockContainer'].create(
- {id: UniqueID.options.generateID(), backgroundColor: 'red'},
- [
- tt.schema.nodes['paragraph'].create(
- {},
- tt.schema.text('Paragraph'),
- ),
- ],
- ),
- tt.schema.nodes['blockContainer'].create(
- {id: UniqueID.options.generateID()},
- [tt.schema.nodes['bulletListItem'].create()],
- ),
- ]),
- ],
- )
})
afterEach(() => {
- tt?.destroy()
+ tt.destroy()
editor = undefined as any
tt = undefined as any
- delete (window as Window & {__TEST_OPTIONS?: {}}).__TEST_OPTIONS
+ delete (window as Window & {__TEST_OPTIONS?: any}).__TEST_OPTIONS
})
-describe.skip('Simple ProseMirror Node Conversions', () => {
+describe('Simple ProseMirror Node Conversions', () => {
it('Convert simple block to node', async () => {
- const firstNodeConversion = blockToNode(simpleBlock, tt.schema)
+ const block: PartialBlock = {
+ type: 'paragraph',
+ }
+ const firstNodeConversion = blockToNode(block, tt.schema)
expect(firstNodeConversion).toMatchSnapshot()
})
it('Convert simple node to block', async () => {
+ const node = tt.schema.nodes['blockContainer'].create(
+ {id: UniqueID.options.generateID()},
+ tt.schema.nodes['paragraph'].create(),
+ )
const firstBlockConversion = nodeToBlock(
- simpleNode,
+ node,
defaultBlockSchema,
)
@@ -138,20 +51,97 @@ describe.skip('Simple ProseMirror Node Conversions', () => {
const firstNodeConversion = blockToNode(firstBlockConversion, tt.schema)
- expect(firstNodeConversion).toStrictEqual(simpleNode)
+ expect(firstNodeConversion).toStrictEqual(node)
})
})
-describe.skip('Complex ProseMirror Node Conversions', () => {
+describe('Complex ProseMirror Node Conversions', () => {
it('Convert complex block to node', async () => {
- const firstNodeConversion = blockToNode(complexBlock, tt.schema)
+ const block: PartialBlock = {
+ type: 'heading',
+ props: {
+ backgroundColor: 'blue',
+ textColor: 'yellow',
+ textAlignment: 'right',
+ level: '2',
+ },
+ content: [
+ {
+ type: 'text',
+ text: 'Heading ',
+ styles: {
+ bold: true,
+ underline: true,
+ },
+ },
+ {
+ type: 'text',
+ text: '2',
+ styles: {
+ italic: true,
+ strike: true,
+ },
+ },
+ ],
+ children: [
+ {
+ type: 'paragraph',
+ props: {
+ backgroundColor: 'red',
+ },
+ content: 'Paragraph',
+ children: [],
+ },
+ {
+ type: 'bulletListItem',
+ },
+ ],
+ }
+ const firstNodeConversion = blockToNode(block, tt.schema)
expect(firstNodeConversion).toMatchSnapshot()
})
it('Convert complex node to block', async () => {
+ const node = tt.schema.nodes['blockContainer'].create(
+ {
+ id: UniqueID.options.generateID(),
+ backgroundColor: 'blue',
+ textColor: 'yellow',
+ },
+ [
+ tt.schema.nodes['heading'].create(
+ {textAlignment: 'right', level: '2'},
+ [
+ tt.schema.text('Heading ', [
+ tt.schema.mark('bold'),
+ tt.schema.mark('underline'),
+ ]),
+ tt.schema.text('2', [
+ tt.schema.mark('italic'),
+ tt.schema.mark('strike'),
+ ]),
+ ],
+ ),
+ tt.schema.nodes['blockGroup'].create({}, [
+ tt.schema.nodes['blockContainer'].create(
+ {id: UniqueID.options.generateID(), backgroundColor: 'red'},
+ [
+ tt.schema.nodes['paragraph'].create(
+ {},
+ tt.schema.text('Paragraph'),
+ ),
+ ],
+ ),
+ tt.schema.nodes['blockContainer'].create(
+ {id: UniqueID.options.generateID()},
+ [tt.schema.nodes['bulletListItem'].create()],
+ ),
+ ]),
+ ],
+ )
const firstBlockConversion = nodeToBlock(
- complexNode,
+ node,
defaultBlockSchema,
)
@@ -159,11 +149,11 @@ describe.skip('Complex ProseMirror Node Conversions', () => {
const firstNodeConversion = blockToNode(firstBlockConversion, tt.schema)
- expect(firstNodeConversion).toStrictEqual(complexNode)
+ expect(firstNodeConversion).toStrictEqual(node)
})
})
-describe.skip('links', () => {
+describe('links', () => {
it('Convert a block with link', async () => {
const block: PartialBlock = {
id: UniqueID.options.generateID(),
@@ -267,3 +257,239 @@ describe.skip('links', () => {
expect(outputBlock).toStrictEqual(fullOriginalBlock)
})
})
+
+describe('hard breaks', () => {
+ it('Convert a block with a hard break', async () => {
+ const block: PartialBlock = {
+ id: UniqueID.options.generateID(),
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Text1\nText2',
+ styles: {},
+ },
+ ],
+ }
+ const node = blockToNode(block, tt.schema)
+ expect(node).toMatchSnapshot()
+ const outputBlock = nodeToBlock(
+ node,
+ defaultBlockSchema,
+ )
+
+ // Temporary fix to set props to {}, because at this point
+ // we don't have an easy way to access default props at runtime,
+ // so partialBlockToBlockForTesting will not set them.
+ ;(outputBlock as any).props = {}
+ const fullOriginalBlock = partialBlockToBlockForTesting(block)
+
+ expect(outputBlock).toStrictEqual(fullOriginalBlock)
+ })
+
+ it('Convert a block with multiple hard breaks', async () => {
+ const block: PartialBlock = {
+ id: UniqueID.options.generateID(),
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Text1\nText2\nText3',
+ styles: {},
+ },
+ ],
+ }
+ const node = blockToNode(block, tt.schema)
+ expect(node).toMatchSnapshot()
+ const outputBlock = nodeToBlock(
+ node,
+ defaultBlockSchema,
+ )
+
+ // Temporary fix to set props to {}, because at this point
+ // we don't have an easy way to access default props at runtime,
+ // so partialBlockToBlockForTesting will not set them.
+ ;(outputBlock as any).props = {}
+ const fullOriginalBlock = partialBlockToBlockForTesting(block)
+
+ expect(outputBlock).toStrictEqual(fullOriginalBlock)
+ })
+
+ it('Convert a block with a hard break at the start', async () => {
+ const block: PartialBlock = {
+ id: UniqueID.options.generateID(),
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: '\nText1',
+ styles: {},
+ },
+ ],
+ }
+ const node = blockToNode(block, tt.schema)
+ expect(node).toMatchSnapshot()
+ const outputBlock = nodeToBlock(
+ node,
+ defaultBlockSchema,
+ )
+
+ // Temporary fix to set props to {}, because at this point
+ // we don't have an easy way to access default props at runtime,
+ // so partialBlockToBlockForTesting will not set them.
+ ;(outputBlock as any).props = {}
+ const fullOriginalBlock = partialBlockToBlockForTesting(block)
+
+ expect(outputBlock).toStrictEqual(fullOriginalBlock)
+ })
+
+ it('Convert a block with a hard break at the end', async () => {
+ const block: PartialBlock = {
+ id: UniqueID.options.generateID(),
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Text1\n',
+ styles: {},
+ },
+ ],
+ }
+ const node = blockToNode(block, tt.schema)
+ expect(node).toMatchSnapshot()
+ const outputBlock = nodeToBlock(
+ node,
+ defaultBlockSchema,
+ )
+
+ // Temporary fix to set props to {}, because at this point
+ // we don't have an easy way to access default props at runtime,
+ // so partialBlockToBlockForTesting will not set them.
+ ;(outputBlock as any).props = {}
+ const fullOriginalBlock = partialBlockToBlockForTesting(block)
+
+ expect(outputBlock).toStrictEqual(fullOriginalBlock)
+ })
+
+ it('Convert a block with only a hard break', async () => {
+ const block: PartialBlock = {
+ id: UniqueID.options.generateID(),
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: '\n',
+ styles: {},
+ },
+ ],
+ }
+ const node = blockToNode(block, tt.schema)
+ expect(node).toMatchSnapshot()
+ const outputBlock = nodeToBlock(
+ node,
+ defaultBlockSchema,
+ )
+
+ // Temporary fix to set props to {}, because at this point
+ // we don't have an easy way to access default props at runtime,
+ // so partialBlockToBlockForTesting will not set them.
+ ;(outputBlock as any).props = {}
+ const fullOriginalBlock = partialBlockToBlockForTesting(block)
+
+ expect(outputBlock).toStrictEqual(fullOriginalBlock)
+ })
+
+ it('Convert a block with a hard break and different styles', async () => {
+ const block: PartialBlock = {
+ id: UniqueID.options.generateID(),
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'Text1\n',
+ styles: {},
+ },
+ {
+ type: 'text',
+ text: 'Text2',
+ styles: {bold: true},
+ },
+ ],
+ }
+ const node = blockToNode(block, tt.schema)
+ expect(node).toMatchSnapshot()
+ const outputBlock = nodeToBlock(
+ node,
+ defaultBlockSchema,
+ )
+
+ // Temporary fix to set props to {}, because at this point
+ // we don't have an easy way to access default props at runtime,
+ // so partialBlockToBlockForTesting will not set them.
+ ;(outputBlock as any).props = {}
+ const fullOriginalBlock = partialBlockToBlockForTesting(block)
+
+ expect(outputBlock).toStrictEqual(fullOriginalBlock)
+ })
+
+ it('Convert a block with a hard break in a link', async () => {
+ const block: PartialBlock = {
+ id: UniqueID.options.generateID(),
+ type: 'paragraph',
+ content: [
+ {
+ type: 'link',
+ href: 'https://www.website.com',
+ content: 'Link1\nLink1',
+ },
+ ],
+ }
+ const node = blockToNode(block, tt.schema)
+ expect(node).toMatchSnapshot()
+ const outputBlock = nodeToBlock(
+ node,
+ defaultBlockSchema,
+ )
+
+ // Temporary fix to set props to {}, because at this point
+ // we don't have an easy way to access default props at runtime,
+ // so partialBlockToBlockForTesting will not set them.
+ ;(outputBlock as any).props = {}
+ const fullOriginalBlock = partialBlockToBlockForTesting(block)
+
+ expect(outputBlock).toStrictEqual(fullOriginalBlock)
+ })
+
+ it('Convert a block with a hard break between links', async () => {
+ const block: PartialBlock = {
+ id: UniqueID.options.generateID(),
+ type: 'paragraph',
+ content: [
+ {
+ type: 'link',
+ href: 'https://www.website.com',
+ content: 'Link1\n',
+ },
+ {
+ type: 'link',
+ href: 'https://www.website2.com',
+ content: 'Link2',
+ },
+ ],
+ }
+ const node = blockToNode(block, tt.schema)
+ expect(node).toMatchSnapshot()
+ const outputBlock = nodeToBlock(
+ node,
+ defaultBlockSchema,
+ )
+
+ // Temporary fix to set props to {}, because at this point
+ // we don't have an easy way to access default props at runtime,
+ // so partialBlockToBlockForTesting will not set them.
+ ;(outputBlock as any).props = {}
+ const fullOriginalBlock = partialBlockToBlockForTesting(block)
+
+ expect(outputBlock).toStrictEqual(fullOriginalBlock)
+ })
+})
diff --git a/frontend/packages/editor/src/blocknote/core/api/nodeConversions/nodeConversions.ts b/frontend/packages/editor/src/blocknote/core/api/nodeConversions/nodeConversions.ts
index 838ad1c984..080c7ab8af 100644
--- a/frontend/packages/editor/src/blocknote/core/api/nodeConversions/nodeConversions.ts
+++ b/frontend/packages/editor/src/blocknote/core/api/nodeConversions/nodeConversions.ts
@@ -16,9 +16,9 @@ import {
Styles,
ToggledStyle,
} from '../../extensions/Blocks/api/inlineContentTypes'
-import {getBlockInfoFromPos} from '../../extensions/Blocks/helpers/getBlockInfoFromPos'
import UniqueID from '../../extensions/UniqueID/UniqueID'
import {UnreachableCaseError} from '../../shared/utils'
+import {getBlockInfo} from '../../extensions/Blocks/helpers/getBlockInfoFromPos'
const toggleStyles = new Set([
'bold',
@@ -91,7 +91,7 @@ function styledTextArrayToNodes(
content: string | StyledText[],
schema: Schema,
): Node[] {
- let nodes: Node[] = []
+ const nodes: Node[] = []
if (typeof content === 'string') {
nodes.push(
@@ -113,7 +113,7 @@ export function inlineContentToNodes(
blockContent: PartialInlineContent[],
schema: Schema,
): Node[] {
- let nodes: Node[] = []
+ const nodes: Node[] = []
for (const content of blockContent) {
if (content.type === 'link') {
@@ -369,7 +369,7 @@ export function nodeToBlock(
return cachedBlock
}
- const blockInfo = getBlockInfoFromPos(node, 0)!
+ const blockInfo = getBlockInfo(node)
let id = blockInfo.id
@@ -380,7 +380,7 @@ export function nodeToBlock(
const props: any = {}
for (const [attr, value] of Object.entries({
- ...blockInfo.node.attrs,
+ ...node.attrs,
...blockInfo.contentNode.attrs,
})) {
const blockSpec = blockSchema[blockInfo.contentType.name]
@@ -414,7 +414,7 @@ export function nodeToBlock(
const children: Block[] = []
for (let i = 0; i < blockInfo.numChildBlocks; i++) {
children.push(
- nodeToBlock(blockInfo.node.lastChild!.child(i), blockSchema, blockCache),
+ nodeToBlock(node.lastChild!.child(i), blockSchema, blockCache),
)
}
diff --git a/frontend/packages/editor/src/blocknote/core/editor.module.css b/frontend/packages/editor/src/blocknote/core/editor.module.css
index 0c7723e881..d6def15c8e 100644
--- a/frontend/packages/editor/src/blocknote/core/editor.module.css
+++ b/frontend/packages/editor/src/blocknote/core/editor.module.css
@@ -47,9 +47,21 @@ Tippy popups that are appended to document.body directly
.defaultStyles {
font-size: 16px;
font-weight: normal;
- font-family: 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont,
- 'Open Sans', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell',
- 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
+ font-family:
+ 'Inter',
+ 'SF Pro Display',
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Open Sans',
+ 'Segoe UI',
+ 'Roboto',
+ 'Oxygen',
+ 'Ubuntu',
+ 'Cantarell',
+ 'Fira Sans',
+ 'Droid Sans',
+ 'Helvetica Neue',
+ sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/Blocks/PreviousBlockTypePlugin.ts b/frontend/packages/editor/src/blocknote/core/extensions/Blocks/PreviousBlockTypePlugin.ts
index ad29663923..72a8a5ffc3 100644
--- a/frontend/packages/editor/src/blocknote/core/extensions/Blocks/PreviousBlockTypePlugin.ts
+++ b/frontend/packages/editor/src/blocknote/core/extensions/Blocks/PreviousBlockTypePlugin.ts
@@ -96,7 +96,7 @@ export const PreviousBlockTypePlugin = () => {
const newNodes = findChildren(newState.doc, (node) => node.attrs.id)
// Traverses all block containers in the new editor state.
- for (let node of newNodes) {
+ for (const node of newNodes) {
const oldNode = oldNodesById.get(node.node.attrs.id)
const oldContentNode = oldNode?.node.firstChild
@@ -191,7 +191,7 @@ export const PreviousBlockTypePlugin = () => {
pluginState.currentTransactionOldBlockAttrs[node.attrs.id]
const decorationAttrs: any = {}
- for (let [nodeAttr, val] of Object.entries(prevAttrs)) {
+ for (const [nodeAttr, val] of Object.entries(prevAttrs)) {
decorationAttrs['data-prev-' + nodeAttributes[nodeAttr]] =
val || 'none'
}
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/Blocks/api/blockTypes.ts b/frontend/packages/editor/src/blocknote/core/extensions/Blocks/api/blockTypes.ts
index 50bca161cb..3a322b4cfd 100644
--- a/frontend/packages/editor/src/blocknote/core/extensions/Blocks/api/blockTypes.ts
+++ b/frontend/packages/editor/src/blocknote/core/extensions/Blocks/api/blockTypes.ts
@@ -2,6 +2,7 @@
import {Node, NodeConfig} from '@tiptap/core'
import {BlockNoteEditor} from '../../../BlockNoteEditor'
import {InlineContent, PartialInlineContent} from './inlineContentTypes'
+import {DefaultBlockSchema} from './defaultBlocks'
export type BlockNoteDOMElement =
| 'editor'
@@ -162,7 +163,7 @@ type BlocksWithoutChildren = {
// Converts each block spec into a Block object without children, merges them
// into a union type, and adds a children property
-export type Block =
+export type Block =
BlocksWithoutChildren[keyof BlocksWithoutChildren] & {
children: Block[]
}
@@ -187,7 +188,7 @@ type PartialBlocksWithoutChildren = {
// Same as Block, but as a partial type with some changes to make it easier to
// create/update blocks in the editor.
-export type PartialBlock =
+export type PartialBlock =
PartialBlocksWithoutChildren[keyof PartialBlocksWithoutChildren] &
Partial<{
children: PartialBlock[]
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/Blocks/helpers/getBlockInfoFromPos.ts b/frontend/packages/editor/src/blocknote/core/extensions/Blocks/helpers/getBlockInfoFromPos.ts
index fed94e4b6f..b43a7b9e69 100644
--- a/frontend/packages/editor/src/blocknote/core/extensions/Blocks/helpers/getBlockInfoFromPos.ts
+++ b/frontend/packages/editor/src/blocknote/core/extensions/Blocks/helpers/getBlockInfoFromPos.ts
@@ -1,40 +1,91 @@
import {Node, NodeType} from 'prosemirror-model'
-export type BlockInfo = {
+export type BlockInfoWithoutPositions = {
id: string
node: Node
contentNode: Node
contentType: NodeType
numChildBlocks: number
+}
+
+export type BlockInfo = BlockInfoWithoutPositions & {
startPos: number
endPos: number
depth: number
}
/**
- * Retrieves information regarding the most nested block node in a ProseMirror doc, that a given position lies in.
+ * Helper function for `getBlockInfoFromPos`, returns information regarding
+ * provided blockContainer node.
+ * @param blockContainer The blockContainer node to retrieve info for.
+ */
+export function getBlockInfo(blockContainer: Node): BlockInfoWithoutPositions {
+ const id = blockContainer.attrs['id']
+ const contentNode = blockContainer.firstChild!
+ const contentType = contentNode.type
+ const numChildBlocks =
+ blockContainer.childCount === 2 ? blockContainer.lastChild!.childCount : 0
+
+ return {
+ id,
+ node: blockContainer,
+ contentNode,
+ contentType,
+ numChildBlocks,
+ }
+}
+
+/**
+ * Retrieves information regarding the nearest blockContainer node in a
+ * ProseMirror doc, relative to a position.
* @param doc The ProseMirror doc.
- * @param posInBlock A position somewhere within a block node.
- * @returns A BlockInfo object for the block the given position is in, or undefined if the position is not in a block
- * for the given doc.
+ * @param pos An integer position.
+ * @returns A BlockInfo object for the nearest blockContainer node.
*/
-export function getBlockInfoFromPos(
- doc: Node,
- posInBlock: number,
-): BlockInfo | undefined {
- if (posInBlock < 0 || posInBlock > doc.nodeSize) {
- return undefined
+export function getBlockInfoFromPos(doc: Node, pos: number): BlockInfo {
+ // If the position is outside the outer block group, we need to move it to the
+ // nearest block. This happens when the collaboration plugin is active, where
+ // the selection is placed at the very end of the doc.
+ const outerBlockGroupStartPos = 1
+ const outerBlockGroupEndPos = doc.nodeSize - 2
+ if (pos <= outerBlockGroupStartPos) {
+ pos = outerBlockGroupStartPos + 1
+
+ while (
+ doc.resolve(pos).parent.type.name !== 'blockContainer' &&
+ pos < outerBlockGroupEndPos
+ ) {
+ pos++
+ }
+ } else if (pos >= outerBlockGroupEndPos) {
+ pos = outerBlockGroupEndPos - 1
+
+ while (
+ doc.resolve(pos).parent.type.name !== 'blockContainer' &&
+ pos > outerBlockGroupStartPos
+ ) {
+ pos--
+ }
}
- const $pos = doc.resolve(posInBlock)
+ // This gets triggered when a node selection on a block is active, i.e. when
+ // you drag and drop a block.
+ if (doc.resolve(pos).parent.type.name === 'blockGroup') {
+ pos++
+ }
+
+ const $pos = doc.resolve(pos)
const maxDepth = $pos.depth
let node = $pos.node(maxDepth)
let depth = maxDepth
+ // eslint-disable-next-line no-constant-condition
while (true) {
if (depth < 0) {
- return undefined
+ throw new Error(
+ 'Could not find blockContainer node. This can only happen if the underlying BlockNote schema has been edited.',
+ )
}
if (node.type.name === 'blockContainer') {
@@ -45,10 +96,7 @@ export function getBlockInfoFromPos(
node = $pos.node(depth)
}
- const id = node.attrs['id']
- const contentNode = node.firstChild!
- const contentType = contentNode.type
- const numChildBlocks = node.childCount === 2 ? node.lastChild!.childCount : 0
+ const {id, contentNode, contentType, numChildBlocks} = getBlockInfo(node)
const startPos = $pos.start(depth)
const endPos = $pos.end(depth)
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/Blocks/index.ts b/frontend/packages/editor/src/blocknote/core/extensions/Blocks/index.ts
index 59ec2f15e4..15d2de2b4b 100644
--- a/frontend/packages/editor/src/blocknote/core/extensions/Blocks/index.ts
+++ b/frontend/packages/editor/src/blocknote/core/extensions/Blocks/index.ts
@@ -1,7 +1,6 @@
import {Node} from '@tiptap/core'
export {BlockContainer} from './nodes/BlockContainer'
export {BlockGroup} from './nodes/BlockGroup'
-
export const Doc = Node.create({
name: 'doc',
topNode: true,
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/Blocks/nodes/Block.module.css b/frontend/packages/editor/src/blocknote/core/extensions/Blocks/nodes/Block.module.css
index 0c29282ece..a1f431692e 100644
--- a/frontend/packages/editor/src/blocknote/core/extensions/Blocks/nodes/Block.module.css
+++ b/frontend/packages/editor/src/blocknote/core/extensions/Blocks/nodes/Block.module.css
@@ -5,6 +5,7 @@ BASIC STYLES
.blockOuter {
line-height: 1.5;
transition: margin 0.2s;
+ padding-inline: 0.5em;
}
/*Ensures blocks & block content spans editor width*/
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/Blocks/nodes/BlockContainer.ts b/frontend/packages/editor/src/blocknote/core/extensions/Blocks/nodes/BlockContainer.ts
index 2556a28eee..005ad7fba2 100644
--- a/frontend/packages/editor/src/blocknote/core/extensions/Blocks/nodes/BlockContainer.ts
+++ b/frontend/packages/editor/src/blocknote/core/extensions/Blocks/nodes/BlockContainer.ts
@@ -6,15 +6,15 @@ import {
inlineContentToNodes,
} from '../../../api/nodeConversions/nodeConversions'
-import {getBlockInfoFromPos} from '../helpers/getBlockInfoFromPos'
-import {PreviousBlockTypePlugin} from '../PreviousBlockTypePlugin'
-import styles from './Block.module.css'
-import BlockAttributes from './BlockAttributes'
import {
BlockNoteDOMAttributes,
BlockSchema,
PartialBlock,
} from '../api/blockTypes'
+import {getBlockInfoFromPos} from '../helpers/getBlockInfoFromPos'
+import {PreviousBlockTypePlugin} from '../PreviousBlockTypePlugin'
+import styles from './Block.module.css'
+import BlockAttributes from './BlockAttributes'
import {mergeCSSClasses} from '../../../shared/utils'
declare module '@tiptap/core' {
@@ -58,14 +58,14 @@ export const BlockContainer = Node.create<{
parseHTML() {
return [
{
- tag: 'li',
+ tag: 'div',
getAttrs: (element) => {
if (typeof element === 'string') {
return false
}
const attrs: Record = {}
- for (let [nodeAttr, HTMLAttr] of Object.entries(BlockAttributes)) {
+ for (const [nodeAttr, HTMLAttr] of Object.entries(BlockAttributes)) {
if (element.getAttribute(HTMLAttr)) {
attrs[nodeAttr] = element.getAttribute(HTMLAttr)!
}
@@ -85,7 +85,7 @@ export const BlockContainer = Node.create<{
const domAttributes = this.options.domAttributes?.blockContainer || {}
return [
- 'li',
+ 'div',
mergeAttributes(HTMLAttributes, {
class: styles.blockOuter,
'data-node-type': 'block-outer',
@@ -154,7 +154,6 @@ export const BlockContainer = Node.create<{
// Creates ProseMirror nodes for each child block, including their descendants.
for (const child of block.children) {
- // @ts-ignore
childNodes.push(blockToNode(child, state.schema))
}
@@ -286,6 +285,7 @@ export const BlockContainer = Node.create<{
}
// Deletes next block and adds its text content to the nearest previous block.
+
if (dispatch) {
dispatch(
state.tr
@@ -302,6 +302,7 @@ export const BlockContainer = Node.create<{
new TextSelection(state.doc.resolve(prevBlockEndPos - 1)),
)
}
+
return true
},
// Splits a block at a given position. Content after the position is moved to a new block below, at the same
@@ -528,15 +529,15 @@ export const BlockContainer = Node.create<{
}),
// Reverts block content type to a paragraph if the selection is at the start of the block.
() =>
- commands.command(({state, view}) => {
- const blockInfo = getBlockInfoFromPos(
+ commands.command(({state}) => {
+ const {contentType} = getBlockInfoFromPos(
state.doc,
state.selection.from,
)!
const selectionAtBlockStart =
state.selection.$anchor.parentOffset === 0
- const isParagraph = blockInfo.contentType.name === 'paragraph'
+ const isParagraph = contentType.name === 'paragraph'
if (selectionAtBlockStart && !isParagraph) {
return commands.BNUpdateBlock(state.selection.from, {
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/Blocks/nodes/BlockContainer.tsx b/frontend/packages/editor/src/blocknote/core/extensions/Blocks/nodes/BlockContainer.tsx
new file mode 100644
index 0000000000..2f8f9f57e4
--- /dev/null
+++ b/frontend/packages/editor/src/blocknote/core/extensions/Blocks/nodes/BlockContainer.tsx
@@ -0,0 +1,719 @@
+import {mergeAttributes, Node} from '@tiptap/core'
+import {Fragment, Node as PMNode, Slice} from 'prosemirror-model'
+import {NodeSelection, TextSelection} from 'prosemirror-state'
+import {
+ blockToNode,
+ inlineContentToNodes,
+} from '../../../api/nodeConversions/nodeConversions'
+
+import {
+ BlockNoteDOMAttributes,
+ BlockSchema,
+ PartialBlock,
+} from '../api/blockTypes'
+import {getBlockInfoFromPos} from '../helpers/getBlockInfoFromPos'
+import {PreviousBlockTypePlugin} from '../PreviousBlockTypePlugin'
+import styles from './Block.module.css'
+import BlockAttributes from './BlockAttributes'
+import {mergeCSSClasses} from '../../../shared/utils'
+
+declare module '@tiptap/core' {
+ interface Commands {
+ block: {
+ BNCreateBlock: (pos: number) => ReturnType
+ BNDeleteBlock: (posInBlock: number) => ReturnType
+ BNMergeBlocks: (posBetweenBlocks: number) => ReturnType
+ BNSplitBlock: (posInBlock: number, keepType: boolean) => ReturnType
+ BNUpdateBlock: (
+ posInBlock: number,
+ block: PartialBlock,
+ ) => ReturnType
+ BNCreateOrUpdateBlock: (
+ posInBlock: number,
+ block: PartialBlock,
+ ) => ReturnType
+ UpdateGroup: (
+ posInBlock: number,
+ listType: string,
+ start?: string,
+ ) => ReturnType
+ }
+ }
+}
+
+/**
+ * The main "Block node" documents consist of
+ */
+export const BlockContainer = Node.create<{
+ domAttributes?: BlockNoteDOMAttributes
+}>({
+ name: 'blockContainer',
+ group: 'blockContainer',
+ // A block always contains content, and optionally a blockGroup which contains nested blocks
+ content: 'blockContent blockGroup?',
+ // Ensures content-specific keyboard handlers trigger first.
+ priority: 50,
+ defining: true,
+
+ parseHTML() {
+ return [
+ {
+ tag: 'div',
+ getAttrs: (element) => {
+ if (typeof element === 'string') {
+ return false
+ }
+
+ const attrs: Record = {}
+ for (const [nodeAttr, HTMLAttr] of Object.entries(BlockAttributes)) {
+ if (element.getAttribute(HTMLAttr)) {
+ attrs[nodeAttr] = element.getAttribute(HTMLAttr)!
+ }
+ }
+
+ if (element.getAttribute('data-node-type') === 'blockContainer') {
+ return attrs
+ }
+
+ return false
+ },
+ },
+ ]
+ },
+
+ renderHTML({HTMLAttributes}) {
+ const domAttributes = this.options.domAttributes?.blockContainer || {}
+ let attrs = mergeAttributes(HTMLAttributes, {
+ class: styles.blockOuter,
+ 'data-node-type': 'block-outer',
+ })
+
+ console.log(`== ~ renderHTML ~ attrs:`, attrs)
+
+ let innerAttrs = {
+ ...domAttributes,
+ class: mergeCSSClasses(styles.block, domAttributes.class),
+ 'data-node-type': this.name,
+ }
+ return [
+ 'div',
+ attrs,
+ ['div', mergeAttributes(innerAttrs, HTMLAttributes), 0],
+ ]
+ },
+
+ addCommands() {
+ return {
+ // Creates a new text block at a given position.
+ BNCreateBlock:
+ (pos) =>
+ ({state, dispatch}) => {
+ const newBlock = state.schema.nodes['blockContainer'].createAndFill()!
+
+ if (dispatch) {
+ state.tr.insert(pos, newBlock)
+ }
+
+ return true
+ },
+ // Deletes a block at a given position.
+ BNDeleteBlock:
+ (posInBlock) =>
+ ({state, dispatch}) => {
+ const blockInfo = getBlockInfoFromPos(state.doc, posInBlock)
+ if (blockInfo === undefined) {
+ return false
+ }
+
+ const {startPos, endPos} = blockInfo
+
+ if (dispatch) {
+ state.tr.deleteRange(startPos, endPos)
+ }
+
+ return true
+ },
+ // Updates a block at a given position.
+ BNUpdateBlock:
+ (posInBlock, block) =>
+ ({state, dispatch}) => {
+ const blockInfo = getBlockInfoFromPos(state.doc, posInBlock)
+ if (blockInfo === undefined) {
+ return false
+ }
+
+ const {startPos, endPos, node, contentNode} = blockInfo
+
+ if (dispatch) {
+ // Adds blockGroup node with child blocks if necessary.
+ if (block.children !== undefined) {
+ const childNodes = []
+
+ // Creates ProseMirror nodes for each child block, including their descendants.
+ for (const child of block.children) {
+ childNodes.push(blockToNode(child, state.schema))
+ }
+
+ // Checks if a blockGroup node already exists.
+ if (node.childCount === 2) {
+ // Replaces all child nodes in the existing blockGroup with the ones created earlier.
+ state.tr.replace(
+ startPos + contentNode.nodeSize + 1,
+ endPos - 1,
+ new Slice(Fragment.from(childNodes), 0, 0),
+ )
+ } else {
+ // Inserts a new blockGroup containing the child nodes created earlier.
+ state.tr.insert(
+ startPos + contentNode.nodeSize,
+ state.schema.nodes['blockGroup'].create({}, childNodes),
+ )
+ }
+ }
+
+ // Replaces the blockContent node's content if necessary.
+ if (block.content !== undefined) {
+ let content: PMNode[] = []
+
+ // Checks if the provided content is a string or InlineContent[] type.
+ if (typeof block.content === 'string') {
+ // Adds a single text node with no marks to the content.
+ content.push(state.schema.text(block.content))
+ } else {
+ // Adds a text node with the provided styles converted into marks to the content, for each InlineContent
+ // object.
+ content = inlineContentToNodes(block.content, state.schema)
+ }
+
+ // Replaces the contents of the blockContent node with the previously created text node(s).
+ state.tr.replace(
+ startPos + 1,
+ startPos + contentNode.nodeSize - 1,
+ new Slice(Fragment.from(content), 0, 0),
+ )
+ }
+
+ // Changes the blockContent node type and adds the provided props as attributes. Also preserves all existing
+ // attributes that are compatible with the new type.
+ state.tr.setNodeMarkup(
+ startPos,
+ block.type === undefined
+ ? undefined
+ : state.schema.nodes[block.type],
+ {
+ ...contentNode.attrs,
+ ...block.props,
+ },
+ )
+
+ // Adds all provided props as attributes to the parent blockContainer node too, and also preserves existing
+ // attributes.
+ state.tr.setNodeMarkup(startPos - 1, undefined, {
+ ...node.attrs,
+ ...block.props,
+ })
+ }
+
+ return true
+ },
+ // Appends the text contents of a block to the nearest previous block, given a position between them. Children of
+ // the merged block are moved out of it first, rather than also being merged.
+ //
+ // In the example below, the position passed into the function is between Block1 and Block2.
+ //
+ // Block1
+ // Block2
+ // Block3
+ // Block4
+ // Block5
+ //
+ // Becomes:
+ //
+ // Block1
+ // Block2Block3
+ // Block4
+ // Block5
+ BNMergeBlocks:
+ (posBetweenBlocks) =>
+ ({state, dispatch}) => {
+ const nextNodeIsBlock =
+ state.doc.resolve(posBetweenBlocks + 1).node().type.name ===
+ 'blockContainer'
+ const prevNodeIsBlock =
+ state.doc.resolve(posBetweenBlocks - 1).node().type.name ===
+ 'blockContainer'
+
+ if (!nextNodeIsBlock || !prevNodeIsBlock) {
+ return false
+ }
+
+ const nextBlockInfo = getBlockInfoFromPos(
+ state.doc,
+ posBetweenBlocks + 1,
+ )
+
+ const {node, contentNode, startPos, endPos, depth} = nextBlockInfo!
+
+ // Removes a level of nesting all children of the next block by 1 level, if it contains both content and block
+ // group nodes.
+ if (node.childCount === 2) {
+ const childBlocksStart = state.doc.resolve(
+ startPos + contentNode.nodeSize + 1,
+ )
+ const childBlocksEnd = state.doc.resolve(endPos - 1)
+ const childBlocksRange = childBlocksStart.blockRange(childBlocksEnd)
+
+ // Moves the block group node inside the block into the block group node that the current block is in.
+ if (dispatch) {
+ state.tr.lift(childBlocksRange!, depth - 1)
+ }
+ }
+
+ let prevBlockEndPos = posBetweenBlocks - 1
+ let prevBlockInfo = getBlockInfoFromPos(state.doc, prevBlockEndPos)
+
+ // Finds the nearest previous block, regardless of nesting level.
+ while (prevBlockInfo!.numChildBlocks > 0) {
+ prevBlockEndPos--
+ prevBlockInfo = getBlockInfoFromPos(state.doc, prevBlockEndPos)
+ if (prevBlockInfo === undefined) {
+ return false
+ }
+ }
+
+ // Deletes next block and adds its text content to the nearest previous block.
+
+ if (dispatch) {
+ dispatch(
+ state.tr
+ .deleteRange(startPos, startPos + contentNode.nodeSize)
+ .replace(
+ prevBlockEndPos - 1,
+ startPos,
+ new Slice(contentNode.content, 0, 0),
+ )
+ .scrollIntoView(),
+ )
+
+ state.tr.setSelection(
+ new TextSelection(state.doc.resolve(prevBlockEndPos - 1)),
+ )
+ }
+
+ return true
+ },
+ // Splits a block at a given position. Content after the position is moved to a new block below, at the same
+ // nesting level.
+ BNSplitBlock:
+ (posInBlock, keepType) =>
+ ({state, dispatch}) => {
+ const blockInfo = getBlockInfoFromPos(state.doc, posInBlock)
+ if (blockInfo === undefined) {
+ return false
+ }
+
+ const {contentNode, contentType, startPos, endPos, depth} = blockInfo
+
+ const originalBlockContent = state.doc.cut(startPos + 1, posInBlock)
+ const newBlockContent = state.doc.cut(posInBlock, endPos - 1)
+
+ const newBlock = state.schema.nodes['blockContainer'].createAndFill()!
+
+ const newBlockInsertionPos = endPos + 1
+ const newBlockContentPos = newBlockInsertionPos + 2
+
+ if (dispatch) {
+ // Creates a new block. Since the schema requires it to have a content node, a paragraph node is created
+ // automatically, spanning newBlockContentPos to newBlockContentPos + 1.
+ state.tr.insert(newBlockInsertionPos, newBlock)
+
+ // Replaces the content of the newly created block's content node. Doesn't replace the whole content node so
+ // its type doesn't change.
+ state.tr.replace(
+ newBlockContentPos,
+ newBlockContentPos + 1,
+ newBlockContent.content.size > 0
+ ? new Slice(
+ Fragment.from(newBlockContent),
+ depth + 2,
+ depth + 2,
+ )
+ : undefined,
+ )
+
+ // Changes the type of the content node. The range doesn't matter as long as both from and to positions are
+ // within the content node.
+ if (keepType) {
+ state.tr.setBlockType(
+ newBlockContentPos,
+ newBlockContentPos,
+ state.schema.node(contentType).type,
+ contentNode.attrs,
+ )
+ }
+
+ // Sets the selection to the start of the new block's content node.
+ state.tr.setSelection(
+ new TextSelection(state.doc.resolve(newBlockContentPos)),
+ )
+
+ // Replaces the content of the original block's content node. Doesn't replace the whole content node so its
+ // type doesn't change.
+ state.tr.replace(
+ startPos + 1,
+ endPos - 1,
+ originalBlockContent.content.size > 0
+ ? new Slice(
+ Fragment.from(originalBlockContent),
+ depth + 2,
+ depth + 2,
+ )
+ : undefined,
+ )
+ }
+
+ return true
+ },
+ // Updates a block group at a given position.
+ UpdateGroup:
+ (posInBlock, listType, start) =>
+ ({state, dispatch}) => {
+ if (posInBlock < 0) posInBlock = state.selection.from
+ const $pos = state.doc.resolve(posInBlock)
+ const maxDepth = $pos.depth
+ // Set group to first node found at position
+ let group = $pos.node(maxDepth)
+ let container
+ let depth = maxDepth
+
+ // Find block group, block container and depth it is at
+ while (true) {
+ if (depth < 0) {
+ break
+ }
+
+ if (group.type.name === 'blockGroup') {
+ break
+ }
+
+ if (group.type.name === 'blockContainer') {
+ container = group
+ }
+
+ depth -= 1
+ group = $pos.node(depth)
+ }
+
+ // If block is first block in the document do nothing
+ if (
+ $pos.node(depth - 1).type.name === 'doc' &&
+ group.firstChild?.attrs.id === container.attrs.id
+ )
+ return false
+
+ // If block is not the first in its' group, sink list item and then update group
+ if (
+ group.firstChild &&
+ container &&
+ group.firstChild.attrs.id !== container.attrs.id
+ ) {
+ setTimeout(() => {
+ this.editor
+ .chain()
+ .sinkListItem('blockContainer')
+ .UpdateGroup(-1, listType, start)
+ .run()
+
+ return true
+ })
+
+ return false
+ }
+
+ // If inserting other list type in another list, sink list item and then update group
+ if (
+ group.attrs.listType !== 'div' &&
+ group.attrs.listType !== listType &&
+ container
+ ) {
+ setTimeout(() => {
+ this.editor
+ .chain()
+ .sinkListItem('blockContainer')
+ .UpdateGroup(-1, listType, start)
+ .run()
+
+ return true
+ })
+ return false
+ }
+
+ if (dispatch && group.type.name === 'blockGroup') {
+ start
+ ? state.tr.setNodeMarkup($pos.before(depth), null, {
+ ...group.attrs,
+ listType: listType,
+ start: parseInt(start),
+ })
+ : state.tr.setNodeMarkup($pos.before(depth), null, {
+ ...group.attrs,
+ listType: listType,
+ })
+ }
+
+ return true
+ },
+ }
+ },
+
+ addProseMirrorPlugins() {
+ return [PreviousBlockTypePlugin()]
+ },
+
+ addKeyboardShortcuts() {
+ // handleBackspace is partially adapted from https://github.com/ueberdosis/tiptap/blob/ed56337470efb4fd277128ab7ef792b37cfae992/packages/core/src/extensions/keymap.ts
+ const handleBackspace = () =>
+ this.editor.commands.first(({commands}) => [
+ // Deletes the selection if it's not empty.
+ () => commands.deleteSelection(),
+ // Undoes an input rule if one was triggered in the last editor state change.
+ () => commands.undoInputRule(),
+ // If previous block is media, node select it
+ () =>
+ commands.command(({state, view}) => {
+ const blockInfo = getBlockInfoFromPos(
+ state.doc,
+ state.selection.from,
+ )!
+ const prevBlockInfo = getBlockInfoFromPos(
+ state.doc,
+ state.selection.$anchor.pos - state.selection.$anchor.depth,
+ )
+ const selectionAtBlockStart =
+ state.selection.$anchor.parentOffset === 0
+
+ if (selectionAtBlockStart) {
+ if (blockInfo.contentType.name === 'image') {
+ let tr = state.tr
+ const selection = NodeSelection.create(
+ state.doc,
+ blockInfo.startPos,
+ )
+ tr = tr.setSelection(selection)
+ view.dispatch(tr)
+ return true
+ }
+ if (!prevBlockInfo) return false
+ if (
+ ['file', 'embed', 'video'].includes(
+ prevBlockInfo.contentType.name,
+ ) ||
+ (prevBlockInfo.contentType.name === 'image' &&
+ prevBlockInfo.contentNode.attrs.url.length === 0)
+ ) {
+ let tr = state.tr
+ const selection = NodeSelection.create(
+ state.doc,
+ prevBlockInfo.startPos,
+ )
+ tr = tr.setSelection(selection)
+ view.dispatch(tr)
+ return true
+ }
+ }
+
+ return false
+ }),
+ // Reverts block content type to a paragraph if the selection is at the start of the block.
+ () =>
+ commands.command(({state}) => {
+ const {contentType} = getBlockInfoFromPos(
+ state.doc,
+ state.selection.from,
+ )!
+
+ const selectionAtBlockStart =
+ state.selection.$anchor.parentOffset === 0
+ const isParagraph = contentType.name === 'paragraph'
+
+ if (selectionAtBlockStart && !isParagraph) {
+ return commands.BNUpdateBlock(state.selection.from, {
+ type: 'paragraph',
+ props: {},
+ })
+ }
+
+ return false
+ }),
+ // Removes a level of nesting if the block is indented if the selection is at the start of the block.
+ () =>
+ commands.command(({state}) => {
+ const selectionAtBlockStart =
+ state.selection.$anchor.parentOffset === 0
+
+ if (selectionAtBlockStart) {
+ return commands.liftListItem('blockContainer')
+ }
+
+ return false
+ }),
+ // Merges block with the previous one if it isn't indented, isn't the first block in the doc, and the selection
+ // is at the start of the block.
+ () =>
+ commands.command(({state}) => {
+ const {depth, startPos} = getBlockInfoFromPos(
+ state.doc,
+ state.selection.from,
+ )!
+
+ const selectionAtBlockStart =
+ state.selection.$anchor.parentOffset === 0
+ const selectionEmpty =
+ state.selection.anchor === state.selection.head
+ const blockAtDocStart = startPos === 2
+
+ const posBetweenBlocks = startPos - 1
+
+ if (
+ !blockAtDocStart &&
+ selectionAtBlockStart &&
+ selectionEmpty &&
+ depth === 2
+ ) {
+ return commands.BNMergeBlocks(posBetweenBlocks)
+ }
+
+ return false
+ }),
+ ])
+
+ const handleEnter = () =>
+ this.editor.commands.first(({commands}) => [
+ // Removes a level of nesting if the block is empty & indented, while the selection is also empty & at the start
+ // of the block.
+ () =>
+ commands.command(({state}) => {
+ const {node, depth} = getBlockInfoFromPos(
+ state.doc,
+ state.selection.from,
+ )!
+
+ const selectionAtBlockStart =
+ state.selection.$anchor.parentOffset === 0
+ const selectionEmpty =
+ state.selection.anchor === state.selection.head
+ const blockEmpty = node.textContent.length === 0
+ const blockIndented = depth > 2
+
+ if (
+ selectionAtBlockStart &&
+ selectionEmpty &&
+ blockEmpty &&
+ blockIndented
+ ) {
+ return commands.liftListItem('blockContainer')
+ }
+
+ return false
+ }),
+ // Creates a new block and moves the selection to it if the current one is empty, while the selection is also
+ // empty & at the start of the block.
+ () =>
+ commands.command(({state, chain}) => {
+ const {node, endPos} = getBlockInfoFromPos(
+ state.doc,
+ state.selection.from,
+ )!
+
+ const selectionAtBlockStart =
+ state.selection.$anchor.parentOffset === 0
+ const selectionEmpty =
+ state.selection.anchor === state.selection.head
+ const blockEmpty = node.textContent.length === 0
+
+ if (selectionAtBlockStart && selectionEmpty && blockEmpty) {
+ const newBlockInsertionPos = endPos + 1
+ const newBlockContentPos = newBlockInsertionPos + 2
+
+ chain()
+ .BNCreateBlock(newBlockInsertionPos)
+ .setTextSelection(newBlockContentPos)
+ .run()
+
+ return true
+ }
+
+ return false
+ }),
+ // Splits the current block, moving content inside that's after the cursor to a new text block below. Also
+ // deletes the selection beforehand, if it's not empty.
+ () =>
+ commands.command(({state, chain}) => {
+ const {node} = getBlockInfoFromPos(state.doc, state.selection.from)!
+
+ const blockEmpty = node.textContent.length === 0
+
+ if (!blockEmpty) {
+ chain()
+ .deleteSelection()
+ .BNSplitBlock(state.selection.from, false)
+ .run()
+
+ return true
+ }
+
+ return false
+ }),
+ ])
+
+ return {
+ Backspace: handleBackspace,
+ Enter: handleEnter,
+ // Always returning true for tab key presses ensures they're not captured by the browser. Otherwise, they blur the
+ // editor since the browser will try to use tab for keyboard navigation.
+ Tab: () => {
+ this.editor.commands.sinkListItem('blockContainer')
+ return true
+ },
+ 'Shift-Tab': () => {
+ this.editor.commands.liftListItem('blockContainer')
+ return true
+ },
+ 'Mod-Alt-0': () =>
+ this.editor.commands.BNCreateBlock(
+ this.editor.state.selection.anchor + 2,
+ ),
+ 'Mod-Alt-1': () =>
+ this.editor.commands.BNUpdateBlock(this.editor.state.selection.anchor, {
+ type: 'heading',
+ props: {
+ level: '1',
+ },
+ }),
+ 'Mod-Alt-2': () =>
+ this.editor.commands.BNUpdateBlock(this.editor.state.selection.anchor, {
+ type: 'heading',
+ props: {
+ level: '2',
+ },
+ }),
+ 'Mod-Alt-3': () =>
+ this.editor.commands.BNUpdateBlock(this.editor.state.selection.anchor, {
+ type: 'heading',
+ props: {
+ level: '3',
+ },
+ }),
+ 'Mod-Shift-7': () =>
+ this.editor.commands.BNUpdateBlock(this.editor.state.selection.anchor, {
+ type: 'bulletListItem',
+ props: {},
+ }),
+ 'Mod-Shift-8': () =>
+ this.editor.commands.BNUpdateBlock(this.editor.state.selection.anchor, {
+ type: 'numberedListItem',
+ props: {},
+ }),
+ }
+ },
+})
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/FormattingToolbar/FormattingToolbarExtension.ts b/frontend/packages/editor/src/blocknote/core/extensions/FormattingToolbar/FormattingToolbarExtension.ts
deleted file mode 100644
index f83d482ba9..0000000000
--- a/frontend/packages/editor/src/blocknote/core/extensions/FormattingToolbar/FormattingToolbarExtension.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import {Extension} from '@tiptap/core'
-import {PluginKey} from 'prosemirror-state'
-import {BlockNoteEditor, BlockSchema} from '../..'
-import {FormattingToolbarFactory} from './FormattingToolbarFactoryTypes'
-import {createFormattingToolbarPlugin} from './FormattingToolbarPlugin'
-
-export type FormattingToolbarOptions = {
- formattingToolbarFactory: FormattingToolbarFactory
- editor: BlockNoteEditor
-}
-
-/**
- * The menu that is displayed when selecting a piece of text.
- */
-export const createFormattingToolbarExtension = <
- BSchema extends BlockSchema,
->() =>
- Extension.create>({
- name: 'FormattingToolbarExtension',
-
- addProseMirrorPlugins() {
- if (!this.options.formattingToolbarFactory || !this.options.editor) {
- throw new Error(
- 'required args not defined for FormattingToolbarExtension',
- )
- }
-
- return [
- createFormattingToolbarPlugin({
- tiptapEditor: this.editor,
- editor: this.options.editor,
- formattingToolbarFactory: this.options.formattingToolbarFactory,
- pluginKey: new PluginKey('FormattingToolbarPlugin'),
- }),
- ]
- },
- })
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/FormattingToolbar/FormattingToolbarFactoryTypes.ts b/frontend/packages/editor/src/blocknote/core/extensions/FormattingToolbar/FormattingToolbarFactoryTypes.ts
deleted file mode 100644
index b09464301e..0000000000
--- a/frontend/packages/editor/src/blocknote/core/extensions/FormattingToolbar/FormattingToolbarFactoryTypes.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import {EditorElement, ElementFactory} from '../../shared/EditorElement'
-import {BlockNoteEditor} from '../../BlockNoteEditor'
-import {BlockSchema} from '../Blocks/api/blockTypes'
-
-export type FormattingToolbarStaticParams = {
- editor: BlockNoteEditor
-
- getReferenceRect: () => DOMRect
-}
-
-export type FormattingToolbarDynamicParams = {}
-
-export type FormattingToolbar = EditorElement
-export type FormattingToolbarFactory =
- ElementFactory<
- FormattingToolbarStaticParams,
- FormattingToolbarDynamicParams
- >
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/FormattingToolbar/FormattingToolbarPlugin.ts b/frontend/packages/editor/src/blocknote/core/extensions/FormattingToolbar/FormattingToolbarPlugin.ts
index aad18635c7..c67ce6bf88 100644
--- a/frontend/packages/editor/src/blocknote/core/extensions/FormattingToolbar/FormattingToolbarPlugin.ts
+++ b/frontend/packages/editor/src/blocknote/core/extensions/FormattingToolbar/FormattingToolbarPlugin.ts
@@ -1,50 +1,26 @@
-import {
- Editor,
- isNodeSelection,
- isTextSelection,
- posToDOMRect,
-} from '@tiptap/core'
+import {isNodeSelection, isTextSelection, posToDOMRect} from '@tiptap/core'
import {EditorState, Plugin, PluginKey} from 'prosemirror-state'
import {EditorView} from 'prosemirror-view'
-import {BlockNoteEditor, BlockSchema} from '../..'
import {
- FormattingToolbar,
- FormattingToolbarFactory,
- FormattingToolbarStaticParams,
-} from './FormattingToolbarFactoryTypes'
-
-// Same as TipTap bubblemenu plugin, but with these changes:
-// https://github.com/ueberdosis/tiptap/pull/2596/files
-export interface FormattingToolbarPluginProps {
- pluginKey: PluginKey
- tiptapEditor: Editor
- editor: BlockNoteEditor
- formattingToolbarFactory: FormattingToolbarFactory
-}
+ BaseUiElementCallbacks,
+ BaseUiElementState,
+ BlockNoteEditor,
+ BlockSchema,
+} from '../..'
+import {EventEmitter} from '../../shared/EventEmitter'
-export type FormattingToolbarViewProps =
- FormattingToolbarPluginProps & {
- view: EditorView
- }
+export type FormattingToolbarCallbacks = BaseUiElementCallbacks
-export class FormattingToolbarView {
- public editor: BlockNoteEditor
- private ttEditor: Editor
+export type FormattingToolbarState = BaseUiElementState
- public view: EditorView
-
- public formattingToolbar: FormattingToolbar
+export class FormattingToolbarView {
+ private formattingToolbarState?: FormattingToolbarState
+ public updateFormattingToolbar: () => void
public preventHide = false
-
public preventShow = false
-
- public toolbarIsOpen = false
-
public prevWasEditable: boolean | null = null
- private lastPosition: DOMRect | undefined
-
public shouldShow: (props: {
view: EditorView
state: EditorState
@@ -60,30 +36,32 @@ export class FormattingToolbarView {
const isEmptyTextBlock =
!doc.textBetween(from, to).length && isTextSelection(state.selection)
- // Don't show if the selection is a node selection
- const isNode = isNodeSelection(state.selection)
-
- return !(!view.hasFocus() || empty || isEmptyTextBlock || isNode)
+ return !(!view.hasFocus() || empty || isEmptyTextBlock)
}
- constructor({
- editor,
- tiptapEditor,
- formattingToolbarFactory,
- view,
- }: FormattingToolbarViewProps) {
- this.editor = editor
- this.ttEditor = tiptapEditor
- this.view = view
+ constructor(
+ private readonly editor: BlockNoteEditor,
+ private readonly pmView: EditorView,
+ updateFormattingToolbar: (
+ formattingToolbarState: FormattingToolbarState,
+ ) => void,
+ ) {
+ this.updateFormattingToolbar = () => {
+ if (!this.formattingToolbarState) {
+ throw new Error('Attempting to update uninitialized formatting toolbar')
+ }
+
+ updateFormattingToolbar(this.formattingToolbarState)
+ }
- this.formattingToolbar = formattingToolbarFactory(this.getStaticParams())
+ pmView.dom.addEventListener('mousedown', this.viewMousedownHandler)
+ pmView.dom.addEventListener('mouseup', this.viewMouseupHandler)
+ pmView.dom.addEventListener('dragstart', this.dragstartHandler)
- this.view.dom.addEventListener('mousedown', this.viewMousedownHandler)
- this.view.dom.addEventListener('mouseup', this.viewMouseupHandler)
- this.view.dom.addEventListener('dragstart', this.dragstartHandler)
+ pmView.dom.addEventListener('focus', this.focusHandler)
+ pmView.dom.addEventListener('blur', this.blurHandler)
- this.ttEditor.on('focus', this.focusHandler)
- this.ttEditor.on('blur', this.blurHandler)
+ document.addEventListener('scroll', this.scrollHandler)
}
viewMousedownHandler = () => {
@@ -92,42 +70,54 @@ export class FormattingToolbarView {
viewMouseupHandler = () => {
this.preventShow = false
- setTimeout(() => this.update(this.ttEditor.view))
+ setTimeout(() => this.update(this.pmView))
}
+ // For dragging the whole editor.
dragstartHandler = () => {
- this.formattingToolbar.hide()
- this.toolbarIsOpen = false
+ if (this.formattingToolbarState?.show) {
+ this.formattingToolbarState.show = false
+ this.updateFormattingToolbar()
+ }
}
focusHandler = () => {
// we use `setTimeout` to make sure `selection` is already updated
- setTimeout(() => this.update(this.ttEditor.view))
+ setTimeout(() => this.update(this.pmView))
}
- blurHandler = ({event}: {event: FocusEvent}) => {
+ blurHandler = (event: FocusEvent) => {
if (this.preventHide) {
this.preventHide = false
return
}
+ const editorWrapper = this.pmView.dom.parentElement!
+
// Checks if the focus is moving to an element outside the editor. If it is,
// the toolbar is hidden.
if (
// An element is clicked.
event &&
event.relatedTarget &&
- // Element is outside the toolbar.
- (this.formattingToolbar.element === (event.relatedTarget as Node) ||
- this.formattingToolbar.element?.contains(event.relatedTarget as Node))
+ // Element is inside the editor.
+ (editorWrapper === (event.relatedTarget as Node) ||
+ editorWrapper.contains(event.relatedTarget as Node))
) {
return
}
- if (this.toolbarIsOpen) {
- this.formattingToolbar.hide()
- this.toolbarIsOpen = false
+ if (this.formattingToolbarState?.show) {
+ this.formattingToolbarState.show = false
+ this.updateFormattingToolbar()
+ }
+ }
+
+ scrollHandler = () => {
+ if (this.formattingToolbarState?.show) {
+ this.formattingToolbarState.referencePos = this.getSelectionBoundingBox()
+ this.updateFormattingToolbar()
}
}
@@ -159,53 +149,48 @@ export class FormattingToolbarView {
to,
})
- // Checks if menu should be shown.
+ // Checks if menu should be shown/updated.
if (
this.editor.isEditable &&
- !this.toolbarIsOpen &&
!this.preventShow &&
(shouldShow || this.preventHide)
) {
- this.formattingToolbar.render({}, true)
- this.toolbarIsOpen = true
+ this.formattingToolbarState = {
+ show: true,
+ referencePos: this.getSelectionBoundingBox(),
+ }
- return
- }
+ this.updateFormattingToolbar()
- // Checks if menu should be updated.
- if (
- this.toolbarIsOpen &&
- !this.preventShow &&
- (shouldShow || this.preventHide)
- ) {
- this.formattingToolbar.render({}, false)
return
}
// Checks if menu should be hidden.
if (
- this.toolbarIsOpen &&
+ this.formattingToolbarState?.show &&
!this.preventHide &&
(!shouldShow || this.preventShow || !this.editor.isEditable)
) {
- this.formattingToolbar.hide()
- this.toolbarIsOpen = false
+ this.formattingToolbarState.show = false
+ this.updateFormattingToolbar()
return
}
}
destroy() {
- this.view.dom.removeEventListener('mousedown', this.viewMousedownHandler)
- this.view.dom.removeEventListener('mouseup', this.viewMouseupHandler)
- this.view.dom.removeEventListener('dragstart', this.dragstartHandler)
+ this.pmView.dom.removeEventListener('mousedown', this.viewMousedownHandler)
+ this.pmView.dom.removeEventListener('mouseup', this.viewMouseupHandler)
+ this.pmView.dom.removeEventListener('dragstart', this.dragstartHandler)
- this.ttEditor.off('focus', this.focusHandler)
- this.ttEditor.off('blur', this.blurHandler)
+ this.pmView.dom.removeEventListener('focus', this.focusHandler)
+ this.pmView.dom.removeEventListener('blur', this.blurHandler)
+
+ document.removeEventListener('scroll', this.scrollHandler)
}
getSelectionBoundingBox() {
- const {state} = this.ttEditor.view
+ const {state} = this.pmView
const {selection} = state
// support for CellSelections
@@ -214,41 +199,41 @@ export class FormattingToolbarView {
const to = Math.max(...ranges.map((range) => range.$to.pos))
if (isNodeSelection(selection)) {
- const node = this.ttEditor.view.nodeDOM(from) as HTMLElement
+ const node = this.pmView.nodeDOM(from) as HTMLElement
if (node) {
return node.getBoundingClientRect()
}
}
- return posToDOMRect(this.ttEditor.view, from, to)
+ return posToDOMRect(this.pmView, from, to)
}
+}
- getStaticParams(): FormattingToolbarStaticParams {
- return {
- editor: this.editor,
- getReferenceRect: () => {
- if (!this.toolbarIsOpen) {
- if (this.lastPosition === undefined) {
- throw new Error(
- 'Attempted to access selection reference rect before rendering formatting toolbar.',
- )
- }
- return this.lastPosition
- }
- const selectionBoundingBox = this.getSelectionBoundingBox()
- this.lastPosition = selectionBoundingBox
- return selectionBoundingBox
+export const formattingToolbarPluginKey = new PluginKey(
+ 'FormattingToolbarPlugin',
+)
+
+export class FormattingToolbarProsemirrorPlugin<
+ BSchema extends BlockSchema,
+> extends EventEmitter {
+ private view: FormattingToolbarView | undefined
+ public readonly plugin: Plugin
+
+ constructor(editor: BlockNoteEditor) {
+ super()
+ this.plugin = new Plugin({
+ key: formattingToolbarPluginKey,
+ view: (editorView) => {
+ this.view = new FormattingToolbarView(editor, editorView, (state) => {
+ this.emit('update', state)
+ })
+ return this.view
},
- }
+ })
}
-}
-export const createFormattingToolbarPlugin = (
- options: FormattingToolbarPluginProps,
-) => {
- return new Plugin({
- key: new PluginKey('FormattingToolbarPlugin'),
- view: (view) => new FormattingToolbarView({view, ...options}),
- })
+ public onUpdate(callback: (state: FormattingToolbarState) => void) {
+ return this.on('update', callback)
+ }
}
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/HyperlinkToolbar/HyperlinkMark.ts b/frontend/packages/editor/src/blocknote/core/extensions/HyperlinkToolbar/HyperlinkMark.ts
deleted file mode 100644
index 7dd3bc5e5a..0000000000
--- a/frontend/packages/editor/src/blocknote/core/extensions/HyperlinkToolbar/HyperlinkMark.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import Link, {LinkOptions} from '@/tiptap-extension-link'
-import {
- createHyperlinkToolbarPlugin,
- HyperlinkToolbarPluginProps,
-} from './HyperlinkToolbarPlugin'
-
-/**
- * This custom link includes a special menu for editing/deleting/opening the link.
- * The menu will be triggered by hovering over the link with the mouse,
- * or by moving the cursor inside the link text
- */
-const Hyperlink = Link.extend({
- priority: 500,
- addProseMirrorPlugins() {
- if (!this.options.hyperlinkToolbarFactory) {
- throw new Error('UI Element factory not defined for HyperlinkMark')
- }
-
- return [
- ...(this.parent?.() || []),
- createHyperlinkToolbarPlugin(this.editor, {
- hyperlinkToolbarFactory: this.options.hyperlinkToolbarFactory,
- }),
- ]
- },
-})
-
-export default Hyperlink
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/HyperlinkToolbar/HyperlinkToolbarFactoryTypes.ts b/frontend/packages/editor/src/blocknote/core/extensions/HyperlinkToolbar/HyperlinkToolbarFactoryTypes.ts
deleted file mode 100644
index 18a194a785..0000000000
--- a/frontend/packages/editor/src/blocknote/core/extensions/HyperlinkToolbar/HyperlinkToolbarFactoryTypes.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import {EditorElement, ElementFactory} from '../../shared/EditorElement'
-
-export type HyperlinkToolbarStaticParams = {
- editHyperlink: (url: string, text: string) => void
- deleteHyperlink: () => void
-
- getReferenceRect: () => DOMRect
-}
-
-export type HyperlinkToolbarDynamicParams = {
- url: string
- text: string
-}
-
-export type HyperlinkToolbar = EditorElement
-export type HyperlinkToolbarFactory = ElementFactory<
- HyperlinkToolbarStaticParams,
- HyperlinkToolbarDynamicParams
->
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts b/frontend/packages/editor/src/blocknote/core/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts
index 3dea51833d..b8e7741e17 100644
--- a/frontend/packages/editor/src/blocknote/core/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts
+++ b/frontend/packages/editor/src/blocknote/core/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts
@@ -1,28 +1,22 @@
-import {Editor, getMarkRange, posToDOMRect, Range} from '@tiptap/core'
-import {nanoid} from 'nanoid'
+import {getMarkRange, posToDOMRect, Range} from '@tiptap/core'
+import {EditorView} from '@tiptap/pm/view'
import {Mark} from 'prosemirror-model'
import {Plugin, PluginKey} from 'prosemirror-state'
-import {
- HyperlinkToolbar,
- HyperlinkToolbarDynamicParams,
- HyperlinkToolbarFactory,
- HyperlinkToolbarStaticParams,
-} from './HyperlinkToolbarFactoryTypes'
-const PLUGIN_KEY = new PluginKey('HyperlinkToolbarPlugin')
-
-export type HyperlinkToolbarPluginProps = {
- hyperlinkToolbarFactory: HyperlinkToolbarFactory
+import {BlockNoteEditor} from '../../BlockNoteEditor'
+import {BaseUiElementState} from '../../shared/BaseUiElementTypes'
+import {EventEmitter} from '../../shared/EventEmitter'
+import {BlockSchema} from '../Blocks/api/blockTypes'
+
+export type HyperlinkToolbarState = BaseUiElementState & {
+ // The hovered hyperlink's URL, and the text it's displayed with in the
+ // editor.
+ url: string
+ text: string
}
-export type HyperlinkToolbarViewProps = {
- editor: Editor
- hyperlinkToolbarFactory: HyperlinkToolbarFactory
-}
-
-class HyperlinkToolbarView {
- editor: Editor
-
- hyperlinkToolbar: HyperlinkToolbar
+class HyperlinkToolbarView {
+ private hyperlinkToolbarState?: HyperlinkToolbarState
+ public updateHyperlinkToolbar: () => void
menuUpdateTimer: NodeJS.Timeout | undefined
startMenuUpdateTimer: () => void
@@ -37,12 +31,20 @@ class HyperlinkToolbarView {
hyperlinkMark: Mark | undefined
hyperlinkMarkRange: Range | undefined
- private lastPosition: DOMRect | undefined
-
- constructor({editor, hyperlinkToolbarFactory}: HyperlinkToolbarViewProps) {
- this.editor = editor
+ constructor(
+ private readonly editor: BlockNoteEditor,
+ private readonly pmView: EditorView,
+ updateHyperlinkToolbar: (
+ hyperlinkToolbarState: HyperlinkToolbarState,
+ ) => void,
+ ) {
+ this.updateHyperlinkToolbar = () => {
+ if (!this.hyperlinkToolbarState) {
+ throw new Error('Attempting to update uninitialized hyperlink toolbar')
+ }
- this.hyperlinkToolbar = hyperlinkToolbarFactory(this.getStaticParams())
+ updateHyperlinkToolbar(this.hyperlinkToolbarState)
+ }
this.startMenuUpdateTimer = () => {
this.menuUpdateTimer = setTimeout(() => {
@@ -59,8 +61,9 @@ class HyperlinkToolbarView {
return false
}
- this.editor.view.dom.addEventListener('mouseover', this.mouseOverHandler)
+ this.pmView.dom.addEventListener('mouseover', this.mouseOverHandler)
document.addEventListener('click', this.clickHandler, true)
+ document.addEventListener('scroll', this.scrollHandler)
}
mouseOverHandler = (event: MouseEvent) => {
@@ -78,14 +81,16 @@ class HyperlinkToolbarView {
// mouseHoveredHyperlinkMarkRange.
const hoveredHyperlinkElement = event.target
const posInHoveredHyperlinkMark =
- this.editor.view.posAtDOM(hoveredHyperlinkElement, 0) + 1
- const resolvedPosInHoveredHyperlinkMark = this.editor.state.doc.resolve(
+ this.pmView.posAtDOM(hoveredHyperlinkElement, 0) + 1
+ const resolvedPosInHoveredHyperlinkMark = this.pmView.state.doc.resolve(
posInHoveredHyperlinkMark,
)
const marksAtPos = resolvedPosInHoveredHyperlinkMark.marks()
for (const mark of marksAtPos) {
- if (mark.type.name === this.editor.schema.mark('link').type.name) {
+ if (
+ mark.type.name === this.pmView.state.schema.mark('link').type.name
+ ) {
this.mouseHoveredHyperlinkMark = mark
this.mouseHoveredHyperlinkMarkRange =
getMarkRange(
@@ -105,25 +110,80 @@ class HyperlinkToolbarView {
}
clickHandler = (event: MouseEvent) => {
+ const editorWrapper = this.pmView.dom.parentElement!
+
if (
// Toolbar is open.
this.hyperlinkMark &&
// An element is clicked.
event &&
event.target &&
- // Element is outside the editor.
- this.editor.view.dom !== (event.target as Node) &&
- !this.editor.view.dom.contains(event.target as Node) &&
- // Element is outside the toolbar.
- this.hyperlinkToolbar.element !== (event.target as Node) &&
- !this.hyperlinkToolbar.element?.contains(event.target as Node)
+ // The clicked element is not the editor.
+ !(
+ editorWrapper === (event.target as Node) ||
+ editorWrapper.contains(event.target as Node)
+ )
) {
- this.hyperlinkToolbar.hide()
+ if (this.hyperlinkToolbarState?.show) {
+ this.hyperlinkToolbarState.show = false
+ this.updateHyperlinkToolbar()
+ }
+ }
+ }
+
+ scrollHandler = () => {
+ if (this.hyperlinkMark !== undefined) {
+ if (this.hyperlinkToolbarState?.show) {
+ this.hyperlinkToolbarState.referencePos = posToDOMRect(
+ this.pmView,
+ this.hyperlinkMarkRange!.from,
+ this.hyperlinkMarkRange!.to,
+ )
+ this.updateHyperlinkToolbar()
+ }
+ }
+ }
+
+ editHyperlink(url: string, text: string) {
+ const tr = this.pmView.state.tr.insertText(
+ text,
+ this.hyperlinkMarkRange!.from,
+ this.hyperlinkMarkRange!.to,
+ )
+ tr.addMark(
+ this.hyperlinkMarkRange!.from,
+ this.hyperlinkMarkRange!.from + text.length,
+ this.pmView.state.schema.mark('link', {href: url}),
+ )
+ this.pmView.dispatch(tr)
+ this.pmView.focus()
+
+ if (this.hyperlinkToolbarState?.show) {
+ this.hyperlinkToolbarState.show = false
+ this.updateHyperlinkToolbar()
+ }
+ }
+
+ deleteHyperlink() {
+ this.pmView.dispatch(
+ this.pmView.state.tr
+ .removeMark(
+ this.hyperlinkMarkRange!.from,
+ this.hyperlinkMarkRange!.to,
+ this.hyperlinkMark!.type,
+ )
+ .setMeta('preventAutolink', true),
+ )
+ this.pmView.focus()
+
+ if (this.hyperlinkToolbarState?.show) {
+ this.hyperlinkToolbarState.show = false
+ this.updateHyperlinkToolbar()
}
}
update() {
- if (!this.editor.view.hasFocus()) {
+ if (!this.pmView.hasFocus()) {
return
}
@@ -140,15 +200,17 @@ class HyperlinkToolbarView {
// Finds link mark at the editor selection's position to update keyboardHoveredHyperlinkMark and
// keyboardHoveredHyperlinkMarkRange.
- if (this.editor.state.selection.empty) {
- const marksAtPos = this.editor.state.selection.$from.marks()
+ if (this.pmView.state.selection.empty) {
+ const marksAtPos = this.pmView.state.selection.$from.marks()
for (const mark of marksAtPos) {
- if (mark.type.name === this.editor.schema.mark('link').type.name) {
+ if (
+ mark.type.name === this.pmView.state.schema.mark('link').type.name
+ ) {
this.keyboardHoveredHyperlinkMark = mark
this.keyboardHoveredHyperlinkMarkRange =
getMarkRange(
- this.editor.state.selection.$from,
+ this.pmView.state.selection.$from,
mark.type,
mark.attrs,
) || undefined
@@ -170,125 +232,100 @@ class HyperlinkToolbarView {
}
if (this.hyperlinkMark && this.editor.isEditable) {
- this.getDynamicParams()
-
- // Shows menu.
- if (!prevHyperlinkMark) {
- this.hyperlinkToolbar.render(this.getDynamicParams(), true)
-
- this.hyperlinkToolbar.element?.addEventListener(
- 'mouseleave',
- this.startMenuUpdateTimer,
- )
- this.hyperlinkToolbar.element?.addEventListener(
- 'mouseenter',
- this.stopMenuUpdateTimer,
- )
-
- return
+ this.hyperlinkToolbarState = {
+ show: true,
+ referencePos: posToDOMRect(
+ this.pmView,
+ this.hyperlinkMarkRange!.from,
+ this.hyperlinkMarkRange!.to,
+ ),
+ url: this.hyperlinkMark!.attrs.href,
+ text: this.pmView.state.doc.textBetween(
+ this.hyperlinkMarkRange!.from,
+ this.hyperlinkMarkRange!.to,
+ ),
}
-
- // Updates menu.
- this.hyperlinkToolbar.render(this.getDynamicParams(), false)
+ this.updateHyperlinkToolbar()
return
}
// Hides menu.
- if (prevHyperlinkMark && (!this.hyperlinkMark || !this.editor.isEditable)) {
- this.hyperlinkToolbar.element?.removeEventListener(
- 'mouseleave',
- this.startMenuUpdateTimer,
- )
- this.hyperlinkToolbar.element?.removeEventListener(
- 'mouseenter',
- this.stopMenuUpdateTimer,
- )
-
- this.hyperlinkToolbar.hide()
+ if (
+ this.hyperlinkToolbarState?.show &&
+ prevHyperlinkMark &&
+ (!this.hyperlinkMark || !this.editor.isEditable)
+ ) {
+ this.hyperlinkToolbarState.show = false
+ this.updateHyperlinkToolbar()
return
}
}
destroy() {
- this.editor.view.dom.removeEventListener('mouseover', this.mouseOverHandler)
+ this.pmView.dom.removeEventListener('mouseover', this.mouseOverHandler)
+ document.removeEventListener('scroll', this.scrollHandler)
+ document.removeEventListener('click', this.clickHandler, true)
}
+}
- getStaticParams(): HyperlinkToolbarStaticParams {
- return {
- editHyperlink: (url: string, text: string) => {
- const tr = this.editor.view.state.tr.insertText(
- text,
- this.hyperlinkMarkRange!.from,
- this.hyperlinkMarkRange!.to,
- )
- let id = nanoid(8)
- tr.addMark(
- this.hyperlinkMarkRange!.from,
- this.hyperlinkMarkRange!.from + text.length,
- this.editor.schema.mark('link', {href: url, id}),
- ).setMeta('hmPlugin:uncheckedLink', id)
- this.editor.view.dispatch(tr)
- this.editor.view.focus()
-
- this.hyperlinkToolbar.hide()
+export const hyperlinkToolbarPluginKey = new PluginKey('HyperlinkToolbarPlugin')
+
+export class HyperlinkToolbarProsemirrorPlugin<
+ BSchema extends BlockSchema,
+> extends EventEmitter {
+ private view: HyperlinkToolbarView | undefined
+ public readonly plugin: Plugin
+
+ constructor(editor: BlockNoteEditor) {
+ super()
+ this.plugin = new Plugin({
+ key: hyperlinkToolbarPluginKey,
+ view: (editorView) => {
+ this.view = new HyperlinkToolbarView(editor, editorView, (state) => {
+ this.emit('update', state)
+ })
+ return this.view
},
- deleteHyperlink: () => {
- this.editor.view.dispatch(
- this.editor.view.state.tr
- .removeMark(
- this.hyperlinkMarkRange!.from,
- this.hyperlinkMarkRange!.to,
- this.hyperlinkMark!.type,
- )
- .setMeta('preventAutolink', true),
- )
- this.editor.view.focus()
+ })
+ }
- this.hyperlinkToolbar.hide()
- },
- getReferenceRect: () => {
- if (!this.hyperlinkMark) {
- if (this.lastPosition === undefined) {
- throw new Error(
- 'Attempted to access hyperlink reference rect before rendering hyperlink toolbar.',
- )
- }
- return this.lastPosition
- }
- const hyperlinkBoundingBox = posToDOMRect(
- this.editor.view,
- this.hyperlinkMarkRange!.from,
- this.hyperlinkMarkRange!.to,
- )
- this.lastPosition = hyperlinkBoundingBox
- return hyperlinkBoundingBox
- },
- }
+ public onUpdate(callback: (state: HyperlinkToolbarState) => void) {
+ return this.on('update', callback)
}
- getDynamicParams(): HyperlinkToolbarDynamicParams {
- return {
- url: this.hyperlinkMark!.attrs.href,
- text: this.editor.view.state.doc.textBetween(
- this.hyperlinkMarkRange!.from,
- this.hyperlinkMarkRange!.to,
- ),
- }
+ /**
+ * Edit the currently hovered hyperlink.
+ */
+ public editHyperlink = (url: string, text: string) => {
+ this.view!.editHyperlink(url, text)
+ }
+
+ /**
+ * Delete the currently hovered hyperlink.
+ */
+ public deleteHyperlink = () => {
+ this.view!.deleteHyperlink()
}
-}
-export const createHyperlinkToolbarPlugin = (
- editor: Editor,
- options: HyperlinkToolbarPluginProps,
-) => {
- return new Plugin({
- key: PLUGIN_KEY,
- view: () =>
- new HyperlinkToolbarView({
- editor: editor,
- hyperlinkToolbarFactory: options.hyperlinkToolbarFactory,
- }),
- })
+ /**
+ * When hovering on/off hyperlinks using the mouse cursor, the hyperlink
+ * toolbar will open & close with a delay.
+ *
+ * This function starts the delay timer, and should be used for when the mouse cursor enters the hyperlink toolbar.
+ */
+ public startHideTimer = () => {
+ this.view!.startMenuUpdateTimer()
+ }
+
+ /**
+ * When hovering on/off hyperlinks using the mouse cursor, the hyperlink
+ * toolbar will open & close with a delay.
+ *
+ * This function stops the delay timer, and should be used for when the mouse cursor exits the hyperlink toolbar.
+ */
+ public stopHideTimer = () => {
+ this.view!.stopMenuUpdateTimer()
+ }
}
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/Placeholder/PlaceholderExtension.ts b/frontend/packages/editor/src/blocknote/core/extensions/Placeholder/PlaceholderExtension.ts
index 73af29ebf3..48bba7f83d 100644
--- a/frontend/packages/editor/src/blocknote/core/extensions/Placeholder/PlaceholderExtension.ts
+++ b/frontend/packages/editor/src/blocknote/core/extensions/Placeholder/PlaceholderExtension.ts
@@ -2,7 +2,7 @@ import {Editor, Extension} from '@tiptap/core'
import {Node as ProsemirrorNode} from 'prosemirror-model'
import {Plugin, PluginKey} from 'prosemirror-state'
import {Decoration, DecorationSet} from 'prosemirror-view'
-import {SlashMenuPluginKey} from '../SlashMenu/SlashMenuExtension'
+import {slashMenuPluginKey} from '../SlashMenu/SlashMenuPlugin'
const PLUGIN_KEY = new PluginKey(`blocknote-placeholder`)
@@ -55,7 +55,7 @@ export const Placeholder = Extension.create({
decorations: (state) => {
const {doc, selection} = state
// Get state of slash menu
- const menuState = SlashMenuPluginKey.getState(state)
+ const menuState = slashMenuPluginKey.getState(state)
const active =
this.editor.isEditable || !this.options.showOnlyWhenEditable
const {anchor} = selection
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/SelectableBlocks/SelectableBlocksExtension.ts b/frontend/packages/editor/src/blocknote/core/extensions/SelectableBlocks/SelectableBlocksExtension.ts
deleted file mode 100644
index c49fc2ecdd..0000000000
--- a/frontend/packages/editor/src/blocknote/core/extensions/SelectableBlocks/SelectableBlocksExtension.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import {Extension} from '@tiptap/core'
-import {createSelectableBlocksPlugin} from './SelectableBlocksPlugin'
-
-export const SelectableBlocksExtension = Extension.create({
- name: 'selectableBlocks',
-
- addProseMirrorPlugins() {
- return [createSelectableBlocksPlugin()]
- },
-})
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/SelectableBlocks/SelectableBlocksPlugin.ts b/frontend/packages/editor/src/blocknote/core/extensions/SelectableBlocks/SelectableBlocksPlugin.ts
deleted file mode 100644
index 16a946da4e..0000000000
--- a/frontend/packages/editor/src/blocknote/core/extensions/SelectableBlocks/SelectableBlocksPlugin.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import {Node} from 'prosemirror-model'
-import {
- NodeSelection,
- Plugin,
- PluginKey,
- TextSelection,
-} from 'prosemirror-state'
-import {EditorView} from 'prosemirror-view'
-
-export const createSelectableBlocksPlugin = () => {
- return new Plugin({
- key: new PluginKey('SelectableBlocksPlugin'),
- props: {
- handleClickOn: (
- view: EditorView,
- _,
- node: Node,
- nodePos: number,
- event: MouseEvent,
- ) => {
- if (!view.editable) return false
- if (
- // @ts-ignore
- (node.type.name === 'image' && event.target?.nodeName === 'IMG') ||
- ['file', 'embed', 'video'].includes(node.type.name)
- ) {
- let tr = view.state.tr
- const selection = NodeSelection.create(view.state.doc, nodePos)
- tr = tr.setSelection(selection)
- view.dispatch(tr)
- return true
- }
- return false
- },
- },
- })
-}
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/SideMenu/MultipleNodeSelection.ts b/frontend/packages/editor/src/blocknote/core/extensions/SideMenu/MultipleNodeSelection.ts
new file mode 100644
index 0000000000..e6e2c4dacb
--- /dev/null
+++ b/frontend/packages/editor/src/blocknote/core/extensions/SideMenu/MultipleNodeSelection.ts
@@ -0,0 +1,87 @@
+import {Fragment, Node, ResolvedPos, Slice} from 'prosemirror-model'
+import {Selection} from 'prosemirror-state'
+import {Mappable} from 'prosemirror-transform'
+
+/**
+ * This class represents an editor selection which spans multiple nodes/blocks. It's currently only used to allow users
+ * to drag multiple blocks at the same time. Expects the selection anchor and head to be between nodes, i.e. just before
+ * the first target node and just after the last, and that anchor and head are at the same nesting level.
+ *
+ * Partially based on ProseMirror's NodeSelection implementation:
+ * (https://github.com/ProseMirror/prosemirror-state/blob/master/src/selection.ts)
+ * MultipleNodeSelection differs from NodeSelection in the following ways:
+ * 1. Stores which nodes are included in the selection instead of just a single node.
+ * 2. Already expects the selection to start just before the first target node and ends just after the last, while a
+ * NodeSelection automatically sets both anchor and head to just before the single target node.
+ */
+export class MultipleNodeSelection extends Selection {
+ nodes: Array
+
+ constructor($anchor: ResolvedPos, $head: ResolvedPos) {
+ super($anchor, $head)
+
+ // Parent is at the same nesting level as anchor/head since they are just before/ just after target nodes.
+ const parentNode = $anchor.node()
+
+ this.nodes = []
+ $anchor.doc.nodesBetween($anchor.pos, $head.pos, (node, _pos, parent) => {
+ if (parent !== null && parent.eq(parentNode)) {
+ this.nodes.push(node)
+ return false
+ }
+ return
+ })
+ }
+
+ static create(doc: Node, from: number, to = from): MultipleNodeSelection {
+ return new MultipleNodeSelection(doc.resolve(from), doc.resolve(to))
+ }
+
+ content(): Slice {
+ return new Slice(Fragment.from(this.nodes), 0, 0)
+ }
+
+ eq(selection: Selection): boolean {
+ if (!(selection instanceof MultipleNodeSelection)) {
+ return false
+ }
+
+ if (this.nodes.length !== selection.nodes.length) {
+ return false
+ }
+
+ if (this.from !== selection.from || this.to !== selection.to) {
+ return false
+ }
+
+ for (let i = 0; i < this.nodes.length; i++) {
+ if (!this.nodes[i].eq(selection.nodes[i])) {
+ return false
+ }
+ }
+
+ return true
+ }
+
+ map(doc: Node, mapping: Mappable): Selection {
+ const fromResult = mapping.mapResult(this.from)
+ const toResult = mapping.mapResult(this.to)
+
+ if (toResult.deleted) {
+ return Selection.near(doc.resolve(fromResult.pos))
+ }
+
+ if (fromResult.deleted) {
+ return Selection.near(doc.resolve(toResult.pos))
+ }
+
+ return new MultipleNodeSelection(
+ doc.resolve(fromResult.pos),
+ doc.resolve(toResult.pos),
+ )
+ }
+
+ toJSON(): any {
+ return {type: 'node', anchor: this.anchor, head: this.head}
+ }
+}
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/SideMenu/SideMenuPlugin.ts b/frontend/packages/editor/src/blocknote/core/extensions/SideMenu/SideMenuPlugin.ts
new file mode 100644
index 0000000000..1816ec198d
--- /dev/null
+++ b/frontend/packages/editor/src/blocknote/core/extensions/SideMenu/SideMenuPlugin.ts
@@ -0,0 +1,603 @@
+import {PluginView} from '@tiptap/pm/state'
+import {Node} from 'prosemirror-model'
+import {NodeSelection, Plugin, PluginKey, Selection} from 'prosemirror-state'
+import * as pv from 'prosemirror-view'
+import {EditorView} from 'prosemirror-view'
+import {BlockNoteEditor} from '../../BlockNoteEditor'
+import styles from '../../editor.module.css'
+import {BaseUiElementState} from '../../shared/BaseUiElementTypes'
+import {EventEmitter} from '../../shared/EventEmitter'
+import {Block, BlockSchema} from '../Blocks/api/blockTypes'
+import {getBlockInfoFromPos} from '../Blocks/helpers/getBlockInfoFromPos'
+import {slashMenuPluginKey} from '../SlashMenu/SlashMenuPlugin'
+import {MultipleNodeSelection} from './MultipleNodeSelection'
+
+const serializeForClipboard = (pv as any).__serializeForClipboard
+// code based on https://github.com/ueberdosis/tiptap/issues/323#issuecomment-506637799
+
+let dragImageElement: Element | undefined
+
+export type SideMenuState = BaseUiElementState & {
+ // The block that the side menu is attached to.
+ block: Block
+}
+
+function getDraggableBlockFromCoords(
+ coords: {left: number; top: number},
+ view: EditorView,
+) {
+ if (!view.dom.isConnected) {
+ // view is not connected to the DOM, this can cause posAtCoords to fail
+ // (Cannot read properties of null (reading 'nearestDesc'), https://github.com/TypeCellOS/BlockNote/issues/123)
+ return undefined
+ }
+
+ const pos = view.posAtCoords(coords)
+ if (!pos) {
+ return undefined
+ }
+ let node = view.domAtPos(pos.pos).node as HTMLElement
+
+ if (node === view.dom) {
+ // mouse over root
+ return undefined
+ }
+
+ while (
+ node &&
+ node.parentNode &&
+ node.parentNode !== view.dom &&
+ !node.hasAttribute?.('data-id')
+ ) {
+ node = node.parentNode as HTMLElement
+ }
+ if (!node) {
+ return undefined
+ }
+ return {node, id: node.getAttribute('data-id')!}
+}
+
+function blockPositionFromCoords(
+ coords: {left: number; top: number},
+ view: EditorView,
+) {
+ const block = getDraggableBlockFromCoords(coords, view)
+
+ if (block && block.node.nodeType === 1) {
+ // TODO: this uses undocumented PM APIs? do we need this / let's add docs?
+ const docView = (view as any).docView
+ const desc = docView.nearestDesc(block.node, true)
+ if (!desc || desc === docView) {
+ return null
+ }
+ return desc.posBefore
+ }
+ return null
+}
+
+function blockPositionsFromSelection(selection: Selection, doc: Node) {
+ // Absolute positions just before the first block spanned by the selection, and just after the last block. Having the
+ // selection start and end just before and just after the target blocks ensures no whitespace/line breaks are left
+ // behind after dragging & dropping them.
+ let beforeFirstBlockPos: number
+ let afterLastBlockPos: number
+
+ // Even the user starts dragging blocks but drops them in the same place, the selection will still be moved just
+ // before & just after the blocks spanned by the selection, and therefore doesn't need to change if they try to drag
+ // the same blocks again. If this happens, the anchor & head move out of the block content node they were originally
+ // in. If the anchor should update but the head shouldn't and vice versa, it means the user selection is outside a
+ // block content node, which should never happen.
+ const selectionStartInBlockContent =
+ doc.resolve(selection.from).node().type.spec.group === 'blockContent'
+ const selectionEndInBlockContent =
+ doc.resolve(selection.to).node().type.spec.group === 'blockContent'
+
+ // Ensures that entire outermost nodes are selected if the selection spans multiple nesting levels.
+ const minDepth = Math.min(selection.$anchor.depth, selection.$head.depth)
+
+ if (selectionStartInBlockContent && selectionEndInBlockContent) {
+ // Absolute positions at the start of the first block in the selection and at the end of the last block. User
+ // selections will always start and end in block content nodes, but we want the start and end positions of their
+ // parent block nodes, which is why minDepth - 1 is used.
+ const startFirstBlockPos = selection.$from.start(minDepth - 1)
+ const endLastBlockPos = selection.$to.end(minDepth - 1)
+
+ // Shifting start and end positions by one moves them just outside the first and last selected blocks.
+ beforeFirstBlockPos = doc.resolve(startFirstBlockPos - 1).pos
+ afterLastBlockPos = doc.resolve(endLastBlockPos + 1).pos
+ } else {
+ beforeFirstBlockPos = selection.from
+ afterLastBlockPos = selection.to
+ }
+
+ return {from: beforeFirstBlockPos, to: afterLastBlockPos}
+}
+
+function setDragImage(view: EditorView, from: number, to = from) {
+ if (from === to) {
+ // Moves to position to be just after the first (and only) selected block.
+ to += view.state.doc.resolve(from + 1).node().nodeSize
+ }
+
+ // Parent element is cloned to remove all unselected children without affecting the editor content.
+ const parentClone = view.domAtPos(from).node.cloneNode(true) as Element
+ const parent = view.domAtPos(from).node as Element
+
+ const getElementIndex = (parentElement: Element, targetElement: Element) =>
+ Array.prototype.indexOf.call(parentElement.children, targetElement)
+
+ const firstSelectedBlockIndex = getElementIndex(
+ parent,
+ // Expects from position to be just before the first selected block.
+ view.domAtPos(from + 1).node.parentElement!,
+ )
+ const lastSelectedBlockIndex = getElementIndex(
+ parent,
+ // Expects to position to be just after the last selected block.
+ view.domAtPos(to - 1).node.parentElement!,
+ )
+
+ for (let i = parent.childElementCount - 1; i >= 0; i--) {
+ if (i > lastSelectedBlockIndex || i < firstSelectedBlockIndex) {
+ parentClone.removeChild(parentClone.children[i])
+ }
+ }
+
+ // dataTransfer.setDragImage(element) only works if element is attached to the DOM.
+ unsetDragImage()
+ dragImageElement = parentClone
+
+ // TODO: This is hacky, need a better way of assigning classes to the editor so that they can also be applied to the
+ // drag preview.
+ const classes = view.dom.className.split(' ')
+ const inheritedClasses = classes
+ .filter(
+ (className) =>
+ !className.includes('bn') &&
+ !className.includes('ProseMirror') &&
+ !className.includes('editor'),
+ )
+ .join(' ')
+
+ dragImageElement.className =
+ dragImageElement.className +
+ ' ' +
+ styles.dragPreview +
+ ' ' +
+ inheritedClasses
+
+ document.body.appendChild(dragImageElement)
+}
+
+function unsetDragImage() {
+ if (dragImageElement !== undefined) {
+ document.body.removeChild(dragImageElement)
+ dragImageElement = undefined
+ }
+}
+
+function dragStart(
+ e: {dataTransfer: DataTransfer | null; clientY: number},
+ view: EditorView,
+) {
+ if (!e.dataTransfer) {
+ return
+ }
+
+ const editorBoundingBox = view.dom.getBoundingClientRect()
+
+ const coords = {
+ left: editorBoundingBox.left + editorBoundingBox.width / 2, // take middle of editor
+ top: e.clientY,
+ }
+
+ const pos = blockPositionFromCoords(coords, view)
+ if (pos != null) {
+ const selection = view.state.selection
+ const doc = view.state.doc
+
+ const {from, to} = blockPositionsFromSelection(selection, doc)
+
+ const draggedBlockInSelection = from <= pos && pos < to
+ const multipleBlocksSelected =
+ selection.$anchor.node() !== selection.$head.node() ||
+ selection instanceof MultipleNodeSelection
+
+ if (draggedBlockInSelection && multipleBlocksSelected) {
+ view.dispatch(
+ view.state.tr.setSelection(MultipleNodeSelection.create(doc, from, to)),
+ )
+ setDragImage(view, from, to)
+ } else {
+ view.dispatch(
+ view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)),
+ )
+ setDragImage(view, pos)
+ }
+
+ const slice = view.state.selection.content()
+ const {dom, text} = serializeForClipboard(view, slice)
+
+ e.dataTransfer.clearData()
+ e.dataTransfer.setData('text/html', dom.innerHTML)
+ e.dataTransfer.setData('text/plain', text)
+ e.dataTransfer.effectAllowed = 'move'
+ e.dataTransfer.setDragImage(dragImageElement!, 0, 0)
+ view.dragging = {slice, move: true}
+ }
+}
+
+export class SideMenuView implements PluginView {
+ private sideMenuState?: SideMenuState
+
+ // When true, the drag handle with be anchored at the same level as root elements
+ // When false, the drag handle with be just to the left of the element
+ // TODO: Is there any case where we want this to be false?
+ private horizontalPosAnchoredAtRoot: boolean
+ private horizontalPosAnchor: number
+
+ private hoveredBlock: HTMLElement | undefined
+
+ // Used to check if currently dragged content comes from this editor instance.
+ public isDragging = false
+
+ public menuFrozen = false
+
+ constructor(
+ private readonly editor: BlockNoteEditor,
+ private readonly pmView: EditorView,
+ private readonly updateSideMenu: (
+ sideMenuState: SideMenuState,
+ ) => void,
+ ) {
+ this.horizontalPosAnchoredAtRoot = true
+ this.horizontalPosAnchor = (
+ this.pmView.dom.firstChild! as HTMLElement
+ ).getBoundingClientRect().x
+
+ document.body.addEventListener('drop', this.onDrop, true)
+ document.body.addEventListener('dragover', this.onDragOver)
+ this.pmView.dom.addEventListener('dragstart', this.onDragStart)
+
+ // Shows or updates menu position whenever the cursor moves, if the menu isn't frozen.
+ document.body.addEventListener('mousemove', this.onMouseMove, true)
+
+ // Makes menu scroll with the page.
+ document.addEventListener('scroll', this.onScroll)
+
+ // Hides and unfreezes the menu whenever the user presses a key.
+ document.body.addEventListener('keydown', this.onKeyDown, true)
+ }
+
+ /**
+ * Sets isDragging when dragging text.
+ */
+ onDragStart = () => {
+ this.isDragging = true
+ }
+
+ /**
+ * If the event is outside the editor contents,
+ * we dispatch a fake event, so that we can still drop the content
+ * when dragging / dropping to the side of the editor
+ */
+ onDrop = (event: DragEvent) => {
+ this.editor._tiptapEditor.commands.blur()
+
+ if ((event as any).synthetic || !this.isDragging) {
+ return
+ }
+
+ const pos = this.pmView.posAtCoords({
+ left: event.clientX,
+ top: event.clientY,
+ })
+
+ this.isDragging = false
+
+ if (!pos || pos.inside === -1) {
+ const evt = new Event('drop', event) as any
+ const editorBoundingBox = (
+ this.pmView.dom.firstChild! as HTMLElement
+ ).getBoundingClientRect()
+ evt.clientX = editorBoundingBox.left + editorBoundingBox.width / 2
+ evt.clientY = event.clientY
+ evt.dataTransfer = event.dataTransfer
+ evt.preventDefault = () => event.preventDefault()
+ evt.synthetic = true // prevent recursion
+ // console.log("dispatch fake drop");
+ this.pmView.dom.dispatchEvent(evt)
+ }
+ }
+
+ /**
+ * If the event is outside the editor contents,
+ * we dispatch a fake event, so that we can still drop the content
+ * when dragging / dropping to the side of the editor
+ */
+ onDragOver = (event: DragEvent) => {
+ if ((event as any).synthetic || !this.isDragging) {
+ return
+ }
+ const pos = this.pmView.posAtCoords({
+ left: event.clientX,
+ top: event.clientY,
+ })
+
+ if (!pos || pos.inside === -1) {
+ const evt = new Event('dragover', event) as any
+ const editorBoundingBox = (
+ this.pmView.dom.firstChild! as HTMLElement
+ ).getBoundingClientRect()
+ evt.clientX = editorBoundingBox.left + editorBoundingBox.width / 2
+ evt.clientY = event.clientY
+ evt.dataTransfer = event.dataTransfer
+ evt.preventDefault = () => event.preventDefault()
+ evt.synthetic = true // prevent recursion
+ // console.log("dispatch fake dragover");
+ this.pmView.dom.dispatchEvent(evt)
+ }
+ }
+
+ onKeyDown = (_event: KeyboardEvent) => {
+ if (this.sideMenuState?.show) {
+ this.sideMenuState.show = false
+ this.updateSideMenu(this.sideMenuState)
+ }
+ this.menuFrozen = false
+ }
+
+ onMouseMove = (event: MouseEvent) => {
+ if (this.menuFrozen) {
+ return
+ }
+
+ // Editor itself may have padding or other styling which affects
+ // size/position, so we get the boundingRect of the first child (i.e. the
+ // blockGroup that wraps all blocks in the editor) for more accurate side
+ // menu placement.
+ const editorBoundingBox = (
+ this.pmView.dom.firstChild! as HTMLElement
+ ).getBoundingClientRect()
+ // We want the full area of the editor to check if the cursor is hovering
+ // above it though.
+ const editorOuterBoundingBox = this.pmView.dom.getBoundingClientRect()
+ const cursorWithinEditor =
+ event.clientX >= editorOuterBoundingBox.left &&
+ event.clientX <= editorOuterBoundingBox.right &&
+ event.clientY >= editorOuterBoundingBox.top &&
+ event.clientY <= editorOuterBoundingBox.bottom
+
+ const editorWrapper = this.pmView.dom.parentElement!
+
+ // Doesn't update if the mouse hovers an element that's over the editor but
+ // isn't a part of it or the side menu.
+ if (
+ // Cursor is within the editor area
+ cursorWithinEditor &&
+ // An element is hovered
+ event &&
+ event.target &&
+ // Element is outside the editor
+ !(
+ editorWrapper === event.target ||
+ editorWrapper.contains(event.target as HTMLElement)
+ )
+ ) {
+ if (this.sideMenuState?.show) {
+ this.sideMenuState.show = false
+ this.updateSideMenu(this.sideMenuState)
+ }
+
+ return
+ }
+
+ this.horizontalPosAnchor = editorBoundingBox.x
+
+ // Gets block at mouse cursor's vertical position.
+ const coords = {
+ left: editorBoundingBox.left + editorBoundingBox.width / 2, // take middle of editor
+ top: event.clientY,
+ }
+ const block = getDraggableBlockFromCoords(coords, this.pmView)
+
+ // Closes the menu if the mouse cursor is beyond the editor vertically.
+ if (!block || !this.editor.isEditable) {
+ if (this.sideMenuState?.show) {
+ this.sideMenuState.show = false
+ this.updateSideMenu(this.sideMenuState)
+ }
+
+ return
+ }
+
+ // Doesn't update if the menu is already open and the mouse cursor is still hovering the same block.
+ if (
+ this.sideMenuState?.show &&
+ this.hoveredBlock?.hasAttribute('data-id') &&
+ this.hoveredBlock?.getAttribute('data-id') === block.id
+ ) {
+ return
+ }
+
+ this.hoveredBlock = block.node
+
+ // Gets the block's content node, which lets to ignore child blocks when determining the block menu's position.
+ const blockContent = block.node.firstChild as HTMLElement
+
+ if (!blockContent) {
+ return
+ }
+
+ // Shows or updates elements.
+ if (this.editor.isEditable) {
+ const blockContentBoundingBox = blockContent.getBoundingClientRect()
+
+ this.sideMenuState = {
+ show: true,
+ referencePos: new DOMRect(
+ this.horizontalPosAnchoredAtRoot
+ ? this.horizontalPosAnchor
+ : blockContentBoundingBox.x,
+ blockContentBoundingBox.y,
+ blockContentBoundingBox.width,
+ blockContentBoundingBox.height,
+ ),
+ block: this.editor.getBlock(
+ this.hoveredBlock!.getAttribute('data-id')!,
+ )!,
+ }
+
+ this.updateSideMenu(this.sideMenuState)
+ }
+ }
+
+ onScroll = () => {
+ if (this.sideMenuState?.show) {
+ const blockContent = this.hoveredBlock!.firstChild as HTMLElement
+ const blockContentBoundingBox = blockContent.getBoundingClientRect()
+
+ this.sideMenuState.referencePos = new DOMRect(
+ this.horizontalPosAnchoredAtRoot
+ ? this.horizontalPosAnchor
+ : blockContentBoundingBox.x,
+ blockContentBoundingBox.y,
+ blockContentBoundingBox.width,
+ blockContentBoundingBox.height,
+ )
+ this.updateSideMenu(this.sideMenuState)
+ }
+ }
+
+ destroy() {
+ if (this.sideMenuState?.show) {
+ this.sideMenuState.show = false
+ this.updateSideMenu(this.sideMenuState)
+ }
+ document.body.removeEventListener('mousemove', this.onMouseMove)
+ document.body.removeEventListener('dragover', this.onDragOver)
+ this.pmView.dom.removeEventListener('dragstart', this.onDragStart)
+ document.body.removeEventListener('drop', this.onDrop, true)
+ document.removeEventListener('scroll', this.onScroll)
+ document.body.removeEventListener('keydown', this.onKeyDown, true)
+ }
+
+ addBlock() {
+ if (this.sideMenuState?.show) {
+ this.sideMenuState.show = false
+ this.updateSideMenu(this.sideMenuState)
+ }
+
+ this.menuFrozen = true
+
+ const blockContent = this.hoveredBlock!.firstChild! as HTMLElement
+ const blockContentBoundingBox = blockContent.getBoundingClientRect()
+
+ const pos = this.pmView.posAtCoords({
+ left: blockContentBoundingBox.left + blockContentBoundingBox.width / 2,
+ top: blockContentBoundingBox.top + blockContentBoundingBox.height / 2,
+ })
+ if (!pos) {
+ return
+ }
+
+ const blockInfo = getBlockInfoFromPos(
+ this.editor._tiptapEditor.state.doc,
+ pos.pos,
+ )
+ if (blockInfo === undefined) {
+ return
+ }
+
+ const {contentNode, endPos} = blockInfo
+
+ // Creates a new block if current one is not empty for the suggestion menu to open in.
+ if (contentNode.textContent.length !== 0) {
+ const newBlockInsertionPos = endPos + 1
+ const newBlockContentPos = newBlockInsertionPos + 2
+
+ this.editor._tiptapEditor
+ .chain()
+ .BNCreateBlock(newBlockInsertionPos)
+ .BNUpdateBlock(newBlockContentPos, {type: 'paragraph', props: {}})
+ .setTextSelection(newBlockContentPos)
+ .run()
+ } else {
+ this.editor._tiptapEditor.commands.setTextSelection(endPos)
+ }
+
+ // Focuses and activates the suggestion menu.
+ this.pmView.focus()
+ this.pmView.dispatch(
+ this.pmView.state.tr.scrollIntoView().setMeta(slashMenuPluginKey, {
+ // TODO import suggestion plugin key
+ activate: true,
+ type: 'drag',
+ }),
+ )
+ }
+}
+
+export const sideMenuPluginKey = new PluginKey('SideMenuPlugin')
+
+export class SideMenuProsemirrorPlugin<
+ BSchema extends BlockSchema,
+> extends EventEmitter {
+ private sideMenuView: SideMenuView | undefined
+ public readonly plugin: Plugin
+
+ constructor(private readonly editor: BlockNoteEditor) {
+ super()
+ this.plugin = new Plugin({
+ key: sideMenuPluginKey,
+ view: (editorView) => {
+ this.sideMenuView = new SideMenuView(
+ editor,
+ editorView,
+ (sideMenuState) => {
+ this.emit('update', sideMenuState)
+ },
+ )
+ return this.sideMenuView
+ },
+ })
+ }
+
+ public onUpdate(callback: (state: SideMenuState) => void) {
+ return this.on('update', callback)
+ }
+
+ /**
+ * If the block is empty, opens the slash menu. If the block has content,
+ * creates a new block below and opens the slash menu in it.
+ */
+ addBlock = () => this.sideMenuView!.addBlock()
+
+ /**
+ * Handles drag & drop events for blocks.
+ */
+ blockDragStart = (event: {
+ dataTransfer: DataTransfer | null
+ clientY: number
+ }) => {
+ this.sideMenuView!.isDragging = true
+ dragStart(event, this.editor.prosemirrorView)
+ }
+
+ /**
+ * Handles drag & drop events for blocks.
+ */
+ blockDragEnd = () => unsetDragImage()
+ /**
+ * Freezes the side menu. When frozen, the side menu will stay
+ * attached to the same block regardless of which block is hovered by the
+ * mouse cursor.
+ */
+ freezeMenu = () => (this.sideMenuView!.menuFrozen = true)
+ /**
+ * Unfreezes the side menu. When frozen, the side menu will stay
+ * attached to the same block regardless of which block is hovered by the
+ * mouse cursor.
+ */
+ unfreezeMenu = () => (this.sideMenuView!.menuFrozen = false)
+}
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/SlashMenu/BaseSlashMenuItem.ts b/frontend/packages/editor/src/blocknote/core/extensions/SlashMenu/BaseSlashMenuItem.ts
index fc3e127303..e34f38655c 100644
--- a/frontend/packages/editor/src/blocknote/core/extensions/SlashMenu/BaseSlashMenuItem.ts
+++ b/frontend/packages/editor/src/blocknote/core/extensions/SlashMenu/BaseSlashMenuItem.ts
@@ -1,34 +1,11 @@
import {SuggestionItem} from '../../shared/plugins/suggestion/SuggestionItem'
import {BlockNoteEditor} from '../../BlockNoteEditor'
import {BlockSchema} from '../Blocks/api/blockTypes'
+import {DefaultBlockSchema} from '../Blocks/api/defaultBlocks'
-/**
- * A class that defines a slash command (/).
- *
- * (Not to be confused with ProseMirror commands nor TipTap commands.)
- */
-export class BaseSlashMenuItem<
- BSchema extends BlockSchema,
-> extends SuggestionItem {
- /**
- * Constructs a new slash-command.
- *
- * @param name The name of the command
- * @param execute The callback for creating a new node
- * @param aliases Aliases for this command
- */
- constructor(
- public readonly name: string,
- public readonly execute: (editor: BlockNoteEditor) => void,
- public readonly aliases: string[] = [],
- ) {
- super(name, (query: string): boolean => {
- return (
- this.name.toLowerCase().startsWith(query.toLowerCase()) ||
- this.aliases.filter((alias) =>
- alias.toLowerCase().startsWith(query.toLowerCase()),
- ).length !== 0
- )
- })
- }
+export type BaseSlashMenuItem<
+ BSchema extends BlockSchema = DefaultBlockSchema,
+> = SuggestionItem & {
+ execute: (editor: BlockNoteEditor) => void
+ aliases?: string[]
}
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/SlashMenu/SlashMenuExtension.ts b/frontend/packages/editor/src/blocknote/core/extensions/SlashMenu/SlashMenuExtension.ts
deleted file mode 100644
index 55c79ffbdd..0000000000
--- a/frontend/packages/editor/src/blocknote/core/extensions/SlashMenu/SlashMenuExtension.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import {Extension} from '@tiptap/core'
-import {PluginKey} from 'prosemirror-state'
-import {createSuggestionPlugin} from '../../shared/plugins/suggestion/SuggestionPlugin'
-import {SuggestionsMenuFactory} from '../../shared/plugins/suggestion/SuggestionsMenuFactoryTypes'
-import {BaseSlashMenuItem} from './BaseSlashMenuItem'
-import {BlockNoteEditor} from '../../BlockNoteEditor'
-import {BlockSchema} from '../Blocks/api/blockTypes'
-
-export type SlashMenuOptions = {
- editor: BlockNoteEditor | undefined
- commands: BaseSlashMenuItem[] | undefined
- slashMenuFactory: SuggestionsMenuFactory | undefined
-}
-
-export const SlashMenuPluginKey = new PluginKey('suggestions-slash-commands')
-
-export const createSlashMenuExtension = () =>
- Extension.create>({
- name: 'slash-command',
-
- addOptions() {
- return {
- editor: undefined,
- commands: undefined,
- slashMenuFactory: undefined,
- }
- },
-
- addProseMirrorPlugins() {
- if (!this.options.slashMenuFactory || !this.options.commands) {
- throw new Error('required args not defined for SlashMenuExtension')
- }
-
- const commands = this.options.commands
-
- return [
- createSuggestionPlugin, BSchema>({
- pluginKey: SlashMenuPluginKey,
- editor: this.options.editor!,
- defaultTriggerCharacter: '/',
- suggestionsMenuFactory: this.options.slashMenuFactory!,
- items: (query) => {
- return commands.filter((cmd: BaseSlashMenuItem) =>
- cmd.match(query),
- )
- },
- onSelectItem: ({item, editor}) => {
- item.execute(editor)
- },
- }),
- ]
- },
- })
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/SlashMenu/SlashMenuPlugin.ts b/frontend/packages/editor/src/blocknote/core/extensions/SlashMenu/SlashMenuPlugin.ts
new file mode 100644
index 0000000000..8ea4a1fbdf
--- /dev/null
+++ b/frontend/packages/editor/src/blocknote/core/extensions/SlashMenu/SlashMenuPlugin.ts
@@ -0,0 +1,51 @@
+import {Plugin, PluginKey} from 'prosemirror-state'
+
+import {BlockNoteEditor} from '../../BlockNoteEditor'
+import {EventEmitter} from '../../shared/EventEmitter'
+import {
+ SuggestionsMenuState,
+ setupSuggestionsMenu,
+} from '../../shared/plugins/suggestion/SuggestionPlugin'
+import {BlockSchema} from '../Blocks/api/blockTypes'
+import {BaseSlashMenuItem} from './BaseSlashMenuItem'
+
+export const slashMenuPluginKey = new PluginKey('SlashMenuPlugin')
+
+export class SlashMenuProsemirrorPlugin<
+ BSchema extends BlockSchema,
+ SlashMenuItem extends BaseSlashMenuItem,
+> extends EventEmitter {
+ public readonly plugin: Plugin
+ public readonly itemCallback: (item: SlashMenuItem) => void
+
+ constructor(editor: BlockNoteEditor, items: SlashMenuItem[]) {
+ super()
+ const suggestions = setupSuggestionsMenu(
+ editor,
+ (state) => {
+ this.emit('update', state)
+ },
+ slashMenuPluginKey,
+ '/',
+ (query) =>
+ items.filter(
+ ({name, aliases}: SlashMenuItem) =>
+ name.toLowerCase().startsWith(query.toLowerCase()) ||
+ (aliases &&
+ aliases.filter((alias) =>
+ alias.toLowerCase().startsWith(query.toLowerCase()),
+ ).length !== 0),
+ ),
+ ({item, editor}) => item.execute(editor),
+ )
+
+ this.plugin = suggestions.plugin
+ this.itemCallback = suggestions.itemCallback
+ }
+
+ public onUpdate(
+ callback: (state: SuggestionsMenuState) => void,
+ ) {
+ return this.on('update', callback)
+ }
+}
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/SlashMenu/defaultSlashMenuItems.ts b/frontend/packages/editor/src/blocknote/core/extensions/SlashMenu/defaultSlashMenuItems.ts
new file mode 100644
index 0000000000..c8bd5f9887
--- /dev/null
+++ b/frontend/packages/editor/src/blocknote/core/extensions/SlashMenu/defaultSlashMenuItems.ts
@@ -0,0 +1,109 @@
+import {BlockNoteEditor} from '../../BlockNoteEditor'
+import {BlockSchema, PartialBlock} from '../Blocks/api/blockTypes'
+import {BaseSlashMenuItem} from './BaseSlashMenuItem'
+import {defaultBlockSchema} from '../Blocks/api/defaultBlocks'
+
+export function insertOrUpdateBlock(
+ editor: BlockNoteEditor,
+ block: PartialBlock,
+) {
+ const currentBlock = editor.getTextCursorPosition().block
+
+ if (
+ (currentBlock.content.length === 1 &&
+ currentBlock.content[0].type === 'text' &&
+ currentBlock.content[0].text === '/') ||
+ currentBlock.content.length === 0
+ ) {
+ editor.updateBlock(currentBlock, block)
+ } else {
+ editor.insertBlocks([block], currentBlock, 'after')
+ editor.setTextCursorPosition(editor.getTextCursorPosition().nextBlock!)
+ }
+}
+
+export const getDefaultSlashMenuItems = (
+ // This type casting is weird, but it's the best way of doing it, as it allows
+ // the schema type to be automatically inferred if it is defined, or be
+ // inferred as any if it is not defined. I don't think it's possible to make it
+ // infer to DefaultBlockSchema if it is not defined.
+ schema: BSchema = defaultBlockSchema as unknown as BSchema,
+) => {
+ const slashMenuItems: BaseSlashMenuItem[] = []
+
+ if ('heading' in schema && 'level' in schema.heading.propSchema) {
+ // Command for creating a level 1 heading
+ if (schema.heading.propSchema.level.values?.includes('1')) {
+ slashMenuItems.push({
+ name: 'Heading',
+ aliases: ['h', 'heading1', 'h1'],
+ execute: (editor) =>
+ insertOrUpdateBlock(editor, {
+ type: 'heading',
+ props: {level: '1'},
+ } as PartialBlock),
+ })
+ }
+
+ // Command for creating a level 2 heading
+ if (schema.heading.propSchema.level.values?.includes('2')) {
+ slashMenuItems.push({
+ name: 'Heading 2',
+ aliases: ['h2', 'heading2', 'subheading'],
+ execute: (editor) =>
+ insertOrUpdateBlock(editor, {
+ type: 'heading',
+ props: {level: '2'},
+ } as PartialBlock),
+ })
+ }
+
+ // Command for creating a level 3 heading
+ if (schema.heading.propSchema.level.values?.includes('3')) {
+ slashMenuItems.push({
+ name: 'Heading 3',
+ aliases: ['h3', 'heading3', 'subheading'],
+ execute: (editor) =>
+ insertOrUpdateBlock(editor, {
+ type: 'heading',
+ props: {level: '3'},
+ } as PartialBlock),
+ })
+ }
+ }
+
+ if ('bulletListItem' in schema) {
+ slashMenuItems.push({
+ name: 'Bullet List',
+ aliases: ['ul', 'list', 'bulletlist', 'bullet list'],
+ execute: (editor) =>
+ insertOrUpdateBlock(editor, {
+ type: 'bulletListItem',
+ } as PartialBlock),
+ })
+ }
+
+ if ('numberedListItem' in schema) {
+ slashMenuItems.push({
+ name: 'Numbered List',
+ aliases: ['li', 'list', 'numberedlist', 'numbered list'],
+ execute: (editor) =>
+ insertOrUpdateBlock(editor, {
+ type: 'numberedListItem',
+ } as PartialBlock),
+ })
+ }
+
+ if ('paragraph' in schema) {
+ slashMenuItems.push({
+ name: 'Paragraph',
+ aliases: ['p'],
+ execute: (editor) =>
+ insertOrUpdateBlock(editor, {
+ type: 'paragraph',
+ } as PartialBlock),
+ })
+ }
+
+ return slashMenuItems
+}
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/SlashMenu/defaultSlashMenuItems.tsx b/frontend/packages/editor/src/blocknote/core/extensions/SlashMenu/defaultSlashMenuItems.tsx
deleted file mode 100644
index 1131f43fe1..0000000000
--- a/frontend/packages/editor/src/blocknote/core/extensions/SlashMenu/defaultSlashMenuItems.tsx
+++ /dev/null
@@ -1,215 +0,0 @@
-import {HMBlockSchema} from '@/schema'
-import {BlockNoteEditor} from '../../BlockNoteEditor'
-import {PartialBlock} from '../Blocks/api/blockTypes'
-import {BaseSlashMenuItem} from './BaseSlashMenuItem'
-
-export function insertOrUpdateBlock(
- editor: BlockNoteEditor,
- block: PartialBlock,
-) {
- const currentBlock = editor.getTextCursorPosition().block
- if (
- (currentBlock.content.length === 1 &&
- currentBlock.content[0].type === 'text' &&
- currentBlock.content[0].text === '/') ||
- currentBlock.content.length === 0
- ) {
- editor.updateBlock(currentBlock, block)
- editor.setTextCursorPosition(currentBlock.id, 'end')
- } else {
- editor.insertBlocks([block], currentBlock, 'after')
- editor.setTextCursorPosition(editor.getTextCursorPosition().nextBlock!)
- }
-}
-
-/**
- * An array containing commands for creating all default blocks.
- */
-export const defaultSlashMenuItems = [
- // Command for creating a level 1 heading
- new BaseSlashMenuItem(
- 'Heading',
- (editor) =>
- insertOrUpdateBlock(editor, {
- type: 'heading',
- props: {level: '1'},
- }),
- ['h', 'heading1', 'h1'],
- ),
-
- // Command for creating a level 2 heading
- new BaseSlashMenuItem(
- 'Heading 2',
- (editor) =>
- insertOrUpdateBlock(editor, {
- type: 'heading',
- props: {level: '2'},
- }),
- ['h2', 'heading2', 'subheading'],
- ),
-
- // Command for creating a level 3 heading
- new BaseSlashMenuItem(
- 'Heading 3',
- (editor) =>
- insertOrUpdateBlock(editor, {
- type: 'heading',
- props: {level: '3'},
- }),
- ['h3', 'heading3', 'subheading'],
- ),
-
- // Command for creating an ordered list
- new BaseSlashMenuItem(
- 'Numbered List',
- (editor) =>
- insertOrUpdateBlock(editor, {
- type: 'numberedListItem',
- }),
- ['li', 'list', 'numberedlist', 'numbered list'],
- ),
-
- // Command for creating a bullet list
- new BaseSlashMenuItem(
- 'Bullet List',
- (editor) =>
- insertOrUpdateBlock(editor, {
- type: 'bulletListItem',
- }),
- ['ul', 'list', 'bulletlist', 'bullet list'],
- ),
-
- // Command for creating a paragraph (pretty useless)
- new BaseSlashMenuItem(
- 'Paragraph',
- (editor) =>
- insertOrUpdateBlock(editor, {
- type: 'paragraph',
- }),
- ['paragraph', 'p'],
- ),
-
- // new BaseSlashMenuItem(
- // 'Code',
- // (editor) =>
- // insertOrUpdateBlock(editor, {
- // type: 'paragraph',
- // props: {type: 'code'}
- // }),
- // ['code']
- // ),
-
- // new BaseSlashMenuItem(
- // 'Blockquote',
- // (editor) =>
- // insertOrUpdateBlock(editor, {
- // type: 'paragraph',
- // props: {type: 'blockquote'},
- // }),
- // ['blockquote'],
- // ),
-
- // replaceRangeWithNode(editor, range, node);
-
- // return true;
- // },
- // ["ol", "orderedlist"],
- // OrderedListIcon,
- // "Used to display an ordered (enumerated) list item"
- // ),
-
- // Command for creating a blockquote
- // blockquote: new SlashCommand(
- // "Block Quote",
- // CommandGroup.BASIC_BLOCKS,
- // (editor, range) => {
- // const paragraph = editor.schema.node("paragraph");
- // const node = editor.schema.node(
- // "blockquote",
- // { "block-id": uniqueId.generate() },
- // paragraph
- // );
-
- // replaceRangeWithNode(editor, range, node);
-
- // return true;
- // },
- // ["quote", "blockquote"],
- // QuoteIcon,
- // "Used to make a quote stand out",
- // "Ctrl+Shift+B"
- // ),
-
- // Command for creating a horizontal rule
- // horizontalRule: new SlashCommand(
- // "Horizontal Rule",
- // CommandGroup.BASIC_BLOCKS,
- // (editor, range) => {
- // const node = editor.schema.node("horizontalRule", {
- // "block-id": uniqueId.generate(),
- // });
-
- // // insert horizontal rule, create a new block after the horizontal rule if applicable
- // // and put the cursor in the block after the horizontal rule.
- // editor
- // .chain()
- // .focus()
- // .replaceRangeAndUpdateSelection(range, node)
- // .command(({ tr, dispatch }) => {
- // if (dispatch) {
- // // the node immediately after the cursor
- // const nodeAfter = tr.selection.$to.nodeAfter;
-
- // // the position of the cursor
- // const cursorPos = tr.selection.$to.pos;
-
- // // check if there is no node after the cursor (end of document)
- // if (!nodeAfter) {
- // // create a new block of the default type (probably paragraph) after the cursor
- // const { parent } = tr.selection.$to;
- // const node = parent.type.contentMatch.defaultType?.create();
-
- // if (node) {
- // tr.insert(cursorPos, node);
- // }
- // }
-
- // // try to put the cursor at the start of the node directly after the inserted horizontal rule
- // tr.doc.nodesBetween(cursorPos, cursorPos + 1, (node, pos) => {
- // if (node.type.name !== "horizontalRule") {
- // tr.setSelection(TextSelection.create(tr.doc, pos));
- // }
- // });
- // }
-
- // return true;
- // })
- // .scrollIntoView()
- // .run();
- // return true;
- // },
- // ["hr", "horizontalrule"],
- // SeparatorIcon,
- // "Used to separate sections with a horizontal line"
- // ),
-
- // Command for creating a table
- // table: new SlashCommand(
- // "Table",
- // CommandGroup.BASIC_BLOCKS,
- // (editor, range) => {
- // editor.chain().focus().deleteRange(range).run();
- // // TODO: add blockid, pending https://github.com/ueberdosis/tiptap/pull/1469
- // editor
- // .chain()
- // .focus()
- // .insertTable({ rows: 1, cols: 2, withHeaderRow: false })
- // .scrollIntoView()
- // .run();
- // return true;
- // },
- // ["table", "database"],
- // TableIcon,
- // "Used to create a simple table"
- // ),
-]
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/SlashMenu/index.ts b/frontend/packages/editor/src/blocknote/core/extensions/SlashMenu/index.ts
deleted file mode 100644
index 08c54452cd..0000000000
--- a/frontend/packages/editor/src/blocknote/core/extensions/SlashMenu/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import {defaultSlashMenuItems} from './defaultSlashMenuItems'
-import {createSlashMenuExtension} from './SlashMenuExtension'
-import {BaseSlashMenuItem} from './BaseSlashMenuItem'
-
-export {defaultSlashMenuItems, BaseSlashMenuItem, createSlashMenuExtension}
diff --git a/frontend/packages/editor/src/blocknote/core/extensions/UniqueID/UniqueID.ts b/frontend/packages/editor/src/blocknote/core/extensions/UniqueID/UniqueID.ts
index a75b5e6e86..018cd74775 100644
--- a/frontend/packages/editor/src/blocknote/core/extensions/UniqueID/UniqueID.ts
+++ b/frontend/packages/editor/src/blocknote/core/extensions/UniqueID/UniqueID.ts
@@ -1,7 +1,6 @@
import {
combineTransactionSteps,
Extension,
- findChildren,
findChildrenInRange,
getChangedRanges,
} from '@tiptap/core'
@@ -58,14 +57,15 @@ const UniqueID = Extension.create({
types: [],
generateID: () => {
// Use mock ID if tests are running.
- if ((window as any).__TEST_OPTIONS) {
- if ((window as any).__TEST_OPTIONS.mockID === undefined) {
- ;(window as any).__TEST_OPTIONS.mockID = 0
+ if (typeof window !== 'undefined' && (window as any).__TEST_OPTIONS) {
+ const testOptions = (window as any).__TEST_OPTIONS
+ if (testOptions.mockID === undefined) {
+ testOptions.mockID = 0
} else {
- ;(window as any).__TEST_OPTIONS.mockID++
+ testOptions.mockID++
}
- return (window as any).__TEST_OPTIONS.mockID.toString() as string
+ return testOptions.mockID.toString() as string
}
return createId()
@@ -92,35 +92,35 @@ const UniqueID = Extension.create({
]
},
// check initial content for missing ids
- onCreate() {
- // Don’t do this when the collaboration extension is active
- // because this may update the content, so Y.js tries to merge these changes.
- // This leads to empty block nodes.
- // See: https://github.com/ueberdosis/tiptap/issues/2400
- if (
- this.editor.extensionManager.extensions.find(
- (extension) => extension.name === 'collaboration',
- )
- ) {
- return
- }
- const {view, state} = this.editor
- const {tr, doc} = state
- const {types, attributeName, generateID} = this.options
- const nodesWithoutId = findChildren(doc, (node) => {
- return (
- types.includes(node.type.name) && node.attrs[attributeName] === null
- )
- })
- nodesWithoutId.forEach(({node, pos}) => {
- tr.setNodeMarkup(pos, undefined, {
- ...node.attrs,
- [attributeName]: generateID(),
- })
- })
- tr.setMeta('addToHistory', false)
- view.dispatch(tr)
- },
+ // onCreate() {
+ // // Don’t do this when the collaboration extension is active
+ // // because this may update the content, so Y.js tries to merge these changes.
+ // // This leads to empty block nodes.
+ // // See: https://github.com/ueberdosis/tiptap/issues/2400
+ // if (
+ // this.editor.extensionManager.extensions.find(
+ // (extension) => extension.name === "collaboration"
+ // )
+ // ) {
+ // return;
+ // }
+ // const { view, state } = this.editor;
+ // const { tr, doc } = state;
+ // const { types, attributeName, generateID } = this.options;
+ // const nodesWithoutId = findChildren(doc, (node) => {
+ // return (
+ // types.includes(node.type.name) && node.attrs[attributeName] === null
+ // );
+ // });
+ // nodesWithoutId.forEach(({ node, pos }) => {
+ // tr.setNodeMarkup(pos, undefined, {
+ // ...node.attrs,
+ // [attributeName]: generateID(),
+ // });
+ // });
+ // tr.setMeta("addToHistory", false);
+ // view.dispatch(tr);
+ // },
addProseMirrorPlugins() {
let dragSourceElement: any = null
let transformPasted = false
@@ -135,7 +135,7 @@ const UniqueID = Extension.create({
const filterTransactions =
this.options.filterTransaction &&
transactions.some((tr) => {
- var _a, _b
+ let _a, _b
return !((_b = (_a = this.options).filterTransaction) === null ||
_b === void 0
? void 0
@@ -167,7 +167,7 @@ const UniqueID = Extension.create({
.filter((id) => id !== null)
const duplicatedNewIds = findDuplicates(newIds)
newNodes.forEach(({node, pos}) => {
- var _a
+ let _a
// instead of checking `node.attrs[attributeName]` directly
// we look at the current state of the node within `tr.doc`.
// this helps to prevent adding new ids to the same node
@@ -202,7 +202,7 @@ const UniqueID = Extension.create({
// we register a global drag handler to track the current drag source element
view(view) {
const handleDragstart = (event: any) => {
- var _a
+ let _a
dragSourceElement = (
(_a = view.dom.parentElement) === null || _a === void 0
? void 0
@@ -225,7 +225,7 @@ const UniqueID = Extension.create({
// only create new ids for dropped content while holding `alt`
// or content is dragged from another editor
drop: (view, event: any) => {
- var _a
+ let _a
if (
dragSourceElement !== view.dom.parentElement ||
((_a = event.dataTransfer) === null || _a === void 0
diff --git a/frontend/packages/editor/src/blocknote/core/index.ts b/frontend/packages/editor/src/blocknote/core/index.ts
index 09ce57714c..7000a8e925 100644
--- a/frontend/packages/editor/src/blocknote/core/index.ts
+++ b/frontend/packages/editor/src/blocknote/core/index.ts
@@ -3,18 +3,18 @@ export * from './BlockNoteExtensions'
export * from './extensions/Blocks/api/block'
export * from './extensions/Blocks/api/blockTypes'
export * from './extensions/Blocks/api/defaultBlocks'
+export * from './extensions/Blocks/api/inlineContentTypes'
+export * from './extensions/Blocks/api/serialization'
+export * as blockStyles from './extensions/Blocks/nodes/Block.module.css'
export * from './extensions/Blocks/helpers/getBlockInfoFromPos'
-export * from './extensions/Blocks/helpers/findBlock'
-export * from './extensions/DraggableBlocks/BlockSideMenuFactoryTypes'
-export * from './extensions/FormattingToolbar/FormattingToolbarFactoryTypes'
-export * from './extensions/HyperlinkToolbar/HyperlinkToolbarFactoryTypes'
-export {defaultSlashMenuItems} from './extensions/SlashMenu/defaultSlashMenuItems'
+export * from './extensions/FormattingToolbar/FormattingToolbarPlugin'
+export * from './extensions/HyperlinkToolbar/HyperlinkToolbarPlugin'
+export * from './extensions/SideMenu/SideMenuPlugin'
export * from './extensions/SlashMenu/BaseSlashMenuItem'
+export * from './extensions/SlashMenu/SlashMenuPlugin'
export * from './extensions/SlashMenu/defaultSlashMenuItems'
-export * from './shared/EditorElement'
+export {getDefaultSlashMenuItems} from './extensions/SlashMenu/defaultSlashMenuItems'
+export * from './shared/BaseUiElementTypes'
export type {SuggestionItem} from './shared/plugins/suggestion/SuggestionItem'
-export * from './shared/plugins/suggestion/SuggestionsMenuFactoryTypes'
-export * from './extensions/Blocks/api/inlineContentTypes'
-export * from './extensions/Blocks/api/serialization'
-export * as blockStyles from './extensions/Blocks/nodes/Block.module.css'
+export * from './shared/plugins/suggestion/SuggestionPlugin'
export * from './shared/utils'
diff --git a/frontend/packages/editor/src/blocknote/core/shared/BaseUiElementTypes.ts b/frontend/packages/editor/src/blocknote/core/shared/BaseUiElementTypes.ts
new file mode 100644
index 0000000000..36eb4b61b7
--- /dev/null
+++ b/frontend/packages/editor/src/blocknote/core/shared/BaseUiElementTypes.ts
@@ -0,0 +1,8 @@
+export type BaseUiElementCallbacks = {
+ destroy: () => void
+}
+
+export type BaseUiElementState = {
+ show: boolean
+ referencePos: DOMRect
+}
diff --git a/frontend/packages/editor/src/blocknote/core/shared/EditorElement.ts b/frontend/packages/editor/src/blocknote/core/shared/EditorElement.ts
index 8f3461d903..e69de29bb2 100644
--- a/frontend/packages/editor/src/blocknote/core/shared/EditorElement.ts
+++ b/frontend/packages/editor/src/blocknote/core/shared/EditorElement.ts
@@ -1,16 +0,0 @@
-export type RequiredStaticParams = Record & {
- getReferenceRect: () => DOMRect
-}
-export type RequiredDynamicParams = Record & {}
-
-export type EditorElement =
- {
- element: HTMLElement | undefined
- render: (params: ElementDynamicParams, isHidden: boolean) => void
- hide: () => void
- }
-
-export type ElementFactory<
- ElementStaticParams extends RequiredStaticParams,
- ElementDynamicParams extends RequiredDynamicParams,
-> = (staticParams: ElementStaticParams) => EditorElement
diff --git a/frontend/packages/editor/src/blocknote/core/shared/EventEmitter.ts b/frontend/packages/editor/src/blocknote/core/shared/EventEmitter.ts
new file mode 100644
index 0000000000..c2fd3fa204
--- /dev/null
+++ b/frontend/packages/editor/src/blocknote/core/shared/EventEmitter.ts
@@ -0,0 +1,59 @@
+// from https://raw.githubusercontent.com/ueberdosis/tiptap/develop/packages/core/src/EventEmitter.ts (MIT)
+
+type StringKeyOf = Extract
+type CallbackType<
+ T extends Record,
+ EventName extends StringKeyOf,
+> = T[EventName] extends any[] ? T[EventName] : [T[EventName]]
+type CallbackFunction<
+ T extends Record,
+ EventName extends StringKeyOf,
+> = (...props: CallbackType) => any
+
+export class EventEmitter> {
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ private callbacks: {[key: string]: Function[]} = {}
+
+ public on>(
+ event: EventName,
+ fn: CallbackFunction,
+ ) {
+ if (!this.callbacks[event]) {
+ this.callbacks[event] = []
+ }
+
+ this.callbacks[event].push(fn)
+
+ return () => this.off(event, fn)
+ }
+
+ protected emit>(
+ event: EventName,
+ ...args: CallbackType
+ ) {
+ const callbacks = this.callbacks[event]
+
+ if (callbacks) {
+ callbacks.forEach((callback) => callback.apply(this, args))
+ }
+ }
+
+ public off>(
+ event: EventName,
+ fn?: CallbackFunction,
+ ) {
+ const callbacks = this.callbacks[event]
+
+ if (callbacks) {
+ if (fn) {
+ this.callbacks[event] = callbacks.filter((callback) => callback !== fn)
+ } else {
+ delete this.callbacks[event]
+ }
+ }
+ }
+
+ protected removeAllListeners(): void {
+ this.callbacks = {}
+ }
+}
diff --git a/frontend/packages/editor/src/blocknote/core/shared/plugins/suggestion/SuggestionItem.ts b/frontend/packages/editor/src/blocknote/core/shared/plugins/suggestion/SuggestionItem.ts
index 07b13d9a5c..8116394218 100644
--- a/frontend/packages/editor/src/blocknote/core/shared/plugins/suggestion/SuggestionItem.ts
+++ b/frontend/packages/editor/src/blocknote/core/shared/plugins/suggestion/SuggestionItem.ts
@@ -1,9 +1,3 @@
-/**
- * A generic interface used in all suggestion menus (slash menu, mentions, etc)
- */
-export class SuggestionItem {
- constructor(
- public name: string,
- public match: (query: string) => boolean,
- ) {}
+export type SuggestionItem = {
+ name: string
}
diff --git a/frontend/packages/editor/src/blocknote/core/shared/plugins/suggestion/SuggestionPlugin.ts b/frontend/packages/editor/src/blocknote/core/shared/plugins/suggestion/SuggestionPlugin.ts
index 5a9a977a9b..abc8c200fb 100644
--- a/frontend/packages/editor/src/blocknote/core/shared/plugins/suggestion/SuggestionPlugin.ts
+++ b/frontend/packages/editor/src/blocknote/core/shared/plugins/suggestion/SuggestionPlugin.ts
@@ -1,144 +1,59 @@
-import {Editor, Range} from '@tiptap/core'
import {EditorState, Plugin, PluginKey} from 'prosemirror-state'
import {Decoration, DecorationSet, EditorView} from 'prosemirror-view'
-import {findBlock} from '../../../extensions/Blocks/helpers/findBlock'
-import {
- SuggestionsMenu,
- SuggestionsMenuDynamicParams,
- SuggestionsMenuFactory,
- SuggestionsMenuStaticParams,
-} from './SuggestionsMenuFactoryTypes'
-import {SuggestionItem} from './SuggestionItem'
import {BlockNoteEditor} from '../../../BlockNoteEditor'
import {BlockSchema} from '../../../extensions/Blocks/api/blockTypes'
-import {getBlockInfoFromPos} from '@/blocknote/core'
-
-export type SuggestionPluginOptions<
- T extends SuggestionItem,
- BSchema extends BlockSchema,
-> = {
- /**
- * The name of the plugin.
- *
- * Used for ensuring that the plugin key is unique when more than one instance of the SuggestionPlugin is used.
- */
- pluginKey: PluginKey
-
- /**
- * The BlockNote editor.
- */
- editor: BlockNoteEditor
-
- /**
- * The character that should trigger the suggestion menu to pop up (e.g. a '/' for commands), when typed by the user.
- */
- defaultTriggerCharacter: string
-
- suggestionsMenuFactory: SuggestionsMenuFactory
-
- /**
- * The callback that gets executed when an item is selected by the user.
- *
- * **NOTE:** The command text is not removed automatically from the editor by this plugin,
- * this should be done manually. The `editor` and `range` properties passed
- * to the callback function might come in handy when doing this.
- */
- onSelectItem?: (props: {item: T; editor: BlockNoteEditor}) => void
-
- /**
- * A function that should supply the plugin with items to suggest, based on a certain query string.
- */
- items?: (query: string) => T[]
-
- allow?: (props: {editor: Editor; range: Range}) => boolean
-}
-
-type SuggestionPluginState = {
- // True when the menu is shown, false when hidden.
- active: boolean
- // The character that triggered the menu being shown. Allowing the trigger to be different to the default
- // trigger allows other extensions to open it programmatically.
- triggerCharacter: string | undefined
- // The editor position just after the trigger character, i.e. where the user query begins. Used to figure out
- // which menu items to show and can also be used to delete the trigger character.
- queryStartPos: number | undefined
- // The items that should be shown in the menu.
- items: T[]
- // The index of the item in the menu that's currently hovered using the keyboard.
- keyboardHoveredItemIndex: number | undefined
- // The number of characters typed after the last query that matched with at least 1 item. Used to close the
- // menu if the user keeps entering queries that don't return any results.
- notFoundCount: number | undefined
- decorationId: string | undefined
-}
+import {findBlock} from '../../../extensions/Blocks/helpers/findBlock'
+import {BaseUiElementState} from '../../BaseUiElementTypes'
+import {SuggestionItem} from './SuggestionItem'
-function getDefaultPluginState<
- T extends SuggestionItem,
->(): SuggestionPluginState {
- return {
- active: false,
- triggerCharacter: undefined,
- queryStartPos: undefined,
- items: [] as T[],
- keyboardHoveredItemIndex: undefined,
- notFoundCount: 0,
- decorationId: undefined,
+export type SuggestionsMenuState =
+ BaseUiElementState & {
+ // The suggested items to display.
+ filteredItems: T[]
+ // The index of the suggested item that's currently hovered by the keyboard.
+ keyboardHoveredItemIndex: number
}
-}
-type SuggestionPluginViewOptions<
- T extends SuggestionItem,
- BSchema extends BlockSchema,
-> = {
- editor: BlockNoteEditor
- pluginKey: PluginKey
- onSelectItem: (props: {item: T; editor: BlockNoteEditor}) => void
- suggestionsMenuFactory: SuggestionsMenuFactory
-}
-
-class SuggestionPluginView<
+class SuggestionsMenuView<
T extends SuggestionItem,
BSchema extends BlockSchema,
> {
- editor: BlockNoteEditor
- pluginKey: PluginKey
-
- suggestionsMenu: SuggestionsMenu
+ private suggestionsMenuState?: SuggestionsMenuState
+ public updateSuggestionsMenu: () => void
pluginState: SuggestionPluginState
- itemCallback: (item: T) => void
- private lastPosition: DOMRect | undefined
-
- constructor({
- editor,
- pluginKey,
- onSelectItem: selectItemCallback = () => {},
- suggestionsMenuFactory,
- }: SuggestionPluginViewOptions) {
- this.editor = editor
- this.pluginKey = pluginKey
+ constructor(
+ private readonly editor: BlockNoteEditor,
+ private readonly pluginKey: PluginKey,
+ updateSuggestionsMenu: (
+ suggestionsMenuState: SuggestionsMenuState,
+ ) => void = () => {
+ // noop
+ },
+ ) {
this.pluginState = getDefaultPluginState()
- this.itemCallback = (item: T) => {
- editor._tiptapEditor
- .chain()
- .focus()
- .deleteRange({
- from:
- this.pluginState.queryStartPos! -
- this.pluginState.triggerCharacter!.length,
- to: editor._tiptapEditor.state.selection.from,
- })
- .run()
+ this.updateSuggestionsMenu = () => {
+ if (!this.suggestionsMenuState) {
+ throw new Error('Attempting to update uninitialized suggestions menu')
+ }
- selectItemCallback({
- item: item,
- editor: editor,
- })
+ updateSuggestionsMenu(this.suggestionsMenuState)
}
- this.suggestionsMenu = suggestionsMenuFactory(this.getStaticParams())
+ document.addEventListener('scroll', this.handleScroll)
+ }
+
+ handleScroll = () => {
+ if (this.suggestionsMenuState?.show) {
+ const decorationNode = document.querySelector(
+ `[data-decoration-id="${this.pluginState.decorationId}"]`,
+ )
+ this.suggestionsMenuState.referencePos =
+ decorationNode!.getBoundingClientRect()
+ this.updateSuggestionsMenu()
+ }
}
update(view: EditorView, prevState: EditorState) {
@@ -160,56 +75,63 @@ class SuggestionPluginView<
this.pluginState = stopped ? prev : next
if (stopped || !this.editor.isEditable) {
- this.suggestionsMenu.hide()
+ this.suggestionsMenuState!.show = false
+ this.updateSuggestionsMenu()
- // Listener stops focus moving to the menu on click.
- this.suggestionsMenu.element!.removeEventListener('mousedown', (event) =>
- event.preventDefault(),
- )
+ return
}
- if (changed) {
- this.suggestionsMenu.render(this.getDynamicParams(), false)
- }
+ const decorationNode = document.querySelector(
+ `[data-decoration-id="${this.pluginState.decorationId}"]`,
+ )
- if (started && this.editor.isEditable) {
- this.suggestionsMenu.render(this.getDynamicParams(), true)
+ if (this.editor.isEditable) {
+ this.suggestionsMenuState = {
+ show: true,
+ referencePos: decorationNode!.getBoundingClientRect(),
+ filteredItems: this.pluginState.items,
+ keyboardHoveredItemIndex: this.pluginState.keyboardHoveredItemIndex!,
+ }
- // Listener stops focus moving to the menu on click.
- this.suggestionsMenu.element!.addEventListener('mousedown', (event) =>
- event.preventDefault(),
- )
+ this.updateSuggestionsMenu()
}
}
- getStaticParams(): SuggestionsMenuStaticParams {
- return {
- itemCallback: (item: T) => this.itemCallback(item),
- getReferenceRect: () => {
- const decorationNode = document.querySelector(
- `[data-decoration-id="${this.pluginState.decorationId}"]`,
- )
- if (!decorationNode) {
- if (this.lastPosition === undefined) {
- throw new Error(
- 'Attempted to access trigger character reference rect before rendering suggestions menu.',
- )
- }
- return this.lastPosition
- }
- const triggerCharacterBoundingBox =
- decorationNode.getBoundingClientRect()
- this.lastPosition = triggerCharacterBoundingBox
- return triggerCharacterBoundingBox
- },
- }
+ destroy() {
+ document.removeEventListener('scroll', this.handleScroll)
}
+}
- getDynamicParams(): SuggestionsMenuDynamicParams {
- return {
- items: this.pluginState.items,
- keyboardHoveredItemIndex: this.pluginState.keyboardHoveredItemIndex!,
- }
+type SuggestionPluginState = {
+ // True when the menu is shown, false when hidden.
+ active: boolean
+ // The character that triggered the menu being shown. Allowing the trigger to be different to the default
+ // trigger allows other extensions to open it programmatically.
+ triggerCharacter: string | undefined
+ // The editor position just after the trigger character, i.e. where the user query begins. Used to figure out
+ // which menu items to show and can also be used to delete the trigger character.
+ queryStartPos: number | undefined
+ // The items that should be shown in the menu.
+ items: T[]
+ // The index of the item in the menu that's currently hovered using the keyboard.
+ keyboardHoveredItemIndex: number | undefined
+ // The number of characters typed after the last query that matched with at least 1 item. Used to close the
+ // menu if the user keeps entering queries that don't return any results.
+ notFoundCount: number | undefined
+ decorationId: string | undefined
+}
+
+function getDefaultPluginState<
+ T extends SuggestionItem,
+>(): SuggestionPluginState {
+ return {
+ active: false,
+ triggerCharacter: undefined,
+ queryStartPos: undefined,
+ items: [] as T[],
+ keyboardHoveredItemIndex: undefined,
+ notFoundCount: 0,
+ decorationId: undefined,
}
}
@@ -222,279 +144,295 @@ class SuggestionPluginView<
* - This version supports generic items instead of only strings (to allow for more advanced filtering for example)
* - This version hides some unnecessary complexity from the user of the plugin.
* - This version handles key events differently
- *
- * @param options options for configuring the plugin
- * @returns the prosemirror plugin
*/
-export function createSuggestionPlugin<
+export const setupSuggestionsMenu = <
T extends SuggestionItem,
BSchema extends BlockSchema,
->({
- pluginKey,
- editor,
- defaultTriggerCharacter,
- suggestionsMenuFactory,
- onSelectItem: selectItemCallback = () => {},
- items = () => [],
-}: SuggestionPluginOptions) {
+>(
+ editor: BlockNoteEditor,
+ updateSuggestionsMenu: (
+ suggestionsMenuState: SuggestionsMenuState,
+ ) => void,
+
+ pluginKey: PluginKey,
+ defaultTriggerCharacter: string,
+ items: (query: string) => T[] = () => [],
+ onSelectItem: (props: {
+ item: T
+ editor: BlockNoteEditor
+ }) => void = () => {
+ // noop
+ },
+) => {
// Assertions
if (defaultTriggerCharacter.length !== 1) {
throw new Error("'char' should be a single character")
}
+ let suggestionsPluginView: SuggestionsMenuView
+
const deactivate = (view: EditorView) => {
view.dispatch(view.state.tr.setMeta(pluginKey, {deactivate: true}))
}
- // Plugin key is passed in as a parameter, so it can be exported and used in the DraggableBlocksPlugin.
- return new Plugin({
- key: pluginKey,
+ return {
+ plugin: new Plugin({
+ key: pluginKey,
- view: (view: EditorView) =>
- new SuggestionPluginView({
- editor: editor,
- pluginKey: pluginKey,
- onSelectItem: (props: {item: T; editor: BlockNoteEditor}) => {
- deactivate(view)
- selectItemCallback(props)
- },
- suggestionsMenuFactory: suggestionsMenuFactory,
- }),
+ view: () => {
+ suggestionsPluginView = new SuggestionsMenuView(
+ editor,
+ pluginKey,
- state: {
- // Initialize the plugin's internal state.
- init(): SuggestionPluginState {
- return getDefaultPluginState()
+ updateSuggestionsMenu,
+ )
+ return suggestionsPluginView
},
- // Apply changes to the plugin state from an editor transaction.
- apply(transaction, prev, oldState, newState): SuggestionPluginState {
- // TODO: More clearly define which transactions should be ignored.
- if (transaction.getMeta('orderedListIndexing') !== undefined) {
- return prev
- }
-
- // Checks if the menu should be shown.
- if (transaction.getMeta(pluginKey)?.activate) {
- return {
- active: true,
- triggerCharacter:
- transaction.getMeta(pluginKey)?.triggerCharacter || '',
- queryStartPos: newState.selection.from,
- items: items(''),
- keyboardHoveredItemIndex: 0,
- // TODO: Maybe should be 1 if the menu has no possible items? Probably redundant since a menu with no items
- // is useless in practice.
- notFoundCount: 0,
- decorationId: `id_${Math.floor(Math.random() * 0xffffffff)}`,
+ state: {
+ // Initialize the plugin's internal state.
+ init(): SuggestionPluginState {
+ return getDefaultPluginState()
+ },
+
+ // Apply changes to the plugin state from an editor transaction.
+ apply(transaction, prev, oldState, newState): SuggestionPluginState {
+ // TODO: More clearly define which transactions should be ignored.
+ if (transaction.getMeta('orderedListIndexing') !== undefined) {
+ return prev
}
- }
-
- // Checks if the menu is hidden, in which case it doesn't need to be hidden or updated.
- if (!prev.active) {
- return prev
- }
-
- const next = {...prev}
-
- // Updates which menu items to show by checking which items the current query (the text between the trigger
- // character and caret) matches with.
- next.items = items(
- newState.doc.textBetween(
- prev.queryStartPos!,
- newState.selection.from,
- ),
- )
- // Updates notFoundCount if the query doesn't match any items.
- next.notFoundCount = 0
- if (next.items.length === 0) {
- // Checks how many characters were typed or deleted since the last transaction, and updates the notFoundCount
- // accordingly. Also ensures the notFoundCount does not become negative.
- next.notFoundCount = Math.max(
- 0,
- prev.notFoundCount! +
- (newState.selection.from - oldState.selection.from),
+ // Checks if the menu should be shown.
+ if (transaction.getMeta(pluginKey)?.activate) {
+ return {
+ active: true,
+ triggerCharacter:
+ transaction.getMeta(pluginKey)?.triggerCharacter || '',
+ queryStartPos: newState.selection.from,
+ items: items(''),
+ keyboardHoveredItemIndex: 0,
+ // TODO: Maybe should be 1 if the menu has no possible items? Probably redundant since a menu with no items
+ // is useless in practice.
+ notFoundCount: 0,
+ decorationId: `id_${Math.floor(Math.random() * 0xffffffff)}`,
+ }
+ }
+
+ // Checks if the menu is hidden, in which case it doesn't need to be hidden or updated.
+ if (!prev.active) {
+ return prev
+ }
+
+ const next = {...prev}
+
+ // Updates which menu items to show by checking which items the current query (the text between the trigger
+ // character and caret) matches with.
+ next.items = items(
+ newState.doc.textBetween(
+ prev.queryStartPos!,
+ newState.selection.from,
+ ),
)
- }
-
- // Hides the menu. This is done after items and notFoundCount are already updated as notFoundCount is needed to
- // check if the menu should be hidden.
- if (
- // Highlighting text should hide the menu.
- newState.selection.from !== newState.selection.to ||
- // Transactions with plugin metadata {deactivate: true} should hide the menu.
- transaction.getMeta(pluginKey)?.deactivate ||
- // Certain mouse events should hide the menu.
- // TODO: Change to global mousedown listener.
- transaction.getMeta('focus') ||
- transaction.getMeta('blur') ||
- transaction.getMeta('pointer') ||
- // Moving the caret before the character which triggered the menu should hide it.
- (prev.active && newState.selection.from < prev.queryStartPos!) ||
- // Entering more than 3 characters, after the last query that matched with at least 1 menu item, should hide
- // the menu.
- next.notFoundCount > 3
- ) {
- return getDefaultPluginState()
- }
-
- // Updates keyboardHoveredItemIndex if necessary.
- if (
- transaction.getMeta(pluginKey)?.selectedItemIndexChanged !== undefined
- ) {
- let newIndex = transaction.getMeta(pluginKey).selectedItemIndexChanged
-
- // Allows selection to jump between first and last items.
- if (newIndex < 0) {
- newIndex = prev.items.length - 1
- } else if (newIndex >= prev.items.length) {
- newIndex = 0
+
+ // Updates notFoundCount if the query doesn't match any items.
+ next.notFoundCount = 0
+ if (next.items.length === 0) {
+ // Checks how many characters were typed or deleted since the last transaction, and updates the notFoundCount
+ // accordingly. Also ensures the notFoundCount does not become negative.
+ next.notFoundCount = Math.max(
+ 0,
+ prev.notFoundCount! +
+ (newState.selection.from - oldState.selection.from),
+ )
+ }
+
+ // Hides the menu. This is done after items and notFoundCount are already updated as notFoundCount is needed to
+ // check if the menu should be hidden.
+ if (
+ // Highlighting text should hide the menu.
+ newState.selection.from !== newState.selection.to ||
+ // Transactions with plugin metadata {deactivate: true} should hide the menu.
+ transaction.getMeta(pluginKey)?.deactivate ||
+ // Certain mouse events should hide the menu.
+ // TODO: Change to global mousedown listener.
+ transaction.getMeta('focus') ||
+ transaction.getMeta('blur') ||
+ transaction.getMeta('pointer') ||
+ // Moving the caret before the character which triggered the menu should hide it.
+ (prev.active && newState.selection.from < prev.queryStartPos!) ||
+ // Entering more than 3 characters, after the last query that matched with at least 1 menu item, should hide
+ // the menu.
+ next.notFoundCount > 3
+ ) {
+ return getDefaultPluginState()
}
- next.keyboardHoveredItemIndex = newIndex
- }
+ // Updates keyboardHoveredItemIndex if the up or down arrow key was
+ // pressed, or resets it if the keyboard cursor moved.
+ if (
+ transaction.getMeta(pluginKey)?.selectedItemIndexChanged !==
+ undefined
+ ) {
+ let newIndex =
+ transaction.getMeta(pluginKey).selectedItemIndexChanged
+
+ // Allows selection to jump between first and last items.
+ if (newIndex < 0) {
+ newIndex = prev.items.length - 1
+ } else if (newIndex >= prev.items.length) {
+ newIndex = 0
+ }
+
+ next.keyboardHoveredItemIndex = newIndex
+ } else if (oldState.selection.from !== newState.selection.from) {
+ next.keyboardHoveredItemIndex = 0
+ }
- return next
+ return next
+ },
},
- },
- props: {
- handleKeyDown(view, event) {
- const menuIsActive = (this as Plugin).getState(view.state).active
+ props: {
+ handleKeyDown(view, event) {
+ const menuIsActive = (this as Plugin).getState(view.state).active
+
+ // Shows the menu if the default trigger character was pressed and the menu isn't active.
+ if (event.key === defaultTriggerCharacter && !menuIsActive) {
+ view.dispatch(
+ view.state.tr
+ .insertText(defaultTriggerCharacter)
+ .scrollIntoView()
+ .setMeta(pluginKey, {
+ activate: true,
+ triggerCharacter: defaultTriggerCharacter,
+ }),
+ )
- let blockInfo = getBlockInfoFromPos(
- view.state.doc,
- view.state.selection.from,
- )
+ return true
+ }
+
+ // Doesn't handle other keystrokes if the menu isn't active.
+ if (!menuIsActive) {
+ return false
+ }
- // Shows the menu if the default trigger character was pressed and the menu isn't active.
- if (
- event.key === defaultTriggerCharacter &&
- !menuIsActive &&
- blockInfo?.contentType.name !== 'image'
- ) {
- view.dispatch(
- view.state.tr
- .insertText(defaultTriggerCharacter)
- .scrollIntoView()
- .setMeta(pluginKey, {
- activate: true,
- triggerCharacter: defaultTriggerCharacter,
+ // Handles keystrokes for navigating the menu.
+ const {
+ triggerCharacter,
+ queryStartPos,
+ items,
+ keyboardHoveredItemIndex,
+ } = pluginKey.getState(view.state)
+
+ // Moves the keyboard selection to the previous item.
+ if (event.key === 'ArrowUp') {
+ view.dispatch(
+ view.state.tr.setMeta(pluginKey, {
+ selectedItemIndexChanged: keyboardHoveredItemIndex - 1,
}),
- )
+ )
+ return true
+ }
- return true
- }
+ // Moves the keyboard selection to the next item.
+ if (event.key === 'ArrowDown') {
+ view.dispatch(
+ view.state.tr.setMeta(pluginKey, {
+ selectedItemIndexChanged: keyboardHoveredItemIndex + 1,
+ }),
+ )
+ return true
+ }
- // Doesn't handle other keystrokes if the menu isn't active.
- if (!menuIsActive) {
- return false
- }
-
- // Handles keystrokes for navigating the menu.
- const {
- triggerCharacter,
- queryStartPos,
- items,
- keyboardHoveredItemIndex,
- } = pluginKey.getState(view.state)
-
- // Moves the keyboard selection to the previous item.
- if (event.key === 'ArrowUp') {
- view.dispatch(
- view.state.tr.setMeta(pluginKey, {
- selectedItemIndexChanged: keyboardHoveredItemIndex - 1,
- }),
- )
- return true
- }
-
- // Moves the keyboard selection to the next item.
- if (event.key === 'ArrowDown') {
- view.dispatch(
- view.state.tr.setMeta(pluginKey, {
- selectedItemIndexChanged: keyboardHoveredItemIndex + 1,
- }),
- )
- return true
- }
-
- // Selects an item and closes the menu.
- if (event.key === 'Enter') {
- deactivate(view)
- editor._tiptapEditor
- .chain()
- .focus()
- .deleteRange({
- from: queryStartPos! - triggerCharacter!.length,
- to: editor._tiptapEditor.state.selection.from,
+ // Selects an item and closes the menu.
+ if (event.key === 'Enter') {
+ deactivate(view)
+ editor._tiptapEditor
+ .chain()
+ .focus()
+ .deleteRange({
+ from: queryStartPos! - triggerCharacter!.length,
+ to: editor._tiptapEditor.state.selection.from,
+ })
+ .run()
+
+ onSelectItem({
+ item: items[keyboardHoveredItemIndex],
+ editor: editor,
})
- .run()
- selectItemCallback({
- item: items[keyboardHoveredItemIndex],
- editor: editor,
- })
+ return true
+ }
- return true
- }
+ // Closes the menu.
+ if (event.key === 'Escape') {
+ deactivate(view)
+ return true
+ }
- // Closes the menu.
- if (event.key === 'Escape') {
- deactivate(view)
- return true
- }
+ return false
+ },
- return false
- },
+ // Setup decorator on the currently active suggestion.
+ decorations(state) {
+ const {active, decorationId, queryStartPos, triggerCharacter} = (
+ this as Plugin
+ ).getState(state)
- // Hides menu in cases where mouse click does not cause an editor state change.
- handleClick(view) {
- deactivate(view)
- },
+ if (!active) {
+ return null
+ }
- // Setup decorator on the currently active suggestion.
- decorations(state) {
- const {active, decorationId, queryStartPos, triggerCharacter} = (
- this as Plugin
- ).getState(state)
-
- if (!active) {
- return null
- }
-
- // If the menu was opened programmatically by another extension, it may not use a trigger character. In this
- // case, the decoration is set on the whole block instead, as the decoration range would otherwise be empty.
- if (triggerCharacter === '') {
- const blockNode = findBlock(state.selection)
- if (blockNode) {
- return DecorationSet.create(state.doc, [
- Decoration.node(
- blockNode.pos,
- blockNode.pos + blockNode.node.nodeSize,
- {
- nodeName: 'span',
- class: 'suggestion-decorator',
- 'data-decoration-id': decorationId,
- },
- ),
- ])
+ // If the menu was opened programmatically by another extension, it may not use a trigger character. In this
+ // case, the decoration is set on the whole block instead, as the decoration range would otherwise be empty.
+ if (triggerCharacter === '') {
+ const blockNode = findBlock(state.selection)
+ if (blockNode) {
+ return DecorationSet.create(state.doc, [
+ Decoration.node(
+ blockNode.pos,
+ blockNode.pos + blockNode.node.nodeSize,
+ {
+ nodeName: 'span',
+ class: 'suggestion-decorator',
+ 'data-decoration-id': decorationId,
+ },
+ ),
+ ])
+ }
}
- }
- // Creates an inline decoration around the trigger character.
- return DecorationSet.create(state.doc, [
- Decoration.inline(
- queryStartPos - triggerCharacter.length,
- queryStartPos,
- {
- nodeName: 'span',
- class: 'suggestion-decorator',
- 'data-decoration-id': decorationId,
- },
- ),
- ])
+ // Creates an inline decoration around the trigger character.
+ return DecorationSet.create(state.doc, [
+ Decoration.inline(
+ queryStartPos - triggerCharacter.length,
+ queryStartPos,
+ {
+ nodeName: 'span',
+ class: 'suggestion-decorator',
+ 'data-decoration-id': decorationId,
+ },
+ ),
+ ])
+ },
},
+ }),
+ itemCallback: (item: T) => {
+ deactivate(editor._tiptapEditor.view)
+ editor._tiptapEditor
+ .chain()
+ .focus()
+ .deleteRange({
+ from:
+ suggestionsPluginView.pluginState.queryStartPos! -
+ suggestionsPluginView.pluginState.triggerCharacter!.length,
+ to: editor._tiptapEditor.state.selection.from,
+ })
+ .run()
+
+ onSelectItem({
+ item: item,
+ editor: editor,
+ })
},
- })
+ }
}
diff --git a/frontend/packages/editor/src/blocknote/core/shared/plugins/suggestion/SuggestionsMenuFactoryTypes.ts b/frontend/packages/editor/src/blocknote/core/shared/plugins/suggestion/SuggestionsMenuFactoryTypes.ts
deleted file mode 100644
index 69cf353596..0000000000
--- a/frontend/packages/editor/src/blocknote/core/shared/plugins/suggestion/SuggestionsMenuFactoryTypes.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import {EditorElement, ElementFactory} from '../../EditorElement'
-import {SuggestionItem} from './SuggestionItem'
-
-export type SuggestionsMenuStaticParams = {
- itemCallback: (item: T) => void
-
- getReferenceRect: () => DOMRect
-}
-
-export type SuggestionsMenuDynamicParams = {
- items: T[]
- keyboardHoveredItemIndex: number
-}
-
-export type SuggestionsMenu = EditorElement<
- SuggestionsMenuDynamicParams
->
-export type SuggestionsMenuFactory = ElementFactory<
- SuggestionsMenuStaticParams,
- SuggestionsMenuDynamicParams
->
diff --git a/frontend/packages/editor/src/blocknote/react/BlockNoteTheme.ts b/frontend/packages/editor/src/blocknote/react/BlockNoteTheme.ts
index 1e3e8fcb81..cb1ff6c571 100644
--- a/frontend/packages/editor/src/blocknote/react/BlockNoteTheme.ts
+++ b/frontend/packages/editor/src/blocknote/react/BlockNoteTheme.ts
@@ -203,7 +203,6 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => {
fontFamily: theme.fontFamily,
},
// Placeholders
- // @ts-expect-error
[`.${blockStyles.isEmpty} .${blockStyles.inlineContent}:before, .${blockStyles.isFilter} .${blockStyles.inlineContent}:before`]:
{
color: theme.colors.sideMenu,
diff --git a/frontend/packages/editor/src/blocknote/react/BlockNoteView.tsx b/frontend/packages/editor/src/blocknote/react/BlockNoteView.tsx
index 3322cadf1f..d9d14dd052 100644
--- a/frontend/packages/editor/src/blocknote/react/BlockNoteView.tsx
+++ b/frontend/packages/editor/src/blocknote/react/BlockNoteView.tsx
@@ -1,11 +1,14 @@
import {BlockNoteEditor, BlockSchema, mergeCSSClasses} from '@/blocknote/core'
-import {createStyles, MantineProvider} from '@mantine/core'
+import {MantineProvider, createStyles} from '@mantine/core'
import {EditorContent} from '@tiptap/react'
import {HTMLAttributes, ReactNode, useMemo} from 'react'
-// import { blockNoteToMantineTheme, Theme } from "./BlockNoteTheme";
-// import { darkDefaultTheme, lightDefaultTheme } from "./defaultThemes";
-// import usePrefersColorScheme from "use-prefers-color-scheme";
-// import { BlockNoteTheme } from "./BlockNoteTheme";
+import {usePrefersColorScheme} from 'use-prefers-color-scheme'
+import {Theme, blockNoteToMantineTheme} from './BlockNoteTheme'
+import {FormattingToolbarPositioner} from './FormattingToolbar/components/FormattingToolbarPositioner'
+import {HyperlinkToolbarPositioner} from './HyperlinkToolbar/components/HyperlinkToolbarPositioner'
+import {SideMenuPositioner} from './SideMenu/components/SideMenuPositioner'
+import {SlashMenuPositioner} from './SlashMenu/components/SlashMenuPositioner'
+import {darkDefaultTheme, lightDefaultTheme} from './defaultThemes'
// Renders the editor as well as all menus & toolbars using default styles.
function BaseBlockNoteView(
@@ -26,7 +29,14 @@ function BaseBlockNoteView(
className={mergeCSSClasses(classes.root, props.className || '')}
{...rest}
>
- {props.children}
+ {props.children || (
+ <>
+
+
+
+
+ >
+ )}
)
}
@@ -34,47 +44,43 @@ function BaseBlockNoteView(
export function BlockNoteView(
props: {
editor: BlockNoteEditor