diff --git a/frontend/apps/site/pages/g/[groupId]/index.tsx b/frontend/apps/site/pages/g/[groupId]/index.tsx index 8b8b86a550..7c955a60fa 100644 --- a/frontend/apps/site/pages/g/[groupId]/index.tsx +++ b/frontend/apps/site/pages/g/[groupId]/index.tsx @@ -3,36 +3,37 @@ import { GetServerSidePropsContext, InferGetServerSidePropsType, } from 'next' +import Head from 'next/head' import {setAllowAnyHostGetCORS} from 'server/cors' import {getPageProps, serverHelpers} from 'server/ssr-helpers' -import Head from 'next/head' import {SiteHead} from '../../../site-head' -import {trpc} from '../../../trpc' -import { - PageSection, - Text, - YStack, - Footer, - ButtonText, - Button, - SideSectionTitle, - SideSection, - View, -} from '@mintter/ui' -import {HMGroup, HMPublication} from '../../../server/json-hm' -import {ReactElement} from 'react' -import {GestureResponderEvent} from 'react-native' import {Timestamp} from '@bufbuild/protobuf' import { + createHmId, + createPublicWebHmUrl, formattedDate, unpackHmId, - createPublicWebHmUrl, - createHmId, } from '@mintter/shared' +import { + Button, + ButtonText, + Footer, + PageSection, + SideSection, + SideSectionTitle, + Text, + Tooltip, + View, + YStack, +} from '@mintter/ui' import {AccountAvatarLink, AccountRow} from 'components/account-row' import {format} from 'date-fns' +import {ReactElement} from 'react' +import {GestureResponderEvent} from 'react-native' import {Paragraph} from 'tamagui' +import {HMGroup, HMPublication} from '../../../server/json-hm' +import {trpc} from '../../../trpc' function GroupOwnerSection({owner}: {owner: string}) { return ( @@ -64,13 +65,11 @@ function LastUpdateSection({time}: {time: string}) { Last Update: - + {format(new Date(time), 'EEEE, MMMM do, yyyy')} - + ) } diff --git a/frontend/apps/site/publication-metadata.tsx b/frontend/apps/site/publication-metadata.tsx index 404cc263ec..9c013f4650 100644 --- a/frontend/apps/site/publication-metadata.tsx +++ b/frontend/apps/site/publication-metadata.tsx @@ -13,6 +13,7 @@ import { SideSectionTitle, SizableText, Text, + Tooltip, XStack, YStack, } from '@mintter/ui' @@ -30,7 +31,7 @@ function IDLabelRow({id, label}: {id?: string; label: string}) { return ( {label}:  - Copy: {id} @@ -47,7 +48,7 @@ function IDLabelRow({id, label}: {id?: string; label: string}) { > {abbreviateCid(id)} - + ) } @@ -552,7 +553,7 @@ function LatestVersionBanner({ {publishTimeRelative} - {format(new Date(record.publishTime), 'EEEE, MMMM do, yyyy')} - + ) } diff --git a/frontend/packages/app/src/components/citations-context.tsx b/frontend/packages/app/src/components/citations-context.tsx index 7a82935bbe..31c30f2c3a 100644 --- a/frontend/packages/app/src/components/citations-context.tsx +++ b/frontend/packages/app/src/components/citations-context.tsx @@ -60,10 +60,10 @@ export function useCitationsForBlock(blockId: string) { let context = useContext(citationsContext) let citations = useMemo(() => { if (!context) return [] - return context.citations.data?.links.filter((link) => { + return context.citations?.data?.links.filter((link) => { return link.target?.blockId == blockId }) - }, [blockId, context.citations]) + }, [blockId, context]) return { citations, diff --git a/frontend/packages/app/src/models/documents.ts b/frontend/packages/app/src/models/documents.ts index a54df721ba..7c2cb5710e 100644 --- a/frontend/packages/app/src/models/documents.ts +++ b/frontend/packages/app/src/models/documents.ts @@ -4,7 +4,6 @@ import { useListen, useQueryInvalidator, } from '@mintter/app/src/app-context' -import {fetchWebLink} from './web-links' import {editorBlockToServerBlock} from '@mintter/app/src/client/editor-to-server' import {serverChildrenToEditorChildren} from '@mintter/app/src/client/server-to-editor' import {useOpenUrl} from '@mintter/app/src/open-url' @@ -16,14 +15,11 @@ import { HMBlockSchema, InlineContent, PartialBlock, - RightsideWidget, - createHyperdocsDocLinkPlugin, - defaultReactSlashMenuItems, - formattingToolbarFactory, + createHypermediaDocLinkPlugin, hmBlockSchema, - insertFile, - insertImage, - insertVideo, + // insertFile, + // insertImage, + // insertVideo, useBlockNote, } from '@mintter/editor' import { @@ -37,7 +33,6 @@ import { normlizeHmId, unpackDocId, } from '@mintter/shared' -import {useWidgetViewFactory} from '@prosemirror-adapter/react' import { FetchQueryOptions, UseMutationOptions, @@ -48,12 +43,13 @@ import { } from '@tanstack/react-query' import {Editor, Extension, findParentNode} from '@tiptap/core' import {Node} from 'prosemirror-model' -import {useEffect, useRef} from 'react' +import {memo, useEffect, useRef} from 'react' import {useGRPCClient} from '../app-context' import {PublicationRouteContext, useNavRoute} from '../utils/navigation' +import {pathNameify} from '../utils/path' import {usePublicationInContext} from './publication' import {queryKeys} from './query-keys' -import {pathNameify} from '../utils/path' +import {fetchWebLink} from './web-links' export type HMBlock = Block export type HMPartialBlock = PartialBlock @@ -592,17 +588,15 @@ export function useDraftEditor( blocks: Block[], parentId: string, ) { - if (isReady.current) { - blocks.forEach((block, index) => { - const leftSibling = index === 0 ? '' : blocks[index - 1]?.id - lastBlockParent.current[block.id] = parentId - lastBlockLeftSibling.current[block.id] = leftSibling - lastBlocks.current[block.id] = block - if (block.children) { - prepareBlockObservations(block.children, block.id) - } - }) - } + blocks.forEach((block, index) => { + const leftSibling = index === 0 ? '' : blocks[index - 1]?.id + lastBlockParent.current[block.id] = parentId + lastBlockLeftSibling.current[block.id] = leftSibling + lastBlocks.current[block.id] = block + if (block.children) { + prepareBlockObservations(block.children, block.id) + } + }) } function getBlockGroup(blockId: BlockIdentifier) { @@ -661,6 +655,7 @@ export function useDraftEditor( ]) // this is to populate the blocks we use to compare changes + prepareBlockObservations(editor.topLevelBlocks, '') isReady.current = true handleAfterReady() @@ -669,6 +664,7 @@ export function useDraftEditor( const editor = useBlockNote({ onEditorContentChange(editor: BlockNoteEditor) { opts?.onEditorState?.(editor.topLevelBlocks) + if (!isReady.current) return if (!readyThings.current[0] || !readyThings.current[1]) return // trim empty blocks from the end of the document before treating them. @@ -784,25 +780,22 @@ export function useDraftEditor( readyThings.current[0] = e handleMaybeReady() }, - uiFactories: { - formattingToolbarFactory, - }, blockSchema: hmBlockSchema, - // @ts-expect-error - slashCommands: [ - ...defaultReactSlashMenuItems.slice(0, 2), - insertImage, - insertFile, - insertVideo, - ...defaultReactSlashMenuItems.slice(2), - ], + // slashCommands: [ + // ...defaultReactSlashMenuItems.slice(0, 2), + // insertImage, + // insertFile, + // insertVideo, + // ...defaultReactSlashMenuItems.slice(2), + // ], + _tiptapOptions: { extensions: [ Extension.create({ - name: 'hyperdocs-link', + name: 'hypermedia-link', addProseMirrorPlugins() { return [ - createHyperdocsDocLinkPlugin({ + createHypermediaDocLinkPlugin({ queryClient, fetchWebLink, }).plugin, @@ -970,8 +963,6 @@ export function usePublicationEditor( }, }) - const widgetViewFactory = useWidgetViewFactory() - // both the publication data and the editor are asyncronously loaded // using a ref to avoid extra renders, and ensure the editor is available and ready const readyThings = useRef<[HyperDocsEditor | null, Publication | null]>([ @@ -1021,12 +1012,6 @@ export function usePublicationEditor( applyPubToEditor(e, readyPub) } }, - uiFactories: { - rightsideFactory: widgetViewFactory({ - component: RightsideWidget, - as: 'div', - }), - }, }) return { diff --git a/frontend/packages/app/src/pages/publication.tsx b/frontend/packages/app/src/pages/publication.tsx index b5f211956f..409dbb4fde 100644 --- a/frontend/packages/app/src/pages/publication.tsx +++ b/frontend/packages/app/src/pages/publication.tsx @@ -10,11 +10,10 @@ import {useNavigate} from '@mintter/app/src/utils/useNavigate' import { MttLink, features, - formattedDateMedium, formattedDateLong, + formattedDateMedium, pluralS, } from '@mintter/shared' -import {ProsemirrorAdapterProvider} from '@prosemirror-adapter/react' import { Button, ButtonText, @@ -32,25 +31,20 @@ import 'allotment/dist/style.css' import {useState} from 'react' import {ErrorBoundary} from 'react-error-boundary' +import {Timestamp} from '@bufbuild/protobuf' import {AppError} from '@mintter/app/src/components/app-error' import {CitationsProvider} from '@mintter/app/src/components/citations-context' import {DebugData} from '@mintter/app/src/components/debug-data' import {HMEditorContainer, HyperMediaEditorView} from '@mintter/editor' -import {useLatestPublication} from '../models/documents' -import {DocumentPlaceholder} from './document-placeholder' -import {getAvatarUrl} from '../utils/account-url' import {AccountLinkAvatar} from '../components/account-link-avatar' import {useAccount} from '../models/accounts' -import {NavRoute} from '../utils/navigation' -import {Timestamp} from '@bufbuild/protobuf' import {useChange} from '../models/changes' +import {useLatestPublication} from '../models/documents' +import {NavRoute} from '../utils/navigation' +import {DocumentPlaceholder} from './document-placeholder' export default function PublicationPage() { - return ( - - - - ) + return } function AuthorLink({author}: {author: string}) { diff --git a/frontend/packages/editor/package.json b/frontend/packages/editor/package.json index 939bd3d74f..290bdc9d22 100644 --- a/frontend/packages/editor/package.json +++ b/frontend/packages/editor/package.json @@ -20,8 +20,6 @@ "@mantine/hooks": "6.0.13", "@mintter/shared": "workspace:*", "@mintter/ui": "workspace:*", - "@prosemirror-adapter/core": "0.2.6", - "@prosemirror-adapter/react": "0.2.6", "@radix-ui/colors": "0.1.9", "@sentry/tracing": "7.49.0", "@tamagui/lucide-icons": "1.61.1", @@ -58,6 +56,7 @@ "remark-rehype": "10.1.0", "remark-stringify": "10.0.2", "unified": "10.1.2", + "use-prefers-color-scheme": "^1.1.3", "y-prosemirror": "1.2.1", "y-protocols": "1.0.5", "yjs": "13.6.4" diff --git a/frontend/packages/editor/src/blocknote/core/BlockNoteEditor._test.ts b/frontend/packages/editor/src/blocknote/core/BlockNoteEditor.test.ts similarity index 100% rename from frontend/packages/editor/src/blocknote/core/BlockNoteEditor._test.ts rename to frontend/packages/editor/src/blocknote/core/BlockNoteEditor.test.ts diff --git a/frontend/packages/editor/src/blocknote/core/BlockNoteEditor.ts b/frontend/packages/editor/src/blocknote/core/BlockNoteEditor.ts index 5f8d0f0157..1ba06db95c 100644 --- a/frontend/packages/editor/src/blocknote/core/BlockNoteEditor.ts +++ b/frontend/packages/editor/src/blocknote/core/BlockNoteEditor.ts @@ -1,9 +1,9 @@ -import applyDevTools from 'prosemirror-dev-tools' -import {Editor, EditorOptions} from '@tiptap/core' +import {Editor, EditorOptions, Extension} from '@tiptap/core' import {Node} from 'prosemirror-model' // import "./blocknote.css"; import {Editor as TiptapEditor} from '@tiptap/core/dist/packages/core/src/Editor' import * as Y from 'yjs' +import {getBlockNoteExtensions} from './BlockNoteExtensions' import { insertBlocks, removeBlocks, @@ -16,9 +16,8 @@ import { HTMLToBlocks, markdownToBlocks, } from './api/formatConversions/formatConversions' -import {nodeToBlock} from './api/nodeConversions/nodeConversions' +import {blockToNode, nodeToBlock} from './api/nodeConversions/nodeConversions' import {getNodeById} from './api/util/nodeUtil' -import {getBlockNoteExtensions, UiFactories} from './BlockNoteExtensions' import styles from './editor.module.css' import { Block, @@ -39,28 +38,27 @@ import { } from './extensions/Blocks/api/inlineContentTypes' import {Selection} from './extensions/Blocks/api/selectionTypes' import {getBlockInfoFromPos} from './extensions/Blocks/helpers/getBlockInfoFromPos' -import {BaseSlashMenuItem, defaultSlashMenuItems} from './extensions/SlashMenu' + +import {FormattingToolbarProsemirrorPlugin} from './extensions/FormattingToolbar/FormattingToolbarPlugin' +import {HyperlinkToolbarProsemirrorPlugin} from './extensions/HyperlinkToolbar/HyperlinkToolbarPlugin' +import {SideMenuProsemirrorPlugin} from './extensions/SideMenu/SideMenuPlugin' +import {BaseSlashMenuItem} from './extensions/SlashMenu/BaseSlashMenuItem' +import {SlashMenuProsemirrorPlugin} from './extensions/SlashMenu/SlashMenuPlugin' +import {getDefaultSlashMenuItems} from './extensions/SlashMenu/defaultSlashMenuItems' +import {UniqueID} from './extensions/UniqueID/UniqueID' import {mergeCSSClasses} from './shared/utils' +import {createRightsideBlockWidgetExtension} from '@/rightside-block-widget' export type BlockNoteEditorOptions = { - linkExtensionOptions: any - // TODO: Figure out if enableBlockNoteExtensions/disableHistoryExtension are needed and document them. enableBlockNoteExtensions: boolean - /** - * UI element factories for creating a custom UI, including custom positioning - * & rendering. - */ - uiFactories: UiFactories - /** - * TODO: why is this called slashCommands and not slashMenuItems? * * (couldn't fix any type, see https://github.com/TypeCellOS/BlockNote/pull/191#discussion_r1210708771) * * @default defaultSlashMenuItems from `./extensions/SlashMenu` */ - slashCommands: BaseSlashMenuItem[] + slashMenuItems: BaseSlashMenuItem[] /** * The HTML element that should be used as the parent element for the editor. @@ -133,6 +131,9 @@ export type BlockNoteEditorOptions = { // tiptap options, undocumented _tiptapOptions: any + + // isEditable + isEditable: boolean } const blockNoteTipTapOptions = { @@ -145,19 +146,12 @@ export class BlockNoteEditor { public readonly _tiptapEditor: TiptapEditor & {contentComponent: any} public blockCache = new WeakMap>() public readonly schema: BSchema - private ready = false - - public get domElement() { - return this._tiptapEditor.view.dom as HTMLDivElement - } - - public isFocused() { - return this._tiptapEditor.view.hasFocus() - } + public ready = false - public focus() { - this._tiptapEditor.view.focus() - } + public readonly sideMenu: SideMenuProsemirrorPlugin + public readonly formattingToolbar: FormattingToolbarProsemirrorPlugin + public readonly slashMenu: SlashMenuProsemirrorPlugin + public readonly hyperlinkToolbar: HyperlinkToolbarProsemirrorPlugin constructor( private readonly options: Partial> = {}, @@ -174,43 +168,86 @@ export class BlockNoteEditor { // be defined. Unfortunately, trying to implement these constraints seems // to be a huge pain, hence the `as any` casts. blockSchema: options.blockSchema || (defaultBlockSchema as any), + editable: options.editable || true, ...options, } + + this.sideMenu = new SideMenuProsemirrorPlugin(this) + this.formattingToolbar = new FormattingToolbarProsemirrorPlugin(this) + this.slashMenu = new SlashMenuProsemirrorPlugin( + this, + newOptions.slashMenuItems || + getDefaultSlashMenuItems(newOptions.blockSchema), + ) + this.hyperlinkToolbar = new HyperlinkToolbarProsemirrorPlugin(this) + const extensions = getBlockNoteExtensions({ - linkExtensionOptions: newOptions.linkExtensionOptions, editor: this, - editable: newOptions.editable, domAttributes: newOptions.domAttributes || {}, - uiFactories: newOptions.uiFactories || {}, - slashCommands: newOptions.slashCommands || defaultSlashMenuItems, blockSchema: newOptions.blockSchema, collaboration: newOptions.collaboration, + editable: newOptions.editable, + }) + + const blockNoteUIExtension = Extension.create({ + name: 'BlockNoteUIExtension', + + addProseMirrorPlugins: () => { + return [ + this.sideMenu.plugin, + this.formattingToolbar.plugin, + this.slashMenu.plugin, + this.hyperlinkToolbar.plugin, + ] + }, }) + extensions.push(blockNoteUIExtension) this.schema = newOptions.blockSchema + const initialContent = + newOptions.initialContent || + (options.collaboration + ? undefined + : [ + { + type: 'paragraph', + id: UniqueID.options.generateID(), + }, + ]) + const tiptapOptions: EditorOptions = { - // TODO: This approach to setting initial content is "cleaner" but requires the PM editor schema, which is only - // created after initializing the TipTap editor. Not sure it's feasible. - // content: - // options.initialContent && - // options.initialContent.map((block) => - // blockToNode(block, this._tiptapEditor.schema).toJSON() - // ), ...blockNoteTipTapOptions, ...newOptions._tiptapOptions, onCreate: () => { newOptions.onEditorReady?.(this) - newOptions.initialContent && - this.replaceBlocks(this.topLevelBlocks, newOptions.initialContent) this.ready = true }, + onBeforeCreate(editor) { + if (!initialContent) { + // when using collaboration + return + } + // we have to set the initial content here, because now we can use the editor schema + // which has been created at this point + const schema = editor.editor.schema + const ic = initialContent.map((block) => blockToNode(block, schema)) + + const root = schema.node( + 'doc', + undefined, + schema.node('blockGroup', undefined, ic), + ) + // override the initialcontent + editor.editor.options.content = root.toJSON() + }, onUpdate: () => { // This seems to be necessary due to a bug in TipTap: // https://github.com/ueberdosis/tiptap/issues/2583 if (!this.ready) { return } + newOptions.onEditorContentChange?.(this) }, onSelectionUpdate: () => { @@ -219,6 +256,7 @@ export class BlockNoteEditor { if (!this.ready) { return } + newOptions.onTextCursorPositionChange?.(this) }, editable: options.editable === undefined ? true : options.editable, @@ -246,9 +284,22 @@ export class BlockNoteEditor { this._tiptapEditor = new Editor(tiptapOptions) as Editor & { contentComponent: any } - if (import.meta.env.DEV) { - applyDevTools(this._tiptapEditor.view) - } + } + + public get prosemirrorView() { + return this._tiptapEditor.view + } + + public get domElement() { + return this._tiptapEditor.view.dom as HTMLDivElement + } + + public isFocused() { + return this._tiptapEditor.view.hasFocus() + } + + public focus() { + this._tiptapEditor.view.focus() } /** @@ -275,7 +326,6 @@ export class BlockNoteEditor { public getBlock( blockIdentifier: BlockIdentifier, ): Block | undefined { - if (!blockIdentifier) return undefined const id = typeof blockIdentifier === 'string' ? blockIdentifier : blockIdentifier.id let newBlock: Block | undefined = undefined @@ -304,7 +354,7 @@ export class BlockNoteEditor { */ public forEachBlock( callback: (block: Block) => boolean, - reverse: boolean = false, + reverse = false, ): void { const blocks = this.topLevelBlocks.slice() @@ -341,6 +391,14 @@ export class BlockNoteEditor { this._tiptapEditor.on('update', callback) } + /** + * Executes a callback whenever the editor's selection changes. + * @param callback The callback to execute. + */ + public onEditorSelectionChange(callback: () => void) { + this._tiptapEditor.on('selectionUpdate', callback) + } + /** * Gets a snapshot of the current text cursor position. * @returns A snapshot of the current text cursor position. @@ -635,7 +693,7 @@ export class BlockNoteEditor { return } - let {from, to} = this._tiptapEditor.state.selection + const {from, to} = this._tiptapEditor.state.selection if (!text) { text = this._tiptapEditor.state.doc.textBetween(from, to) diff --git a/frontend/packages/editor/src/blocknote/core/BlockNoteExtensions.ts b/frontend/packages/editor/src/blocknote/core/BlockNoteExtensions.ts index 6ed295d42b..7768c682d9 100644 --- a/frontend/packages/editor/src/blocknote/core/BlockNoteExtensions.ts +++ b/frontend/packages/editor/src/blocknote/core/BlockNoteExtensions.ts @@ -1,7 +1,10 @@ import {HMBlockSchema} from '@/schema' -import {createRightsideBlockWidgetExtension} from '@/rightside-block-widget' -import {WidgetDecorationFactory} from '@prosemirror-adapter/core' import {Extensions, extensions} from '@tiptap/core' + +import {BlockNoteEditor} from './BlockNoteEditor' + +import {HMBlockContainer} from '@/hypermedia-block-container' +import Link from '@/tiptap-extension-link' import {Bold} from '@tiptap/extension-bold' import {Code} from '@tiptap/extension-code' import Collaboration from '@tiptap/extension-collaboration' @@ -15,43 +18,19 @@ import {Strike} from '@tiptap/extension-strike' import {Text} from '@tiptap/extension-text' import {Underline} from '@tiptap/extension-underline' import * as Y from 'yjs' -import {BlockNoteEditor} from './BlockNoteEditor' import styles from './editor.module.css' import {BackgroundColorExtension} from './extensions/BackgroundColor/BackgroundColorExtension' import {BackgroundColorMark} from './extensions/BackgroundColor/BackgroundColorMark' import {BlockContainer, BlockGroup, Doc} from './extensions/Blocks' -import { - BlockNoteDOMAttributes, - BlockSchema, -} from './extensions/Blocks/api/blockTypes' +import {BlockNoteDOMAttributes} from './extensions/Blocks/api/blockTypes' import {CustomBlockSerializerExtension} from './extensions/Blocks/api/serialization' import blockStyles from './extensions/Blocks/nodes/Block.module.css' -import {BlockSideMenuFactory} from './extensions/DraggableBlocks/BlockSideMenuFactoryTypes' -import {createDraggableBlocksExtension} from './extensions/DraggableBlocks/DraggableBlocksExtension' -import {createFormattingToolbarExtension} from './extensions/FormattingToolbar/FormattingToolbarExtension' -import {FormattingToolbarFactory} from './extensions/FormattingToolbar/FormattingToolbarFactoryTypes' -import HyperlinkMark from './extensions/HyperlinkToolbar/HyperlinkMark' -import {HyperlinkToolbarFactory} from './extensions/HyperlinkToolbar/HyperlinkToolbarFactoryTypes' import {Placeholder} from './extensions/Placeholder/PlaceholderExtension' -import {SelectableBlocksExtension} from './extensions/SelectableBlocks/SelectableBlocksExtension' -import { - BaseSlashMenuItem, - createSlashMenuExtension, -} from './extensions/SlashMenu' import {TextAlignmentExtension} from './extensions/TextAlignment/TextAlignmentExtension' import {TextColorExtension} from './extensions/TextColor/TextColorExtension' import {TextColorMark} from './extensions/TextColor/TextColorMark' import {TrailingNode} from './extensions/TrailingNode/TrailingNodeExtension' import UniqueID from './extensions/UniqueID/UniqueID' -import {SuggestionsMenuFactory} from './shared/plugins/suggestion/SuggestionsMenuFactoryTypes' - -export type UiFactories = Partial<{ - formattingToolbarFactory: FormattingToolbarFactory - hyperlinkToolbarFactory: HyperlinkToolbarFactory - slashMenuFactory: SuggestionsMenuFactory> - blockSideMenuFactory: BlockSideMenuFactory - rightsideFactory: WidgetDecorationFactory -}> /** * Get all the Tiptap extensions BlockNote is configured with by default @@ -60,8 +39,6 @@ export const getBlockNoteExtensions = (opts: { editable?: boolean editor: BlockNoteEditor domAttributes: Partial - uiFactories: UiFactories - slashCommands: BaseSlashMenuItem[] // couldn't fix type, see https://github.com/TypeCellOS/BlockNote/pull/191#discussion_r1210708771 blockSchema: BSchema collaboration?: { fragment: Y.XmlFragment @@ -72,7 +49,6 @@ export const getBlockNoteExtensions = (opts: { provider: any renderCursor?: (user: any) => HTMLElement } - linkExtensionOptions?: any }) => { const ret: Extensions = [ extensions.ClipboardTextSerializer, @@ -107,18 +83,15 @@ export const getBlockNoteExtensions = (opts: { Italic, Strike, Underline, + Link, TextColorMark, TextColorExtension, BackgroundColorMark, BackgroundColorExtension, TextAlignmentExtension, - SelectableBlocksExtension, // nodes Doc, - BlockContainer.configure({ - domAttributes: opts.domAttributes, - }), BlockGroup.configure({ domAttributes: opts.domAttributes, }), @@ -136,88 +109,59 @@ export const getBlockNoteExtensions = (opts: { TrailingNode, ] - if (opts.collaboration) { + if (opts.editable) { + console.log('=== IS EDITABLE', opts.editable) ret.push( - Collaboration.configure({ - fragment: opts.collaboration.fragment, - }), - ) - const defaultRender = (user: {color: string; name: string}) => { - const cursor = document.createElement('span') - - cursor.classList.add(styles['collaboration-cursor__caret']) - cursor.setAttribute('style', `border-color: ${user.color}`) - - const label = document.createElement('span') - - label.classList.add(styles['collaboration-cursor__label']) - label.setAttribute('style', `background-color: ${user.color}`) - label.insertBefore(document.createTextNode(user.name), null) - - const nonbreakingSpace1 = document.createTextNode('\u2060') - const nonbreakingSpace2 = document.createTextNode('\u2060') - cursor.insertBefore(nonbreakingSpace1, null) - cursor.insertBefore(label, null) - cursor.insertBefore(nonbreakingSpace2, null) - return cursor - } - ret.push( - CollaborationCursor.configure({ - user: opts.collaboration.user, - render: opts.collaboration.renderCursor || defaultRender, - provider: opts.collaboration.provider, + BlockContainer.configure({ + domAttributes: opts.domAttributes, }), ) } else { - // disable history extension when collaboration is enabled as Yjs takes care of undo / redo - ret.push(History) - } - - if (opts.uiFactories.blockSideMenuFactory) { + console.log('=== IS NOT EDITABLE', opts.editable) ret.push( - createDraggableBlocksExtension().configure({ - editor: opts.editor, - blockSideMenuFactory: opts.uiFactories.blockSideMenuFactory, - }), - ) - } - - if (opts.uiFactories.formattingToolbarFactory) { - ret.push( - createFormattingToolbarExtension().configure({ - editor: opts.editor, - formattingToolbarFactory: opts.uiFactories.formattingToolbarFactory, - }), - ) - } - if (opts.uiFactories.hyperlinkToolbarFactory) { - ret.push( - HyperlinkMark.configure({ - ...opts.linkExtensionOptions, - hyperlinkToolbarFactory: opts.uiFactories.hyperlinkToolbarFactory, - openOnClick: opts.editable === false, - }), - ) - } - - if (opts.uiFactories.slashMenuFactory) { - ret.push( - createSlashMenuExtension().configure({ - editor: opts.editor, - commands: opts.slashCommands, - slashMenuFactory: opts.uiFactories.slashMenuFactory, + HMBlockContainer.configure({ + domAttributes: opts.domAttributes, }), ) } - if (opts.uiFactories.rightsideFactory) { + if (opts.collaboration) { ret.push( - createRightsideBlockWidgetExtension({ - getWidget: opts.uiFactories.rightsideFactory, - //@ts-expect-error - editor: opts.editor, + Collaboration.configure({ + fragment: opts.collaboration.fragment, }), ) + if (opts.collaboration.provider?.awareness) { + const defaultRender = (user: {color: string; name: string}) => { + const cursor = document.createElement('span') + + cursor.classList.add(styles['collaboration-cursor__caret']) + cursor.setAttribute('style', `border-color: ${user.color}`) + + const label = document.createElement('span') + + label.classList.add(styles['collaboration-cursor__label']) + label.setAttribute('style', `background-color: ${user.color}`) + label.insertBefore(document.createTextNode(user.name), null) + + const nonbreakingSpace1 = document.createTextNode('\u2060') + const nonbreakingSpace2 = document.createTextNode('\u2060') + cursor.insertBefore(nonbreakingSpace1, null) + cursor.insertBefore(label, null) + cursor.insertBefore(nonbreakingSpace2, null) + return cursor + } + ret.push( + CollaborationCursor.configure({ + user: opts.collaboration.user, + render: opts.collaboration.renderCursor || defaultRender, + provider: opts.collaboration.provider, + }), + ) + } + } else { + // disable history extension when collaboration is enabled as Yjs takes care of undo / redo + ret.push(History) } return ret diff --git a/frontend/packages/editor/src/blocknote/core/api/blockManipulation/blockManipulation._test.ts b/frontend/packages/editor/src/blocknote/core/api/blockManipulation/blockManipulation._test.ts index 9d982123b5..88580ccead 100644 --- a/frontend/packages/editor/src/blocknote/core/api/blockManipulation/blockManipulation._test.ts +++ b/frontend/packages/editor/src/blocknote/core/api/blockManipulation/blockManipulation._test.ts @@ -24,7 +24,8 @@ let insert: ( ) => Block[] beforeEach(() => { - ;(window as Window & {__TEST_OPTIONS?: {}}).__TEST_OPTIONS = {} + // TODO: remove any + ;(window as Window & {__TEST_OPTIONS?: any}).__TEST_OPTIONS = {} editor = new BlockNoteEditor() @@ -77,11 +78,10 @@ beforeEach(() => { }) afterEach(() => { - editor?._tiptapEditor?.destroy() - + editor._tiptapEditor.destroy() editor = undefined as any - delete (window as Window & {__TEST_OPTIONS?: {}}).__TEST_OPTIONS + delete (window as Window & {__TEST_OPTIONS?: any}).__TEST_OPTIONS }) describe('Inserting Blocks with Different Placements', () => { diff --git a/frontend/packages/editor/src/blocknote/core/api/blockManipulation/blockManipulation.ts b/frontend/packages/editor/src/blocknote/core/api/blockManipulation/blockManipulation.ts index c546992237..ef699dd2ed 100644 --- a/frontend/packages/editor/src/blocknote/core/api/blockManipulation/blockManipulation.ts +++ b/frontend/packages/editor/src/blocknote/core/api/blockManipulation/blockManipulation.ts @@ -105,7 +105,7 @@ export function removeBlocks( }) if (idsOfBlocksToRemove.size > 0) { - let notFoundIds = [...idsOfBlocksToRemove].join('\n') + const notFoundIds = [...idsOfBlocksToRemove].join('\n') throw Error( 'Blocks with the following IDs could not be found in the editor: ' + diff --git a/frontend/packages/editor/src/blocknote/core/api/formatConversions/formatConversions._test.ts b/frontend/packages/editor/src/blocknote/core/api/formatConversions/formatConversions._test.ts index 1e01029155..017b11950a 100644 --- a/frontend/packages/editor/src/blocknote/core/api/formatConversions/formatConversions._test.ts +++ b/frontend/packages/editor/src/blocknote/core/api/formatConversions/formatConversions._test.ts @@ -1,777 +1,752 @@ import {afterEach, beforeEach, describe, expect, it} from 'vitest' import {Block, BlockNoteEditor} from '../..' import UniqueID from '../../extensions/UniqueID/UniqueID' -import {DefaultBlockSchema} from '../../extensions/Blocks/api/defaultBlocks' let editor: BlockNoteEditor -let nonNestedBlocks: Block[] -let nonNestedHTML: string -let nonNestedMarkdown: string - -let nestedBlocks: Block[] -// let nestedHTML: string; -// let nestedMarkdown: string; - -let styledBlocks: Block[] -let styledHTML: string -let styledMarkdown: string - -let complexBlocks: Block[] -// let complexHTML: string; -// let complexMarkdown: string; - -function removeInlineContentClass(html: string) { - return html.replace(/ class="_inlineContent_52a956"/g, '') -} - -beforeEach(() => { - ;(window as Window & {__TEST_OPTIONS?: {}}).__TEST_OPTIONS = {} - - editor = new BlockNoteEditor() - - nonNestedBlocks = [ - { - id: UniqueID.options.generateID(), - type: 'heading', - props: { - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', - level: '1', +const getNonNestedBlocks = (): Block[] => [ + { + id: UniqueID.options.generateID(), + type: 'heading', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', + level: '1', + }, + content: [ + { + type: 'text', + text: 'Heading', + styles: {}, }, - content: [ - { - type: 'text', - text: 'Heading', - styles: {}, - }, - ], - children: [], + ], + children: [], + }, + { + id: UniqueID.options.generateID(), + type: 'paragraph', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', }, - { - id: UniqueID.options.generateID(), - type: 'paragraph', - props: { - type: 'p', - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', + content: [ + { + type: 'text', + text: 'Paragraph', + styles: {}, }, - content: [ - { - type: 'text', - text: 'Paragraph', - styles: {}, - }, - ], - children: [], + ], + children: [], + }, + { + id: UniqueID.options.generateID(), + type: 'bulletListItem', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', }, - { - id: UniqueID.options.generateID(), - type: 'bulletListItem', - props: { - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', + content: [ + { + type: 'text', + text: 'Bullet List Item', + styles: {}, }, - content: [ - { - type: 'text', - text: 'Bullet List Item', - styles: {}, - }, - ], - children: [], + ], + children: [], + }, + { + id: UniqueID.options.generateID(), + type: 'numberedListItem', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', }, - { - id: UniqueID.options.generateID(), - type: 'numberedListItem', - props: { - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', + content: [ + { + type: 'text', + text: 'Numbered List Item', + styles: {}, }, - content: [ - { - type: 'text', - text: 'Numbered List Item', - styles: {}, - }, - ], - children: [], + ], + children: [], + }, +] + +const getNestedBlocks = (): Block[] => [ + { + id: UniqueID.options.generateID(), + type: 'heading', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', + level: '1', }, - ] - nonNestedHTML = `

Heading

Paragraph

  • Bullet List Item

  1. Numbered List Item

` - nonNestedMarkdown = `# Heading - -Paragraph - -* Bullet List Item - -1. Numbered List Item -` - - nestedBlocks = [ - { - id: UniqueID.options.generateID(), - type: 'heading', - props: { - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', - level: '1', + content: [ + { + type: 'text', + text: 'Heading', + styles: {}, }, - content: [ - { - type: 'text', - text: 'Heading', - styles: {}, + ], + children: [ + { + id: UniqueID.options.generateID(), + type: 'paragraph', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', }, - ], - children: [ - { - id: UniqueID.options.generateID(), - type: 'paragraph', - props: { - type: 'p', - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', + content: [ + { + type: 'text', + text: 'Paragraph', + styles: {}, }, - content: [ - { - type: 'text', - text: 'Paragraph', - styles: {}, + ], + children: [ + { + id: UniqueID.options.generateID(), + type: 'bulletListItem', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', }, - ], - children: [ - { - id: UniqueID.options.generateID(), - type: 'bulletListItem', - props: { - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', + content: [ + { + type: 'text', + text: 'Bullet List Item', + styles: {}, }, - content: [ - { - type: 'text', - text: 'Bullet List Item', - styles: {}, + ], + children: [ + { + id: UniqueID.options.generateID(), + type: 'numberedListItem', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', }, - ], - children: [ - { - id: UniqueID.options.generateID(), - type: 'numberedListItem', - props: { - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', + content: [ + { + type: 'text', + text: 'Numbered List Item', + styles: {}, }, - content: [ - { - type: 'text', - text: 'Numbered List Item', - styles: {}, - }, - ], - children: [], - }, - ], - }, - ], - }, - ], - }, - ] - // nestedHTML = `

Heading

Paragraph

  • Bullet List Item

    1. Numbered List Item

`; - // nestedMarkdown = `# Heading - // - // Paragraph - // - // * Bullet List Item - // - // 1. Numbered List Item - // `; - - styledBlocks = [ - { - id: UniqueID.options.generateID(), - type: 'paragraph', - props: { - type: 'p', - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', - }, - content: [ - { - type: 'text', - text: 'Bold', - styles: { - bold: true, + ], + children: [], + }, + ], }, + ], + }, + ], + }, +] + +const getStyledBlocks = (): Block[] => [ + { + id: UniqueID.options.generateID(), + type: 'paragraph', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', + }, + content: [ + { + type: 'text', + text: 'Bold', + styles: { + bold: true, }, - { - type: 'text', - text: 'Italic', - styles: { - italic: true, - }, + }, + { + type: 'text', + text: 'Italic', + styles: { + italic: true, }, - { - type: 'text', - text: 'Underline', - styles: { - underline: true, - }, + }, + { + type: 'text', + text: 'Underline', + styles: { + underline: true, }, - { - type: 'text', - text: 'Strikethrough', - styles: { - strike: true, - }, + }, + { + type: 'text', + text: 'Strikethrough', + styles: { + strike: true, }, - { - type: 'text', - text: 'TextColor', - styles: { - textColor: 'red', - }, + }, + { + type: 'text', + text: 'TextColor', + styles: { + textColor: 'red', }, - { - type: 'text', - text: 'BackgroundColor', - styles: { - backgroundColor: 'red', - }, + }, + { + type: 'text', + text: 'BackgroundColor', + styles: { + backgroundColor: 'red', }, - { - type: 'text', - text: 'Multiple', - styles: { - bold: true, - italic: true, - }, + }, + { + type: 'text', + text: 'Multiple', + styles: { + bold: true, + italic: true, }, - ], - children: [], + }, + ], + children: [], + }, +] + +const getComplexBlocks = (): Block[] => [ + { + id: UniqueID.options.generateID(), + type: 'heading', + props: { + backgroundColor: 'red', + textColor: 'yellow', + textAlignment: 'right', + level: '1', }, - ] - styledHTML = `

BoldItalicUnderlineStrikethroughTextColorBackgroundColorMultiple

` - styledMarkdown = `**Bold***Italic*Underline~~Strikethrough~~TextColorBackgroundColor***Multiple***` - - complexBlocks = [ - { - id: UniqueID.options.generateID(), - type: 'heading', - props: { - backgroundColor: 'red', - textColor: 'yellow', - textAlignment: 'right', - level: '1', + content: [ + { + type: 'text', + text: 'Heading 1', + styles: {}, }, - content: [ - { - type: 'text', - text: 'Heading 1', - styles: {}, + ], + children: [ + { + id: UniqueID.options.generateID(), + type: 'heading', + props: { + backgroundColor: 'orange', + textColor: 'orange', + textAlignment: 'center', + level: '2', }, - ], - children: [ - { - id: UniqueID.options.generateID(), - type: 'heading', - props: { - backgroundColor: 'orange', - textColor: 'orange', - textAlignment: 'center', - level: '2', + content: [ + { + type: 'text', + text: 'Heading 2', + styles: {}, }, - content: [ - { - type: 'text', - text: 'Heading 2', - styles: {}, + ], + children: [ + { + id: UniqueID.options.generateID(), + type: 'heading', + props: { + backgroundColor: 'yellow', + textColor: 'red', + textAlignment: 'left', + level: '3', }, - ], - children: [ - { - id: UniqueID.options.generateID(), - type: 'heading', - props: { - backgroundColor: 'yellow', - textColor: 'red', - textAlignment: 'left', - level: '3', + content: [ + { + type: 'text', + text: 'Heading 3', + styles: {}, }, - content: [ - { - type: 'text', - text: 'Heading 3', - styles: {}, - }, - ], - children: [], - }, - ], - }, - ], - }, - { - id: UniqueID.options.generateID(), - type: 'paragraph', - props: { - type: 'p', - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', - }, - content: [ - { - type: 'text', - text: 'Paragraph', - styles: { - textColor: 'purple', - backgroundColor: 'green', + ], + children: [], }, + ], + }, + ], + }, + { + id: UniqueID.options.generateID(), + type: 'paragraph', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', + }, + content: [ + { + type: 'text', + text: 'Paragraph', + styles: { + textColor: 'purple', + backgroundColor: 'green', }, - ], - children: [], + }, + ], + children: [], + }, + { + id: UniqueID.options.generateID(), + type: 'paragraph', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', }, - { - id: UniqueID.options.generateID(), - type: 'paragraph', - props: { - type: 'p', - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', + content: [ + { + type: 'text', + text: 'P', + styles: {}, }, - content: [ - { - type: 'text', - text: 'P', - styles: {}, + { + type: 'text', + text: 'ara', + styles: { + bold: true, }, - { - type: 'text', - text: 'ara', - styles: { - bold: true, - }, - }, - { - type: 'text', - text: 'grap', - styles: { - italic: true, - }, - }, - { - type: 'text', - text: 'h', - styles: {}, + }, + { + type: 'text', + text: 'grap', + styles: { + italic: true, }, - ], - children: [], + }, + { + type: 'text', + text: 'h', + styles: {}, + }, + ], + children: [], + }, + { + id: UniqueID.options.generateID(), + type: 'paragraph', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', }, - { - id: UniqueID.options.generateID(), - type: 'paragraph', - props: { - type: 'p', - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', + content: [ + { + type: 'text', + text: 'P', + styles: {}, }, - content: [ - { - type: 'text', - text: 'P', - styles: {}, + { + type: 'text', + text: 'ara', + styles: { + underline: true, }, - { - type: 'text', - text: 'ara', - styles: { - underline: true, - }, - }, - { - type: 'text', - text: 'grap', - styles: { - strike: true, - }, - }, - { - type: 'text', - text: 'h', - styles: {}, + }, + { + type: 'text', + text: 'grap', + styles: { + strike: true, }, - ], - children: [], + }, + { + type: 'text', + text: 'h', + styles: {}, + }, + ], + children: [], + }, + { + id: UniqueID.options.generateID(), + type: 'bulletListItem', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', }, - { - id: UniqueID.options.generateID(), - type: 'bulletListItem', - props: { - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', + content: [ + { + type: 'text', + text: 'Bullet List Item', + styles: {}, }, - content: [ - { - type: 'text', - text: 'Bullet List Item', - styles: {}, - }, - ], - children: [], + ], + children: [], + }, + { + id: UniqueID.options.generateID(), + type: 'bulletListItem', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', }, - { - id: UniqueID.options.generateID(), - type: 'bulletListItem', - props: { - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', + content: [ + { + type: 'text', + text: 'Bullet List Item', + styles: {}, }, - content: [ - { - type: 'text', - text: 'Bullet List Item', - styles: {}, + ], + children: [ + { + id: UniqueID.options.generateID(), + type: 'bulletListItem', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', }, - ], - children: [ - { - id: UniqueID.options.generateID(), - type: 'bulletListItem', - props: { - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', + content: [ + { + type: 'text', + text: 'Bullet List Item', + styles: {}, }, - content: [ - { - type: 'text', - text: 'Bullet List Item', - styles: {}, + ], + children: [ + { + id: UniqueID.options.generateID(), + type: 'bulletListItem', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', }, - ], - children: [ - { - id: UniqueID.options.generateID(), - type: 'bulletListItem', - props: { - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', + content: [ + { + type: 'text', + text: 'Bullet List Item', + styles: {}, }, - content: [ - { - type: 'text', - text: 'Bullet List Item', - styles: {}, - }, - ], - children: [], + ], + children: [], + }, + { + id: UniqueID.options.generateID(), + type: 'paragraph', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', }, - { - id: UniqueID.options.generateID(), - type: 'paragraph', - props: { - type: 'p', - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', + content: [ + { + type: 'text', + text: 'Paragraph', + styles: {}, }, - content: [ - { - type: 'text', - text: 'Paragraph', - styles: {}, - }, - ], - children: [], + ], + children: [], + }, + { + id: UniqueID.options.generateID(), + type: 'numberedListItem', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', }, - { - id: UniqueID.options.generateID(), - type: 'numberedListItem', - props: { - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', + content: [ + { + type: 'text', + text: 'Numbered List Item', + styles: {}, }, - content: [ - { - type: 'text', - text: 'Numbered List Item', - styles: {}, - }, - ], - children: [], + ], + children: [], + }, + { + id: UniqueID.options.generateID(), + type: 'numberedListItem', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', }, - { - id: UniqueID.options.generateID(), - type: 'numberedListItem', - props: { - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', + content: [ + { + type: 'text', + text: 'Numbered List Item', + styles: {}, }, - content: [ - { - type: 'text', - text: 'Numbered List Item', - styles: {}, - }, - ], - children: [], + ], + children: [], + }, + { + id: UniqueID.options.generateID(), + type: 'numberedListItem', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', }, - { - id: UniqueID.options.generateID(), - type: 'numberedListItem', - props: { - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', + content: [ + { + type: 'text', + text: 'Numbered List Item', + styles: {}, }, - content: [ - { - type: 'text', - text: 'Numbered List Item', - styles: {}, + ], + children: [ + { + id: UniqueID.options.generateID(), + type: 'numberedListItem', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', }, - ], - children: [ - { - id: UniqueID.options.generateID(), - type: 'numberedListItem', - props: { - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', + content: [ + { + type: 'text', + text: 'Numbered List Item', + styles: {}, }, - content: [ - { - type: 'text', - text: 'Numbered List Item', - styles: {}, - }, - ], - children: [], - }, - ], - }, - { - id: UniqueID.options.generateID(), - type: 'bulletListItem', - props: { - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', + ], + children: [], }, - content: [ - { - type: 'text', - text: 'Bullet List Item', - styles: {}, - }, - ], - children: [], - }, - ], - }, - { - id: UniqueID.options.generateID(), - type: 'bulletListItem', - props: { - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', + ], }, - content: [ - { - type: 'text', - text: 'Bullet List Item', - styles: {}, + { + id: UniqueID.options.generateID(), + type: 'bulletListItem', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', }, - ], - children: [], - }, - ], - }, - { - id: UniqueID.options.generateID(), - type: 'bulletListItem', - props: { - backgroundColor: 'default', - textColor: 'default', - textAlignment: 'left', + content: [ + { + type: 'text', + text: 'Bullet List Item', + styles: {}, + }, + ], + children: [], + }, + ], }, - content: [ - { - type: 'text', - text: 'Bullet List Item', - styles: {}, + { + id: UniqueID.options.generateID(), + type: 'bulletListItem', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', }, - ], - children: [], + content: [ + { + type: 'text', + text: 'Bullet List Item', + styles: {}, + }, + ], + children: [], + }, + ], + }, + { + id: UniqueID.options.generateID(), + type: 'bulletListItem', + props: { + backgroundColor: 'default', + textColor: 'default', + textAlignment: 'left', }, - ] + content: [ + { + type: 'text', + text: 'Bullet List Item', + styles: {}, + }, + ], + children: [], + }, +] - // complexHTML = `

Heading 1

Heading 2

Heading 3

Paragraph

Paragraph

Paragraph

  • Bullet List Item

  • Bullet List Item

    • Bullet List Item

      • Bullet List Item

      Paragraph

      1. Numbered List Item

      2. Numbered List Item

      3. Numbered List Item

        1. Numbered List Item

      • Bullet List Item

    • Bullet List Item

  • Bullet List Item

`; - // complexMarkdown = `# Heading 1 - // - // ## Heading 2 - // - // ### Heading 3 - // - // Paragraph - // - // P**ara***grap*h - // - // P*ara*~~grap~~h - // - // * Bullet List Item - // - // * Bullet List Item - // - // * Bullet List Item - // - // * Bullet List Item - // - // Paragraph - // - // 1. Numbered List Item - // - // 2. Numbered List Item - // - // 3. Numbered List Item - // - // 1. Numbered List Item - // - // * Bullet List Item - // - // * Bullet List Item - // - // * Bullet List Item - // `; +function removeInlineContentClass(html: string) { + return html.replace(/ class="_inlineContent_([a-zA-Z0-9_-])+"/g, '') +} + +beforeEach(() => { + ;(window as Window & {__TEST_OPTIONS?: any}).__TEST_OPTIONS = {} + + editor = new BlockNoteEditor() }) afterEach(() => { - editor?._tiptapEditor?.destroy() + editor._tiptapEditor.destroy() editor = undefined as any - delete (window as Window & {__TEST_OPTIONS?: {}}).__TEST_OPTIONS + delete (window as Window & {__TEST_OPTIONS?: any}).__TEST_OPTIONS }) -describe.skip('Non-Nested Block/HTML/Markdown Conversions', () => { +describe('Non-Nested Block/HTML/Markdown Conversions', () => { it('Convert non-nested blocks to HTML', async () => { - const output = await editor.blocksToHTML(nonNestedBlocks) + const output = await editor.blocksToHTML(getNonNestedBlocks()) expect(removeInlineContentClass(output)).toMatchSnapshot() }) it('Convert non-nested blocks to Markdown', async () => { - const output = await editor.blocksToMarkdown(nonNestedBlocks) + const output = await editor.blocksToMarkdown(getNonNestedBlocks()) expect(output).toMatchSnapshot() }) it('Convert non-nested HTML to blocks', async () => { - const output = await editor.HTMLToBlocks(nonNestedHTML) + const html = `

Heading

Paragraph

  • Bullet List Item

  1. Numbered List Item

` + const output = await editor.HTMLToBlocks(html) expect(output).toMatchSnapshot() }) it('Convert non-nested Markdown to blocks', async () => { - const output = await editor.markdownToBlocks(nonNestedMarkdown) + const markdown = `# Heading + +Paragraph + +* Bullet List Item + +1. Numbered List Item +` + const output = await editor.markdownToBlocks(markdown) expect(output).toMatchSnapshot() }) }) -describe.skip('Nested Block/HTML/Markdown Conversions', () => { +describe('Nested Block/HTML/Markdown Conversions', () => { it('Convert nested blocks to HTML', async () => { - const output = await editor.blocksToHTML(nestedBlocks) + const output = await editor.blocksToHTML(getNestedBlocks()) expect(removeInlineContentClass(output)).toMatchSnapshot() }) it('Convert nested blocks to Markdown', async () => { - const output = await editor.blocksToMarkdown(nestedBlocks) + const output = await editor.blocksToMarkdown(getNestedBlocks()) expect(output).toMatchSnapshot() }) // // Failing due to nested block parsing bug. // it("Convert nested HTML to blocks", async () => { - // const output = await editor.HTMLToBlocks(nestedHTML); + // const html = `

Heading

Paragraph

  • Bullet List Item

    1. Numbered List Item

`; + // const output = await editor.HTMLToBlocks(html); // // expect(output).toMatchSnapshot(); // }); // // Failing due to nested block parsing bug. // it("Convert nested Markdown to blocks", async () => { - // const output = await editor.markdownToBlocks(nestedMarkdown); + // const markdown = `# Heading + // + // Paragraph + // + // * Bullet List Item + // + // 1. Numbered List Item + // `; + // const output = await editor.markdownToBlocks(markdown); // // expect(output).toMatchSnapshot(); // }); }) -describe.skip('Styled Block/HTML/Markdown Conversions', () => { +describe('Styled Block/HTML/Markdown Conversions', () => { it('Convert styled blocks to HTML', async () => { - const output = await editor.blocksToHTML(styledBlocks) + const output = await editor.blocksToHTML(getStyledBlocks()) expect(removeInlineContentClass(output)).toMatchSnapshot() }) it('Convert styled blocks to Markdown', async () => { - const output = await editor.blocksToMarkdown(styledBlocks) + const output = await editor.blocksToMarkdown(getStyledBlocks()) expect(output).toMatchSnapshot() }) it('Convert styled HTML to blocks', async () => { - const output = await editor.HTMLToBlocks(styledHTML) + const html = `

BoldItalicUnderlineStrikethroughTextColorBackgroundColorMultiple

` + const output = await editor.HTMLToBlocks(html) expect(output).toMatchSnapshot() }) it('Convert styled Markdown to blocks', async () => { - const output = await editor.markdownToBlocks(styledMarkdown) + const markdown = `**Bold***Italic*Underline~~Strikethrough~~TextColorBackgroundColor***Multiple***` + const output = await editor.markdownToBlocks(markdown) expect(output).toMatchSnapshot() }) }) -describe.skip('Complex Block/HTML/Markdown Conversions', () => { +describe('Complex Block/HTML/Markdown Conversions', () => { it('Convert complex blocks to HTML', async () => { - const output = await editor.blocksToHTML(complexBlocks) + const output = await editor.blocksToHTML(getComplexBlocks()) expect(removeInlineContentClass(output)).toMatchSnapshot() }) it('Convert complex blocks to Markdown', async () => { - const output = await editor.blocksToMarkdown(complexBlocks) + const output = await editor.blocksToMarkdown(getComplexBlocks()) expect(output).toMatchSnapshot() }) // // Failing due to nested block parsing bug. // it("Convert complex HTML to blocks", async () => { - // const output = await editor.HTMLToBlocks(complexHTML); + // const html = `

Heading 1

Heading 2

Heading 3

Paragraph

Paragraph

Paragraph

  • Bullet List Item

  • Bullet List Item

    • Bullet List Item

      • Bullet List Item

      Paragraph

      1. Numbered List Item

      2. Numbered List Item

      3. Numbered List Item

        1. Numbered List Item

      • Bullet List Item

    • Bullet List Item

  • Bullet List Item

`; + // const output = await editor.HTMLToBlocks(html); // // expect(output).toMatchSnapshot(); // }); // // Failing due to nested block parsing bug. // it("Convert complex Markdown to blocks", async () => { - // const output = await editor.markdownToBlocks(complexMarkdown); + // const markdown = `# Heading 1 + // + // ## Heading 2 + // + // ### Heading 3 + // + // Paragraph + // + // P**ara***grap*h + // + // P*ara*~~grap~~h + // + // * Bullet List Item + // + // * Bullet List Item + // + // * Bullet List Item + // + // * Bullet List Item + // + // Paragraph + // + // 1. Numbered List Item + // + // 2. Numbered List Item + // + // 3. Numbered List Item + // + // 1. Numbered List Item + // + // * Bullet List Item + // + // * Bullet List Item + // + // * Bullet List Item + // `; + // const output = await editor.markdownToBlocks(markdown); // // expect(output).toMatchSnapshot(); // }); diff --git a/frontend/packages/editor/src/blocknote/core/api/formatConversions/formatConversions.ts b/frontend/packages/editor/src/blocknote/core/api/formatConversions/formatConversions.ts index 3eb199bb0c..b96cb553b5 100644 --- a/frontend/packages/editor/src/blocknote/core/api/formatConversions/formatConversions.ts +++ b/frontend/packages/editor/src/blocknote/core/api/formatConversions/formatConversions.ts @@ -4,7 +4,7 @@ import rehypeRemark from 'rehype-remark' import rehypeStringify from 'rehype-stringify' import remarkGfm from 'remark-gfm' import remarkParse from 'remark-parse' -import remarkRehype from 'remark-rehype' +import remarkRehype, {defaultHandlers} from 'remark-rehype' import remarkStringify from 'remark-stringify' import {unified} from 'unified' import {Block, BlockSchema} from '../../extensions/Blocks/api/blockTypes' @@ -47,7 +47,7 @@ export async function HTMLToBlocks( htmlNode.innerHTML = html.trim() const parser = DOMParser.fromSchema(schema) - const parentNode = parser.parse(htmlNode) + const parentNode = parser.parse(htmlNode) //, { preserveWhitespace: "full" }); const blocks: Block[] = [] @@ -73,6 +73,45 @@ export async function blocksToMarkdown( return markdownString.value as string } +// modefied version of https://github.com/syntax-tree/mdast-util-to-hast/blob/main/lib/handlers/code.js +// that outputs a data-language attribute instead of a CSS class (e.g.: language-typescript) +function code(state: any, node: any) { + const value = node.value ? node.value + '\n' : '' + /** @type {Properties} */ + const properties: any = {} + + if (node.lang) { + // changed line + properties['data-language'] = node.lang + } + + // Create ``. + /** @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
-    // theme?:
-    //   | "light"
-    //   | "dark"
-    //   | Theme
-    //   | {
-    //       light: Theme;
-    //       dark: Theme;
-    //     };
+    theme?:
+      | 'light'
+      | 'dark'
+      | Theme
+      | {
+          light: Theme
+          dark: Theme
+        }
     children?: ReactNode
   } & HTMLAttributes,
 ) {
-  // const {
-  //   theme = { light: lightDefaultTheme, dark: darkDefaultTheme },
-  //   ...rest
-  // } = props;
+  const {theme = {light: lightDefaultTheme, dark: darkDefaultTheme}, ...rest} =
+    props
 
-  // const preferredTheme = window.matchMedia('(prefers-color-scheme: dark)')
-  // .matches;
+  const preferredTheme = usePrefersColorScheme()
 
-  // const mantineTheme = useMemo(() => {
-  //   if (theme === "light") {
-  //     return blockNoteToMantineTheme(lightDefaultTheme);
-  //   }
+  const mantineTheme = useMemo(() => {
+    if (theme === 'light') {
+      return blockNoteToMantineTheme(lightDefaultTheme)
+    }
 
-  //   if (theme === "dark") {
-  //     return blockNoteToMantineTheme(darkDefaultTheme);
-  //   }
+    if (theme === 'dark') {
+      return blockNoteToMantineTheme(darkDefaultTheme)
+    }
 
-  //   if ("light" in theme && "dark" in theme) {
-  //     return blockNoteToMantineTheme(
-  //       theme[preferredTheme ? "dark" : "light"]
-  //     );
-  //   }
+    if ('light' in theme && 'dark' in theme) {
+      return blockNoteToMantineTheme(
+        theme[preferredTheme === 'dark' ? 'dark' : 'light'],
+      )
+    }
 
-  //   return blockNoteToMantineTheme(theme);
-  // }, [preferredTheme, theme]);
+    return blockNoteToMantineTheme(theme)
+  }, [preferredTheme, theme])
 
   return (
-    // TODO: Removed mantine because it conflicts with our styling
-    // 
-    
-    // 
+    
+      
+    
   )
 }
diff --git a/frontend/packages/editor/src/blocknote/react/BlockSideMenu/BlockSideMenuFactory.tsx b/frontend/packages/editor/src/blocknote/react/BlockSideMenu/BlockSideMenuFactory.tsx
deleted file mode 100644
index 06ca67574d..0000000000
--- a/frontend/packages/editor/src/blocknote/react/BlockSideMenu/BlockSideMenuFactory.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import {FC} from 'react'
-import {
-  BlockSchema,
-  BlockSideMenuDynamicParams,
-  BlockSideMenuStaticParams,
-} from '@mintter/app/src/blocknote-core'
-import {MantineThemeOverride} from '@mantine/core'
-import {ReactElementFactory} from '../ElementFactory/components/ReactElementFactory'
-import {BlockSideMenu as ReactBlockSideMenu} from './components/BlockSideMenu'
-import {DragHandleMenuProps} from './components/DragHandleMenu'
-import {DefaultDragHandleMenu} from './components/DefaultDragHandleMenu'
-
-export const createReactBlockSideMenuFactory = (
-  theme: MantineThemeOverride,
-  dragHandleMenu: FC> = DefaultDragHandleMenu,
-) => {
-  const CustomDragHandleMenu = dragHandleMenu
-  const CustomBlockSideMenu = (
-    props: BlockSideMenuStaticParams &
-      BlockSideMenuDynamicParams,
-  ) => 
-
-  return (staticParams: BlockSideMenuStaticParams) =>
-    ReactElementFactory<
-      BlockSideMenuStaticParams,
-      BlockSideMenuDynamicParams
-    >(staticParams, CustomBlockSideMenu, theme, {
-      animation: 'fade',
-      offset: [12, 0],
-      placement: 'left-start',
-      popperOptions: {
-        modifiers: [
-          {
-            name: 'flip',
-            options: {
-              fallbackPlacements: [],
-            },
-          },
-          {
-            name: 'preventOverflow',
-            options: {
-              mainAxis: false,
-              altAxis: false,
-            },
-          },
-        ],
-      },
-    })
-}
diff --git a/frontend/packages/editor/src/blocknote/react/BlockSideMenu/components/BlockSideMenu.tsx b/frontend/packages/editor/src/blocknote/react/BlockSideMenu/components/BlockSideMenu.tsx
deleted file mode 100644
index 47e6de7f59..0000000000
--- a/frontend/packages/editor/src/blocknote/react/BlockSideMenu/components/BlockSideMenu.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import {
-  Block,
-  BlockNoteEditor,
-  BlockSchema,
-} from '@mintter/app/src/blocknote-core'
-import {ActionIcon, Group, Menu} from '@mantine/core'
-import {FC, useEffect, useRef, useState} from 'react'
-import {AiOutlinePlus} from 'react-icons/ai'
-import {MdDragIndicator} from 'react-icons/md'
-import {DefaultDragHandleMenu} from './DefaultDragHandleMenu'
-import {DragHandleMenuProps} from './DragHandleMenu'
-
-export type BlockSideMenuProps = {
-  editor: BlockNoteEditor
-  block: Block
-  dragHandleMenu?: FC>
-  addBlock: () => void
-  blockDragStart: (event: DragEvent) => void
-  blockDragEnd: () => void
-  freezeMenu: () => void
-  unfreezeMenu: () => void
-}
-
-export const BlockSideMenu = (
-  props: BlockSideMenuProps,
-) => {
-  const [dragHandleMenuOpened, setDragHandleMenuOpened] = useState(false)
-
-  const dragHandleRef = useRef(null)
-
-  useEffect(() => {
-    const dragHandle = dragHandleRef.current
-
-    if (dragHandle instanceof HTMLDivElement) {
-      dragHandle.addEventListener('dragstart', props.blockDragStart)
-      dragHandle.addEventListener('dragend', props.blockDragEnd)
-
-      return () => {
-        dragHandle.removeEventListener('dragstart', props.blockDragStart)
-        dragHandle.removeEventListener('dragend', props.blockDragEnd)
-      }
-    }
-
-    return
-  }, [props.blockDragEnd, props.blockDragStart])
-
-  const closeMenu = () => {
-    setDragHandleMenuOpened(false)
-    props.unfreezeMenu()
-  }
-
-  const DragHandleMenu = props.dragHandleMenu || DefaultDragHandleMenu
-
-  return (
-    
-      
-        {
-           {
-              props.addBlock()
-            }}
-          />
-        }
-      
-      
-        
-          
- { - setDragHandleMenuOpened(true) - props.freezeMenu() - }} - size={24} - data-test={'dragHandle'} - > - {} - -
-
- -
-
- ) -} diff --git a/frontend/packages/editor/src/blocknote/react/BlockSideMenu/components/DragHandleMenuItem.tsx b/frontend/packages/editor/src/blocknote/react/BlockSideMenu/components/DragHandleMenuItem.tsx deleted file mode 100644 index c3dc1649ef..0000000000 --- a/frontend/packages/editor/src/blocknote/react/BlockSideMenu/components/DragHandleMenuItem.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import {Menu} from '@mantine/core' -import {PolymorphicComponentProps} from '@mantine/utils' - -export type DragHandleMenuItemProps = PolymorphicComponentProps<'button'> & { - closeMenu: () => void -} - -export const DragHandleMenuItem = (props: DragHandleMenuItemProps) => { - const {closeMenu, onClick, ...propsToPassThrough} = props - return {props.children} -} diff --git a/frontend/packages/editor/src/blocknote/react/ElementFactory/components/EditorElementComponentWrapper.tsx b/frontend/packages/editor/src/blocknote/react/ElementFactory/components/EditorElementComponentWrapper.tsx deleted file mode 100644 index 0c79992fc6..0000000000 --- a/frontend/packages/editor/src/blocknote/react/ElementFactory/components/EditorElementComponentWrapper.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import {MantineProvider, MantineThemeOverride} from '@mantine/core' -import Tippy, {TippyProps} from '@tippyjs/react' -import { - RequiredDynamicParams, - RequiredStaticParams, -} from '@mintter/app/src/blocknote-core' -import {FC, useCallback, useState} from 'react' -/** - * Component used in the ReactElementFactory to wrap the EditorElementComponent in a MantineProvider and Tippy - * component. The MantineProvider is used to add theming while the Tippy component is used to control show/hide - * behavior. - * - * @param props The component props. Includes the same props as ReactElementFactory, as well as the current set of - * EditorDynamicParams. Also provides props to determine if the element should be open and which element it should be - * mounted under. - */ -export function EditorElementComponentWrapper< - ElementStaticParams extends RequiredStaticParams, - ElementDynamicParams extends RequiredDynamicParams, ->(props: { - rootElement: HTMLElement - isOpen: boolean - staticParams: ElementStaticParams - dynamicParams: ElementDynamicParams - editorElementComponent: FC - theme: MantineThemeOverride - tippyProps?: TippyProps -}) { - const EditorElementComponent = props.editorElementComponent - - const [contentCleared, setContentCleared] = useState(false) - - const onShow = useCallback(() => { - setContentCleared(false) - document.body.appendChild(props.rootElement) - }, [props.rootElement]) - - const onHidden = useCallback(() => { - props.rootElement.remove() - setContentCleared(true) - }, [props.rootElement]) - - return ( - - - ) : undefined - } - // Type cast is needed as getReferenceRect will return `undefined` when - // the editor is initialized but the element hasn't been rendered yet. - // Otherwise, it will always return a `DOMRect`. - getReferenceClientRect={props.staticParams.getReferenceRect} - interactive={true} - onShow={onShow} - onHidden={onHidden} - visible={props.isOpen} - {...props.tippyProps} - /> - - ) -} diff --git a/frontend/packages/editor/src/blocknote/react/ElementFactory/components/ReactElementFactory.tsx b/frontend/packages/editor/src/blocknote/react/ElementFactory/components/ReactElementFactory.tsx deleted file mode 100644 index 909925db33..0000000000 --- a/frontend/packages/editor/src/blocknote/react/ElementFactory/components/ReactElementFactory.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import {FC} from 'react' -import {TippyProps} from '@tippyjs/react' -import {createRoot} from 'react-dom/client' -import { - EditorElement, - RequiredDynamicParams, - RequiredStaticParams, -} from '@mintter/app/src/blocknote-core' -import {EditorElementComponentWrapper} from './EditorElementComponentWrapper' -import {MantineThemeOverride} from '@mantine/core' -/** - * The ReactElementFactory is a generic function used to create all other ElementFactories, which are then used in the - * BlockNote editor. The type of ElementFactory created depends on the provided ElementStaticParams and - * ElementDynamicParams, which determine what static/dynamic properties are used in rendering the element. - * ElementStaticParams are initialized when the editor mounts and do not change, while ElementDynamicParams change based - * on the editor state. - * - * @param staticParams Properties used in rendering the element which do not change, regardless of editor state. - * @param EditorElementComponent The element to render, which is a React component. Takes EditorStaticParams and - * EditorDynamicParams as props. - * @param theme The Mantine theme used to style the element. - * @param tippyProps Tippy props, which affect the elements' popup behaviour, e.g. popup position, animation, etc. - */ -export const ReactElementFactory = < - ElementStaticParams extends RequiredStaticParams, - ElementDynamicParams extends RequiredDynamicParams, ->( - staticParams: ElementStaticParams, - EditorElementComponent: FC, - theme: MantineThemeOverride, - tippyProps?: TippyProps, -): EditorElement => { - const rootElement = document.createElement('div') - const root = createRoot(rootElement) - - // Used when hiding the element. Without being passed a set of dynamic params, - // certain menus/toolbars will not render correctly. - let prevDynamicParams: ElementDynamicParams | undefined = undefined - - return { - element: rootElement, - render: (dynamicParams: ElementDynamicParams, _isHidden: boolean) => { - prevDynamicParams = dynamicParams - - root.render( - , - ) - }, - hide: () => { - root.render( - , - ) - - prevDynamicParams = undefined - }, - } -} diff --git a/frontend/packages/editor/src/blocknote/react/FormattingToolbar/FormattingToolbarFactory.tsx b/frontend/packages/editor/src/blocknote/react/FormattingToolbar/FormattingToolbarFactory.tsx deleted file mode 100644 index 812d12ba41..0000000000 --- a/frontend/packages/editor/src/blocknote/react/FormattingToolbar/FormattingToolbarFactory.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { - FormattingToolbarStaticParams, - FormattingToolbarDynamicParams, - BlockNoteEditor, - BlockSchema, -} from '@mintter/app/src/blocknote-core' -import {FormattingToolbar as ReactFormattingToolbar} from './components/FormattingToolbar' -import {ReactElementFactory} from '../ElementFactory/components/ReactElementFactory' -import {FC} from 'react' -import {MantineThemeOverride} from '@mantine/core' - -export const createReactFormattingToolbarFactory = < - BSchema extends BlockSchema, ->( - theme: MantineThemeOverride, - toolbar: FC<{ - editor: BlockNoteEditor - }> = ReactFormattingToolbar, -) => { - return (staticParams: FormattingToolbarStaticParams) => - ReactElementFactory< - FormattingToolbarStaticParams, - FormattingToolbarDynamicParams - >(staticParams, toolbar, theme, { - animation: 'fade', - placement: 'top-start', - }) -} diff --git a/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx b/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx index ae0590a7a2..c7d68ec256 100644 --- a/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx +++ b/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx @@ -1,12 +1,36 @@ -import {useCallback} from 'react' -import {ColorPicker, Menu} from '@mantine/core' -import {BlockNoteEditor, BlockSchema} from '@mintter/app/src/blocknote-core' +import {useCallback, useState} from 'react' +import {Menu} from '@mantine/core' +import {BlockNoteEditor, BlockSchema} from '@/blocknote/core' import {ToolbarButton} from '../../../SharedComponents/Toolbar/components/ToolbarButton' import {ColorIcon} from '../../../SharedComponents/ColorPicker/components/ColorIcon' +import {ColorPicker} from '../../../SharedComponents/ColorPicker/components/ColorPicker' +import {useEditorContentChange} from '../../../hooks/useEditorContentChange' +import {useEditorSelectionChange} from '../../../hooks/useEditorSelectionChange' export const ColorStyleButton = (props: { editor: BlockNoteEditor }) => { + const [currentTextColor, setCurrentTextColor] = useState( + props.editor.getActiveStyles().textColor || 'default', + ) + const [currentBackgroundColor, setCurrentBackgroundColor] = useState( + props.editor.getActiveStyles().backgroundColor || 'default', + ) + + useEditorContentChange(props.editor, () => { + setCurrentTextColor(props.editor.getActiveStyles().textColor || 'default') + setCurrentBackgroundColor( + props.editor.getActiveStyles().backgroundColor || 'default', + ) + }) + + useEditorSelectionChange(props.editor, () => { + setCurrentTextColor(props.editor.getActiveStyles().textColor || 'default') + setCurrentBackgroundColor( + props.editor.getActiveStyles().backgroundColor || 'default', + ) + }) + const setTextColor = useCallback( (color: string) => { props.editor.focus() @@ -34,10 +58,8 @@ export const ColorStyleButton = (props: { mainTooltip={'Colors'} icon={() => ( )} @@ -45,12 +67,9 @@ export const ColorStyleButton = (props: { diff --git a/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx b/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx index e4dacc6c4f..27bdc56697 100644 --- a/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx +++ b/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx @@ -1,12 +1,23 @@ -import {useCallback} from 'react' -import {BlockNoteEditor, BlockSchema} from '@mintter/app/src/blocknote-core' +import {useCallback, useState} from 'react' +import {BlockNoteEditor, BlockSchema} from '@/blocknote/core' import {RiLink} from 'react-icons/ri' import LinkToolbarButton from '../LinkToolbarButton' import {formatKeyboardShortcut} from '../../../utils' +import {useEditorSelectionChange} from '../../../hooks/useEditorSelectionChange' export const CreateLinkButton = (props: { editor: BlockNoteEditor }) => { + const [url, setUrl] = useState( + props.editor.getSelectedLinkUrl() || '', + ) + const [text, setText] = useState(props.editor.getSelectedText() || '') + + useEditorSelectionChange(props.editor, () => { + setText(props.editor.getSelectedText() || '') + setUrl(props.editor.getSelectedLinkUrl() || '') + }) + const setLink = useCallback( (url: string, text?: string) => { props.editor.focus() @@ -17,13 +28,13 @@ export const CreateLinkButton = (props: { return ( ) diff --git a/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultButtons/NestBlockButtons.tsx b/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultButtons/NestBlockButtons.tsx index 37d6a164d3..d0ef84d5f3 100644 --- a/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultButtons/NestBlockButtons.tsx +++ b/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultButtons/NestBlockButtons.tsx @@ -1,13 +1,24 @@ +import {formatKeyboardShortcut} from '../../../utils' import {RiIndentDecrease, RiIndentIncrease} from 'react-icons/ri' - -import {BlockNoteEditor, BlockSchema} from '@mintter/app/src/blocknote-core' -import {useCallback} from 'react' import {ToolbarButton} from '../../../SharedComponents/Toolbar/components/ToolbarButton' -import {formatKeyboardShortcut} from '../../../utils' +import {BlockNoteEditor, BlockSchema} from '@/blocknote/core' +import {useCallback, useState} from 'react' +import {useEditorSelectionChange} from '../../../hooks/useEditorSelectionChange' +import {useEditorContentChange} from '../../../hooks/useEditorContentChange' export const NestBlockButton = (props: { editor: BlockNoteEditor }) => { + const [canNestBlock, setCanNestBlock] = useState() + + useEditorContentChange(props.editor, () => { + setCanNestBlock(props.editor.canNestBlock()) + }) + + useEditorSelectionChange(props.editor, () => { + setCanNestBlock(props.editor.canNestBlock()) + }) + const nestBlock = useCallback(() => { props.editor.focus() props.editor.nestBlock() @@ -16,7 +27,7 @@ export const NestBlockButton = (props: { return ( (props: { export const UnnestBlockButton = (props: { editor: BlockNoteEditor }) => { + const [canUnnestBlock, setCanUnnestBlock] = useState() + + useEditorContentChange(props.editor, () => { + setCanUnnestBlock(props.editor.canUnnestBlock()) + }) + + useEditorSelectionChange(props.editor, () => { + setCanUnnestBlock(props.editor.canUnnestBlock()) + }) + const unnestBlock = useCallback(() => { props.editor.focus() props.editor.unnestBlock() @@ -35,7 +56,7 @@ export const UnnestBlockButton = (props: { return ( (props: { editor: BlockNoteEditor textAlignment: TextAlignment }) => { + const [activeTextAlignment, setActiveTextAlignment] = useState(() => { + const block = props.editor.getTextCursorPosition().block + + if ('textAlignment' in block.props) { + return block.props.textAlignment as TextAlignment + } + + return + }) + + useEditorContentChange(props.editor, () => { + const block = props.editor.getTextCursorPosition().block + + if ('textAlignment' in block.props) { + setActiveTextAlignment(block.props.textAlignment as TextAlignment) + } + }) + + useEditorSelectionChange(props.editor, () => { + const block = props.editor.getTextCursorPosition().block + + if ('textAlignment' in block.props) { + setActiveTextAlignment(block.props.textAlignment as TextAlignment) + } + }) + const show = useMemo(() => { const selection = props.editor.getSelection() @@ -77,10 +105,7 @@ export const TextAlignButton = (props: { return ( setTextAlignment(props.textAlignment)} - isSelected={ - props.editor.getTextCursorPosition().block.props.textAlignment === - props.textAlignment - } + isSelected={activeTextAlignment === props.textAlignment} mainTooltip={ props.textAlignment === 'justify' ? 'Justify Text' diff --git a/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx b/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx index 98b7db3716..14ecada7c4 100644 --- a/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx +++ b/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx @@ -1,3 +1,5 @@ +import {ToolbarButton} from '../../../SharedComponents/Toolbar/components/ToolbarButton' +import {formatKeyboardShortcut} from '../../../utils' import { RiBold, RiCodeFill, @@ -5,14 +7,11 @@ import { RiStrikethrough, RiUnderline, } from 'react-icons/ri' -import { - BlockNoteEditor, - BlockSchema, - ToggledStyle, -} from '@mintter/app/src/blocknote-core' +import {BlockNoteEditor, BlockSchema, ToggledStyle} from '@/blocknote/core' import {IconType} from 'react-icons' -import {ToolbarButton} from '../../../SharedComponents/Toolbar/components/ToolbarButton' -import {formatKeyboardShortcut} from '../../../utils' +import {useState} from 'react' +import {useEditorContentChange} from '../../../hooks/useEditorContentChange' +import {useEditorSelectionChange} from '../../../hooks/useEditorSelectionChange' const shortcuts: Record = { bold: 'Mod+B', @@ -34,6 +33,18 @@ export const ToggledStyleButton = (props: { editor: BlockNoteEditor toggledStyle: ToggledStyle }) => { + const [active, setActive] = useState( + props.toggledStyle in props.editor.getActiveStyles(), + ) + + useEditorContentChange(props.editor, () => { + setActive(props.toggledStyle in props.editor.getActiveStyles()) + }) + + useEditorSelectionChange(props.editor, () => { + setActive(props.toggledStyle in props.editor.getActiveStyles()) + }) + const toggleStyle = (style: ToggledStyle) => { props.editor.focus() props.editor.toggleStyles({[style]: true}) @@ -42,7 +53,7 @@ export const ToggledStyleButton = (props: { return ( toggleStyle(props.toggledStyle)} - isSelected={props.toggledStyle in props.editor.getActiveStyles()} + isSelected={active} mainTooltip={ props.toggledStyle.slice(0, 1).toUpperCase() + props.toggledStyle.slice(1) diff --git a/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx b/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx index 2f719218d5..2d06f1c309 100644 --- a/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx +++ b/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx @@ -1,95 +1,131 @@ -import { - BlockNoteEditor, - BlockSchema, - DefaultBlockSchema, -} from '@mintter/app/src/blocknote-core' -import {useEffect, useState} from 'react' +import {useMemo, useState} from 'react' +import {BlockNoteEditor, BlockSchema} from '@/blocknote/core' import {IconType} from 'react-icons' -import {RiH1, RiH2, RiH3, RiText} from 'react-icons/ri' -import {ToolbarDropdown} from '../../../SharedComponents/Toolbar/components/ToolbarDropdown' +import { + RiH1, + RiH2, + RiH3, + RiListOrdered, + RiListUnordered, + RiText, +} from 'react-icons/ri' -type HeadingLevels = '1' | '2' | '3' +import {ToolbarDropdown} from '../../../SharedComponents/Toolbar/components/ToolbarDropdown' +import {useEditorSelectionChange} from '../../../hooks/useEditorSelectionChange' +import {useEditorContentChange} from '../../../hooks/useEditorContentChange' +import {ToolbarDropdownItemProps} from '../../../SharedComponents/Toolbar/components/ToolbarDropdownItem' -const headingIcons: Record = { - '1': RiH1, - '2': RiH2, - '3': RiH3, +export type BlockTypeDropdownItem = { + name: string + type: string + props?: Record + icon: IconType } -const shouldShow = (schema: BlockSchema) => { - const paragraph = 'paragraph' in schema - const heading = 'heading' in schema && 'level' in schema.heading.propSchema - - return paragraph && heading -} +export const defaultBlockTypeDropdownItems: BlockTypeDropdownItem[] = [ + { + name: 'Paragraph', + type: 'paragraph', + icon: RiText, + }, + { + name: 'Heading', + type: 'heading', + props: {level: '2'}, + icon: RiH2, + }, + // { + // name: 'Heading 2', + // type: 'heading', + // props: {level: '2'}, + // icon: RiH2, + // }, + // { + // name: 'Heading 3', + // type: 'heading', + // props: {level: '3'}, + // icon: RiH3, + // }, + { + name: 'Bullet List', + type: 'bulletListItem', + icon: RiListUnordered, + }, + { + name: 'Numbered List', + type: 'numberedListItem', + icon: RiListOrdered, + }, +] export const BlockTypeDropdown = (props: { editor: BlockNoteEditor + items?: BlockTypeDropdownItem[] }) => { const [block, setBlock] = useState(props.editor.getTextCursorPosition().block) - useEffect(() => setBlock(props.editor.getTextCursorPosition().block), [props]) + const filteredItems: BlockTypeDropdownItem[] = useMemo(() => { + return (props.items || defaultBlockTypeDropdownItems).filter((item) => { + // Checks if block type exists in the schema + if (!(item.type in props.editor.schema)) { + return false + } - if (!shouldShow(props.editor.schema)) { - return null - } + // Checks if props for the block type are valid + for (const [prop, value] of Object.entries(item.props || {})) { + const propSchema = props.editor.schema[item.type].propSchema - // let's cast the editor because "shouldShow" has given us the confidence - // the default block schema is being used - let editor = props.editor as any as BlockNoteEditor + // Checks if the prop exists for the block type + if (!(prop in propSchema)) { + return false + } - return ( - { - props.editor.focus() - props.editor.updateBlock(block, { - type: 'paragraph', - props: {}, - }) - }, - text: 'Paragraph', - icon: RiText, - isSelected: block.type === 'paragraph', - }, - { - onClick: () => { - editor.focus() - editor.updateBlock(block, { - type: 'heading', - props: {level: '1'}, - }) - }, - text: 'Heading', - icon: headingIcons['1'], - isSelected: block.type === 'heading', + // Checks if the prop's value is valid + if ( + propSchema[prop].values !== undefined && + !propSchema[prop].values!.includes(value) + ) { + return false + } + } + + return true + }) + }, [props.editor, props.items]) + + const shouldShow: boolean = useMemo( + () => filteredItems.find((item) => item.type === block.type) !== undefined, + [block.type, filteredItems], + ) + + const fullItems: ToolbarDropdownItemProps[] = useMemo( + () => + filteredItems.map((item) => ({ + text: item.name, + icon: item.icon, + onClick: () => { + props.editor.focus() + props.editor.updateBlock(block, { + type: item.type, + props: {}, + }) }, - // { - // onClick: () => { - // props.editor.focus() - // props.editor.updateBlock(block, { - // type: 'bulletListItem', - // props: {}, - // }) - // }, - // text: 'Bullet List', - // icon: RiListUnordered, - // isSelected: block.type === 'bulletListItem', - // }, - // { - // onClick: () => { - // props.editor.focus() - // props.editor.updateBlock(block, { - // type: 'numberedListItem', - // props: {}, - // }) - // }, - // text: 'Numbered List', - // icon: RiListOrdered, - // isSelected: block.type === 'numberedListItem', - // }, - ]} - /> + isSelected: block.type === item.type, + })), + [block, filteredItems, props.editor], ) + + useEditorContentChange(props.editor, () => { + setBlock(props.editor.getTextCursorPosition().block) + }) + + useEditorSelectionChange(props.editor, () => { + setBlock(props.editor.getTextCursorPosition().block) + }) + + if (!shouldShow) { + return null + } + + return } diff --git a/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/FormattingToolbar.tsx b/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultFormattingToolbar.tsx similarity index 55% rename from frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/FormattingToolbar.tsx rename to frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultFormattingToolbar.tsx index ca57eec9e8..979b4b22a4 100644 --- a/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/FormattingToolbar.tsx +++ b/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/DefaultFormattingToolbar.tsx @@ -1,33 +1,39 @@ -import {BlockNoteEditor, BlockSchema} from '@mintter/app/src/blocknote-core' +import {BlockSchema} from '@/blocknote/core' + import {Toolbar} from '../../SharedComponents/Toolbar/components/Toolbar' -import {ColorStyleButton} from './DefaultButtons/ColorStyleButton' +import {ToggledStyleButton} from './DefaultButtons/ToggledStyleButton' +import { + BlockTypeDropdown, + BlockTypeDropdownItem, +} from './DefaultDropdowns/BlockTypeDropdown' +import {FormattingToolbarProps} from './FormattingToolbarPositioner' +// import {TextAlignButton} from './DefaultButtons/TextAlignButton' +// import {ColorStyleButton} from './DefaultButtons/ColorStyleButton' import {CreateLinkButton} from './DefaultButtons/CreateLinkButton' import { NestBlockButton, UnnestBlockButton, } from './DefaultButtons/NestBlockButtons' -import {TextAlignButton} from './DefaultButtons/TextAlignButton' -import {ToggledStyleButton} from './DefaultButtons/ToggledStyleButton' -import {BlockTypeDropdown} from './DefaultDropdowns/BlockTypeDropdown' -export const FormattingToolbar = (props: { - editor: BlockNoteEditor -}) => { +export const DefaultFormattingToolbar = ( + props: FormattingToolbarProps & { + blockTypeDropdownItems?: BlockTypeDropdownItem[] + }, +) => { return ( - + - - + {/* - + */} - + {/* */} diff --git a/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/FormattingToolbarPositioner.tsx b/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/FormattingToolbarPositioner.tsx new file mode 100644 index 0000000000..c012cbccad --- /dev/null +++ b/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/FormattingToolbarPositioner.tsx @@ -0,0 +1,72 @@ +import { + BlockNoteEditor, + BlockSchema, + DefaultBlockSchema, +} from '@/blocknote/core' +import Tippy from '@tippyjs/react' +import {FC, useEffect, useMemo, useRef, useState} from 'react' +import {sticky} from 'tippy.js' + +import {DefaultFormattingToolbar} from './DefaultFormattingToolbar' + +export type FormattingToolbarProps< + BSchema extends BlockSchema = DefaultBlockSchema, +> = { + editor: BlockNoteEditor +} + +export const FormattingToolbarPositioner = < + BSchema extends BlockSchema = DefaultBlockSchema, +>(props: { + editor: BlockNoteEditor + formattingToolbar?: FC> +}) => { + const [show, setShow] = useState(false) + + const referencePos = useRef() + + useEffect(() => { + return props.editor.formattingToolbar.onUpdate((state) => { + setShow(state.show) + + referencePos.current = state.referencePos + }) + }, [props.editor]) + + const getReferenceClientRect = useMemo( + () => { + if (!referencePos) { + return undefined + } + return () => referencePos.current! + }, + [referencePos.current], // eslint-disable-line + ) + + const formattingToolbarElement = useMemo(() => { + const FormattingToolbar = + props.formattingToolbar || DefaultFormattingToolbar + + return + }, [props.editor, props.formattingToolbar]) + + return ( + + ) +} + +// We want Tippy to call `getReferenceClientRect` whenever the reference +// DOMRect's position changes. This happens automatically on scroll, but we need +// the `sticky` plugin to make it happen in all cases. This is most evident +// when changing the text alignment using the formatting toolbar. +const tippyPlugins = [sticky] diff --git a/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/LinkToolbarButton.tsx b/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/LinkToolbarButton.tsx index 5889b2bceb..7513859bc6 100644 --- a/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/LinkToolbarButton.tsx +++ b/frontend/packages/editor/src/blocknote/react/FormattingToolbar/components/LinkToolbarButton.tsx @@ -1,10 +1,10 @@ import Tippy from '@tippyjs/react' import {useCallback, useEffect, useRef, useState} from 'react' -import {EditHyperlinkMenu} from '../../HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenu' import { ToolbarButton, ToolbarButtonProps, } from '../../SharedComponents/Toolbar/components/ToolbarButton' +import {EditHyperlinkMenu} from '../../HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenu' type HyperlinkButtonProps = ToolbarButtonProps & { hyperlinkIsActive: boolean diff --git a/frontend/packages/editor/src/blocknote/react/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx b/frontend/packages/editor/src/blocknote/react/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx index ba0bbc1681..81b2a50057 100644 --- a/frontend/packages/editor/src/blocknote/react/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx +++ b/frontend/packages/editor/src/blocknote/react/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx @@ -1,5 +1,5 @@ import {createStyles, Stack} from '@mantine/core' -import {forwardRef, useState} from 'react' +import {forwardRef, HTMLAttributes, useState} from 'react' import {RiLink, RiText} from 'react-icons/ri' import {EditHyperlinkMenuItem} from './EditHyperlinkMenuItem' @@ -15,33 +15,37 @@ export type EditHyperlinkMenuProps = { */ export const EditHyperlinkMenu = forwardRef< HTMLDivElement, - EditHyperlinkMenuProps ->((props, ref) => { + EditHyperlinkMenuProps & HTMLAttributes +>(({url, text, update, className, ...props}, ref) => { const {classes} = createStyles({root: {}})(undefined, { name: 'EditHyperlinkMenu', }) - const [url, setUrl] = useState(props.url) - const [title, setTitle] = useState(props.text) + const [currentUrl, setCurrentUrl] = useState(url) + const [currentText, setCurrentText] = useState(text) return ( - + setUrl(value)} - onSubmit={() => props.update(url, title)} + value={currentUrl} + onChange={(value) => setCurrentUrl(value)} + onSubmit={() => update(currentUrl, currentText)} /> setTitle(value)} - onSubmit={() => props.update(url, title)} + value={currentText} + onChange={(value) => setCurrentText(value)} + onSubmit={() => update(url, currentText)} /> ) diff --git a/frontend/packages/editor/src/blocknote/react/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenuItemIcon.tsx b/frontend/packages/editor/src/blocknote/react/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenuItemIcon.tsx index 0bb130056f..cf5a940c79 100644 --- a/frontend/packages/editor/src/blocknote/react/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenuItemIcon.tsx +++ b/frontend/packages/editor/src/blocknote/react/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenuItemIcon.tsx @@ -1,7 +1,7 @@ import {IconType} from 'react-icons' import Tippy from '@tippyjs/react' -import {Container} from '@mantine/core' import {TooltipContent} from '../../../SharedComponents/Tooltip/components/TooltipContent' +import {Container} from '@mantine/core' export type EditHyperlinkMenuItemIconProps = { icon: IconType diff --git a/frontend/packages/editor/src/blocknote/react/HyperlinkToolbar/HyperlinkToolbarFactory.tsx b/frontend/packages/editor/src/blocknote/react/HyperlinkToolbar/HyperlinkToolbarFactory.tsx deleted file mode 100644 index e2d940b21b..0000000000 --- a/frontend/packages/editor/src/blocknote/react/HyperlinkToolbar/HyperlinkToolbarFactory.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { - HyperlinkToolbar, - HyperlinkToolbarDynamicParams, - HyperlinkToolbarFactory, - HyperlinkToolbarStaticParams, -} from '@mintter/app/src/blocknote-core' -import {HyperlinkToolbar as ReactHyperlinkToolbar} from './components/HyperlinkToolbar' -import {ReactElementFactory} from '../ElementFactory/components/ReactElementFactory' -import {MantineThemeOverride} from '@mantine/core' - -export const createReactHyperlinkToolbarFactory = - (theme: MantineThemeOverride): HyperlinkToolbarFactory => - (staticParams): HyperlinkToolbar => - ReactElementFactory< - HyperlinkToolbarStaticParams, - HyperlinkToolbarDynamicParams - >(staticParams, ReactHyperlinkToolbar, theme, { - animation: 'fade', - placement: 'top-start', - }) diff --git a/frontend/packages/editor/src/blocknote/react/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx b/frontend/packages/editor/src/blocknote/react/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx new file mode 100644 index 0000000000..ab0a80f724 --- /dev/null +++ b/frontend/packages/editor/src/blocknote/react/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx @@ -0,0 +1,62 @@ +import {useRef, useState} from 'react' +import {RiExternalLinkFill, RiLinkUnlink} from 'react-icons/ri' + +import {HyperlinkToolbarProps} from './HyperlinkToolbarPositioner' + +import {Toolbar} from '../../SharedComponents/Toolbar/components/Toolbar' +import {ToolbarButton} from '../../SharedComponents/Toolbar/components/ToolbarButton' +import {EditHyperlinkMenu} from '../EditHyperlinkMenu/components/EditHyperlinkMenu' + +export const DefaultHyperlinkToolbar = (props: HyperlinkToolbarProps) => { + const [isEditing, setIsEditing] = useState(false) + const editMenuRef = useRef(null) + + if (isEditing) { + return ( + props.editHyperlink(url, text)} + // TODO: Better way of waiting for fade out + onBlur={(event) => + setTimeout(() => { + if (editMenuRef.current?.contains(event.relatedTarget)) { + return + } + setIsEditing(false) + }, 500) + } + ref={editMenuRef} + /> + ) + } + + return ( + + setIsEditing(true)} + > + Edit Link + + { + window.open(props.url, '_blank') + }} + icon={RiExternalLinkFill} + /> + + + ) +} diff --git a/frontend/packages/editor/src/blocknote/react/HyperlinkToolbar/components/HyperlinkToolbar.tsx b/frontend/packages/editor/src/blocknote/react/HyperlinkToolbar/components/HyperlinkToolbar.tsx deleted file mode 100644 index d58f2f5cec..0000000000 --- a/frontend/packages/editor/src/blocknote/react/HyperlinkToolbar/components/HyperlinkToolbar.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import {useState} from 'react' -import {Button} from '@mantine/core' -import {EditHyperlinkMenu} from '../EditHyperlinkMenu/components/EditHyperlinkMenu' -import {Toolbar} from '../../SharedComponents/Toolbar/components/Toolbar' -import {ToolbarButton} from '../../SharedComponents/Toolbar/components/ToolbarButton' -import {RiExternalLinkFill, RiLinkUnlink} from 'react-icons/ri' -import {useOpenUrl} from '@mintter/app/src/open-url' -// import rootStyles from "../../../root.module.css"; - -export type HyperlinkToolbarProps = { - url: string - text: string - editHyperlink: (url: string, text: string) => void - deleteHyperlink: () => void -} - -/** - * Main menu component for the hyperlink extension. - * Renders a toolbar that appears on hyperlink hover. - */ -export const HyperlinkToolbar = (props: HyperlinkToolbarProps) => { - const [isEditing, setIsEditing] = useState(false) - const openUrl = useOpenUrl() - - if (isEditing) { - return ( - - ) - } - - return ( - - - - { - openUrl(props.url, true) - }} - icon={RiExternalLinkFill} - /> - - - ) -} diff --git a/frontend/packages/editor/src/blocknote/react/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx b/frontend/packages/editor/src/blocknote/react/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx new file mode 100644 index 0000000000..ea5d5bde81 --- /dev/null +++ b/frontend/packages/editor/src/blocknote/react/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx @@ -0,0 +1,87 @@ +import { + BaseUiElementState, + BlockNoteEditor, + BlockSchema, + DefaultBlockSchema, + HyperlinkToolbarProsemirrorPlugin, + HyperlinkToolbarState, +} from '@/blocknote/core' +import Tippy from '@tippyjs/react' +import {FC, useEffect, useMemo, useRef, useState} from 'react' + +import {DefaultHyperlinkToolbar} from './DefaultHyperlinkToolbar' + +export type HyperlinkToolbarProps = Pick< + HyperlinkToolbarProsemirrorPlugin, + 'editHyperlink' | 'deleteHyperlink' | 'startHideTimer' | 'stopHideTimer' +> & + Omit + +export const HyperlinkToolbarPositioner = < + BSchema extends BlockSchema = DefaultBlockSchema, +>(props: { + editor: BlockNoteEditor + hyperlinkToolbar?: FC +}) => { + const [show, setShow] = useState(false) + const [url, setUrl] = useState() + const [text, setText] = useState() + + const referencePos = useRef() + + useEffect(() => { + return props.editor.hyperlinkToolbar.on( + 'update', + (hyperlinkToolbarState) => { + setShow(hyperlinkToolbarState.show) + setUrl(hyperlinkToolbarState.url) + setText(hyperlinkToolbarState.text) + + referencePos.current = hyperlinkToolbarState.referencePos + }, + ) + }, [props.editor]) + + const getReferenceClientRect = useMemo( + () => { + if (!referencePos.current) { + return undefined + } + + return () => referencePos.current! + }, + [referencePos.current], // eslint-disable-line + ) + + const hyperlinkToolbarElement = useMemo(() => { + if (!url || !text) { + return null + } + + const HyperlinkToolbar = props.hyperlinkToolbar || DefaultHyperlinkToolbar + + return ( + + ) + }, [props.hyperlinkToolbar, props.editor, text, url]) + + return ( + setIsEditing(false)} + content={hyperlinkToolbarElement} + getReferenceClientRect={getReferenceClientRect} + interactive={true} + visible={show} + animation={'fade'} + placement={'top-start'} + /> + ) +} diff --git a/frontend/packages/editor/src/blocknote/react/SharedComponents/Toolbar/components/Toolbar.tsx b/frontend/packages/editor/src/blocknote/react/SharedComponents/Toolbar/components/Toolbar.tsx index 3a92778604..a99581b17d 100644 --- a/frontend/packages/editor/src/blocknote/react/SharedComponents/Toolbar/components/Toolbar.tsx +++ b/frontend/packages/editor/src/blocknote/react/SharedComponents/Toolbar/components/Toolbar.tsx @@ -1,10 +1,23 @@ import {createStyles, Group} from '@mantine/core' -import {ReactNode} from 'react' +import {forwardRef, HTMLAttributes} from 'react' -export const Toolbar = (props: {children: ReactNode}) => { +export const Toolbar = forwardRef< + HTMLDivElement, + HTMLAttributes +>((props, ref) => { const {classes} = createStyles({root: {}})(undefined, { name: 'Toolbar', }) - return {props.children} -} + return ( + + {props.children} + + ) +}) diff --git a/frontend/packages/editor/src/blocknote/react/SharedComponents/Toolbar/components/ToolbarButton.tsx b/frontend/packages/editor/src/blocknote/react/SharedComponents/Toolbar/components/ToolbarButton.tsx index cda54bf19b..6884bb4724 100644 --- a/frontend/packages/editor/src/blocknote/react/SharedComponents/Toolbar/components/ToolbarButton.tsx +++ b/frontend/packages/editor/src/blocknote/react/SharedComponents/Toolbar/components/ToolbarButton.tsx @@ -1,6 +1,6 @@ import {ActionIcon, Button} from '@mantine/core' import Tippy from '@tippyjs/react' -import {ForwardedRef, forwardRef, MouseEvent} from 'react' +import {ForwardedRef, MouseEvent, forwardRef} from 'react' import {IconType} from 'react-icons' import {TooltipContent} from '../../Tooltip/components/TooltipContent' diff --git a/frontend/packages/editor/src/blocknote/react/SharedComponents/Toolbar/components/ToolbarDropdown.tsx b/frontend/packages/editor/src/blocknote/react/SharedComponents/Toolbar/components/ToolbarDropdown.tsx index a763efa541..a0d4ea4c9d 100644 --- a/frontend/packages/editor/src/blocknote/react/SharedComponents/Toolbar/components/ToolbarDropdown.tsx +++ b/frontend/packages/editor/src/blocknote/react/SharedComponents/Toolbar/components/ToolbarDropdown.tsx @@ -1,32 +1,30 @@ import {Menu} from '@mantine/core' -import {MouseEvent} from 'react' -import {IconType} from 'react-icons' -import {ToolbarDropdownItem} from './ToolbarDropdownItem' +import { + ToolbarDropdownItem, + ToolbarDropdownItemProps, +} from './ToolbarDropdownItem' import {ToolbarDropdownTarget} from './ToolbarDropdownTarget' export type ToolbarDropdownProps = { - items: Array<{ - onClick?: (e: MouseEvent) => void - text: string - icon?: IconType - isSelected?: boolean - isDisabled?: boolean - }> + items: ToolbarDropdownItemProps[] isDisabled?: boolean } export function ToolbarDropdown(props: ToolbarDropdownProps) { - const activeItem = props.items.filter((p) => p.isSelected)[0] + const selectedItem = props.items.filter((p) => p.isSelected)[0] - if (!activeItem) { + if (!selectedItem) { return null } return ( - // @ts-expect-error - + {props.items.map((item) => ( diff --git a/frontend/packages/editor/src/blocknote/react/SideMenu/components/DefaultButtons/AddBlockButton.tsx b/frontend/packages/editor/src/blocknote/react/SideMenu/components/DefaultButtons/AddBlockButton.tsx new file mode 100644 index 0000000000..eeecc8e2d6 --- /dev/null +++ b/frontend/packages/editor/src/blocknote/react/SideMenu/components/DefaultButtons/AddBlockButton.tsx @@ -0,0 +1,16 @@ +import {AiOutlinePlus} from 'react-icons/ai' +import {SideMenuButton} from '../SideMenuButton' +import {SideMenuProps} from '../SideMenuPositioner' +import {BlockSchema} from '@/blocknote/core' + +export const AddBlockButton = ( + props: SideMenuProps, +) => ( + + + +) diff --git a/frontend/packages/editor/src/blocknote/react/SideMenu/components/DefaultButtons/DragHandle.tsx b/frontend/packages/editor/src/blocknote/react/SideMenu/components/DefaultButtons/DragHandle.tsx new file mode 100644 index 0000000000..423f4477d0 --- /dev/null +++ b/frontend/packages/editor/src/blocknote/react/SideMenu/components/DefaultButtons/DragHandle.tsx @@ -0,0 +1,35 @@ +import {Menu} from '@mantine/core' +import {SideMenuButton} from '../SideMenuButton' +import {MdDragIndicator} from 'react-icons/md' +import {SideMenuProps} from '../SideMenuPositioner' +import {BlockSchema} from '@/blocknote/core' +import {DefaultDragHandleMenu} from '../DragHandleMenu/DefaultDragHandleMenu' + +export const DragHandle = ( + props: SideMenuProps, +) => { + const DragHandleMenu = props.dragHandleMenu || DefaultDragHandleMenu + + return ( + + +
+ + + +
+
+ +
+ ) +} diff --git a/frontend/packages/editor/src/blocknote/react/SideMenu/components/DefaultSideMenu.tsx b/frontend/packages/editor/src/blocknote/react/SideMenu/components/DefaultSideMenu.tsx new file mode 100644 index 0000000000..2007e64e10 --- /dev/null +++ b/frontend/packages/editor/src/blocknote/react/SideMenu/components/DefaultSideMenu.tsx @@ -0,0 +1,15 @@ +import {BlockSchema} from '@/blocknote/core' + +import {SideMenuProps} from './SideMenuPositioner' +import {SideMenu} from './SideMenu' +import {AddBlockButton} from './DefaultButtons/AddBlockButton' +import {DragHandle} from './DefaultButtons/DragHandle' + +export const DefaultSideMenu = ( + props: SideMenuProps, +) => ( + + + + +) diff --git a/frontend/packages/editor/src/blocknote/react/BlockSideMenu/components/DefaultButtons/BlockColorsButton.tsx b/frontend/packages/editor/src/blocknote/react/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx similarity index 92% rename from frontend/packages/editor/src/blocknote/react/BlockSideMenu/components/DefaultButtons/BlockColorsButton.tsx rename to frontend/packages/editor/src/blocknote/react/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx index 9ae133900a..c75c6d0e17 100644 --- a/frontend/packages/editor/src/blocknote/react/BlockSideMenu/components/DefaultButtons/BlockColorsButton.tsx +++ b/frontend/packages/editor/src/blocknote/react/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx @@ -1,10 +1,11 @@ import {ReactNode, useCallback, useRef, useState} from 'react' import {Box, Menu} from '@mantine/core' -import {BlockSchema, PartialBlock} from '@mintter/app/src/blocknote-core' import {HiChevronRight} from 'react-icons/hi' +import {BlockSchema, PartialBlock} from '@/blocknote/core' + import {DragHandleMenuProps} from '../DragHandleMenu' import {DragHandleMenuItem} from '../DragHandleMenuItem' -import {ColorPicker} from '../../../SharedComponents/ColorPicker/components/ColorPicker' +import {ColorPicker} from '../../../../SharedComponents/ColorPicker/components/ColorPicker' export const BlockColorsButton = ( props: DragHandleMenuProps & {children: ReactNode}, @@ -38,7 +39,6 @@ export const BlockColorsButton = ( return ( diff --git a/frontend/packages/editor/src/blocknote/react/BlockSideMenu/components/DefaultButtons/RemoveBlockButton.tsx b/frontend/packages/editor/src/blocknote/react/SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx similarity index 64% rename from frontend/packages/editor/src/blocknote/react/BlockSideMenu/components/DefaultButtons/RemoveBlockButton.tsx rename to frontend/packages/editor/src/blocknote/react/SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx index c375f76458..a22d3c1de7 100644 --- a/frontend/packages/editor/src/blocknote/react/BlockSideMenu/components/DefaultButtons/RemoveBlockButton.tsx +++ b/frontend/packages/editor/src/blocknote/react/SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx @@ -1,5 +1,5 @@ import {ReactNode} from 'react' -import {BlockSchema} from '@mintter/app/src/blocknote-core' +import {BlockSchema} from '@/blocknote/core' import {DragHandleMenuProps} from '../DragHandleMenu' import {DragHandleMenuItem} from '../DragHandleMenuItem' @@ -9,11 +9,7 @@ export const RemoveBlockButton = ( ) => { return ( { - props.closeMenu() - props.editor.removeBlocks([props.block]) - }} + onClick={() => props.editor.removeBlocks([props.block])} > {props.children} diff --git a/frontend/packages/editor/src/blocknote/react/BlockSideMenu/components/DefaultDragHandleMenu.tsx b/frontend/packages/editor/src/blocknote/react/SideMenu/components/DragHandleMenu/DefaultDragHandleMenu.tsx similarity index 76% rename from frontend/packages/editor/src/blocknote/react/BlockSideMenu/components/DefaultDragHandleMenu.tsx rename to frontend/packages/editor/src/blocknote/react/SideMenu/components/DragHandleMenu/DefaultDragHandleMenu.tsx index ef66175471..6d01344ef1 100644 --- a/frontend/packages/editor/src/blocknote/react/BlockSideMenu/components/DefaultDragHandleMenu.tsx +++ b/frontend/packages/editor/src/blocknote/react/SideMenu/components/DragHandleMenu/DefaultDragHandleMenu.tsx @@ -1,13 +1,14 @@ +import {BlockSchema} from '@/blocknote/core' + import {DragHandleMenu, DragHandleMenuProps} from './DragHandleMenu' import {RemoveBlockButton} from './DefaultButtons/RemoveBlockButton' import {BlockColorsButton} from './DefaultButtons/BlockColorsButton' -import {BlockSchema} from '@mintter/app/src/blocknote-core' export const DefaultDragHandleMenu = ( props: DragHandleMenuProps, ) => ( Delete - {/* Colors */} + Colors ) diff --git a/frontend/packages/editor/src/blocknote/react/BlockSideMenu/components/DragHandleMenu.tsx b/frontend/packages/editor/src/blocknote/react/SideMenu/components/DragHandleMenu/DragHandleMenu.tsx similarity index 79% rename from frontend/packages/editor/src/blocknote/react/BlockSideMenu/components/DragHandleMenu.tsx rename to frontend/packages/editor/src/blocknote/react/SideMenu/components/DragHandleMenu/DragHandleMenu.tsx index ee4ee31752..a413ab4033 100644 --- a/frontend/packages/editor/src/blocknote/react/BlockSideMenu/components/DragHandleMenu.tsx +++ b/frontend/packages/editor/src/blocknote/react/SideMenu/components/DragHandleMenu/DragHandleMenu.tsx @@ -1,15 +1,10 @@ import {ReactNode} from 'react' import {createStyles, Menu} from '@mantine/core' -import { - Block, - BlockSchema, - BlockNoteEditor, -} from '@mintter/app/src/blocknote-core' +import {Block, BlockNoteEditor, BlockSchema} from '@/blocknote/core' export type DragHandleMenuProps = { editor: BlockNoteEditor block: Block - closeMenu: () => void } export const DragHandleMenu = (props: {children: ReactNode}) => { diff --git a/frontend/packages/editor/src/blocknote/react/SideMenu/components/DragHandleMenu/DragHandleMenuItem.tsx b/frontend/packages/editor/src/blocknote/react/SideMenu/components/DragHandleMenu/DragHandleMenuItem.tsx new file mode 100644 index 0000000000..cbcbc946cf --- /dev/null +++ b/frontend/packages/editor/src/blocknote/react/SideMenu/components/DragHandleMenu/DragHandleMenuItem.tsx @@ -0,0 +1,9 @@ +import {Menu} from '@mantine/core' +import {PolymorphicComponentProps} from '@mantine/utils' + +export const DragHandleMenuItem = ( + props: PolymorphicComponentProps<'button'>, +) => { + const {children, ...remainingProps} = props + return {children} +} diff --git a/frontend/packages/editor/src/blocknote/react/SideMenu/components/SideMenu.tsx b/frontend/packages/editor/src/blocknote/react/SideMenu/components/SideMenu.tsx new file mode 100644 index 0000000000..0b2a935e7f --- /dev/null +++ b/frontend/packages/editor/src/blocknote/react/SideMenu/components/SideMenu.tsx @@ -0,0 +1,14 @@ +import {createStyles, Group} from '@mantine/core' +import {ReactNode} from 'react' + +export const SideMenu = (props: {children: ReactNode}) => { + const {classes} = createStyles({root: {}})(undefined, { + name: 'SideMenu', + }) + + return ( + + {props.children} + + ) +} diff --git a/frontend/packages/editor/src/blocknote/react/SideMenu/components/SideMenuButton.tsx b/frontend/packages/editor/src/blocknote/react/SideMenu/components/SideMenuButton.tsx new file mode 100644 index 0000000000..d91c4ddf51 --- /dev/null +++ b/frontend/packages/editor/src/blocknote/react/SideMenu/components/SideMenuButton.tsx @@ -0,0 +1,5 @@ +import {ActionIcon} from '@mantine/core' + +export const SideMenuButton = (props: {children: JSX.Element}) => ( + {props.children} +) diff --git a/frontend/packages/editor/src/blocknote/react/SideMenu/components/SideMenuPositioner.tsx b/frontend/packages/editor/src/blocknote/react/SideMenu/components/SideMenuPositioner.tsx new file mode 100644 index 0000000000..c6b2a012fc --- /dev/null +++ b/frontend/packages/editor/src/blocknote/react/SideMenu/components/SideMenuPositioner.tsx @@ -0,0 +1,111 @@ +import { + Block, + BlockNoteEditor, + BlockSchema, + DefaultBlockSchema, + SideMenuProsemirrorPlugin, +} from '@/blocknote/core' +import Tippy from '@tippyjs/react' +import {FC, useEffect, useMemo, useRef, useState} from 'react' + +import {DefaultSideMenu} from './DefaultSideMenu' +import {DragHandleMenuProps} from './DragHandleMenu/DragHandleMenu' + +export type SideMenuProps = + Pick< + SideMenuProsemirrorPlugin, + | 'blockDragStart' + | 'blockDragEnd' + | 'addBlock' + | 'freezeMenu' + | 'unfreezeMenu' + > & { + block: Block + editor: BlockNoteEditor + dragHandleMenu?: FC> + } + +export const SideMenuPositioner = < + BSchema extends BlockSchema = DefaultBlockSchema, +>(props: { + editor: BlockNoteEditor + sideMenu?: FC> + placement?: 'left' | 'right' +}) => { + const [show, setShow] = useState(false) + const [block, setBlock] = useState>() + + const referencePos = useRef() + + useEffect(() => { + return props.editor.sideMenu.onUpdate((sideMenuState) => { + setShow(sideMenuState.show) + setBlock(sideMenuState.block) + referencePos.current = sideMenuState.referencePos + }) + }, [props.editor]) + + const getReferenceClientRect = useMemo( + () => { + if (!referencePos.current) { + return undefined + } + + return () => referencePos.current! + }, + [referencePos.current], // eslint-disable-line + ) + + const sideMenuElement = useMemo(() => { + if (!block) { + return null + } + + const SideMenu = props.sideMenu || DefaultSideMenu + + return ( + + ) + }, [block, props.editor, props.sideMenu]) + + return ( + + ) +} + +const offset: [number, number] = [0, 0] +const popperOptions = { + modifiers: [ + { + name: 'flip', + options: { + fallbackPlacements: [], + }, + }, + { + name: 'preventOverflow', + options: { + mainAxis: false, + altAxis: false, + }, + }, + ], +} diff --git a/frontend/packages/editor/src/blocknote/react/SlashMenu/ReactSlashMenuItem.ts b/frontend/packages/editor/src/blocknote/react/SlashMenu/ReactSlashMenuItem.ts index 61020bf01c..84e8000e43 100644 --- a/frontend/packages/editor/src/blocknote/react/SlashMenu/ReactSlashMenuItem.ts +++ b/frontend/packages/editor/src/blocknote/react/SlashMenu/ReactSlashMenuItem.ts @@ -1,17 +1,14 @@ -import {BaseSlashMenuItem, BlockNoteEditor, BlockSchema} from '@/blocknote/core' +import { + BaseSlashMenuItem, + BlockSchema, + DefaultBlockSchema, +} from '@/blocknote/core' -export class ReactSlashMenuItem< - BSchema extends BlockSchema, -> extends BaseSlashMenuItem { - constructor( - public readonly name: string, - public readonly execute: (editor: BlockNoteEditor) => void, - public readonly aliases: string[] = [], - public readonly group: string, - public readonly icon: JSX.Element, - public readonly hint?: string, - public readonly shortcut?: string, - ) { - super(name, execute, aliases) - } +export type ReactSlashMenuItem< + BSchema extends BlockSchema = DefaultBlockSchema, +> = BaseSlashMenuItem & { + group: string + icon: JSX.Element + hint?: string + shortcut?: string } diff --git a/frontend/packages/editor/src/blocknote/react/SlashMenu/SlashMenuFactory.tsx b/frontend/packages/editor/src/blocknote/react/SlashMenu/SlashMenuFactory.tsx deleted file mode 100644 index 898c4ef34a..0000000000 --- a/frontend/packages/editor/src/blocknote/react/SlashMenu/SlashMenuFactory.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { - BlockSchema, - SuggestionsMenu, - SuggestionsMenuDynamicParams, - SuggestionsMenuFactory, - SuggestionsMenuStaticParams, -} from '@mintter/app/src/blocknote-core' -import {SlashMenu} from './components/SlashMenu' -import {ReactSlashMenuItem} from './ReactSlashMenuItem' -import {ReactElementFactory} from '../ElementFactory/components/ReactElementFactory' -import {MantineThemeOverride} from '@mantine/core' - -export const createReactSlashMenuFactory = - ( - theme: MantineThemeOverride, - ): SuggestionsMenuFactory> => - (staticParams): SuggestionsMenu> => - ReactElementFactory< - SuggestionsMenuStaticParams>, - SuggestionsMenuDynamicParams> - >(staticParams, SlashMenu, theme, { - animation: 'fade', - placement: 'bottom-start', - }) diff --git a/frontend/packages/editor/src/blocknote/react/SlashMenu/components/SlashMenu.tsx b/frontend/packages/editor/src/blocknote/react/SlashMenu/components/DefaultSlashMenu.tsx similarity index 68% rename from frontend/packages/editor/src/blocknote/react/SlashMenu/components/SlashMenu.tsx rename to frontend/packages/editor/src/blocknote/react/SlashMenu/components/DefaultSlashMenu.tsx index bd42cdd063..6643aba7eb 100644 --- a/frontend/packages/editor/src/blocknote/react/SlashMenu/components/SlashMenu.tsx +++ b/frontend/packages/editor/src/blocknote/react/SlashMenu/components/DefaultSlashMenu.tsx @@ -1,16 +1,11 @@ import {createStyles, Menu} from '@mantine/core' import * as _ from 'lodash' -import {SlashMenuItem} from './SlashMenuItem' -import {ReactSlashMenuItem} from '../ReactSlashMenuItem' -import {BlockSchema} from '@mintter/app/src/blocknote-core' -export type SlashMenuProps = { - items: ReactSlashMenuItem[] - keyboardHoveredItemIndex: number - itemCallback: (item: ReactSlashMenuItem) => void -} +import {SlashMenuItem} from './SlashMenuItem' +import {SlashMenuProps} from './SlashMenuPositioner' +import {BlockSchema} from '@/blocknote/core' -export function SlashMenu( +export function DefaultSlashMenu( props: SlashMenuProps, ) { const {classes} = createStyles({root: {}})(undefined, { @@ -19,12 +14,16 @@ export function SlashMenu( const renderedItems: any[] = [] let index = 0 - const groups = _.groupBy(props.items, (i) => i.group) + const groups = _.groupBy(props.filteredItems, (i) => i.group) - _.forEach(groups, (el) => { - renderedItems.push({el[0].group}) + _.forEach(groups, (groupedItems) => { + renderedItems.push( + + {groupedItems[0].group} + , + ) - for (const item of el) { + for (const item of groupedItems) { renderedItems.push( ( trigger={'hover'} closeDelay={10000000} > - + event.preventDefault()} + className={classes.root} + > {renderedItems.length > 0 ? ( renderedItems ) : ( diff --git a/frontend/packages/editor/src/blocknote/react/SlashMenu/components/SlashMenuPositioner.tsx b/frontend/packages/editor/src/blocknote/react/SlashMenu/components/SlashMenuPositioner.tsx new file mode 100644 index 0000000000..707885ad14 --- /dev/null +++ b/frontend/packages/editor/src/blocknote/react/SlashMenu/components/SlashMenuPositioner.tsx @@ -0,0 +1,88 @@ +import { + BlockNoteEditor, + BlockSchema, + DefaultBlockSchema, + SlashMenuProsemirrorPlugin, + SuggestionsMenuState, +} from '@/blocknote/core' +import Tippy from '@tippyjs/react' +import {FC, useEffect, useMemo, useRef, useState} from 'react' + +import {ReactSlashMenuItem} from '../ReactSlashMenuItem' +import {DefaultSlashMenu} from './DefaultSlashMenu' + +export type SlashMenuProps = + Pick, 'itemCallback'> & + Pick< + SuggestionsMenuState>, + 'filteredItems' | 'keyboardHoveredItemIndex' + > + +export const SlashMenuPositioner = < + BSchema extends BlockSchema = DefaultBlockSchema, +>(props: { + editor: BlockNoteEditor + slashMenu?: FC> +}) => { + const [show, setShow] = useState(false) + const [filteredItems, setFilteredItems] = + useState[]>() + const [keyboardHoveredItemIndex, setKeyboardHoveredItemIndex] = + useState() + + const referencePos = useRef() + + useEffect(() => { + return props.editor.slashMenu.onUpdate((slashMenuState) => { + setShow(slashMenuState.show) + setFilteredItems(slashMenuState.filteredItems) + setKeyboardHoveredItemIndex(slashMenuState.keyboardHoveredItemIndex) + + referencePos.current = slashMenuState.referencePos + }) + }, [props.editor]) + + const getReferenceClientRect = useMemo( + () => { + if (!referencePos.current) { + return undefined + } + + return () => referencePos.current! + }, + [referencePos.current], // eslint-disable-line + ) + + const slashMenuElement = useMemo(() => { + if (!filteredItems || keyboardHoveredItemIndex === undefined) { + return null + } + + const SlashMenu = props.slashMenu || DefaultSlashMenu + + return ( + props.editor.slashMenu.itemCallback(item)} + keyboardHoveredItemIndex={keyboardHoveredItemIndex} + /> + ) + }, [ + filteredItems, + keyboardHoveredItemIndex, + props.editor.slashMenu, + props.slashMenu, + ]) + + return ( + + ) +} diff --git a/frontend/packages/editor/src/blocknote/react/SlashMenu/defaultReactSlashMenuItems.tsx b/frontend/packages/editor/src/blocknote/react/SlashMenu/defaultReactSlashMenuItems.tsx index 97338dcade..b41c2b4322 100644 --- a/frontend/packages/editor/src/blocknote/react/SlashMenu/defaultReactSlashMenuItems.tsx +++ b/frontend/packages/editor/src/blocknote/react/SlashMenu/defaultReactSlashMenuItems.tsx @@ -1,19 +1,26 @@ import { BaseSlashMenuItem, + BlockSchema, + defaultBlockSchema, DefaultBlockSchema, - defaultSlashMenuItems, + getDefaultSlashMenuItems, } from '@/blocknote/core' -import {HMBlockSchema} from '@/schema' -import {MdPreview} from 'react-icons/md' import { RiChatQuoteLine, RiCodeLine, RiFolder2Line, RiH1, + RiH2, + RiH3, + RiListOrdered, + RiListUnordered, RiPlayCircleLine, RiText, } from 'react-icons/ri' +import {formatKeyboardShortcut} from '../utils' import {ReactSlashMenuItem} from './ReactSlashMenuItem' +import {MdPreview} from 'react-icons/md' + const extraFields: Record< string, Omit< @@ -57,7 +64,6 @@ const extraFields: Record< hint: 'Used for the body of your document', // shortcut: formatKeyboardShortcut('Mod-Alt-0'), }, - Code: { group: 'Text Content', icon: , @@ -91,19 +97,18 @@ const extraFields: Record< }, } -export const defaultReactSlashMenuItems = defaultSlashMenuItems - .map((item) => { - if (!extraFields[item.name]) { - return false - } - return new ReactSlashMenuItem( - item.name, - item.execute, - item.aliases, - extraFields[item.name].group, - extraFields[item.name].icon, - extraFields[item.name].hint, - extraFields[item.name].shortcut, - ) - }) - .filter(Boolean) +export function getDefaultReactSlashMenuItems( + // 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, +): ReactSlashMenuItem[] { + const slashMenuItems: BaseSlashMenuItem[] = + getDefaultSlashMenuItems(schema) + + return slashMenuItems.map((item) => ({ + ...item, + ...extraFields[item.name], + })) +} diff --git a/frontend/packages/editor/src/blocknote/react/defaultThemes.ts b/frontend/packages/editor/src/blocknote/react/defaultThemes.ts index 0ddeb962e6..cc45518249 100644 --- a/frontend/packages/editor/src/blocknote/react/defaultThemes.ts +++ b/frontend/packages/editor/src/blocknote/react/defaultThemes.ts @@ -16,8 +16,8 @@ export const defaultColorScheme = [ export const lightDefaultTheme: Theme = { colors: { editor: { - text: defaultColorScheme[5], - background: defaultColorScheme[0], + text: 'inherit', + background: 'transparent', }, menu: { text: defaultColorScheme[5], @@ -89,8 +89,8 @@ export const lightDefaultTheme: Theme = { export const darkDefaultTheme: Theme = { colors: { editor: { - text: defaultColorScheme[2], - background: defaultColorScheme[6], + text: 'inherit', + background: 'transparent', }, menu: { text: defaultColorScheme[2], diff --git a/frontend/packages/editor/src/blocknote/react/hooks/useBlockNote.ts b/frontend/packages/editor/src/blocknote/react/hooks/useBlockNote.ts index 832f34c05c..c9af60d6d1 100644 --- a/frontend/packages/editor/src/blocknote/react/hooks/useBlockNote.ts +++ b/frontend/packages/editor/src/blocknote/react/hooks/useBlockNote.ts @@ -2,116 +2,39 @@ import { BlockNoteEditor, BlockNoteEditorOptions, BlockSchema, + defaultBlockSchema, DefaultBlockSchema, } from '@/blocknote/core' import {HMBlockSchema} from '@/schema' -import {DependencyList, FC, useEffect, useState} from 'react' -import {blockNoteToMantineTheme} from '../BlockNoteTheme' -import {createReactBlockSideMenuFactory} from '../BlockSideMenu/BlockSideMenuFactory' -import {DragHandleMenuProps} from '../BlockSideMenu/components/DragHandleMenu' -import {createReactFormattingToolbarFactory} from '../FormattingToolbar/FormattingToolbarFactory' -import {createReactHyperlinkToolbarFactory} from '../HyperlinkToolbar/HyperlinkToolbarFactory' -import {defaultReactSlashMenuItems} from '../SlashMenu/defaultReactSlashMenuItems' -import {createReactSlashMenuFactory} from '../SlashMenu/SlashMenuFactory' -import {darkDefaultTheme, lightDefaultTheme} from '../defaultThemes' - -//based on https://github.com/ueberdosis/tiptap/blob/main/packages/react/src/useEditor.ts - -type CustomElements = Partial<{ - formattingToolbar: FC<{editor: BlockNoteEditor}> - dragHandleMenu: FC> -}> - -function useForceUpdate() { - const [, setValue] = useState(0) - - return () => setValue((value) => value + 1) -} +import {DependencyList, useMemo, useRef} from 'react' +import {getDefaultReactSlashMenuItems} from '../SlashMenu/defaultReactSlashMenuItems' + +const initEditor = ( + options: Partial>, +) => + new BlockNoteEditor({ + slashMenuItems: getDefaultReactSlashMenuItems( + options.blockSchema || defaultBlockSchema, + ), + ...options, + }) /** * Main hook for importing a BlockNote editor into a React project */ -export const useBlockNote = ( - options: Partial< - BlockNoteEditorOptions & { - customElements: CustomElements - } - > = {}, +export const useBlockNote = < + BSchema extends HMBlockSchema = DefaultBlockSchema, +>( + options: Partial> = {}, deps: DependencyList = [], -) => { - const [editor, setEditor] = useState | null>(null) - const forceUpdate = useForceUpdate() - const preferDark = window.matchMedia('(prefers-color-scheme: dark)').matches - - useEffect(() => { - let isMounted = true - // TODO: Fix typing. UiFactories expects only BaseSlashMenuItems, not extended types. Can be fixed with a generic, - // but it would have to be on several different classes (BlockNoteEditor, BlockNoteEditorOptions, UiFactories) and - // gets messy quick. - let newOptions: Record = { - slashCommands: defaultReactSlashMenuItems, - ...options, - } - - if (newOptions.customElements && newOptions.uiFactories) { - console.warn( - 'BlockNote editor initialized with both `customElements` and `uiFactories` options, prioritizing `uiFactories`.', - ) - } - - let uiFactories = { - formattingToolbarFactory: createReactFormattingToolbarFactory( - blockNoteToMantineTheme( - preferDark ? darkDefaultTheme : lightDefaultTheme, - ), - newOptions.customElements?.formattingToolbar, - ), - hyperlinkToolbarFactory: createReactHyperlinkToolbarFactory( - blockNoteToMantineTheme( - preferDark ? darkDefaultTheme : lightDefaultTheme, - ), - ), - slashMenuFactory: createReactSlashMenuFactory( - blockNoteToMantineTheme( - preferDark ? darkDefaultTheme : lightDefaultTheme, - ), - ), - blockSideMenuFactory: createReactBlockSideMenuFactory( - blockNoteToMantineTheme( - preferDark ? darkDefaultTheme : lightDefaultTheme, - ), - newOptions.customElements?.dragHandleMenu, - ), - ...newOptions.uiFactories, - } +): BlockNoteEditor => { + const editorRef = useRef>() - newOptions = { - ...newOptions, - uiFactories, + return useMemo(() => { + if (editorRef.current) { + editorRef.current._tiptapEditor.destroy() } - - const instance = new BlockNoteEditor( - newOptions as Partial>, - ) - - setEditor(instance) - - instance._tiptapEditor.on('transaction', () => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (isMounted) { - forceUpdate() - } - }) - }) - }) - - return () => { - instance._tiptapEditor.destroy() - isMounted = false - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, deps) - - return editor + editorRef.current = initEditor(options) + return editorRef.current + }, deps) //eslint-disable-line react-hooks/exhaustive-deps } diff --git a/frontend/packages/editor/src/blocknote/react/hooks/useEditorContentChange.ts b/frontend/packages/editor/src/blocknote/react/hooks/useEditorContentChange.ts new file mode 100644 index 0000000000..abaeb0cd2a --- /dev/null +++ b/frontend/packages/editor/src/blocknote/react/hooks/useEditorContentChange.ts @@ -0,0 +1,15 @@ +import {BlockNoteEditor, BlockSchema} from '@/blocknote/core' +import {useEffect} from 'react' + +export function useEditorContentChange( + editor: BlockNoteEditor, + callback: () => void, +) { + useEffect(() => { + editor._tiptapEditor.on('update', callback) + + return () => { + editor._tiptapEditor.off('update', callback) + } + }, [callback, editor._tiptapEditor]) +} diff --git a/frontend/packages/editor/src/blocknote/react/hooks/useEditorSelectionChange.ts b/frontend/packages/editor/src/blocknote/react/hooks/useEditorSelectionChange.ts new file mode 100644 index 0000000000..57406df389 --- /dev/null +++ b/frontend/packages/editor/src/blocknote/react/hooks/useEditorSelectionChange.ts @@ -0,0 +1,15 @@ +import {BlockNoteEditor, BlockSchema} from '@/blocknote/core' +import {useEffect} from 'react' + +export function useEditorSelectionChange( + editor: BlockNoteEditor, + callback: () => void, +) { + useEffect(() => { + editor._tiptapEditor.on('selectionUpdate', callback) + + return () => { + editor._tiptapEditor.off('selectionUpdate', callback) + } + }, [callback, editor._tiptapEditor]) +} diff --git a/frontend/packages/editor/src/blocknote/react/index.ts b/frontend/packages/editor/src/blocknote/react/index.ts index 74ed1bc5ee..76bcd392f1 100644 --- a/frontend/packages/editor/src/blocknote/react/index.ts +++ b/frontend/packages/editor/src/blocknote/react/index.ts @@ -3,33 +3,43 @@ export * from './BlockNoteView' export * from './BlockNoteTheme' export * from './defaultThemes' -export * from './ReactBlockSpec' - -export * from './BlockSideMenu/BlockSideMenuFactory' -export * from './BlockSideMenu/components/DragHandleMenu' -export * from './BlockSideMenu/components/DragHandleMenuItem' -export * from './BlockSideMenu/components/DefaultButtons/RemoveBlockButton' -export * from './BlockSideMenu/components/DefaultButtons/BlockColorsButton' - -export * from './FormattingToolbar/FormattingToolbarFactory' -export * from './FormattingToolbar/components/FormattingToolbar' +export * from './FormattingToolbar/components/FormattingToolbarPositioner' +export * from './FormattingToolbar/components/DefaultFormattingToolbar' export * from './FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown' -export * from './FormattingToolbar/components/DefaultButtons/ToggledStyleButton' -export * from './FormattingToolbar/components/DefaultButtons/TextAlignButton' export * from './FormattingToolbar/components/DefaultButtons/ColorStyleButton' -export * from './FormattingToolbar/components/DefaultButtons/NestBlockButtons' export * from './FormattingToolbar/components/DefaultButtons/CreateLinkButton' -export * from './ElementFactory/components/ReactElementFactory' +export * from './FormattingToolbar/components/DefaultButtons/NestBlockButtons' +export * from './FormattingToolbar/components/DefaultButtons/TextAlignButton' +export * from './FormattingToolbar/components/DefaultButtons/ToggledStyleButton' -export * from './hooks/useBlockNote' -export * from './hooks/useEditorForceUpdate' +export * from './HyperlinkToolbar/components/HyperlinkToolbarPositioner' -export * from './HyperlinkToolbar/HyperlinkToolbarFactory' +export * from './SideMenu/components/SideMenuPositioner' +export * from './SideMenu/components/SideMenu' +export * from './SideMenu/components/SideMenuButton' +export * from './SideMenu/components/DefaultSideMenu' +export * from './SideMenu/components/DefaultButtons/AddBlockButton' +export * from './SideMenu/components/DefaultButtons/DragHandle' -export * from './SlashMenu/SlashMenuFactory' +export * from './SideMenu/components/DragHandleMenu/DragHandleMenu' +export * from './SideMenu/components/DragHandleMenu/DragHandleMenuItem' +export * from './SideMenu/components/DragHandleMenu/DefaultDragHandleMenu' +export * from './SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton' +export * from './SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton' + +export * from './SlashMenu/components/SlashMenuPositioner' +export * from './SlashMenu/components/SlashMenuItem' +export * from './SlashMenu/components/DefaultSlashMenu' export * from './SlashMenu/ReactSlashMenuItem' export * from './SlashMenu/defaultReactSlashMenuItems' export * from './SharedComponents/Toolbar/components/Toolbar' export * from './SharedComponents/Toolbar/components/ToolbarButton' export * from './SharedComponents/Toolbar/components/ToolbarDropdown' + +export * from './hooks/useBlockNote' +export * from './hooks/useEditorForceUpdate' +export * from './hooks/useEditorContentChange' +export * from './hooks/useEditorSelectionChange' + +export * from './ReactBlockSpec' diff --git a/frontend/packages/editor/src/blocknote/react/types/styles.d.ts b/frontend/packages/editor/src/blocknote/react/types/styles.d.ts new file mode 100644 index 0000000000..f57bdaee63 --- /dev/null +++ b/frontend/packages/editor/src/blocknote/react/types/styles.d.ts @@ -0,0 +1 @@ +declare module "*.module.css"; \ No newline at end of file diff --git a/frontend/packages/editor/src/editor.css b/frontend/packages/editor/src/editor.css index 596454c53a..07b4a63171 100644 --- a/frontend/packages/editor/src/editor.css +++ b/frontend/packages/editor/src/editor.css @@ -52,3 +52,11 @@ transform: rotate(360deg); } } + +.block-container { + position: relative; +} + +.block-container-hovered { + background-color: var(--backgroundPress); +} diff --git a/frontend/packages/editor/src/editor.tsx b/frontend/packages/editor/src/editor.tsx index 008803ee82..1c81950d92 100644 --- a/frontend/packages/editor/src/editor.tsx +++ b/frontend/packages/editor/src/editor.tsx @@ -1,11 +1,38 @@ -import './blocknote/core/style.css' -import {BlockNoteView} from './blocknote' import {HyperDocsEditor} from '@mintter/app/src/models/documents' import {YStack} from '@mintter/ui' +import { + BlockNoteView, + FormattingToolbarPositioner, + HyperlinkToolbarPositioner, + SideMenu, + SideMenuPositioner, + SideMenuProps, + SlashMenuPositioner, +} from './blocknote' +import './blocknote/core/style.css' import './editor.css' +function RightsideMenu(props: SideMenuProps) { + return ( + + hello + + ) +} + export function HyperMediaEditorView({editor}: {editor: HyperDocsEditor}) { - return + if (editor.isEditable) { + return ( + + + + + + + ) + } else { + return + } } export function HMEditorContainer({children}: {children: React.ReactNode}) { diff --git a/frontend/packages/editor/src/embed-block.tsx b/frontend/packages/editor/src/embed-block.tsx index cc707f5646..76ecf23127 100644 --- a/frontend/packages/editor/src/embed-block.tsx +++ b/frontend/packages/editor/src/embed-block.tsx @@ -1,9 +1,6 @@ import {PartialMessage} from '@bufbuild/protobuf' -import { - Block as BlockNoteBlock, - BlockNoteEditor, - InlineContent, -} from './blocknote' +import {usePublication} from '@mintter/app/src/models/documents' +import {useOpenUrl} from '@mintter/app/src/open-url' import {unpackHmIdWithAppRoute} from '@mintter/app/src/utils/navigation' import {useNavigate} from '@mintter/app/src/utils/useNavigate' import type { @@ -15,6 +12,7 @@ import type { Block as ServerBlock, } from '@mintter/shared' import { + BACKEND_FILE_URL, Block, EmbedBlock as EmbedBlockType, getCIDFromIPFSUrl, @@ -26,12 +24,14 @@ import {Spinner, Text, View, XStack, YStack, styled} from '@mintter/ui' import {AlertCircle} from '@tamagui/lucide-icons' import {ComponentProps, useEffect, useMemo, useState} from 'react' import {ErrorBoundary} from 'react-error-boundary' -import {getBlockInfoFromPos} from './blocknote' -import {createReactBlockSpec} from './blocknote' +import { + Block as BlockNoteBlock, + BlockNoteEditor, + InlineContent, +} from './blocknote' +import {getBlockInfoFromPos} from './blocknote/core' +import {createReactBlockSpec} from './blocknote/react' import {HMBlockSchema, hmBlockSchema} from './schema' -import {BACKEND_FILE_URL} from '@mintter/shared' -import {usePublication} from '@mintter/app/src/models/documents' -import {useOpenUrl} from '@mintter/app/src/open-url' const EditorText = styled(Text, { fontSize: '$5', diff --git a/frontend/packages/editor/src/file.tsx b/frontend/packages/editor/src/file.tsx index 6d9dbabb99..a6a0b4ec4f 100644 --- a/frontend/packages/editor/src/file.tsx +++ b/frontend/packages/editor/src/file.tsx @@ -16,12 +16,11 @@ import {RiFile2Fill, RiFile2Line} from 'react-icons/ri' import { Block, BlockNoteEditor, - ReactSlashMenuItem, - createReactBlockSpec, defaultProps, getBlockInfoFromPos, insertOrUpdateBlock, -} from './blocknote' +} from './blocknote/core' +import {createReactBlockSpec} from './blocknote/react' export const FileBlock = createReactBlockSpec({ type: 'file', @@ -568,18 +567,18 @@ function FileForm({ ) } -export const insertFile = new ReactSlashMenuItem( - 'File', - (editor: BlockNoteEditor) => { - insertOrUpdateBlock(editor, { - type: 'file', - props: { - url: '', - }, - }) - }, - ['file', 'folder'], - 'Media', - , - 'Insert a file', -) +// export const insertFile = new ReactSlashMenuItem( +// 'File', +// (editor: BlockNoteEditor) => { +// insertOrUpdateBlock(editor, { +// type: 'file', +// props: { +// url: '', +// }, +// }) +// }, +// ['file', 'folder'], +// 'Media', +// , +// 'Insert a file', +// ) diff --git a/frontend/packages/editor/src/formatting-toolbar.tsx b/frontend/packages/editor/src/formatting-toolbar.tsx deleted file mode 100644 index 7c7dcf3809..0000000000 --- a/frontend/packages/editor/src/formatting-toolbar.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { - BlockNoteEditor, - BlockSchema, - BlockTypeDropdown, - CreateLinkButton, - NestBlockButton, - ReactElementFactory, - RequiredStaticParams, - ToggledStyleButton, - Toolbar, - UnnestBlockButton, - blockNoteToMantineTheme, - darkDefaultTheme, - lightDefaultTheme, -} from './blocknote' - -export const FormattingToolbar = (props: { - editor: BlockNoteEditor -}) => { - return ( - - - - - - - - - - {/* */} - - - - - - - ) -} - -export const formattingToolbarFactory = ( - staticParams: RequiredStaticParams, -) => { - const preferDark = window.matchMedia('(prefers-color-scheme: dark)').matches - - return ReactElementFactory( - staticParams, - FormattingToolbar, - blockNoteToMantineTheme(preferDark ? darkDefaultTheme : lightDefaultTheme), - { - animation: 'fade', - placement: 'top-start', - }, - ) -} diff --git a/frontend/packages/editor/src/hypermedia-block-container.tsx b/frontend/packages/editor/src/hypermedia-block-container.tsx new file mode 100644 index 0000000000..3b62a59117 --- /dev/null +++ b/frontend/packages/editor/src/hypermedia-block-container.tsx @@ -0,0 +1,853 @@ +import {useCitationsForBlock} from '@mintter/app/src/components/citations-context' +import {mergeAttributes, Node, NodeViewProps} from '@tiptap/core' +import {Fragment, Node as PMNode, Slice} from 'prosemirror-model' +import {NodeSelection, TextSelection} from 'prosemirror-state' +import { + blockToNode, + inlineContentToNodes, +} from './blocknote/core/api/nodeConversions/nodeConversions' + +import { + BlockNoteDOMAttributes, + BlockSchema, + getBlockInfoFromPos, + PartialBlock, +} from './blocknote' + +import {copyTextToClipboard} from '@mintter/app/src/copy-to-clipboard' +import {usePublication} from '@mintter/app/src/models/documents' +import {toast} from '@mintter/app/src/toast' +import {useNavRoute} from '@mintter/app/src/utils/navigation' +import {useNavigate} from '@mintter/app/src/utils/useNavigate' +import {createPublicWebHmUrl, unpackHmId} from '@mintter/shared' +import {Button, Copy, SizableText, XStack} from '@mintter/ui' +import { + NodeViewContent, + NodeViewWrapper, + ReactNodeViewRenderer, +} from '@tiptap/react' +import {useState} from 'react' +import {mergeCSSClasses} from './blocknote' +import styles from './blocknote/core/extensions/Blocks/nodes/Block.module.css' +import BlockAttributes from './blocknote/core/extensions/Blocks/nodes/BlockAttributes' +import {PreviousBlockTypePlugin} from './blocknote/core/extensions/Blocks/PreviousBlockTypePlugin' + +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 + } + } +} + +function BlockHelper({ + blockId, + active = false, +}: { + blockId: string + active: boolean +}) { + let {citations} = useCitationsForBlock(blockId) + let route = useNavRoute() + let replace = useNavigate('replace') + let pub = usePublication({ + documentId: route.key == 'publication' ? route.documentId : undefined, + versionId: route.key == 'publication' ? route.versionId : undefined, + enabled: route.key == 'publication' && !!route.documentId, + }) + + function onCopy() { + const docId = pub.data?.document?.id + ? unpackHmId(pub.data?.document?.id) + : null + const docVersion = pub.data?.version + if (docId && docId.type === 'd' && docVersion && blockId) { + copyTextToClipboard( + createPublicWebHmUrl('d', docId.eid, { + version: docVersion, + blockRef: blockId, + }), + ) + toast.success('Block reference copied!') + } else { + console.log('Block reference copy failed') + } + } + + function onCitation() { + if (route.key == 'publication') { + // if (route.accessory) return replace({...route, accessory: null}) + replace({...route, accessory: {key: 'citations'}}) + } + } + + return ( + + + ) : null} + + ) +} + +/** + * The main "Block node" documents consist of + */ +export const HMBlockContainer = 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, + + addNodeView() { + const domAttributes = this.options.domAttributes?.blockContainer || {} + + const Container = (props: NodeViewProps) => { + let [hovered, setHovered] = useState(false) + let blockId = props.node.attrs.id + + return ( + +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + + +
+
+ ) + } + + return ReactNodeViewRenderer(Container, {attrs: domAttributes}) + }, + + 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 || {} + + return [ + 'div', + mergeAttributes(HTMLAttributes, { + class: styles.blockOuter, + 'data-node-type': 'block-outer', + }), + [ + 'div', + mergeAttributes( + { + ...domAttributes, + class: mergeCSSClasses(styles.block, domAttributes.class), + 'data-node-type': this.name, + }, + 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/hyperdocs-link-plugin.tsx b/frontend/packages/editor/src/hypermedia-link-plugin.tsx similarity index 95% rename from frontend/packages/editor/src/hyperdocs-link-plugin.tsx rename to frontend/packages/editor/src/hypermedia-link-plugin.tsx index 12db893600..e14a66d7f9 100644 --- a/frontend/packages/editor/src/hyperdocs-link-plugin.tsx +++ b/frontend/packages/editor/src/hypermedia-link-plugin.tsx @@ -3,10 +3,10 @@ import {EditorView} from '@tiptap/pm/view' import {Plugin, PluginKey} from 'prosemirror-state' import {AppQueryClient} from '@mintter/app/src/query-client' -export const hyperdocsPluginKey = new PluginKey('hyperdocs-link') +export const hypermediaPluginKey = new PluginKey('hypermedia-link') // TODO: use `createX` function instead of just exporting the plugin -export function createHyperdocsDocLinkPlugin({ +export function createHypermediaDocLinkPlugin({ queryClient, fetchWebLink, }: { @@ -15,7 +15,7 @@ export function createHyperdocsDocLinkPlugin({ fetchWebLink: any }) { let plugin = new Plugin({ - key: hyperdocsPluginKey, + key: hypermediaPluginKey, view(editorView) { return { update(view, prevState) { diff --git a/frontend/packages/editor/src/image.tsx b/frontend/packages/editor/src/image.tsx index 158e0c8d53..7278af0153 100644 --- a/frontend/packages/editor/src/image.tsx +++ b/frontend/packages/editor/src/image.tsx @@ -18,7 +18,6 @@ import { Block, BlockNoteEditor, DefaultBlockSchema, - ReactSlashMenuItem, createReactBlockSpec, defaultProps, getBlockInfoFromPos, @@ -679,21 +678,21 @@ function ImageForm({ ) } -export const insertImage = new ReactSlashMenuItem< - DefaultBlockSchema & {image: typeof ImageBlock} ->( - 'Image', - // @ts-ignore - (editor: BlockNoteEditor) => { - insertOrUpdateBlock(editor, { - type: 'image', - props: { - url: '', - }, - }) - }, - ['image', 'img', 'picture'], - 'Media', - , - 'Insert an image', -) +// export const insertImage = new ReactSlashMenuItem< +// DefaultBlockSchema & {image: typeof ImageBlock} +// >( +// 'Image', +// // @ts-ignore +// (editor: BlockNoteEditor) => { +// insertOrUpdateBlock(editor, { +// type: 'image', +// props: { +// url: '', +// }, +// }) +// }, +// ['image', 'img', 'picture'], +// 'Media', +// , +// 'Insert an image', +// ) diff --git a/frontend/packages/editor/src/index.ts b/frontend/packages/editor/src/index.ts index 85512416bf..db9c3ceb0d 100644 --- a/frontend/packages/editor/src/index.ts +++ b/frontend/packages/editor/src/index.ts @@ -1,11 +1,9 @@ export * from './editor' export * from './embed-block' export * from './file' -export * from './formatting-toolbar' export * from './heading-component-plugin' -export * from './hyperdocs-link-plugin' +export * from './hypermedia-link-plugin' export * from './image' export * from './video' export * from './blocknote' export * from './schema' -export * from './rightside-block-widget' diff --git a/frontend/packages/editor/src/rightside-block-widget.tsx b/frontend/packages/editor/src/rightside-block-widget.tsx deleted file mode 100644 index d9c7b9ba30..0000000000 --- a/frontend/packages/editor/src/rightside-block-widget.tsx +++ /dev/null @@ -1,312 +0,0 @@ -import {Button, Copy, SizableText, XStack} from '@mintter/ui' -import {WidgetDecorationFactory} from '@prosemirror-adapter/core' -import {useWidgetViewContext} from '@prosemirror-adapter/react' -import {Editor, Extension} from '@tiptap/core' -import {EditorState, Plugin, PluginKey} from '@tiptap/pm/state' -import {Decoration, DecorationSet, EditorView} from '@tiptap/pm/view' -import {useMemo} from 'react' -import {BlockNoteEditor} from './blocknote' -import {HMBlockSchema} from './schema' -import {useDocCitations} from '@mintter/app/src/models/content-graph' -import {usePublication} from '@mintter/app/src/models/documents' -import {toast} from '@mintter/app/src/toast' -import {copyTextToClipboard} from '@mintter/app/src/copy-to-clipboard' -import {createPublicWebHmUrl, unpackHmId} from '@mintter/shared' -import {useNavigate} from '@mintter/app/src/utils/useNavigate' -import {useNavRoute} from '@mintter/app/src/utils/navigation' - -export function createRightsideBlockWidgetExtension({ - getWidget, - editor, -}: { - getWidget: WidgetDecorationFactory - editor: BlockNoteEditor -}) { - return Extension.create({ - name: 'rightside-block', - addProseMirrorPlugins() { - return [ - createRightsideBlockWidgetPlugin({ - getWidget, - editor, - ttEditor: this.editor, - }), - ] - }, - }) -} - -let RightSidePluginKey = new PluginKey('rightside-block') - -export function createRightsideBlockWidgetPlugin({ - getWidget, - editor, - ttEditor, -}: { - getWidget: WidgetDecorationFactory - editor: BlockNoteEditor - ttEditor: Editor -}) { - return new Plugin({ - key: RightSidePluginKey, - view: () => new MouseMoveView({editor, ttEditor: ttEditor}), - state: { - init() { - return DecorationSet.empty - }, - apply(tr, decos, oldState, newState) { - let hoveredBlockId = tr.getMeta(RightSidePluginKey) - if (hoveredBlockId) { - return updateDecorations(newState, getWidget, hoveredBlockId) - } - if (oldState.doc.eq(newState.doc)) return decos - - return updateDecorations(newState, getWidget) - }, - }, - props: { - decorations(state) { - return this.getState(state) - }, - }, - }) -} - -function updateDecorations( - state: EditorState, - getWidget: WidgetDecorationFactory, - activeId?: string, -) { - const decorations: Decoration[] = [] - - state.doc.descendants((node, pos) => { - if (!node.attrs.id) return - - const widget = getWidget(pos + node.nodeSize - 1, { - id: node.attrs.id, - active: node.attrs.id == activeId, - ignoreSelection: true, - }) - decorations.push(widget) - }) - - return DecorationSet.create(state.doc, decorations) -} - -export function RightsideWidget() { - let {citations, spec} = useBlockCitation() - - let route = useNavRoute() - let replace = useNavigate('replace') - let pub = usePublication({ - documentId: route.key == 'publication' ? route.documentId : undefined, - versionId: route.key == 'publication' ? route.versionId : undefined, - enabled: route.key == 'publication' && !!route.documentId, - }) - - function onCopy() { - const docId = pub.data?.document?.id - ? unpackHmId(pub.data?.document?.id) - : null - const docVersion = pub.data?.version - if (docId && docId.type === 'd' && docVersion && spec && spec.id) { - copyTextToClipboard( - createPublicWebHmUrl('d', docId.eid, { - version: docVersion, - blockRef: spec.id, - }), - ) - toast.success('Block reference copied!') - } else { - appError('Block reference copy failed', {docUrl, spec}) - } - } - - function onCitation() { - if (route.key == 'publication') { - // if (route.accessory) return replace({...route, accessory: null}) - replace({...route, accessory: {key: 'citations'}}) - } - } - - return ( - - {citations?.length ? ( - - ) : null} - -