diff --git a/examples/vanilla/src/ui/blockSideMenuFactory.ts b/examples/vanilla/src/ui/blockSideMenuFactory.ts index 039e6939d7..799b648476 100644 --- a/examples/vanilla/src/ui/blockSideMenuFactory.ts +++ b/examples/vanilla/src/ui/blockSideMenuFactory.ts @@ -37,9 +37,9 @@ export const blockSideMenuFactory: BlockSideMenuFactory = ( container.style.display = "block"; } - container.style.top = params.referenceRect.y + "px"; + container.style.top = staticParams.getReferenceRect()!.y + "px"; container.style.left = - params.referenceRect.x - container.offsetWidth + "px"; + staticParams.getReferenceRect()!.x - container.offsetWidth + "px"; }, hide: () => { container.style.display = "none"; diff --git a/examples/vanilla/src/ui/formattingToolbarFactory.ts b/examples/vanilla/src/ui/formattingToolbarFactory.ts index 2a9387ea0a..d64600f9c5 100644 --- a/examples/vanilla/src/ui/formattingToolbarFactory.ts +++ b/examples/vanilla/src/ui/formattingToolbarFactory.ts @@ -38,8 +38,8 @@ export const formattingToolbarFactory: FormattingToolbarFactory< "bold" in staticParams.editor.getActiveStyles() ? "unset bold" : "set bold"; - container.style.top = params.referenceRect.y + "px"; - container.style.left = params.referenceRect.x + "px"; + container.style.top = staticParams.getReferenceRect()!.y + "px"; + container.style.left = staticParams.getReferenceRect()!.x + "px"; }, hide: () => { container.style.display = "none"; diff --git a/examples/vanilla/src/ui/hyperlinkToolbarFactory.ts b/examples/vanilla/src/ui/hyperlinkToolbarFactory.ts index 3e1aface51..7450e73bcb 100644 --- a/examples/vanilla/src/ui/hyperlinkToolbarFactory.ts +++ b/examples/vanilla/src/ui/hyperlinkToolbarFactory.ts @@ -43,8 +43,8 @@ export const hyperlinkToolbarFactory: HyperlinkToolbarFactory = ( container.style.display = "block"; } - container.style.top = params.referenceRect.y + "px"; - container.style.left = params.referenceRect.x + "px"; + container.style.top = staticParams.getReferenceRect()!.y + "px"; + container.style.left = staticParams.getReferenceRect()!.x + "px"; }, hide: () => { container.style.display = "none"; diff --git a/examples/vanilla/src/ui/slashMenuFactory.ts b/examples/vanilla/src/ui/slashMenuFactory.ts index 4730827626..aba00536bd 100644 --- a/examples/vanilla/src/ui/slashMenuFactory.ts +++ b/examples/vanilla/src/ui/slashMenuFactory.ts @@ -53,8 +53,8 @@ export const slashMenuFactory: SuggestionsMenuFactory< container.style.display = "block"; } - container.style.top = params.referenceRect.y + "px"; - container.style.left = params.referenceRect.x + "px"; + container.style.top = staticParams.getReferenceRect()!.y + "px"; + container.style.left = staticParams.getReferenceRect()!.x + "px"; }, hide: () => { container.style.display = "none"; diff --git a/packages/core/src/extensions/DraggableBlocks/BlockSideMenuFactoryTypes.ts b/packages/core/src/extensions/DraggableBlocks/BlockSideMenuFactoryTypes.ts index fbb0980895..97507121c6 100644 --- a/packages/core/src/extensions/DraggableBlocks/BlockSideMenuFactoryTypes.ts +++ b/packages/core/src/extensions/DraggableBlocks/BlockSideMenuFactoryTypes.ts @@ -12,12 +12,12 @@ export type BlockSideMenuStaticParams = { freezeMenu: () => void; unfreezeMenu: () => void; + + getReferenceRect: () => DOMRect; }; export type BlockSideMenuDynamicParams = { block: Block; - - referenceRect: DOMRect; }; export type BlockSideMenu = EditorElement< diff --git a/packages/core/src/extensions/DraggableBlocks/DraggableBlocksPlugin.ts b/packages/core/src/extensions/DraggableBlocks/DraggableBlocksPlugin.ts index fd0f79727e..fedf775fac 100644 --- a/packages/core/src/extensions/DraggableBlocks/DraggableBlocksPlugin.ts +++ b/packages/core/src/extensions/DraggableBlocks/DraggableBlocksPlugin.ts @@ -250,6 +250,8 @@ export class BlockMenuView { menuOpen = false; menuFrozen = false; + private lastPosition: DOMRect | undefined; + constructor({ tiptapEditor, editor, @@ -272,9 +274,6 @@ export class BlockMenuView { // 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 selects the editor with the mouse or presses a key. // TODO: Better integration with suggestions menu and only editor scope? document.body.addEventListener("mousedown", this.onMouseDown, true); @@ -463,20 +462,6 @@ export class BlockMenuView { } }; - onScroll = () => { - // 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 a more accurate bounding box. - const editorBoundingBox = ( - this.ttEditor.view.dom.firstChild! as HTMLElement - ).getBoundingClientRect(); - - this.horizontalPosAnchor = editorBoundingBox.x; - - if (this.menuOpen) { - this.blockMenu.render(this.getDynamicParams(), false); - } - }; - destroy() { if (this.menuOpen) { this.menuOpen = false; @@ -487,7 +472,6 @@ export class BlockMenuView { this.ttEditor.view.dom.removeEventListener("dragstart", this.onDragStart); document.body.removeEventListener("drop", this.onDrop); document.body.removeEventListener("mousedown", this.onMouseDown); - document.removeEventListener("scroll", this.onScroll); document.body.removeEventListener("keydown", this.onKeyDown); } @@ -556,23 +540,32 @@ export class BlockMenuView { unfreezeMenu: () => { this.menuFrozen = false; }, + getReferenceRect: () => { + if (!this.menuOpen) { + if (this.lastPosition === undefined) { + throw new Error( + "Attempted to access block reference rect before rendering block side menu." + ); + } + + return this.lastPosition; + } + + const blockContent = this.hoveredBlock!.firstChild! as HTMLElement; + const blockContentBoundingBox = blockContent.getBoundingClientRect(); + if (this.horizontalPosAnchoredAtRoot) { + blockContentBoundingBox.x = this.horizontalPosAnchor; + } + this.lastPosition = blockContentBoundingBox; + + return blockContentBoundingBox; + }, }; } getDynamicParams(): BlockSideMenuDynamicParams { - const blockContent = this.hoveredBlock!.firstChild! as HTMLElement; - const blockContentBoundingBox = blockContent.getBoundingClientRect(); - return { block: this.editor.getBlock(this.hoveredBlock!.getAttribute("data-id")!)!, - referenceRect: new DOMRect( - this.horizontalPosAnchoredAtRoot - ? this.horizontalPosAnchor - : blockContentBoundingBox.x, - blockContentBoundingBox.y, - blockContentBoundingBox.width, - blockContentBoundingBox.height - ), }; } } diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarFactoryTypes.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarFactoryTypes.ts index f424508038..0c257caf67 100644 --- a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarFactoryTypes.ts +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarFactoryTypes.ts @@ -4,15 +4,13 @@ import { BlockSchema } from "../Blocks/api/blockTypes"; export type FormattingToolbarStaticParams = { editor: BlockNoteEditor; -}; -export type FormattingToolbarDynamicParams = { - referenceRect: DOMRect; + getReferenceRect: () => DOMRect; }; -export type FormattingToolbar = EditorElement< - FormattingToolbarDynamicParams ->; +export type FormattingToolbarDynamicParams = {}; + +export type FormattingToolbar = EditorElement; export type FormattingToolbarFactory = ElementFactory< FormattingToolbarStaticParams, diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts index 2ee29815a6..c96eac3dc2 100644 --- a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts @@ -9,7 +9,6 @@ import { EditorView } from "prosemirror-view"; import { BlockNoteEditor, BlockSchema } from "../.."; import { FormattingToolbar, - FormattingToolbarDynamicParams, FormattingToolbarFactory, FormattingToolbarStaticParams, } from "./FormattingToolbarFactoryTypes"; @@ -44,6 +43,8 @@ export class FormattingToolbarView { public prevWasEditable: boolean | null = null; + private lastPosition: DOMRect | undefined; + public shouldShow: (props: { view: EditorView; state: EditorState; @@ -80,8 +81,6 @@ export class FormattingToolbarView { this.ttEditor.on("focus", this.focusHandler); this.ttEditor.on("blur", this.blurHandler); - - document.addEventListener("scroll", this.scrollHandler); } viewMousedownHandler = () => { @@ -129,12 +128,6 @@ export class FormattingToolbarView { } }; - scrollHandler = () => { - if (this.toolbarIsOpen) { - this.formattingToolbar.render(this.getDynamicParams(), false); - } - }; - update(view: EditorView, oldState?: EditorState) { const { state, composing } = view; const { doc, selection } = state; @@ -170,7 +163,7 @@ export class FormattingToolbarView { !this.preventShow && (shouldShow || this.preventHide) ) { - this.formattingToolbar.render(this.getDynamicParams(), true); + this.formattingToolbar.render({}, true); this.toolbarIsOpen = true; return; @@ -182,7 +175,7 @@ export class FormattingToolbarView { !this.preventShow && (shouldShow || this.preventHide) ) { - this.formattingToolbar.render(this.getDynamicParams(), false); + this.formattingToolbar.render({}, false); return; } @@ -206,8 +199,6 @@ export class FormattingToolbarView { this.ttEditor.off("focus", this.focusHandler); this.ttEditor.off("blur", this.blurHandler); - - document.removeEventListener("scroll", this.scrollHandler); } getSelectionBoundingBox() { @@ -233,12 +224,22 @@ export class FormattingToolbarView { getStaticParams(): FormattingToolbarStaticParams { return { editor: this.editor, - }; - } - - getDynamicParams(): FormattingToolbarDynamicParams { - return { - referenceRect: this.getSelectionBoundingBox(), + 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; + }, }; } } diff --git a/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarFactoryTypes.ts b/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarFactoryTypes.ts index 6002c848fd..fe2e7b5e07 100644 --- a/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarFactoryTypes.ts +++ b/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarFactoryTypes.ts @@ -3,13 +3,13 @@ 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; - - referenceRect: DOMRect; }; export type HyperlinkToolbar = EditorElement; diff --git a/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts b/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts index d93e333a83..e5a74fb2c9 100644 --- a/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts +++ b/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts @@ -36,6 +36,8 @@ class HyperlinkToolbarView { hyperlinkMark: Mark | undefined; hyperlinkMarkRange: Range | undefined; + private lastPosition: DOMRect | undefined; + constructor({ editor, hyperlinkToolbarFactory }: HyperlinkToolbarViewProps) { this.editor = editor; @@ -58,7 +60,6 @@ class HyperlinkToolbarView { this.editor.view.dom.addEventListener("mouseover", this.mouseOverHandler); document.addEventListener("click", this.clickHandler, true); - document.addEventListener("scroll", this.scrollHandler); } mouseOverHandler = (event: MouseEvent) => { @@ -120,12 +121,6 @@ class HyperlinkToolbarView { } }; - scrollHandler = () => { - if (this.hyperlinkMark !== undefined) { - this.hyperlinkToolbar.render(this.getDynamicParams(), false); - } - }; - update() { if (!this.editor.view.hasFocus()) { return; @@ -220,7 +215,6 @@ class HyperlinkToolbarView { "mouseover", this.mouseOverHandler ); - document.removeEventListener("scroll", this.scrollHandler); } getStaticParams(): HyperlinkToolbarStaticParams { @@ -255,6 +249,26 @@ class HyperlinkToolbarView { 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; + }, }; } @@ -265,11 +279,6 @@ class HyperlinkToolbarView { this.hyperlinkMarkRange!.from, this.hyperlinkMarkRange!.to ), - referenceRect: posToDOMRect( - this.editor.view, - this.hyperlinkMarkRange!.from, - this.hyperlinkMarkRange!.to - ), }; } } diff --git a/packages/core/src/shared/EditorElement.ts b/packages/core/src/shared/EditorElement.ts index d085d32f0c..48c0c40593 100644 --- a/packages/core/src/shared/EditorElement.ts +++ b/packages/core/src/shared/EditorElement.ts @@ -1,7 +1,7 @@ -export type RequiredStaticParams = Record; -export type RequiredDynamicParams = Record & { - referenceRect: DOMRect; +export type RequiredStaticParams = Record & { + getReferenceRect: () => DOMRect; }; +export type RequiredDynamicParams = Record & {}; export type EditorElement = { diff --git a/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts b/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts index 0eaf7d7c20..edbed53178 100644 --- a/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts +++ b/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts @@ -107,6 +107,8 @@ class SuggestionPluginView< pluginState: SuggestionPluginState; itemCallback: (item: T) => void; + private lastPosition: DOMRect | undefined; + constructor({ editor, pluginKey, @@ -137,16 +139,8 @@ class SuggestionPluginView< }; this.suggestionsMenu = suggestionsMenuFactory(this.getStaticParams()); - - document.addEventListener("scroll", this.handleScroll); } - handleScroll = () => { - if (this.pluginKey.getState(this.editor._tiptapEditor.state).active) { - this.suggestionsMenu.render(this.getDynamicParams(), false); - } - }; - update(view: EditorView, prevState: EditorState) { const prev = this.pluginKey.getState(prevState); const next = this.pluginKey.getState(view.state); @@ -188,25 +182,37 @@ class SuggestionPluginView< } } - destroy() { - document.removeEventListener("scroll", this.handleScroll); - } - 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; + }, }; } getDynamicParams(): SuggestionsMenuDynamicParams { - const decorationNode = document.querySelector( - `[data-decoration-id="${this.pluginState.decorationId}"]` - ); - return { items: this.pluginState.items, keyboardHoveredItemIndex: this.pluginState.keyboardHoveredItemIndex!, - referenceRect: decorationNode!.getBoundingClientRect(), }; } } diff --git a/packages/core/src/shared/plugins/suggestion/SuggestionsMenuFactoryTypes.ts b/packages/core/src/shared/plugins/suggestion/SuggestionsMenuFactoryTypes.ts index faa59326c3..20fd5dda39 100644 --- a/packages/core/src/shared/plugins/suggestion/SuggestionsMenuFactoryTypes.ts +++ b/packages/core/src/shared/plugins/suggestion/SuggestionsMenuFactoryTypes.ts @@ -3,13 +3,13 @@ import { SuggestionItem } from "./SuggestionItem"; export type SuggestionsMenuStaticParams = { itemCallback: (item: T) => void; + + getReferenceRect: () => DOMRect; }; export type SuggestionsMenuDynamicParams = { items: T[]; keyboardHoveredItemIndex: number; - - referenceRect: DOMRect; }; export type SuggestionsMenu = EditorElement< diff --git a/packages/react/src/BlockNoteTheme.ts b/packages/react/src/BlockNoteTheme.ts index 2d941f3a38..e840718141 100644 --- a/packages/react/src/BlockNoteTheme.ts +++ b/packages/react/src/BlockNoteTheme.ts @@ -268,6 +268,7 @@ export const getBlockNoteTheme = ( SlashMenu: { styles: () => ({ root: { + position: "relative", ".mantine-Menu-item": { // Icon ".mantine-Menu-itemIcon": { diff --git a/packages/react/src/ElementFactory/components/EditorElementComponentWrapper.tsx b/packages/react/src/ElementFactory/components/EditorElementComponentWrapper.tsx index 3113bc55eb..6c41ee5f26 100644 --- a/packages/react/src/ElementFactory/components/EditorElementComponentWrapper.tsx +++ b/packages/react/src/ElementFactory/components/EditorElementComponentWrapper.tsx @@ -1,6 +1,6 @@ import { MantineProvider, MantineThemeOverride } from "@mantine/core"; import Tippy, { TippyProps } from "@tippyjs/react"; -import { RequiredDynamicParams } from "@blocknote/core"; +import { RequiredDynamicParams, RequiredStaticParams } from "@blocknote/core"; import { FC, useCallback, useState } from "react"; /** @@ -13,7 +13,7 @@ import { FC, useCallback, useState } from "react"; * mounted under. */ export function EditorElementComponentWrapper< - ElementStaticParams extends Record, + ElementStaticParams extends RequiredStaticParams, ElementDynamicParams extends RequiredDynamicParams >(props: { rootElement: HTMLElement; @@ -28,11 +28,6 @@ export function EditorElementComponentWrapper< const [contentCleared, setContentCleared] = useState(false); - const getReferenceClientRect = useCallback( - () => props.dynamicParams!.referenceRect, - [props.dynamicParams] - ); - const onShow = useCallback(() => { setContentCleared(false); document.body.appendChild(props.rootElement); @@ -55,9 +50,10 @@ export function EditorElementComponentWrapper< /> ) : undefined } - getReferenceClientRect={ - !contentCleared ? getReferenceClientRect : 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} diff --git a/packages/react/src/ElementFactory/components/ReactElementFactory.tsx b/packages/react/src/ElementFactory/components/ReactElementFactory.tsx index 4e21b1e1ef..79d3261269 100644 --- a/packages/react/src/ElementFactory/components/ReactElementFactory.tsx +++ b/packages/react/src/ElementFactory/components/ReactElementFactory.tsx @@ -1,7 +1,11 @@ import { FC } from "react"; import { TippyProps } from "@tippyjs/react"; import { createRoot } from "react-dom/client"; -import { EditorElement, RequiredDynamicParams } from "@blocknote/core"; +import { + EditorElement, + RequiredDynamicParams, + RequiredStaticParams, +} from "@blocknote/core"; import { EditorElementComponentWrapper } from "./EditorElementComponentWrapper"; import { MantineThemeOverride } from "@mantine/core"; @@ -19,7 +23,7 @@ import { MantineThemeOverride } from "@mantine/core"; * @param tippyProps Tippy props, which affect the elements' popup behaviour, e.g. popup position, animation, etc. */ export const ReactElementFactory = < - ElementStaticParams extends Record, + ElementStaticParams extends RequiredStaticParams, ElementDynamicParams extends RequiredDynamicParams >( staticParams: ElementStaticParams, @@ -30,8 +34,8 @@ export const ReactElementFactory = < const rootElement = document.createElement("div"); const root = createRoot(rootElement); - // Used when hiding the element. If we were to pass in undefined instead, the element would be immediately cleared, not - // leaving time for the fade out animation to complete. + // 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 { @@ -63,6 +67,8 @@ export const ReactElementFactory = < tippyProps={tippyProps} /> ); + + prevDynamicParams = undefined; }, }; };