diff --git a/examples/02-ui-components/custom-ui/ColorMenu.tsx b/examples/02-ui-components/custom-ui/ColorMenu.tsx index 439f8d45f2..9b4c5ab3c0 100644 --- a/examples/02-ui-components/custom-ui/ColorMenu.tsx +++ b/examples/02-ui-components/custom-ui/ColorMenu.tsx @@ -18,7 +18,7 @@ export const colors = [ // Formatting Toolbar sub menu for changing text and background color export const ColorMenu = ( - props: FormattingToolbarProps & HTMLAttributes + props: FormattingToolbarProps & HTMLAttributes ) => { const { editor, className, ...rest } = props; diff --git a/examples/02-ui-components/custom-ui/CustomFormattingToolbar.tsx b/examples/02-ui-components/custom-ui/CustomFormattingToolbar.tsx index 6c860cbd37..3d1abcad65 100644 --- a/examples/02-ui-components/custom-ui/CustomFormattingToolbar.tsx +++ b/examples/02-ui-components/custom-ui/CustomFormattingToolbar.tsx @@ -30,7 +30,7 @@ type CustomFormattingToolbarState = { backgroundColor: string; }; -export const CustomFormattingToolbar = (props: FormattingToolbarProps) => { +export const CustomFormattingToolbar = (props: FormattingToolbarProps) => { // Function to get the state of toolbar buttons (active/inactive) const getState = (): CustomFormattingToolbarState => { const block = props.editor.getTextCursorPosition().block; diff --git a/examples/02-ui-components/custom-ui/CustomSideMenu.tsx b/examples/02-ui-components/custom-ui/CustomSideMenu.tsx index 24cd541129..54eb96d5b4 100644 --- a/examples/02-ui-components/custom-ui/CustomSideMenu.tsx +++ b/examples/02-ui-components/custom-ui/CustomSideMenu.tsx @@ -1,9 +1,9 @@ import { BlockNoteEditor } from "@blocknote/core"; -import { SideMenuPositioner } from "@blocknote/react"; +import { DefaultPositionedSideMenu } from "@blocknote/react"; import { RxDragHandleHorizontal } from "react-icons/rx"; export const CustomSideMenu = (props: { editor: BlockNoteEditor }) => ( - ( // Side menu consists of only a drag handle diff --git a/examples/02-ui-components/custom-ui/CustomSlashMenu.tsx b/examples/02-ui-components/custom-ui/CustomSlashMenu.tsx index 61136593d9..c956f8d437 100644 --- a/examples/02-ui-components/custom-ui/CustomSlashMenu.tsx +++ b/examples/02-ui-components/custom-ui/CustomSlashMenu.tsx @@ -1,83 +1,84 @@ import { BlockNoteEditor } from "@blocknote/core"; -import { ReactSlashMenuItem, SlashMenuPositioner } from "@blocknote/react"; -import { - RiH1, - RiH2, - RiH3, - RiListOrdered, - RiListUnordered, - RiText, -} from "react-icons/ri"; +// import { ReactSlashMenuItem, SlashMenuPositioner } from "@blocknote/react"; +// import { +// RiH1, +// RiH2, +// RiH3, +// RiListOrdered, +// RiListUnordered, +// RiText, +// } from "react-icons/ri"; // Icons for slash menu items -const icons = { - Paragraph: RiText, - Heading: RiH1, - "Heading 2": RiH2, - "Heading 3": RiH3, - "Numbered List": RiListOrdered, - "Bullet List": RiListUnordered, -}; +// const icons = { +// Paragraph: RiText, +// Heading: RiH1, +// "Heading 2": RiH2, +// "Heading 3": RiH3, +// "Numbered List": RiListOrdered, +// "Bullet List": RiListUnordered, +// }; export const CustomSlashMenu = (props: { editor: BlockNoteEditor }) => { const editor = props.editor; - return ( - { - // Sorts items by group - const groups: Record = {}; - for (const item of props.filteredItems) { - if (!groups[item.group]) { - groups[item.group] = []; - } + return
TODO
; + // TODO + // { + // // Sorts items by group + // const groups: Record = {}; + // for (const item of props.filteredItems) { + // if (!groups[item.group]) { + // groups[item.group] = []; + // } - groups[item.group].push(item); - } + // groups[item.group].push(item); + // } - // If query matches no items, show "No matches" message - if (props.filteredItems.length === 0) { - return
No matches
; - } + // // If query matches no items, show "No matches" message + // if (props.filteredItems.length === 0) { + // return
No matches
; + // } - return ( -
- {Object.entries(groups).map(([group, items]) => ( - // Component for each group -
- {/*Group label*/} -
{group}
- {/*Group items*/} -
- {items.map((item) => { - const Icon = - item.name in icons - ? icons[item.name as keyof typeof icons] - : "div"; - return ( - - ); - })} -
-
- ))} -
- ); - }} - /> - ); + // return ( + //
+ // {Object.entries(groups).map(([group, items]) => ( + // // Component for each group + //
+ // {/*Group label*/} + //
{group}
+ // {/*Group items*/} + //
+ // {items.map((item) => { + // const Icon = + // item.name in icons + // ? icons[item.name as keyof typeof icons] + // : "div"; + // return ( + // + // ); + // })} + //
+ //
+ // ))} + //
+ // ); + // }} + // /> + // ); }; diff --git a/examples/02-ui-components/custom-ui/LinkMenu.tsx b/examples/02-ui-components/custom-ui/LinkMenu.tsx index 374d237e25..e951e0b93f 100644 --- a/examples/02-ui-components/custom-ui/LinkMenu.tsx +++ b/examples/02-ui-components/custom-ui/LinkMenu.tsx @@ -3,7 +3,7 @@ import { HTMLAttributes, useState } from "react"; // Formatting Toolbar sub menu for creating links export const LinkMenu = ( - props: FormattingToolbarProps & HTMLAttributes + props: FormattingToolbarProps & HTMLAttributes ) => { const { editor, className, ...rest } = props; diff --git a/examples/02-ui-components/formatting-toolbar-buttons/App.tsx b/examples/02-ui-components/formatting-toolbar-buttons/App.tsx index 8e48bbf2d1..41ed8abda3 100644 --- a/examples/02-ui-components/formatting-toolbar-buttons/App.tsx +++ b/examples/02-ui-components/formatting-toolbar-buttons/App.tsx @@ -1,28 +1,10 @@ -import { - BlockNoteView, - FormattingToolbarPositioner, - HyperlinkToolbarPositioner, - SideMenuPositioner, - SlashMenuPositioner, - useBlockNote, -} from "@blocknote/react"; +import { BlockNoteView, useBlockNote } from "@blocknote/react"; import "@blocknote/react/style.css"; -import { CustomFormattingToolbar } from "./CustomFormattingToolbar"; export default function App() { // Creates a new editor instance. const editor = useBlockNote(); // Renders the editor instance. - return ( - - - - - - - ); + return {/* TODO */}; } diff --git a/examples/02-ui-components/formatting-toolbar-buttons/CustomFormattingToolbar.tsx b/examples/02-ui-components/formatting-toolbar-buttons/CustomFormattingToolbar.tsx index 3e90a2435c..fc93b1d001 100644 --- a/examples/02-ui-components/formatting-toolbar-buttons/CustomFormattingToolbar.tsx +++ b/examples/02-ui-components/formatting-toolbar-buttons/CustomFormattingToolbar.tsx @@ -13,7 +13,7 @@ import { } from "@blocknote/react"; import { CustomButton } from "./CustomButton"; -export function CustomFormattingToolbar(props: FormattingToolbarProps) { +export function CustomFormattingToolbar(props: FormattingToolbarProps) { return ( diff --git a/examples/02-ui-components/side-menu-buttons/App.tsx b/examples/02-ui-components/side-menu-buttons/App.tsx index e7789d6a55..ceec9e3d59 100644 --- a/examples/02-ui-components/side-menu-buttons/App.tsx +++ b/examples/02-ui-components/side-menu-buttons/App.tsx @@ -1,15 +1,6 @@ -import { - BlockNoteView, - FormattingToolbarPositioner, - HyperlinkToolbarPositioner, - SideMenuPositioner, - SlashMenuPositioner, - useBlockNote, -} from "@blocknote/react"; +import { BlockNoteView, useBlockNote } from "@blocknote/react"; import "@blocknote/react/style.css"; -import { CustomSideMenu } from "./CustomSideMenu"; - export default function App() { // Creates a new editor instance. const editor = useBlockNote(); @@ -17,10 +8,11 @@ export default function App() { // Renders the editor instance. return ( + {/* TODO - + */} ); } diff --git a/examples/02-ui-components/side-menu-drag-handle-items/App.tsx b/examples/02-ui-components/side-menu-drag-handle-items/App.tsx index ef6009df4d..c25f538267 100644 --- a/examples/02-ui-components/side-menu-drag-handle-items/App.tsx +++ b/examples/02-ui-components/side-menu-drag-handle-items/App.tsx @@ -1,16 +1,6 @@ -import { - BlockNoteView, - DefaultSideMenu, - FormattingToolbarPositioner, - HyperlinkToolbarPositioner, - SideMenuPositioner, - SlashMenuPositioner, - useBlockNote, -} from "@blocknote/react"; +import { BlockNoteView, useBlockNote } from "@blocknote/react"; import "@blocknote/react/style.css"; -import { CustomDragHandleMenu } from "./CustomDragHandleMenu"; - export default function App() { // Creates a new editor instance. const editor = useBlockNote(); @@ -18,15 +8,16 @@ export default function App() { // Renders the editor instance. return ( - + {/* TODO */} + {/* ( - )} - /> + )} */} + {/* /> */} ); } diff --git a/examples/02-ui-components/slash-menu-items/App.tsx b/examples/02-ui-components/slash-menu-items/App.tsx index 5e908646e5..bbcdd03620 100644 --- a/examples/02-ui-components/slash-menu-items/App.tsx +++ b/examples/02-ui-components/slash-menu-items/App.tsx @@ -1,12 +1,12 @@ import { BlockNoteView, useBlockNote } from "@blocknote/react"; import "@blocknote/react/style.css"; -import { customSlashMenuItems } from "./CustomSlashMenuItems"; +// import { customSlashMenuItems } from "./CustomSlashMenuItems"; export default function App() { // Creates a new editor instance. const editor = useBlockNote({ - slashMenuItems: customSlashMenuItems, + // slashMenuItems: customSlashMenuItems, TODO }); // Renders the editor instance. diff --git a/examples/02-ui-components/ui-elements-remove/App.tsx b/examples/02-ui-components/ui-elements-remove/App.tsx index 6c8ff5de49..29da124b9b 100644 --- a/examples/02-ui-components/ui-elements-remove/App.tsx +++ b/examples/02-ui-components/ui-elements-remove/App.tsx @@ -1,9 +1,9 @@ import { BlockNoteView, - FormattingToolbarPositioner, - HyperlinkToolbarPositioner, - ImageToolbarPositioner, - SlashMenuPositioner, + // FormattingToolbarPositioner, + // HyperlinkToolbarPositioner, + // ImageToolbarPositioner, + // SlashMenuPositioner, useBlockNote, } from "@blocknote/react"; import "@blocknote/react/style.css"; @@ -15,10 +15,11 @@ export default function App() { // Renders the editor instance. return ( + {/* TODO - + */} ); } diff --git a/examples/02-ui-components/ui-elements-replace/App.tsx b/examples/02-ui-components/ui-elements-replace/App.tsx index 82dc3d4845..a10f466641 100644 --- a/examples/02-ui-components/ui-elements-replace/App.tsx +++ b/examples/02-ui-components/ui-elements-replace/App.tsx @@ -1,12 +1,4 @@ -import { - BlockNoteView, - FormattingToolbarPositioner, - HyperlinkToolbarPositioner, - ImageToolbarPositioner, - SideMenuPositioner, - SlashMenuPositioner, - useBlockNote, -} from "@blocknote/react"; +import { BlockNoteView, useBlockNote } from "@blocknote/react"; import "@blocknote/react/style.css"; export default function App() { @@ -16,14 +8,15 @@ export default function App() { // Renders the editor instance. return ( - + {/* TODO */} + {/*
Side Menu
} /> - + */}
); } diff --git a/examples/06-custom-schema/alert-block/Alert.tsx b/examples/06-custom-schema/alert-block/Alert.tsx index eb0970141e..21d453f68b 100644 --- a/examples/06-custom-schema/alert-block/Alert.tsx +++ b/examples/06-custom-schema/alert-block/Alert.tsx @@ -1,8 +1,8 @@ -import { DefaultBlockSchema, defaultProps } from "@blocknote/core"; -import { createReactBlockSpec, ReactSlashMenuItem } from "@blocknote/react"; -import { RiAlertFill } from "react-icons/ri"; -import { MdCancel, MdCheckCircle, MdError, MdInfo } from "react-icons/md"; +import { BlockNoteEditor, defaultProps } from "@blocknote/core"; +import { createReactBlockSpec } from "@blocknote/react"; import { Menu } from "@mantine/core"; +import { MdCancel, MdCheckCircle, MdError, MdInfo } from "react-icons/md"; +import { RiAlertFill } from "react-icons/ri"; import "./styles.css"; // The types of alerts that users can choose from @@ -114,7 +114,7 @@ export const Alert = createReactBlockSpec( // Slash menu item to insert an Alert block export const insertAlert = { name: "Alert", - execute: (editor) => { + execute: (editor: BlockNoteEditor) => { const block = editor.getTextCursorPosition().block; const blockIsEmpty = Array.isArray(block) && (block.content as any[]).length === 0; @@ -148,8 +148,4 @@ export const insertAlert = { group: "Other", icon: , hint: "Used to emphasize text", -} satisfies ReactSlashMenuItem< - DefaultBlockSchema & { - alert: (typeof Alert)["config"]; - } ->; +}; diff --git a/examples/06-custom-schema/alert-block/App.tsx b/examples/06-custom-schema/alert-block/App.tsx index 71df4bb0e4..98834e271a 100644 --- a/examples/06-custom-schema/alert-block/App.tsx +++ b/examples/06-custom-schema/alert-block/App.tsx @@ -1,20 +1,8 @@ import { defaultBlockSpecs } from "@blocknote/core"; -import { - BlockNoteView, - defaultBlockTypeDropdownItems, - DefaultFormattingToolbar, - FormattingToolbarPositioner, - getDefaultReactSlashMenuItems, - HyperlinkToolbarPositioner, - ImageToolbarPositioner, - SideMenuPositioner, - SlashMenuPositioner, - useBlockNote, -} from "@blocknote/react"; +import { BlockNoteView, useBlockNote } from "@blocknote/react"; import "@blocknote/react/style.css"; -import { RiAlertFill } from "react-icons/ri"; -import { Alert, insertAlert } from "./Alert"; +import { Alert } from "./Alert"; // Our block specs, which contain the configs and implementations for blocks // that we want our editor to use. @@ -31,13 +19,14 @@ export default function App() { // Tells BlockNote which blocks to use. blockSpecs: blockSpecsWithAlert, // Adds slash menu item to insert alert block. - slashMenuItems: [...getDefaultReactSlashMenuItems(), insertAlert], + // slashMenuItems: [...getDefaultReactSlashMenuItems(), insertAlert], TODO }); return ( {/*Adds alert item to block type dropdown in the Formatting Toolbar*/} - ( )} - /> + /> */} {/*Other toolbars & menus are defaults*/} - + + {/* - + */} ); } diff --git a/examples/06-custom-schema/react-custom-styles/App.tsx b/examples/06-custom-schema/react-custom-styles/App.tsx index 3c4a13c0d3..2420367435 100644 --- a/examples/06-custom-schema/react-custom-styles/App.tsx +++ b/examples/06-custom-schema/react-custom-styles/App.tsx @@ -7,7 +7,7 @@ import { } from "@blocknote/core"; import { BlockNoteView, - FormattingToolbarPositioner, + DefaultPositionedFormattingToolbar, Toolbar, ToolbarButton, createReactStyleSpec, @@ -113,8 +113,8 @@ export default function App() { ); return ( - - + diff --git a/examples/09-vanilla-js/react-vanilla-custom-styles/App.tsx b/examples/09-vanilla-js/react-vanilla-custom-styles/App.tsx index 32f0ad4d60..e98f666753 100644 --- a/examples/09-vanilla-js/react-vanilla-custom-styles/App.tsx +++ b/examples/09-vanilla-js/react-vanilla-custom-styles/App.tsx @@ -7,7 +7,7 @@ import { } from "@blocknote/core"; import { BlockNoteView, - FormattingToolbarPositioner, + DefaultPositionedFormattingToolbar, Toolbar, ToolbarButton, useActiveStyles, @@ -122,8 +122,8 @@ export default function App() { ); return ( - - + diff --git a/package-lock.json b/package-lock.json index 6a51f720eb..0a5a019f65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12532,18 +12532,10 @@ "version": "4.0.8", "license": "MIT" }, - "node_modules/lodash.foreach": { - "version": "4.5.0", - "license": "MIT" - }, "node_modules/lodash.get": { "version": "4.4.2", "license": "MIT" }, - "node_modules/lodash.groupby": { - "version": "4.6.0", - "license": "MIT" - }, "node_modules/lodash.ismatch": { "version": "4.4.0", "dev": true, @@ -21436,8 +21428,6 @@ "@mantine/utils": "^6.0.21", "@tiptap/core": "^2.0.3", "@tiptap/react": "^2.0.3", - "lodash.foreach": "^4.5.0", - "lodash.groupby": "^4.6.0", "lodash.merge": "^4.6.2", "react": "^18", "react-dom": "^18.2.0", diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 8308a6abd5..b0ed827414 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -30,9 +30,7 @@ import { FormattingToolbarProsemirrorPlugin } from "../extensions/FormattingTool import { HyperlinkToolbarProsemirrorPlugin } from "../extensions/HyperlinkToolbar/HyperlinkToolbarPlugin"; import { ImageToolbarProsemirrorPlugin } from "../extensions/ImageToolbar/ImageToolbarPlugin"; 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 { SuggestionMenuProseMirrorPlugin } from "../extensions/SuggestionMenu/SuggestionPlugin"; import { TableHandlesProsemirrorPlugin } from "../extensions/TableHandles/TableHandlesPlugin"; import { UniqueID } from "../extensions/UniqueID/UniqueID"; import { @@ -40,7 +38,6 @@ import { BlockNoteDOMAttributes, BlockSchema, BlockSchemaFromSpecs, - BlockSchemaWithBlock, BlockSpecs, getBlockSchemaFromSpecs, getInlineContentSchemaFromSpecs, @@ -80,13 +77,6 @@ export type BlockNoteEditorOptions< > = { // TODO: Figure out if enableBlockNoteExtensions/disableHistoryExtension are needed and document them. enableBlockNoteExtensions: boolean; - /** - * - * (couldn't fix any type, see https://github.com/TypeCellOS/BlockNote/pull/191#discussion_r1210708771) - * - * @default defaultSlashMenuItems from `./extensions/SlashMenu` - */ - slashMenuItems: BaseSlashMenuItem[]; /** * An object containing attributes that should be added to HTML elements of the editor. @@ -190,12 +180,6 @@ export class BlockNoteEditor< SSchema >; public readonly formattingToolbar: FormattingToolbarProsemirrorPlugin; - public readonly slashMenu: SlashMenuProsemirrorPlugin< - BSchema, - ISchema, - SSchema, - any - >; public readonly hyperlinkToolbar: HyperlinkToolbarProsemirrorPlugin< BSchema, ISchema, @@ -207,18 +191,15 @@ export class BlockNoteEditor< SSchema >; public readonly tableHandles: - | TableHandlesProsemirrorPlugin< - BSchema extends BlockSchemaWithBlock< - "table", - DefaultBlockSchema["table"] - > - ? BSchema - : any, - ISchema, - SSchema - > + | TableHandlesProsemirrorPlugin | undefined; + public readonly suggestionMenus: SuggestionMenuProseMirrorPlugin< + BSchema, + ISchema, + SSchema + >; + public readonly uploadFile: ((file: File) => Promise) | undefined; public static create< @@ -282,11 +263,9 @@ export class BlockNoteEditor< this.sideMenu = new SideMenuProsemirrorPlugin(this); this.formattingToolbar = new FormattingToolbarProsemirrorPlugin(this); - this.slashMenu = new SlashMenuProsemirrorPlugin( - this, - newOptions.slashMenuItems || - (getDefaultSlashMenuItems(this.blockSchema) as any) - ); + + this.suggestionMenus = new SuggestionMenuProseMirrorPlugin(this); + this.hyperlinkToolbar = new HyperlinkToolbarProsemirrorPlugin(this); this.imageToolbar = new ImageToolbarProsemirrorPlugin(this); @@ -311,9 +290,9 @@ export class BlockNoteEditor< return [ this.sideMenu.plugin, this.formattingToolbar.plugin, - this.slashMenu.plugin, this.hyperlinkToolbar.plugin, this.imageToolbar.plugin, + this.suggestionMenus.plugin, ...(this.tableHandles ? [this.tableHandles.plugin] : []), ]; }, diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index ec2277e5b9..902ed5d498 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -66,6 +66,7 @@ export const getBlockNoteExtensions = < // DropCursor, Placeholder.configure({ + editor: opts.editor, includeChildren: true, showOnlyCurrent: false, }), diff --git a/packages/core/src/extensions-shared/BaseUiElementTypes.ts b/packages/core/src/extensions-shared/BaseUiElementTypes.ts deleted file mode 100644 index 4ca4cc9f4c..0000000000 --- a/packages/core/src/extensions-shared/BaseUiElementTypes.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type BaseUiElementCallbacks = { - destroy: () => void; -}; - -export type BaseUiElementState = { - show: boolean; - referencePos: DOMRect; -}; diff --git a/packages/core/src/extensions-shared/README.md b/packages/core/src/extensions-shared/README.md deleted file mode 100644 index 89c300fd7d..0000000000 --- a/packages/core/src/extensions-shared/README.md +++ /dev/null @@ -1,3 +0,0 @@ -### @blocknote/core/src/extensions-shared - -Helper functions / base plugins for @blocknote/core/src/extensions \ No newline at end of file diff --git a/packages/core/src/extensions-shared/UiElementPosition.ts b/packages/core/src/extensions-shared/UiElementPosition.ts new file mode 100644 index 0000000000..59b0a61463 --- /dev/null +++ b/packages/core/src/extensions-shared/UiElementPosition.ts @@ -0,0 +1,4 @@ +export type UiElementPosition = { + show: boolean; + referencePos: DOMRect; +}; diff --git a/packages/core/src/extensions-shared/suggestion/SuggestionItem.ts b/packages/core/src/extensions-shared/suggestion/SuggestionItem.ts deleted file mode 100644 index aebd7be2cd..0000000000 --- a/packages/core/src/extensions-shared/suggestion/SuggestionItem.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type SuggestionItem = { - name: string; -}; diff --git a/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts b/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts deleted file mode 100644 index 23ff2ad1b6..0000000000 --- a/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts +++ /dev/null @@ -1,448 +0,0 @@ -import { findParentNode } from "@tiptap/core"; -import { EditorState, Plugin, PluginKey } from "prosemirror-state"; -import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; -import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; -import { BaseUiElementState } from "../BaseUiElementTypes"; -import { SuggestionItem } from "./SuggestionItem"; - -const findBlock = findParentNode((node) => node.type.name === "blockContainer"); - -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; - }; - -class SuggestionsMenuView< - T extends SuggestionItem, - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema -> { - private suggestionsMenuState?: SuggestionsMenuState; - public updateSuggestionsMenu: () => void; - - pluginState: SuggestionPluginState; - - constructor( - private readonly editor: BlockNoteEditor, - private readonly pluginKey: PluginKey, - updateSuggestionsMenu: ( - suggestionsMenuState: SuggestionsMenuState - ) => void = () => { - // noop - } - ) { - this.pluginState = getDefaultPluginState(); - - this.updateSuggestionsMenu = () => { - if (!this.suggestionsMenuState) { - throw new Error("Attempting to update uninitialized suggestions menu"); - } - - updateSuggestionsMenu(this.suggestionsMenuState); - }; - - 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) { - const prev = this.pluginKey.getState(prevState); - const next = this.pluginKey.getState(view.state); - - // See how the state changed - const started = !prev.active && next.active; - const stopped = prev.active && !next.active; - // TODO: Currently also true for cases in which an update isn't needed so selected list item index updates still - // cause the view to update. May need to be more strict. - const changed = prev.active && next.active; - - // Cancel when suggestion isn't active - if (!started && !changed && !stopped) { - return; - } - - this.pluginState = stopped ? prev : next; - - if (stopped || !this.editor.isEditable) { - this.suggestionsMenuState!.show = false; - this.updateSuggestionsMenu(); - - return; - } - - const decorationNode = document.querySelector( - `[data-decoration-id="${this.pluginState.decorationId}"]` - ); - - if (this.editor.isEditable) { - this.suggestionsMenuState = { - show: true, - referencePos: decorationNode!.getBoundingClientRect(), - filteredItems: this.pluginState.items, - keyboardHoveredItemIndex: this.pluginState.keyboardHoveredItemIndex!, - }; - - this.updateSuggestionsMenu(); - } - } - - destroy() { - document.removeEventListener("scroll", this.handleScroll); - } -} - -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, - }; -} - -/** - * A ProseMirror plugin for suggestions, designed to make '/'-commands possible as well as mentions. - * - * This is basically a simplified version of TipTap's [Suggestions](https://github.com/ueberdosis/tiptap/tree/db92a9b313c5993b723c85cd30256f1d4a0b65e1/packages/suggestion) plugin. - * - * This version is adapted from the aforementioned version in the following ways: - * - 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 - */ -export const setupSuggestionsMenu = < - T extends SuggestionItem, - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema ->( - 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 })); - }; - - return { - plugin: new Plugin({ - key: pluginKey, - - view: () => { - suggestionsPluginView = new SuggestionsMenuView( - editor, - pluginKey, - - updateSuggestionsMenu - ); - return suggestionsPluginView; - }, - - 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 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 - ) - ); - - // 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(); - } - - // 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; - }, - }, - - 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, - }) - ); - - 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") { - if (items.length === 0) { - return true; - } - - deactivate(view); - editor._tiptapEditor - .chain() - .focus() - .deleteRange({ - from: queryStartPos! - triggerCharacter!.length, - to: editor._tiptapEditor.state.selection.from, - }) - .run(); - - onSelectItem({ - item: items[keyboardHoveredItemIndex], - editor: editor, - }); - - return true; - } - - // Closes the menu. - if (event.key === "Escape") { - deactivate(view); - return true; - } - - return false; - }, - - // 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: "bn-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: "bn-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/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts index dac58aae43..5a04c3a39d 100644 --- a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts @@ -3,20 +3,15 @@ import { EditorState, Plugin, PluginKey } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; -import { - BaseUiElementCallbacks, - BaseUiElementState, -} from "../../extensions-shared/BaseUiElementTypes"; import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; +import { UiElementPosition } from "../../extensions-shared/UiElementPosition"; import { EventEmitter } from "../../util/EventEmitter"; -export type FormattingToolbarCallbacks = BaseUiElementCallbacks; - -export type FormattingToolbarState = BaseUiElementState; +export type FormattingToolbarState = UiElementPosition; export class FormattingToolbarView { - private formattingToolbarState?: FormattingToolbarState; - public updateFormattingToolbar: () => void; + public state?: FormattingToolbarState; + public emitUpdate: () => void; public preventHide = false; public preventShow = false; @@ -36,18 +31,16 @@ export class FormattingToolbarView { StyleSchema >, private readonly pmView: EditorView, - updateFormattingToolbar: ( - formattingToolbarState: FormattingToolbarState - ) => void + emitUpdate: (state: FormattingToolbarState) => void ) { - this.updateFormattingToolbar = () => { - if (!this.formattingToolbarState) { + this.emitUpdate = () => { + if (!this.state) { throw new Error( "Attempting to update uninitialized formatting toolbar" ); } - updateFormattingToolbar(this.formattingToolbarState); + emitUpdate(this.state); }; pmView.dom.addEventListener("mousedown", this.viewMousedownHandler); @@ -72,9 +65,9 @@ export class FormattingToolbarView { // For dragging the whole editor. dragHandler = () => { - if (this.formattingToolbarState?.show) { - this.formattingToolbarState.show = false; - this.updateFormattingToolbar(); + if (this.state?.show) { + this.state.show = false; + this.emitUpdate(); } }; @@ -105,16 +98,16 @@ export class FormattingToolbarView { return; } - if (this.formattingToolbarState?.show) { - this.formattingToolbarState.show = false; - this.updateFormattingToolbar(); + if (this.state?.show) { + this.state.show = false; + this.emitUpdate(); } }; scrollHandler = () => { - if (this.formattingToolbarState?.show) { - this.formattingToolbarState.referencePos = this.getSelectionBoundingBox(); - this.updateFormattingToolbar(); + if (this.state?.show) { + this.state.referencePos = this.getSelectionBoundingBox(); + this.emitUpdate(); } }; @@ -152,24 +145,24 @@ export class FormattingToolbarView { !this.preventShow && (shouldShow || this.preventHide) ) { - this.formattingToolbarState = { + this.state = { show: true, referencePos: this.getSelectionBoundingBox(), }; - this.updateFormattingToolbar(); + this.emitUpdate(); return; } // Checks if menu should be hidden. if ( - this.formattingToolbarState?.show && + this.state?.show && !this.preventHide && (!shouldShow || this.preventShow || !this.editor.isEditable) ) { - this.formattingToolbarState.show = false; - this.updateFormattingToolbar(); + this.state.show = false; + this.emitUpdate(); return; } diff --git a/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts b/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts index 30aa623af3..c0d524c0b4 100644 --- a/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts +++ b/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts @@ -2,12 +2,13 @@ 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 type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; -import { BaseUiElementState } from "../../extensions-shared/BaseUiElementTypes"; import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; +import { UiElementPosition } from "../../extensions-shared/UiElementPosition"; import { EventEmitter } from "../../util/EventEmitter"; -export type HyperlinkToolbarState = BaseUiElementState & { +export type HyperlinkToolbarState = UiElementPosition & { // The hovered hyperlink's URL, and the text it's displayed with in the // editor. url: string; @@ -15,8 +16,8 @@ export type HyperlinkToolbarState = BaseUiElementState & { }; class HyperlinkToolbarView { - private hyperlinkToolbarState?: HyperlinkToolbarState; - public updateHyperlinkToolbar: () => void; + public state?: HyperlinkToolbarState; + public emitUpdate: () => void; menuUpdateTimer: ReturnType | undefined; startMenuUpdateTimer: () => void; @@ -34,16 +35,14 @@ class HyperlinkToolbarView { constructor( private readonly editor: BlockNoteEditor, private readonly pmView: EditorView, - updateHyperlinkToolbar: ( - hyperlinkToolbarState: HyperlinkToolbarState - ) => void + emitUpdate: (state: HyperlinkToolbarState) => void ) { - this.updateHyperlinkToolbar = () => { - if (!this.hyperlinkToolbarState) { + this.emitUpdate = () => { + if (!this.state) { throw new Error("Attempting to update uninitialized hyperlink toolbar"); } - updateHyperlinkToolbar(this.hyperlinkToolbarState); + emitUpdate(this.state); }; this.startMenuUpdateTimer = () => { @@ -124,22 +123,22 @@ class HyperlinkToolbarView { editorWrapper.contains(event.target as Node) ) ) { - if (this.hyperlinkToolbarState?.show) { - this.hyperlinkToolbarState.show = false; - this.updateHyperlinkToolbar(); + if (this.state?.show) { + this.state.show = false; + this.emitUpdate(); } } }; scrollHandler = () => { if (this.hyperlinkMark !== undefined) { - if (this.hyperlinkToolbarState?.show) { - this.hyperlinkToolbarState.referencePos = posToDOMRect( + if (this.state?.show) { + this.state.referencePos = posToDOMRect( this.pmView, this.hyperlinkMarkRange!.from, this.hyperlinkMarkRange!.to ); - this.updateHyperlinkToolbar(); + this.emitUpdate(); } } }; @@ -158,9 +157,9 @@ class HyperlinkToolbarView { this.pmView.dispatch(tr); this.pmView.focus(); - if (this.hyperlinkToolbarState?.show) { - this.hyperlinkToolbarState.show = false; - this.updateHyperlinkToolbar(); + if (this.state?.show) { + this.state.show = false; + this.emitUpdate(); } } @@ -176,9 +175,9 @@ class HyperlinkToolbarView { ); this.pmView.focus(); - if (this.hyperlinkToolbarState?.show) { - this.hyperlinkToolbarState.show = false; - this.updateHyperlinkToolbar(); + if (this.state?.show) { + this.state.show = false; + this.emitUpdate(); } } @@ -232,7 +231,7 @@ class HyperlinkToolbarView { } if (this.hyperlinkMark && this.editor.isEditable) { - this.hyperlinkToolbarState = { + this.state = { show: true, referencePos: posToDOMRect( this.pmView, @@ -245,19 +244,19 @@ class HyperlinkToolbarView { this.hyperlinkMarkRange!.to ), }; - this.updateHyperlinkToolbar(); + this.emitUpdate(); return; } // Hides menu. if ( - this.hyperlinkToolbarState?.show && + this.state?.show && prevHyperlinkMark && (!this.hyperlinkMark || !this.editor.isEditable) ) { - this.hyperlinkToolbarState.show = false; - this.updateHyperlinkToolbar(); + this.state.show = false; + this.emitUpdate(); return; } diff --git a/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts b/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts index 98fe041755..abc9b0bee7 100644 --- a/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts +++ b/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts @@ -1,7 +1,6 @@ import { EditorState, Plugin, PluginKey } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; -import { EventEmitter } from "../../util/EventEmitter"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; import { BlockSchema, @@ -9,17 +8,14 @@ import { SpecificBlock, StyleSchema, } from "../../schema"; -import { - BaseUiElementCallbacks, - BaseUiElementState, -} from "../../extensions-shared/BaseUiElementTypes"; -export type ImageToolbarCallbacks = BaseUiElementCallbacks; +import { UiElementPosition } from "../../extensions-shared/UiElementPosition"; +import { EventEmitter } from "../../util/EventEmitter"; export type ImageToolbarState< B extends BlockSchema, I extends InlineContentSchema, - S extends StyleSchema = StyleSchema -> = BaseUiElementState & { + S extends StyleSchema +> = UiElementPosition & { block: SpecificBlock; }; @@ -28,24 +24,22 @@ export class ImageToolbarView< I extends InlineContentSchema, S extends StyleSchema > { - private imageToolbarState?: ImageToolbarState; - public updateImageToolbar: () => void; + public state?: ImageToolbarState; + public emitUpdate: () => void; public prevWasEditable: boolean | null = null; constructor( private readonly pluginKey: PluginKey, private readonly pmView: EditorView, - updateImageToolbar: ( - imageToolbarState: ImageToolbarState - ) => void + emitUpdate: (state: ImageToolbarState) => void ) { - this.updateImageToolbar = () => { - if (!this.imageToolbarState) { + this.emitUpdate = () => { + if (!this.state) { throw new Error("Attempting to update uninitialized image toolbar"); } - updateImageToolbar(this.imageToolbarState); + emitUpdate(this.state); }; pmView.dom.addEventListener("mousedown", this.mouseDownHandler); @@ -58,17 +52,17 @@ export class ImageToolbarView< } mouseDownHandler = () => { - if (this.imageToolbarState?.show) { - this.imageToolbarState.show = false; - this.updateImageToolbar(); + if (this.state?.show) { + this.state.show = false; + this.emitUpdate(); } }; // For dragging the whole editor. dragstartHandler = () => { - if (this.imageToolbarState?.show) { - this.imageToolbarState.show = false; - this.updateImageToolbar(); + if (this.state?.show) { + this.state.show = false; + this.emitUpdate(); } }; @@ -88,21 +82,20 @@ export class ImageToolbarView< return; } - if (this.imageToolbarState?.show) { - this.imageToolbarState.show = false; - this.updateImageToolbar(); + if (this.state?.show) { + this.state.show = false; + this.emitUpdate(); } }; scrollHandler = () => { - if (this.imageToolbarState?.show) { + if (this.state?.show) { const blockElement = document.querySelector( - `[data-node-type="blockContainer"][data-id="${this.imageToolbarState.block.id}"]` + `[data-node-type="blockContainer"][data-id="${this.state.block.id}"]` )!; - this.imageToolbarState.referencePos = - blockElement.getBoundingClientRect(); - this.updateImageToolbar(); + this.state.referencePos = blockElement.getBoundingClientRect(); + this.emitUpdate(); } }; @@ -111,18 +104,18 @@ export class ImageToolbarView< block: SpecificBlock; } = this.pluginKey.getState(view.state); - if (!this.imageToolbarState?.show && pluginState.block) { + if (!this.state?.show && pluginState.block) { const blockElement = document.querySelector( `[data-node-type="blockContainer"][data-id="${pluginState.block.id}"]` )!; - this.imageToolbarState = { + this.state = { show: true, referencePos: blockElement.getBoundingClientRect(), block: pluginState.block, }; - this.updateImageToolbar(); + this.emitUpdate(); return; } @@ -131,10 +124,10 @@ export class ImageToolbarView< !view.state.selection.eq(prevState.selection) || !view.state.doc.eq(prevState.doc) ) { - if (this.imageToolbarState?.show) { - this.imageToolbarState.show = false; + if (this.state?.show) { + this.state.show = false; - this.updateImageToolbar(); + this.emitUpdate(); } } } diff --git a/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts b/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts index 4bfa6ec41f..71e80d75b3 100644 --- a/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts +++ b/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts @@ -2,7 +2,8 @@ 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/SlashMenuPlugin"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { suggestionMenuPluginKey } from "../SuggestionMenu/SuggestionPlugin"; const PLUGIN_KEY = new PluginKey(`blocknote-placeholder`); @@ -14,6 +15,7 @@ const PLUGIN_KEY = new PluginKey(`blocknote-placeholder`); * */ export interface PlaceholderOptions { + editor: BlockNoteEditor | undefined; emptyEditorClass: string; emptyNodeClass: string; isFilterClass: string; @@ -36,6 +38,7 @@ export const Placeholder = Extension.create({ addOptions() { return { + editor: undefined, emptyEditorClass: "bn-is-editor-empty", emptyNodeClass: "bn-is-empty", isFilterClass: "bn-is-filter", @@ -55,7 +58,7 @@ export const Placeholder = Extension.create({ decorations: (state) => { const { doc, selection } = state; // Get state of slash menu - const menuState = slashMenuPluginKey.getState(state); + const menuState = suggestionMenuPluginKey.getState(state); const active = this.editor.isEditable || !this.options.showOnlyWhenEditable; const { anchor } = selection; diff --git a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts index 76815c5ab0..2b55e8c273 100644 --- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts +++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts @@ -2,16 +2,17 @@ import { PluginView } from "@tiptap/pm/state"; import { Node } from "prosemirror-model"; import { NodeSelection, Plugin, PluginKey, Selection } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; + import { createExternalHTMLExporter } from "../../api/exporters/html/externalHTMLExporter"; import { createInternalHTMLSerializer } from "../../api/exporters/html/internalHTMLSerializer"; import { cleanHTMLToMarkdown } from "../../api/exporters/markdown/markdownExporter"; import { getBlockInfoFromPos } from "../../api/getBlockInfoFromPos"; import { Block } from "../../blocks/defaultBlocks"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; -import { BaseUiElementState } from "../../extensions-shared/BaseUiElementTypes"; +import { UiElementPosition } from "../../extensions-shared/UiElementPosition"; import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; import { EventEmitter } from "../../util/EventEmitter"; -import { slashMenuPluginKey } from "../SlashMenu/SlashMenuPlugin"; +import { suggestionMenuPluginKey } from "../SuggestionMenu/SuggestionPlugin"; import { MultipleNodeSelection } from "./MultipleNodeSelection"; let dragImageElement: Element | undefined; @@ -20,7 +21,7 @@ export type SideMenuState< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema -> = BaseUiElementState & { +> = UiElementPosition & { // The block that the side menu is attached to. block: Block; }; @@ -251,7 +252,8 @@ export class SideMenuView< S extends StyleSchema > implements PluginView { - private sideMenuState?: SideMenuState; + private state?: SideMenuState; + private readonly emitUpdate: (state: SideMenuState) => void; // 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 @@ -269,10 +271,16 @@ export class SideMenuView< constructor( private readonly editor: BlockNoteEditor, private readonly pmView: EditorView, - private readonly updateSideMenu: ( - sideMenuState: SideMenuState - ) => void + emitUpdate: (state: SideMenuState) => void ) { + this.emitUpdate = () => { + if (!this.state) { + throw new Error("Attempting to update uninitialized side menu"); + } + + emitUpdate(this.state); + }; + this.horizontalPosAnchoredAtRoot = true; this.horizontalPosAnchor = ( this.pmView.dom.firstChild! as HTMLElement @@ -365,17 +373,17 @@ export class SideMenuView< }; onKeyDown = (_event: KeyboardEvent) => { - if (this.sideMenuState?.show) { - this.sideMenuState.show = false; - this.updateSideMenu(this.sideMenuState); + if (this.state?.show) { + this.state.show = false; + this.emitUpdate(this.state); } this.menuFrozen = false; }; onMouseDown = (_event: MouseEvent) => { - if (this.sideMenuState && !this.sideMenuState.show) { - this.sideMenuState.show = true; - this.updateSideMenu(this.sideMenuState); + if (this.state && !this.state.show) { + this.state.show = true; + this.emitUpdate(this.state); } this.menuFrozen = false; }; @@ -417,9 +425,9 @@ export class SideMenuView< editorWrapper.contains(event.target as HTMLElement) ) ) { - if (this.sideMenuState?.show) { - this.sideMenuState.show = false; - this.updateSideMenu(this.sideMenuState); + if (this.state?.show) { + this.state.show = false; + this.emitUpdate(this.state); } return; @@ -436,9 +444,9 @@ export class SideMenuView< // 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); + if (this.state?.show) { + this.state.show = false; + this.emitUpdate(this.state); } return; @@ -446,7 +454,7 @@ export class SideMenuView< // Doesn't update if the menu is already open and the mouse cursor is still hovering the same block. if ( - this.sideMenuState?.show && + this.state?.show && this.hoveredBlock?.hasAttribute("data-id") && this.hoveredBlock?.getAttribute("data-id") === block.id ) { @@ -466,7 +474,7 @@ export class SideMenuView< if (this.editor.isEditable) { const blockContentBoundingBox = blockContent.getBoundingClientRect(); - this.sideMenuState = { + this.state = { show: true, referencePos: new DOMRect( this.horizontalPosAnchoredAtRoot @@ -481,16 +489,16 @@ export class SideMenuView< )!, }; - this.updateSideMenu(this.sideMenuState); + this.emitUpdate(this.state); } }; onScroll = () => { - if (this.sideMenuState?.show) { + if (this.state?.show) { const blockContent = this.hoveredBlock!.firstChild as HTMLElement; const blockContentBoundingBox = blockContent.getBoundingClientRect(); - this.sideMenuState.referencePos = new DOMRect( + this.state.referencePos = new DOMRect( this.horizontalPosAnchoredAtRoot ? this.horizontalPosAnchor : blockContentBoundingBox.x, @@ -498,14 +506,14 @@ export class SideMenuView< blockContentBoundingBox.width, blockContentBoundingBox.height ); - this.updateSideMenu(this.sideMenuState); + this.emitUpdate(this.state); } }; destroy() { - if (this.sideMenuState?.show) { - this.sideMenuState.show = false; - this.updateSideMenu(this.sideMenuState); + if (this.state?.show) { + this.state.show = false; + this.emitUpdate(this.state); } document.body.removeEventListener("mousemove", this.onMouseMove); document.body.removeEventListener("dragover", this.onDragOver); @@ -517,9 +525,9 @@ export class SideMenuView< } addBlock() { - if (this.sideMenuState?.show) { - this.sideMenuState.show = false; - this.updateSideMenu(this.sideMenuState); + if (this.state?.show) { + this.state.show = false; + this.emitUpdate(this.state); } this.menuFrozen = true; @@ -556,7 +564,7 @@ export class SideMenuView< this.editor._tiptapEditor .chain() .BNCreateBlock(newBlockInsertionPos) - .BNUpdateBlock(newBlockContentPos, { type: "paragraph", props: {} }) + // .BNUpdateBlock(newBlockContentPos, { type: "paragraph", props: {} }) .setTextSelection(newBlockContentPos) .run(); } else { @@ -566,10 +574,9 @@ export class SideMenuView< // 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", + this.pmView.state.tr.scrollIntoView().setMeta(suggestionMenuPluginKey, { + triggerCharacter: "/", + fromUserInput: false, }) ); } @@ -582,7 +589,7 @@ export class SideMenuProsemirrorPlugin< I extends InlineContentSchema, S extends StyleSchema > extends EventEmitter { - private sideMenuView: SideMenuView | undefined; + public view: SideMenuView | undefined; public readonly plugin: Plugin; constructor(private readonly editor: BlockNoteEditor) { @@ -590,14 +597,10 @@ export class SideMenuProsemirrorPlugin< this.plugin = new Plugin({ key: sideMenuPluginKey, view: (editorView) => { - this.sideMenuView = new SideMenuView( - editor, - editorView, - (sideMenuState) => { - this.emit("update", sideMenuState); - } - ); - return this.sideMenuView; + this.view = new SideMenuView(editor, editorView, (state) => { + this.emit("update", state); + }); + return this.view; }, }); } @@ -610,7 +613,7 @@ export class SideMenuProsemirrorPlugin< * 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(); + addBlock = () => this.view!.addBlock(); /** * Handles drag & drop events for blocks. @@ -619,7 +622,7 @@ export class SideMenuProsemirrorPlugin< dataTransfer: DataTransfer | null; clientY: number; }) => { - this.sideMenuView!.isDragging = true; + this.view!.isDragging = true; dragStart(event, this.editor); }; @@ -632,11 +635,11 @@ export class SideMenuProsemirrorPlugin< * attached to the same block regardless of which block is hovered by the * mouse cursor. */ - freezeMenu = () => (this.sideMenuView!.menuFrozen = true); + freezeMenu = () => (this.view!.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); + unfreezeMenu = () => (this.view!.menuFrozen = false); } diff --git a/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts b/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts deleted file mode 100644 index 42d42bebbd..0000000000 --- a/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; -import { SuggestionItem } from "../../extensions-shared/suggestion/SuggestionItem"; -import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; - -export type BaseSlashMenuItem< - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema -> = SuggestionItem & { - execute: (editor: BlockNoteEditor) => void; - aliases?: string[]; -}; diff --git a/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts b/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts deleted file mode 100644 index c58a32cbce..0000000000 --- a/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Plugin, PluginKey } from "prosemirror-state"; - -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; -import { - SuggestionsMenuState, - setupSuggestionsMenu, -} from "../../extensions-shared/suggestion/SuggestionPlugin"; -import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; -import { EventEmitter } from "../../util/EventEmitter"; -import { BaseSlashMenuItem } from "./BaseSlashMenuItem"; - -export const slashMenuPluginKey = new PluginKey("SlashMenuPlugin"); - -export class SlashMenuProsemirrorPlugin< - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, - 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/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts deleted file mode 100644 index bdc88415c1..0000000000 --- a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { - Block, - PartialBlock, - defaultBlockSchema, -} from "../../blocks/defaultBlocks"; -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; -import { - BlockSchema, - InlineContentSchema, - StyleSchema, - isStyledTextInlineContent, -} from "../../schema"; -import { imageToolbarPluginKey } from "../ImageToolbar/ImageToolbarPlugin"; -import { BaseSlashMenuItem } from "./BaseSlashMenuItem"; - -// Sets the editor's text cursor position to the next content editable block, -// so either a block with inline content or a table. The last block is always a -// paragraph, so this function won't try to set the cursor position past the -// last block. -function setSelectionToNextContentEditableBlock< - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema ->(editor: BlockNoteEditor) { - let block = editor.getTextCursorPosition().block; - let contentType = editor.blockSchema[block.type].content; - - while (contentType === "none") { - block = editor.getTextCursorPosition().nextBlock!; - contentType = editor.blockSchema[block.type].content as - | "inline" - | "table" - | "none"; - editor.setTextCursorPosition(block, "end"); - } -} - -// Checks if the current block is empty or only contains a slash, and if so, -// updates the current block instead of inserting a new one below. If the new -// block doesn't contain editable content, the cursor is moved to the next block -// that does. -function insertOrUpdateBlock< - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema ->( - editor: BlockNoteEditor, - block: PartialBlock -): Block { - const currentBlock = editor.getTextCursorPosition().block; - - if (currentBlock.content === undefined) { - throw new Error("Slash Menu open in a block that doesn't contain content."); - } - - if ( - Array.isArray(currentBlock.content) && - ((currentBlock.content.length === 1 && - isStyledTextInlineContent(currentBlock.content[0]) && - 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!, - "end" - ); - } - - const insertedBlock = editor.getTextCursorPosition().block; - setSelectionToNextContentEditableBlock(editor); - - return insertedBlock; -} - -export const getDefaultSlashMenuItems = < - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema ->( - 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", - }), - }); - } - - if ("numberedListItem" in schema) { - slashMenuItems.push({ - name: "Numbered List", - aliases: ["li", "list", "numberedlist", "numbered list"], - execute: (editor) => - insertOrUpdateBlock(editor, { - type: "numberedListItem", - }), - }); - } - - if ("paragraph" in schema) { - slashMenuItems.push({ - name: "Paragraph", - aliases: ["p"], - execute: (editor) => - insertOrUpdateBlock(editor, { - type: "paragraph", - }), - }); - } - - if ("table" in schema) { - slashMenuItems.push({ - name: "Table", - aliases: ["table"], - execute: (editor) => { - insertOrUpdateBlock(editor, { - type: "table", - content: { - type: "tableContent", - rows: [ - { - cells: ["", "", ""], - }, - { - cells: ["", "", ""], - }, - ], - }, - } as PartialBlock); - }, - }); - } - - if ("image" in schema) { - slashMenuItems.push({ - name: "Image", - aliases: [ - "image", - "imageUpload", - "upload", - "img", - "picture", - "media", - "url", - "drive", - "dropbox", - ], - execute: (editor) => { - const insertedBlock = insertOrUpdateBlock(editor, { - type: "image", - }); - - // Immediately open the image toolbar - editor._tiptapEditor.view.dispatch( - editor._tiptapEditor.state.tr.setMeta(imageToolbarPluginKey, { - block: insertedBlock, - }) - ); - }, - }); - } - - return slashMenuItems; -}; diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts new file mode 100644 index 0000000000..74f4e11572 --- /dev/null +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts @@ -0,0 +1,353 @@ +import { findParentNode } from "@tiptap/core"; +import { EditorState, Plugin, PluginKey } from "prosemirror-state"; +import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; + +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; +import { UiElementPosition } from "../../extensions-shared/UiElementPosition"; +import { EventEmitter } from "../../util/EventEmitter"; + +const findBlock = findParentNode((node) => node.type.name === "blockContainer"); + +export type SuggestionMenuState = UiElementPosition & { + query: string; +}; + +class SuggestionMenuView< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> { + private state?: SuggestionMenuState; + public emitUpdate: (triggerCharacter: string) => void; + + pluginState: SuggestionPluginState; + + constructor( + private readonly editor: BlockNoteEditor, + emitUpdate: (menuName: string, state: SuggestionMenuState) => void + ) { + this.pluginState = undefined; + + this.emitUpdate = (menuName: string) => { + if (!this.state) { + throw new Error("Attempting to update uninitialized suggestions menu"); + } + + emitUpdate(menuName, this.state); + }; + + document.addEventListener("scroll", this.handleScroll); + } + + handleScroll = () => { + if (this.state?.show) { + const decorationNode = document.querySelector( + `[data-decoration-id="${this.pluginState!.decorationId}"]` + ); + this.state.referencePos = decorationNode!.getBoundingClientRect(); + this.emitUpdate(this.pluginState!.triggerCharacter!); + } + }; + + update(view: EditorView, prevState: EditorState) { + const prev: SuggestionPluginState = + suggestionMenuPluginKey.getState(prevState); + const next: SuggestionPluginState = suggestionMenuPluginKey.getState( + view.state + ); + + // See how the state changed + const started = prev === undefined && next !== undefined; + const stopped = prev !== undefined && next === undefined; + const changed = prev !== undefined && next !== undefined; + + // Cancel when suggestion isn't active + if (!started && !changed && !stopped) { + return; + } + + this.pluginState = stopped ? prev : next; + + if (stopped || !this.editor.isEditable) { + this.state!.show = false; + this.emitUpdate(this.pluginState!.triggerCharacter); + + return; + } + + const decorationNode = document.querySelector( + `[data-decoration-id="${this.pluginState!.decorationId}"]` + ); + + if (this.editor.isEditable) { + this.state = { + show: true, + referencePos: decorationNode!.getBoundingClientRect(), + query: this.pluginState!.query, + }; + + this.emitUpdate(this.pluginState!.triggerCharacter!); + } + } + + destroy() { + document.removeEventListener("scroll", this.handleScroll); + } + + closeMenu = () => { + this.editor._tiptapEditor.view.dispatch( + this.editor._tiptapEditor.view.state.tr.setMeta( + suggestionMenuPluginKey, + null + ) + ); + }; + + clearQuery = () => { + if (this.pluginState === undefined) { + return; + } + + this.editor._tiptapEditor + .chain() + .focus() + .deleteRange({ + from: + this.pluginState.queryStartPos! - + (this.pluginState.fromUserInput + ? this.pluginState.triggerCharacter!.length + : 0), + to: this.editor._tiptapEditor.state.selection.from, + }) + .run(); + }; +} + +type SuggestionPluginState = + | { + triggerCharacter: string; + fromUserInput: boolean; + queryStartPos: number; + query: string; + decorationId: string; + } + | undefined; + +export const suggestionMenuPluginKey = new PluginKey("SuggestionMenuPlugin"); + +/** + * A ProseMirror plugin for suggestions, designed to make '/'-commands possible as well as mentions. + * + * This is basically a simplified version of TipTap's [Suggestions](https://github.com/ueberdosis/tiptap/tree/db92a9b313c5993b723c85cd30256f1d4a0b65e1/packages/suggestion) plugin. + * + * This version is adapted from the aforementioned version in the following ways: + * - 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 + */ +export class SuggestionMenuProseMirrorPlugin< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> extends EventEmitter { + private view: SuggestionMenuView | undefined; + public readonly plugin: Plugin; + + private triggerCharacters: string[] = []; + + constructor(editor: BlockNoteEditor) { + super(); + const triggerCharacters = this.triggerCharacters; + this.plugin = new Plugin({ + key: suggestionMenuPluginKey, + + view: () => { + this.view = new SuggestionMenuView( + editor, + (triggerCharacter, state) => { + this.emit(`update ${triggerCharacter}`, state); + } + ); + return this.view; + }, + + state: { + // Initialize the plugin's internal state. + init(): SuggestionPluginState { + return undefined; + }, + + // 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; + } + + // Either contains the trigger character if the menu should be shown, + // or null if it should be hidden. + const suggestionPluginTransactionMeta: { + triggerCharacter: string; + fromUserInput?: boolean; + } | null = transaction.getMeta(suggestionMenuPluginKey); + + // Only opens a menu of no menu is already open + if ( + typeof suggestionPluginTransactionMeta === "object" && + suggestionPluginTransactionMeta !== null && + prev === undefined + ) { + return { + triggerCharacter: + suggestionPluginTransactionMeta.triggerCharacter, + fromUserInput: + suggestionPluginTransactionMeta.fromUserInput !== false, + queryStartPos: newState.selection.from, + query: "", + 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 === undefined) { + return prev; + } + + // Checks if the menu should be hidden. + if ( + // Highlighting text should hide the menu. + newState.selection.from !== newState.selection.to || + // Transactions with plugin metadata should hide the menu. + suggestionPluginTransactionMeta === null || + // 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.triggerCharacter !== undefined && + newState.selection.from < prev.queryStartPos!) + ) { + return undefined; + } + + const next = { ...prev }; + + // Updates the current query. + next.query = newState.doc.textBetween( + prev.queryStartPos!, + newState.selection.from + ); + + return next; + }, + }, + + props: { + handleKeyDown(view, event) { + const suggestionPluginState: SuggestionPluginState = ( + this as Plugin + ).getState(view.state); + + if ( + triggerCharacters.includes(event.key) && + suggestionPluginState === undefined + ) { + event.preventDefault(); + + view.dispatch( + view.state.tr + .insertText(event.key) + .scrollIntoView() + .setMeta(suggestionMenuPluginKey, { + triggerCharacter: event.key, + }) + ); + + return true; + } + + return false; + }, + + // Setup decorator on the currently active suggestion. + decorations(state) { + const suggestionPluginState: SuggestionPluginState = ( + this as Plugin + ).getState(state); + + if (suggestionPluginState === undefined) { + 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 (!suggestionPluginState.fromUserInput) { + const blockNode = findBlock(state.selection); + if (blockNode) { + return DecorationSet.create(state.doc, [ + Decoration.node( + blockNode.pos, + blockNode.pos + blockNode.node.nodeSize, + { + nodeName: "span", + class: "bn-suggestion-decorator", + "data-decoration-id": suggestionPluginState.decorationId, + } + ), + ]); + } + } + // Creates an inline decoration around the trigger character. + return DecorationSet.create(state.doc, [ + Decoration.inline( + suggestionPluginState.queryStartPos! - + suggestionPluginState.triggerCharacter!.length, + suggestionPluginState.queryStartPos!, + { + nodeName: "span", + class: "bn-suggestion-decorator", + "data-decoration-id": suggestionPluginState.decorationId, + } + ), + ]); + }, + }, + }); + } + + public onUpdate( + triggerCharacter: string, + callback: (state: SuggestionMenuState) => void + ) { + if (!this.triggerCharacters.includes(triggerCharacter)) { + this.addTriggerCharacter(triggerCharacter); + } + // TODO: be able to remove the triggerCharacter + return this.on(`update ${triggerCharacter}`, callback); + } + + addTriggerCharacter = (triggerCharacter: string) => { + this.triggerCharacters.push(triggerCharacter); + }; + + // TODO: Should this be called automatically when listeners are removed? + removeTriggerCharacter = (triggerCharacter: string) => { + this.triggerCharacters = this.triggerCharacters.filter( + (c) => c !== triggerCharacter + ); + }; + + closeMenu = () => this.view!.closeMenu(); + + clearQuery = () => this.view!.clearQuery(); +} + +export function createSuggestionMenu< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>(editor: BlockNoteEditor, triggerCharacter: string) { + editor.suggestionMenus.addTriggerCharacter(triggerCharacter); +} diff --git a/packages/core/src/extensions/SuggestionMenu/defaultSlashMenuItems.ts b/packages/core/src/extensions/SuggestionMenu/defaultSlashMenuItems.ts new file mode 100644 index 0000000000..628ef510b3 --- /dev/null +++ b/packages/core/src/extensions/SuggestionMenu/defaultSlashMenuItems.ts @@ -0,0 +1,224 @@ +import { + Block, + DefaultBlockSchema, + PartialBlock, +} from "../../blocks/defaultBlocks"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { + BlockSchema, + InlineContentSchema, + StyleSchema, + isStyledTextInlineContent, +} from "../../schema"; +import { formatKeyboardShortcut } from "../../util/browser"; +import { imageToolbarPluginKey } from "../ImageToolbar/ImageToolbarPlugin"; + +// Sets the editor's text cursor position to the next content editable block, +// so either a block with inline content or a table. The last block is always a +// paragraph, so this function won't try to set the cursor position past the +// last block. +function setSelectionToNextContentEditableBlock< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>(editor: BlockNoteEditor) { + let block = editor.getTextCursorPosition().block; + let contentType = editor.blockSchema[block.type].content; + + while (contentType === "none") { + block = editor.getTextCursorPosition().nextBlock!; + contentType = editor.blockSchema[block.type].content as + | "inline" + | "table" + | "none"; + editor.setTextCursorPosition(block, "end"); + } +} + +// Checks if the current block is empty or only contains a slash, and if so, +// updates the current block instead of inserting a new one below. If the new +// block doesn't contain editable content, the cursor is moved to the next block +// that does. +export function insertOrUpdateBlock< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + editor: BlockNoteEditor, + block: PartialBlock +): Block { + const currentBlock = editor.getTextCursorPosition().block; + + if (currentBlock.content === undefined) { + throw new Error("Slash Menu open in a block that doesn't contain content."); + } + + if ( + Array.isArray(currentBlock.content) && + ((currentBlock.content.length === 1 && + isStyledTextInlineContent(currentBlock.content[0]) && + 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!, + "end" + ); + } + + const insertedBlock = editor.getTextCursorPosition().block; + setSelectionToNextContentEditableBlock(editor); + + return insertedBlock; +} + +export function getDefaultSlashMenuItems() { + return [ + { + title: "Heading 1", + // Unfortunately, we can't use a more specific BlockNoteEditor type here, + // Typescript seems to get in the way there + // This means that we don't have type checking for calling insertOrUpdateBlock etc. :( + onItemClick: (editor: BlockNoteEditor) => { + insertOrUpdateBlock(editor, { + type: "heading", + props: { level: 1 }, + } satisfies PartialBlock); + }, + subtext: "Used for a top-level heading", + badge: formatKeyboardShortcut("Mod-Alt-1"), + aliases: ["h", "heading1", "h1"], + group: "Headings", + }, + { + title: "Heading 2", + onItemClick: (editor: BlockNoteEditor) => { + insertOrUpdateBlock(editor, { + type: "heading", + props: { level: 2 }, + } satisfies PartialBlock); + }, + subtext: "Used for key sections", + badge: formatKeyboardShortcut("Mod-Alt-2"), + aliases: ["h2", "heading2", "subheading"], + group: "Headings", + }, + { + title: "Heading 3", + onItemClick: (editor: BlockNoteEditor) => { + insertOrUpdateBlock(editor, { + type: "heading", + props: { level: 3 }, + } satisfies PartialBlock); + }, + subtext: "Used for subsections and group headings", + badge: formatKeyboardShortcut("Mod-Alt-3"), + aliases: ["h3", "heading3", "subheading"], + group: "Headings", + }, + { + title: "Numbered List", + onItemClick: (editor: BlockNoteEditor) => { + insertOrUpdateBlock(editor, { + type: "numberedListItem", + } satisfies PartialBlock); + }, + subtext: "Used to display a numbered list", + badge: formatKeyboardShortcut("Mod-Shift-7"), + aliases: ["ol", "li", "list", "numberedlist", "numbered list"], + group: "Basic blocks", + }, + { + title: "Bullet List", + onItemClick: (editor: BlockNoteEditor) => { + insertOrUpdateBlock(editor, { + type: "bulletListItem", + } satisfies PartialBlock); + }, + subtext: "Used to display an unordered list", + badge: formatKeyboardShortcut("Mod-Shift-8"), + aliases: ["ul", "li", "list", "bulletlist", "bullet list"], + group: "Basic blocks", + }, + { + title: "Paragraph", + onItemClick: (editor: BlockNoteEditor) => { + insertOrUpdateBlock(editor, { + type: "paragraph", + } satisfies PartialBlock); + }, + subtext: "Used for the body of your document", + badge: formatKeyboardShortcut("Mod-Alt-0"), + aliases: ["p", "paragraph"], + group: "Basic blocks", + }, + { + title: "Table", + onItemClick: (editor: BlockNoteEditor) => { + insertOrUpdateBlock(editor, { + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: ["", "", ""], + }, + { + cells: ["", "", ""], + }, + ], + }, + } satisfies PartialBlock); + }, + subtext: "Used for for tables", + aliases: ["table"], + group: "Advanced", + badge: undefined, + }, + { + title: "Image", + onItemClick: (editor: BlockNoteEditor) => { + const insertedBlock = insertOrUpdateBlock(editor, { + type: "image", + } satisfies PartialBlock); + + // Immediately open the image toolbar + editor._tiptapEditor.view.dispatch( + editor._tiptapEditor.state.tr.setMeta(imageToolbarPluginKey, { + block: insertedBlock, + }) + ); + }, + subtext: "Insert an image", + aliases: [ + "image", + "imageUpload", + "upload", + "img", + "picture", + "media", + "url", + "drive", + "dropbox", + ], + group: "Media", + }, + ] as const; +} + +export function filterSuggestionItems< + T extends { title: string; aliases?: readonly string[] } +>(items: T[], query: string) { + return items.filter( + ({ title, aliases }) => + title.toLowerCase().startsWith(query.toLowerCase()) || + (aliases && + aliases.filter((alias) => + alias.toLowerCase().startsWith(query.toLowerCase()) + ).length !== 0) + ); +} diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts index d4532e98d3..f379981f47 100644 --- a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts +++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts @@ -1,11 +1,7 @@ import { Plugin, PluginKey, PluginView } from "prosemirror-state"; import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; import { nodeToBlock } from "../../api/nodeConversions/nodeConversions"; -import { - Block, - DefaultBlockSchema, - PartialBlock, -} from "../../blocks/defaultBlocks"; +import { Block, DefaultBlockSchema } from "../../blocks/defaultBlocks"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; import { BlockFromConfigNoChildren, @@ -19,26 +15,6 @@ import { getDraggableBlockFromCoords } from "../SideMenu/SideMenuPlugin"; let dragImageElement: HTMLElement | undefined; -function setHiddenDragImage() { - if (dragImageElement) { - return; - } - - dragImageElement = document.createElement("div"); - dragImageElement.innerHTML = "_"; - dragImageElement.style.opacity = "0"; - dragImageElement.style.height = "1px"; - dragImageElement.style.width = "1px"; - document.body.appendChild(dragImageElement); -} - -function unsetHiddenDragImage() { - if (dragImageElement) { - document.body.removeChild(dragImageElement); - dragImageElement = undefined; - } -} - export type TableHandlesState< I extends InlineContentSchema, S extends StyleSchema @@ -60,6 +36,26 @@ export type TableHandlesState< | undefined; }; +function setHiddenDragImage() { + if (dragImageElement) { + return; + } + + dragImageElement = document.createElement("div"); + dragImageElement.innerHTML = "_"; + dragImageElement.style.opacity = "0"; + dragImageElement.style.height = "1px"; + dragImageElement.style.width = "1px"; + document.body.appendChild(dragImageElement); +} + +function unsetHiddenDragImage() { + if (dragImageElement) { + document.body.removeChild(dragImageElement); + dragImageElement = undefined; + } +} + function getChildIndex(node: Element) { return Array.prototype.indexOf.call(node.parentElement!.childNodes, node); } @@ -93,7 +89,7 @@ export class TableHandlesView< > implements PluginView { public state?: TableHandlesState; - public updateState: () => void; + public emitUpdate: () => void; public tableId: string | undefined; public tablePos: number | undefined; @@ -103,16 +99,20 @@ export class TableHandlesView< public prevWasEditable: boolean | null = null; constructor( - private readonly editor: BlockNoteEditor, + private readonly editor: BlockNoteEditor< + BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, + I, + S + >, private readonly pmView: EditorView, - updateState: (state: TableHandlesState) => void + emitUpdate: (state: TableHandlesState) => void ) { - this.updateState = () => { + this.emitUpdate = () => { if (!this.state) { throw new Error("Attempting to update uninitialized image toolbar"); } - updateState(this.state); + emitUpdate(this.state); }; pmView.dom.addEventListener("mousemove", this.mouseMoveHandler); @@ -133,7 +133,7 @@ export class TableHandlesView< if (!target || !this.editor.isEditable) { if (this.state?.show) { this.state.show = false; - this.updateState(); + this.emitUpdate(); } return; } @@ -198,7 +198,7 @@ export class TableHandlesView< draggingState: undefined, }; - this.updateState(); + this.emitUpdate(); return false; }; @@ -288,7 +288,7 @@ export class TableHandlesView< // Emits a state update if any of the fields have changed. if (emitStateUpdate) { - this.updateState(); + this.emitUpdate(); } // Dispatches a dummy transaction to force a decorations update if @@ -329,7 +329,7 @@ export class TableHandlesView< type: "tableContent", rows: rows, }, - } as PartialBlock); + }); }; scrollHandler = () => { @@ -345,7 +345,7 @@ export class TableHandlesView< this.state.referencePosTable = tableElement.getBoundingClientRect(); this.state.referencePosCell = cellElement.getBoundingClientRect(); - this.updateState(); + this.emitUpdate(); } }; @@ -362,14 +362,25 @@ export class TableHandlesView< export const tableHandlesPluginKey = new PluginKey("TableHandlesPlugin"); export class TableHandlesProsemirrorPlugin< - BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, I extends InlineContentSchema, S extends StyleSchema > extends EventEmitter { - private view: TableHandlesView | undefined; + private view: + | TableHandlesView< + BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, + I, + S + > + | undefined; public readonly plugin: Plugin; - constructor(private readonly editor: BlockNoteEditor) { + constructor( + private readonly editor: BlockNoteEditor< + BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, + I, + S + > + ) { super(); this.plugin = new Plugin({ key: tableHandlesPluginKey, @@ -533,7 +544,7 @@ export class TableHandlesProsemirrorPlugin< originalIndex: this.view!.state.colIndex, mousePos: event.clientX, }; - this.view!.updateState(); + this.view!.emitUpdate(); this.editor._tiptapEditor.view.dispatch( this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, { @@ -569,7 +580,7 @@ export class TableHandlesProsemirrorPlugin< originalIndex: this.view!.state.rowIndex, mousePos: event.clientY, }; - this.view!.updateState(); + this.view!.emitUpdate(); this.editor._tiptapEditor.view.dispatch( this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, { @@ -598,7 +609,7 @@ export class TableHandlesProsemirrorPlugin< } this.view!.state.draggingState = undefined; - this.view!.updateState(); + this.view!.emitUpdate(); this.editor._tiptapEditor.view.dispatch( this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, null) @@ -611,11 +622,15 @@ export class TableHandlesProsemirrorPlugin< * Freezes the drag handles. When frozen, they will stay attached to the same * cell regardless of which cell is hovered by the mouse cursor. */ - freezeHandles = () => (this.view!.menuFrozen = true); + freezeHandles = () => { + this.view!.menuFrozen = true; + }; /** * Unfreezes the drag handles. When frozen, they will stay attached to the * same cell regardless of which cell is hovered by the mouse cursor. */ - unfreezeHandles = () => (this.view!.menuFrozen = false); + unfreezeHandles = () => { + this.view!.menuFrozen = false; + }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9678ae5a55..1c6603c158 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,17 +7,14 @@ export * from "./blocks/defaultProps"; export * from "./editor/BlockNoteEditor"; export * from "./editor/BlockNoteExtensions"; export * from "./editor/selectionTypes"; -export * from "./extensions-shared/BaseUiElementTypes"; -export type { SuggestionItem } from "./extensions-shared/suggestion/SuggestionItem"; -export * from "./extensions-shared/suggestion/SuggestionPlugin"; export * from "./extensions/FormattingToolbar/FormattingToolbarPlugin"; export * from "./extensions/HyperlinkToolbar/HyperlinkToolbarPlugin"; export * from "./extensions/ImageToolbar/ImageToolbarPlugin"; +export * from "./extensions/SuggestionMenu/defaultSlashMenuItems"; +export * from "./extensions/SuggestionMenu/SuggestionPlugin"; export * from "./extensions/SideMenu/SideMenuPlugin"; -export * from "./extensions/SlashMenu/BaseSlashMenuItem"; -export * from "./extensions/SlashMenu/SlashMenuPlugin"; -export { getDefaultSlashMenuItems } from "./extensions/SlashMenu/defaultSlashMenuItems"; export * from "./extensions/TableHandles/TableHandlesPlugin"; +export * from "./extensions-shared/UiElementPosition"; export * from "./schema"; export * from "./util/browser"; export * from "./util/string"; diff --git a/packages/react/package.json b/packages/react/package.json index 52ee0be09c..a62adb8000 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -57,8 +57,6 @@ "@mantine/utils": "^6.0.21", "@tiptap/core": "^2.0.3", "@tiptap/react": "^2.0.3", - "lodash.foreach": "^4.5.0", - "lodash.groupby": "^4.6.0", "lodash.merge": "^4.6.2", "react": "^18", "react-dom": "^18.2.0", diff --git a/packages/react/src/components-shared/UiComponentTypes.ts b/packages/react/src/components-shared/UiComponentTypes.ts new file mode 100644 index 0000000000..cc2b299455 --- /dev/null +++ b/packages/react/src/components-shared/UiComponentTypes.ts @@ -0,0 +1,17 @@ +import { BlockNoteEditor } from "@blocknote/core"; +import { CSSProperties } from "react"; + +export type UiComponentData< + UiElementData, + UiElementPluginName extends keyof BlockNoteEditor +> = UiElementData & + Omit< + BlockNoteEditor[UiElementPluginName], + "plugin" | "on" | "onPositionUpdate" | "onDataUpdate" | "off" + >; + +export type UiComponentPosition = { + isMounted: boolean; + ref: (node: HTMLElement | null) => void; + style: CSSProperties; +}; diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/ReplaceImageButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/ReplaceImageButton.tsx index b58abd4423..6a3df0e517 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/ReplaceImageButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/ReplaceImageButton.tsx @@ -39,10 +39,7 @@ export const ReplaceImageButton = (props: { /> - + ); diff --git a/packages/react/src/components/FormattingToolbar/DefaultFormattingToolbar.tsx b/packages/react/src/components/FormattingToolbar/DefaultFormattingToolbar.tsx index ca0cffbc06..794e14830d 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultFormattingToolbar.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultFormattingToolbar.tsx @@ -1,23 +1,32 @@ -import { BlockSchema } from "@blocknote/core"; +import { + BlockNoteEditor, + BlockSchema, + DefaultBlockSchema, +} from "@blocknote/core"; import { Toolbar } from "../../components-shared/Toolbar/Toolbar"; -import { ColorStyleButton } from "./DefaultButtons/ColorStyleButton"; -import { CreateLinkButton } from "./DefaultButtons/CreateLinkButton"; +import { + BlockTypeDropdown, + BlockTypeDropdownItem, +} from "./DefaultDropdowns/BlockTypeDropdown"; import { ImageCaptionButton } from "./DefaultButtons/ImageCaptionButton"; +import { ReplaceImageButton } from "./DefaultButtons/ReplaceImageButton"; +import { ToggledStyleButton } from "./DefaultButtons/ToggledStyleButton"; +import { TextAlignButton } from "./DefaultButtons/TextAlignButton"; +import { ColorStyleButton } from "./DefaultButtons/ColorStyleButton"; import { NestBlockButton, UnnestBlockButton, } from "./DefaultButtons/NestBlockButtons"; -import { ReplaceImageButton } from "./DefaultButtons/ReplaceImageButton"; -import { TextAlignButton } from "./DefaultButtons/TextAlignButton"; -import { ToggledStyleButton } from "./DefaultButtons/ToggledStyleButton"; -import { - BlockTypeDropdown, - BlockTypeDropdownItem, -} from "./DefaultDropdowns/BlockTypeDropdown"; -import type { FormattingToolbarProps } from "./FormattingToolbarPositioner"; +import { CreateLinkButton } from "./DefaultButtons/CreateLinkButton"; + +export type FormattingToolbarProps = { + editor: BlockNoteEditor; +}; -export const DefaultFormattingToolbar = ( +export const DefaultFormattingToolbar = < + BSchema extends BlockSchema = DefaultBlockSchema +>( props: FormattingToolbarProps & { blockTypeDropdownItems?: BlockTypeDropdownItem[]; } diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbarPositioner.tsx b/packages/react/src/components/FormattingToolbar/DefaultPositionedFormattingToolbar.tsx similarity index 57% rename from packages/react/src/components/FormattingToolbar/FormattingToolbarPositioner.tsx rename to packages/react/src/components/FormattingToolbar/DefaultPositionedFormattingToolbar.tsx index 008b65e14b..1c855daa14 100644 --- a/packages/react/src/components/FormattingToolbar/FormattingToolbarPositioner.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultPositionedFormattingToolbar.tsx @@ -4,16 +4,16 @@ import { DefaultBlockSchema, DefaultProps, } from "@blocknote/core"; -import { - flip, - offset, - useFloating, - useTransitionStyles, -} from "@floating-ui/react"; -import { FC, useEffect, useRef, useState } from "react"; +import { flip, offset } from "@floating-ui/react"; +import { FC, useState } from "react"; import { useEditorContentOrSelectionChange } from "../../hooks/useEditorContentOrSelectionChange"; -import { DefaultFormattingToolbar } from "./DefaultFormattingToolbar"; +import { useUIElementPositioning } from "../../hooks/useUIElementPositioning"; +import { useUIPluginState } from "../../hooks/useUIPluginState"; +import { + DefaultFormattingToolbar, + FormattingToolbarProps, +} from "./DefaultFormattingToolbar"; const textAlignmentToPlacement = ( textAlignment: DefaultProps["textAlignment"] @@ -30,19 +30,12 @@ const textAlignmentToPlacement = ( } }; -export type FormattingToolbarProps< - BSchema extends BlockSchema = DefaultBlockSchema -> = { - editor: BlockNoteEditor; -}; - -export const FormattingToolbarPositioner = < +export const DefaultPositionedFormattingToolbar = < BSchema extends BlockSchema = DefaultBlockSchema >(props: { editor: BlockNoteEditor; formattingToolbar?: FC>; }) => { - const [show, setShow] = useState(false); const [placement, setPlacement] = useState<"top-start" | "top" | "top-end">( () => { const block = props.editor.getTextCursorPosition().block; @@ -57,26 +50,6 @@ export const FormattingToolbarPositioner = < } ); - const referencePos = useRef(); - - const { refs, update, context, floatingStyles } = useFloating({ - open: show, - placement, - middleware: [offset(10), flip()], - }); - - const { isMounted, styles } = useTransitionStyles(context); - - useEffect(() => { - return props.editor.formattingToolbar.onUpdate((state) => { - setShow(state.show); - - referencePos.current = state.referencePos; - - update(); - }); - }, [props.editor, update]); - useEditorContentOrSelectionChange(() => { const block = props.editor.getTextCursorPosition().block; @@ -91,22 +64,27 @@ export const FormattingToolbarPositioner = < } }, props.editor); - useEffect(() => { - refs.setReference({ - getBoundingClientRect: () => referencePos.current!, - }); - }, [refs]); - - const FormattingToolbar = props.formattingToolbar || DefaultFormattingToolbar; + const state = useUIPluginState( + props.editor.formattingToolbar.onUpdate.bind(props.editor.formattingToolbar) + ); + const { isMounted, ref, style } = useUIElementPositioning( + state?.show || false, + state?.referencePos || null, + 3000, + { + placement, + middleware: [offset(10), flip()], + } + ); - if (!isMounted) { + if (!isMounted || !state) { return null; } + const FormattingToolbar = props.formattingToolbar || DefaultFormattingToolbar; + return ( -
+
); diff --git a/packages/react/src/components/HyperlinkToolbar/DefaultHyperlinkToolbar.tsx b/packages/react/src/components/HyperlinkToolbar/DefaultHyperlinkToolbar.tsx index 4e9e72f1ce..1bbbc2334c 100644 --- a/packages/react/src/components/HyperlinkToolbar/DefaultHyperlinkToolbar.tsx +++ b/packages/react/src/components/HyperlinkToolbar/DefaultHyperlinkToolbar.tsx @@ -1,29 +1,43 @@ -import { BlockSchema, InlineContentSchema } from "@blocknote/core"; +import { + BlockNoteEditor, + HyperlinkToolbarState, + UiElementPosition, +} from "@blocknote/core"; import { useRef, useState } from "react"; import { RiExternalLinkFill, RiLinkUnlink } from "react-icons/ri"; -import { StyleSchema } from "@blocknote/core"; import { Toolbar } from "../../components-shared/Toolbar/Toolbar"; import { ToolbarButton } from "../../components-shared/Toolbar/ToolbarButton"; import { EditHyperlinkMenu } from "./EditHyperlinkMenu/components/EditHyperlinkMenu"; -import type { HyperlinkToolbarProps } from "./HyperlinkToolbarPositioner"; -export const DefaultHyperlinkToolbar = < - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema ->( - props: HyperlinkToolbarProps -) => { +export type HyperlinkToolbarProps = Omit< + HyperlinkToolbarState, + keyof UiElementPosition +> & + Pick< + BlockNoteEditor["hyperlinkToolbar"], + "deleteHyperlink" | "editHyperlink" | "startHideTimer" | "stopHideTimer" + >; + +export const DefaultHyperlinkToolbar = (props: HyperlinkToolbarProps) => { const [isEditing, setIsEditing] = useState(false); const editMenuRef = useRef(null); + const { + text, + url, + deleteHyperlink, + editHyperlink, + startHideTimer, + stopHideTimer, + } = props; + if (isEditing) { return ( setTimeout(() => { @@ -39,9 +53,7 @@ export const DefaultHyperlinkToolbar = < } return ( - + { - window.open(props.url, "_blank"); + window.open(url, "_blank"); }} icon={RiExternalLinkFill} /> diff --git a/packages/react/src/components/HyperlinkToolbar/DefaultPositionedHyperlinkToolbar.tsx b/packages/react/src/components/HyperlinkToolbar/DefaultPositionedHyperlinkToolbar.tsx new file mode 100644 index 0000000000..b6b59a7db4 --- /dev/null +++ b/packages/react/src/components/HyperlinkToolbar/DefaultPositionedHyperlinkToolbar.tsx @@ -0,0 +1,61 @@ +import { + BlockNoteEditor, + BlockSchema, + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, + InlineContentSchema, + StyleSchema, +} from "@blocknote/core"; +import { flip, offset } from "@floating-ui/react"; +import { FC } from "react"; + +import { useUIPluginState } from "../../hooks/useUIPluginState"; +import { useUIElementPositioning } from "../../hooks/useUIElementPositioning"; +import { + DefaultHyperlinkToolbar, + HyperlinkToolbarProps, +} from "./DefaultHyperlinkToolbar"; + +export const DefaultPositionedHyperlinkToolbar = < + BSchema extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema +>(props: { + editor: BlockNoteEditor; + hyperlinkToolbar?: FC; +}) => { + const callbacks = { + deleteHyperlink: props.editor.hyperlinkToolbar.deleteHyperlink, + editHyperlink: props.editor.hyperlinkToolbar.editHyperlink, + startHideTimer: props.editor.hyperlinkToolbar.startHideTimer, + stopHideTimer: props.editor.hyperlinkToolbar.stopHideTimer, + }; + + const state = useUIPluginState( + props.editor.hyperlinkToolbar.onUpdate.bind(props.editor.hyperlinkToolbar) + ); + const { isMounted, ref, style } = useUIElementPositioning( + state?.show || false, + state?.referencePos || null, + 4000, + { + placement: "top-start", + middleware: [offset(10), flip()], + } + ); + + if (!isMounted || !state) { + return null; + } + + const { show, referencePos, ...data } = state; + + const HyperlinkToolbar = props.hyperlinkToolbar || DefaultHyperlinkToolbar; + + return ( +
+ +
+ ); +}; diff --git a/packages/react/src/components/HyperlinkToolbar/HyperlinkToolbarPositioner.tsx b/packages/react/src/components/HyperlinkToolbar/HyperlinkToolbarPositioner.tsx deleted file mode 100644 index fcd702f7c2..0000000000 --- a/packages/react/src/components/HyperlinkToolbar/HyperlinkToolbarPositioner.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { - BaseUiElementState, - BlockNoteEditor, - BlockSchema, - DefaultBlockSchema, - DefaultInlineContentSchema, - DefaultStyleSchema, - HyperlinkToolbarProsemirrorPlugin, - HyperlinkToolbarState, - InlineContentSchema, - StyleSchema, -} from "@blocknote/core"; -import { - flip, - offset, - useFloating, - useTransitionStyles, -} from "@floating-ui/react"; -import { FC, useEffect, useRef, useState } from "react"; - -import { DefaultHyperlinkToolbar } from "./DefaultHyperlinkToolbar"; - -export type HyperlinkToolbarProps< - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema -> = Pick< - HyperlinkToolbarProsemirrorPlugin, - "editHyperlink" | "deleteHyperlink" | "startHideTimer" | "stopHideTimer" -> & - Omit; - -export const HyperlinkToolbarPositioner = < - BSchema extends BlockSchema = DefaultBlockSchema, - I extends InlineContentSchema = DefaultInlineContentSchema, - S extends StyleSchema = DefaultStyleSchema ->(props: { - editor: BlockNoteEditor; - hyperlinkToolbar?: FC>; -}) => { - const [show, setShow] = useState(false); - const [url, setUrl] = useState(); - const [text, setText] = useState(); - - const referencePos = useRef(); - - const { refs, update, context, floatingStyles } = useFloating({ - open: show, - placement: "top-start", - middleware: [offset(10), flip()], - }); - - const { isMounted, styles } = useTransitionStyles(context); - - useEffect(() => { - return props.editor.hyperlinkToolbar.on( - "update", - (hyperlinkToolbarState) => { - setShow(hyperlinkToolbarState.show); - setUrl(hyperlinkToolbarState.url); - setText(hyperlinkToolbarState.text); - - referencePos.current = hyperlinkToolbarState.referencePos; - - update(); - } - ); - }, [props.editor, update]); - - useEffect(() => { - refs.setReference({ - getBoundingClientRect: () => referencePos.current!, - }); - }, [refs]); - - if (!url || !text || !isMounted) { - return null; - } - - const HyperlinkToolbar = props.hyperlinkToolbar || DefaultHyperlinkToolbar; - - return ( -
- -
- ); -}; diff --git a/packages/react/src/components/ImageToolbar/DefaultImageToolbar.tsx b/packages/react/src/components/ImageToolbar/DefaultImageToolbar.tsx index d02e1a2579..b8444e5f7c 100644 --- a/packages/react/src/components/ImageToolbar/DefaultImageToolbar.tsx +++ b/packages/react/src/components/ImageToolbar/DefaultImageToolbar.tsx @@ -1,5 +1,11 @@ -import { BlockSchema, PartialBlock } from "@blocknote/core"; - +import { + BlockNoteEditor, + BlockSchema, + DefaultBlockSchema, + ImageToolbarState, + PartialBlock, + UiElementPosition, +} from "@blocknote/core"; import { Button, FileInput, @@ -15,11 +21,17 @@ import { useEffect, useState, } from "react"; + import { Toolbar } from "../../components-shared/Toolbar/Toolbar"; -import type { ImageToolbarProps } from "./ImageToolbarPositioner"; + +export type ImageToolbarProps< + BSchema extends BlockSchema = DefaultBlockSchema +> = { + editor: BlockNoteEditor; +} & Omit, keyof UiElementPosition>; export const DefaultImageToolbar = ( - props: ImageToolbarProps + props: ImageToolbarProps ) => { const [openTab, setOpenTab] = useState<"upload" | "embed">( props.editor.uploadFile !== undefined ? "upload" : "embed" diff --git a/packages/react/src/components/ImageToolbar/DefaultPositionedImageToolbar.tsx b/packages/react/src/components/ImageToolbar/DefaultPositionedImageToolbar.tsx new file mode 100644 index 0000000000..6fc7455138 --- /dev/null +++ b/packages/react/src/components/ImageToolbar/DefaultPositionedImageToolbar.tsx @@ -0,0 +1,45 @@ +import { + BlockNoteEditor, + BlockSchema, + DefaultBlockSchema, +} from "@blocknote/core"; +import { FC } from "react"; +import { flip, offset } from "@floating-ui/react"; + +import { useUIPluginState } from "../../hooks/useUIPluginState"; +import { useUIElementPositioning } from "../../hooks/useUIElementPositioning"; +import { DefaultImageToolbar, ImageToolbarProps } from "./DefaultImageToolbar"; + +export const DefaultPositionedImageToolbar = < + BSchema extends BlockSchema = DefaultBlockSchema +>(props: { + editor: BlockNoteEditor; + imageToolbar?: FC>; +}) => { + const state = useUIPluginState( + props.editor.imageToolbar.onUpdate.bind(props.editor.imageToolbar) + ); + const { isMounted, ref, style } = useUIElementPositioning( + state?.show || false, + state?.referencePos || null, + 5000, + { + placement: "bottom", + middleware: [offset(10), flip()], + } + ); + + if (!isMounted || !state) { + return null; + } + + const { show, referencePos, ...data } = state; + + const ImageToolbar = props.imageToolbar || DefaultImageToolbar; + + return ( +
+ +
+ ); +}; diff --git a/packages/react/src/components/ImageToolbar/ImageToolbarPositioner.tsx b/packages/react/src/components/ImageToolbar/ImageToolbarPositioner.tsx deleted file mode 100644 index 1a72153522..0000000000 --- a/packages/react/src/components/ImageToolbar/ImageToolbarPositioner.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { - BaseUiElementState, - BlockNoteEditor, - BlockSchema, - DefaultBlockSchema, - DefaultInlineContentSchema, - ImageToolbarState, - InlineContentSchema, - SpecificBlock, -} from "@blocknote/core"; -import { - flip, - offset, - useFloating, - useTransitionStyles, -} from "@floating-ui/react"; -import { FC, useEffect, useRef, useState } from "react"; - -import { DefaultImageToolbar } from "./DefaultImageToolbar"; - -export type ImageToolbarProps< - BSchema extends BlockSchema = DefaultBlockSchema, - I extends InlineContentSchema = DefaultInlineContentSchema -> = Omit, keyof BaseUiElementState> & { - editor: BlockNoteEditor; -}; - -export const ImageToolbarPositioner = < - BSchema extends BlockSchema = DefaultBlockSchema, - I extends InlineContentSchema = DefaultInlineContentSchema ->(props: { - editor: BlockNoteEditor; - imageToolbar?: FC>; -}) => { - const [show, setShow] = useState(false); - const [block, setBlock] = useState>(); - - const referencePos = useRef(); - - const { refs, update, context, floatingStyles } = useFloating({ - open: show, - placement: "bottom", - middleware: [offset(10), flip()], - }); - - const { isMounted, styles } = useTransitionStyles(context); - - useEffect(() => { - return props.editor.imageToolbar.onUpdate((imageToolbarState) => { - setShow(imageToolbarState.show); - setBlock(imageToolbarState.block); - - referencePos.current = imageToolbarState.referencePos; - - update(); - }); - }, [props.editor, update]); - - useEffect(() => { - refs.setReference({ - getBoundingClientRect: () => referencePos.current!, - }); - }, [refs]); - - const ImageToolbar = props.imageToolbar || DefaultImageToolbar; - - if (!isMounted) { - return null; - } - - return ( -
- -
- ); -}; diff --git a/packages/react/src/components/SideMenu/DefaultButtons/AddBlockButton.tsx b/packages/react/src/components/SideMenu/DefaultButtons/AddBlockButton.tsx index ea7fba393f..f234f55486 100644 --- a/packages/react/src/components/SideMenu/DefaultButtons/AddBlockButton.tsx +++ b/packages/react/src/components/SideMenu/DefaultButtons/AddBlockButton.tsx @@ -1,11 +1,7 @@ -import { BlockSchema } from "@blocknote/core"; import { AiOutlinePlus } from "react-icons/ai"; import { SideMenuButton } from "../SideMenuButton"; -import type { SideMenuProps } from "../SideMenuPositioner"; -export const AddBlockButton = ( - props: SideMenuProps -) => ( +export const AddBlockButton = (props: { addBlock: () => void }) => ( ( - props: SideMenuProps -) => { +export const DragHandle = (props: { + editor: BlockNoteEditor; + block: Block; + blockDragStart: (event: { + dataTransfer: DataTransfer | null; + clientY: number; + }) => void; + blockDragEnd: () => void; + freezeMenu: () => void; + unfreezeMenu: () => void; + dragHandleMenu?: FC>; +}) => { const DragHandleMenu = props.dragHandleMenu || DefaultDragHandleMenu; return ( diff --git a/packages/react/src/components/SideMenu/DefaultPositionedSideMenu.tsx b/packages/react/src/components/SideMenu/DefaultPositionedSideMenu.tsx new file mode 100644 index 0000000000..bdefa9592a --- /dev/null +++ b/packages/react/src/components/SideMenu/DefaultPositionedSideMenu.tsx @@ -0,0 +1,57 @@ +import { + BlockNoteEditor, + BlockSchema, + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, + InlineContentSchema, + StyleSchema, +} from "@blocknote/core"; +import { FC } from "react"; + +import { useUIPluginState } from "../../hooks/useUIPluginState"; +import { useUIElementPositioning } from "../../hooks/useUIElementPositioning"; +import { DefaultSideMenu, SideMenuProps } from "./DefaultSideMenu"; + +export const DefaultPositionedSideMenu = < + BSchema extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema +>(props: { + editor: BlockNoteEditor; + sideMenu?: FC>; +}) => { + const callbacks = { + addBlock: props.editor.sideMenu.addBlock, + blockDragStart: props.editor.sideMenu.blockDragStart, + blockDragEnd: props.editor.sideMenu.blockDragEnd, + freezeMenu: props.editor.sideMenu.freezeMenu, + unfreezeMenu: props.editor.sideMenu.unfreezeMenu, + }; + + const state = useUIPluginState( + props.editor.sideMenu.onUpdate.bind(props.editor.sideMenu) + ); + const { isMounted, ref, style } = useUIElementPositioning( + state?.show || false, + state?.referencePos || null, + 1000, + { + placement: "left", + } + ); + + if (!isMounted || !state) { + return null; + } + + const { show, referencePos, ...data } = state; + + const SideMenu = props.sideMenu || DefaultSideMenu; + + return ( +
+ +
+ ); +}; diff --git a/packages/react/src/components/SideMenu/DefaultSideMenu.tsx b/packages/react/src/components/SideMenu/DefaultSideMenu.tsx index 9bdf66991e..63db4a328d 100644 --- a/packages/react/src/components/SideMenu/DefaultSideMenu.tsx +++ b/packages/react/src/components/SideMenu/DefaultSideMenu.tsx @@ -1,20 +1,50 @@ -import { BlockSchema, InlineContentSchema } from "@blocknote/core"; - -import { StyleSchema } from "@blocknote/core"; +import { + BlockNoteEditor, + BlockSchema, + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, + InlineContentSchema, + SideMenuState, + StyleSchema, + UiElementPosition, +} from "@blocknote/core"; import { AddBlockButton } from "./DefaultButtons/AddBlockButton"; import { DragHandle } from "./DefaultButtons/DragHandle"; import { SideMenu } from "./SideMenu"; -import type { SideMenuProps } from "./SideMenuPositioner"; +import { FC } from "react"; +import type { DragHandleMenuProps } from "./DragHandleMenu/DragHandleMenu"; + +export type SideMenuProps< + BSchema extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema +> = { + editor: BlockNoteEditor; + dragHandleMenu?: FC>; +} & Omit, keyof UiElementPosition> & + Pick< + BlockNoteEditor["sideMenu"], + | "addBlock" + | "blockDragStart" + | "blockDragEnd" + | "freezeMenu" + | "unfreezeMenu" + >; export const DefaultSideMenu = < - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema + BSchema extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema >( props: SideMenuProps -) => ( - - - - -); +) => { + const { addBlock, ...rest } = props; + + return ( + + + + + ); +}; diff --git a/packages/react/src/components/SideMenu/SideMenuPositioner.tsx b/packages/react/src/components/SideMenu/SideMenuPositioner.tsx deleted file mode 100644 index fc4198d305..0000000000 --- a/packages/react/src/components/SideMenu/SideMenuPositioner.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { - Block, - BlockNoteEditor, - BlockSchema, - DefaultBlockSchema, - DefaultInlineContentSchema, - DefaultStyleSchema, - InlineContentSchema, - SideMenuProsemirrorPlugin, - StyleSchema, -} from "@blocknote/core"; -import { useFloating, useTransitionStyles } from "@floating-ui/react"; -import { FC, useEffect, useRef, useState } from "react"; - -import { DefaultSideMenu } from "./DefaultSideMenu"; -import { DragHandleMenuProps } from "./DragHandleMenu/DragHandleMenu"; - -export type SideMenuProps< - BSchema extends BlockSchema = DefaultBlockSchema, - I extends InlineContentSchema = DefaultInlineContentSchema, - S extends StyleSchema = DefaultStyleSchema -> = Pick< - SideMenuProsemirrorPlugin, - "blockDragStart" | "blockDragEnd" | "addBlock" | "freezeMenu" | "unfreezeMenu" -> & { - block: Block; - editor: BlockNoteEditor; - dragHandleMenu?: FC>; -}; - -export const SideMenuPositioner = < - BSchema extends BlockSchema = DefaultBlockSchema, - I extends InlineContentSchema = DefaultInlineContentSchema, - S extends StyleSchema = DefaultStyleSchema ->(props: { - editor: BlockNoteEditor; - sideMenu?: FC>; -}) => { - const [show, setShow] = useState(false); - const [block, setBlock] = useState>(); - - const referencePos = useRef(); - - const { refs, update, context, floatingStyles } = useFloating({ - open: show, - placement: "left", - }); - - const { isMounted, styles } = useTransitionStyles(context); - - useEffect(() => { - return props.editor.sideMenu.onUpdate((sideMenuState) => { - setShow(sideMenuState.show); - setBlock(sideMenuState.block); - - referencePos.current = sideMenuState.referencePos; - - update(); - }); - }, [props.editor, update]); - - useEffect(() => { - refs.setReference({ - getBoundingClientRect: () => referencePos.current!, - }); - }, [refs]); - - if (!block || !isMounted) { - return null; - } - - const SideMenu = props.sideMenu || DefaultSideMenu; - - return ( -
- -
- ); -}; diff --git a/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx b/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx deleted file mode 100644 index ead8ff2573..0000000000 --- a/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Menu } from "@mantine/core"; -import foreach from "lodash.foreach"; -import groupBy from "lodash.groupby"; - -import { BlockSchema } from "@blocknote/core"; -import { SlashMenuItem } from "./SlashMenuItem"; -import type { SlashMenuProps } from "./SlashMenuPositioner"; - -export function DefaultSlashMenu( - props: SlashMenuProps -) { - const renderedItems: any[] = []; - let index = 0; - - const groups = groupBy(props.filteredItems, (i) => i.group); - - foreach(groups, (groupedItems) => { - renderedItems.push( - - {groupedItems[0].group} - - ); - - for (const item of groupedItems) { - renderedItems.push( - props.itemCallback(item)} - /> - ); - index++; - } - }); - - return ( - - event.preventDefault()} - className={"bn-slash-menu"}> - {renderedItems.length > 0 ? ( - renderedItems - ) : ( - No match found - )} - - - ); -} diff --git a/packages/react/src/components/SlashMenu/SlashMenuPositioner.tsx b/packages/react/src/components/SlashMenu/SlashMenuPositioner.tsx deleted file mode 100644 index c3b29447e5..0000000000 --- a/packages/react/src/components/SlashMenu/SlashMenuPositioner.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { - BlockNoteEditor, - BlockSchema, - DefaultBlockSchema, - SlashMenuProsemirrorPlugin, - SuggestionsMenuState, -} from "@blocknote/core"; -import { - flip, - offset, - size, - useFloating, - useTransitionStyles, -} from "@floating-ui/react"; -import { FC, useEffect, useRef, useState } from "react"; - -import { ReactSlashMenuItem } from "../../slashMenuItems/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(); - - const { refs, update, context, floatingStyles } = useFloating({ - open: show, - placement: "bottom-start", - middleware: [ - offset(10), - // Flips the slash menu placement to maximize the space available, and - // prevents the menu from being cut off by the confines of the screen. - flip(), - size({ - apply({ availableHeight, elements }) { - Object.assign(elements.floating.style, { - maxHeight: `${availableHeight - 10}px`, - }); - }, - }), - ], - }); - - const { isMounted, styles } = useTransitionStyles(context); - - useEffect(() => { - return props.editor.slashMenu.onUpdate((slashMenuState) => { - setShow(slashMenuState.show); - setFilteredItems(slashMenuState.filteredItems); - setKeyboardHoveredItemIndex(slashMenuState.keyboardHoveredItemIndex); - - referencePos.current = slashMenuState.referencePos; - - update(); - }); - }, [props.editor, show, update]); - - useEffect(() => { - refs.setReference({ - getBoundingClientRect: () => referencePos.current!, - }); - }, [refs]); - - if (!isMounted || !filteredItems || keyboardHoveredItemIndex === undefined) { - return null; - } - - const SlashMenu = props.slashMenu || DefaultSlashMenu; - - return ( -
- props.editor.slashMenu.itemCallback(item)} - keyboardHoveredItemIndex={keyboardHoveredItemIndex} - /> -
- ); -}; diff --git a/packages/react/src/components/SuggestionMenu/DefaultPositionedSuggestionMenu.tsx b/packages/react/src/components/SuggestionMenu/DefaultPositionedSuggestionMenu.tsx new file mode 100644 index 0000000000..d4c11ced18 --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/DefaultPositionedSuggestionMenu.tsx @@ -0,0 +1,111 @@ +import { + BlockNoteEditor, + BlockSchema, + InlineContentSchema, + StyleSchema, + SuggestionMenuState, +} from "@blocknote/core"; +import { flip, offset, size } from "@floating-ui/react"; +import { FC } from "react"; + +import { useUIElementPositioning } from "../../hooks/useUIElementPositioning"; +import { useUIPluginState } from "../../hooks/useUIPluginState"; +import { DefaultSuggestionMenu } from "./DefaultSuggestionMenu"; +import { MantineSuggestionMenu } from "./mantine/MantineSuggestionMenu"; +import { DefaultSuggestionItem, SuggestionMenuProps } from "./types"; + +type ArrayElement = A extends readonly (infer T)[] ? T : never; + +type ItemType Promise> = + ArrayElement>>; + +export function DefaultPositionedSuggestionMenu< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema, + // This is a bit hacky, but only way I found to make types work so the optionality + // of suggestionMenuComponent depends on the return type of getItems + GetItemsType extends (query: string) => Promise +>( + props: { + editor: BlockNoteEditor; + triggerCharacter: string; + getItems: GetItemsType; + onItemClick?: (item: ItemType) => void; + } & (ItemType extends DefaultSuggestionItem + ? { + // can be undefined + suggestionMenuComponent?: FC< + SuggestionMenuProps> + >; + } + : { + // getItems doesn't return DefaultSuggestionItem, so suggestionMenuComponent is required + suggestionMenuComponent: FC< + SuggestionMenuProps> + >; + }) +) { + const { + editor, + triggerCharacter, + onItemClick, + getItems, + suggestionMenuComponent, + } = props; + + const callbacks = { + closeMenu: editor.suggestionMenus.closeMenu, + clearQuery: editor.suggestionMenus.clearQuery, + }; + + const state = useUIPluginState( + (callback: (state: SuggestionMenuState) => void) => + props.editor.suggestionMenus.onUpdate.bind(editor.suggestionMenus)( + triggerCharacter, + callback + ) + ); + const { isMounted, ref, style } = useUIElementPositioning( + state?.show || false, + state?.referencePos || null, + 2000, + { + placement: "bottom-start", + middleware: [ + offset(10), + // Flips the menu placement to maximize the space available, and prevents + // the menu from being cut off by the confines of the screen. + flip(), + size({ + apply({ availableHeight, elements }) { + Object.assign(elements.floating.style, { + maxHeight: `${availableHeight - 10}px`, + }); + }, + }), + ], + } + ); + + if (!isMounted || !state) { + return null; + } + + const { show, referencePos, ...data } = state; + + return ( +
+ +
+ ); +} diff --git a/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.test.tsx b/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.test.tsx new file mode 100644 index 0000000000..a797d5fe2f --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.test.tsx @@ -0,0 +1,40 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { it } from "vitest"; +import { DefaultPositionedSuggestionMenu } from "./DefaultPositionedSuggestionMenu"; + +it("has good typing", () => { + // invalid, because DefaultSuggestionItem doesn't have a title property, so the default MantineSuggestionMenu doesn't wrok + let menu = ( + // @ts-expect-error + [{ name: "hello" }]} + editor={undefined as any} + triggerCharacter="/" + /> + ); + + // valid, because getItems returns DefaultSuggestionItem so suggestionMenuComponent is optional + menu = ( + [{ title: "hello" }]} + editor={undefined as any} + triggerCharacter="/" + /> + ); + + // validate type of onItemClick + menu = ( + [{ hello: "hello" }]} + editor={undefined as any} + onItemClick={(item) => { + console.log(item.hello); + }} + triggerCharacter="/" + /> + ); + + // prevent typescript unused error + console.log("menu", menu); +}); diff --git a/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.tsx b/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.tsx new file mode 100644 index 0000000000..4243c1dde5 --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.tsx @@ -0,0 +1,70 @@ +import { + BlockNoteEditor, + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "@blocknote/core"; +import { FC, useCallback } from "react"; + +import { useCloseSuggestionMenuNoItems } from "./hooks/useCloseSuggestionMenuNoItems"; +import { useLoadSuggestionMenuItems } from "./hooks/useLoadSuggestionMenuItems"; +import { useSuggestionMenuKeyboardNavigation } from "./hooks/useSuggestionMenuKeyboardNavigation"; +import { SuggestionMenuProps } from "./types"; + +export function DefaultSuggestionMenu< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema, + Item +>(props: { + editor: BlockNoteEditor; + query: string; + closeMenu: () => void; + clearQuery: () => void; + getItems: (query: string) => Promise; + onItemClick?: (item: Item) => void; + suggestionMenuComponent: FC>; +}) { + const { + editor, + getItems, + suggestionMenuComponent, + query, + clearQuery, + closeMenu, + onItemClick, + } = props; + + const clickHandler = useCallback( + (item: Item) => { + closeMenu(); + clearQuery(); + onItemClick?.(item); + }, + [onItemClick, closeMenu, clearQuery] + ); + + const { items, usedQuery, loadingState } = useLoadSuggestionMenuItems( + query, + getItems + ); + + useCloseSuggestionMenuNoItems(items, usedQuery, closeMenu); + + const selectedIndex = useSuggestionMenuKeyboardNavigation( + editor, + items, + closeMenu, + onItemClick + ); + + const Comp = suggestionMenuComponent; + return ( + + ); +} diff --git a/packages/react/src/components/SuggestionMenu/defaultReactSlashMenuItems.tsx b/packages/react/src/components/SuggestionMenu/defaultReactSlashMenuItems.tsx new file mode 100644 index 0000000000..34fabe491a --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/defaultReactSlashMenuItems.tsx @@ -0,0 +1,32 @@ +import { getDefaultSlashMenuItems } from "@blocknote/core"; +import { + RiH1, + RiH2, + RiH3, + RiImage2Fill, + RiListOrdered, + RiListUnordered, + RiTable2, + RiText, +} from "react-icons/ri"; + +const icons = { + "Heading 1": RiH1, + "Heading 2": RiH2, + "Heading 3": RiH3, + "Numbered List": RiListOrdered, + "Bullet List": RiListUnordered, + Paragraph: RiText, + Table: RiTable2, + Image: RiImage2Fill, +}; + +export function getDefaultReactSlashMenuItems() { + return getDefaultSlashMenuItems().map((item) => { + const Icon = icons[item.title]; + return { + ...item, + icon: , + }; + }); +} diff --git a/packages/react/src/components/SuggestionMenu/hooks/useCloseSuggestionMenuNoItems.ts b/packages/react/src/components/SuggestionMenu/hooks/useCloseSuggestionMenuNoItems.ts new file mode 100644 index 0000000000..1de4f7348c --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/hooks/useCloseSuggestionMenuNoItems.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef } from "react"; + +// Hook which closes the suggestion after a certain number of consecutive +// invalid queries are made. An invalid query is one which returns no items, and +// each invalid query must be longer than the previous one to close the menu +export function useCloseSuggestionMenuNoItems( + items: Item[], + usedQuery: string | undefined, + closeMenu: () => void, + invalidQueries = 3 +) { + const lastUsefulQueryLength = useRef(0); + + useEffect(() => { + if (usedQuery === undefined) { + return; + } + + if (items.length > 0) { + lastUsefulQueryLength.current = usedQuery.length; + } else if ( + usedQuery.length - lastUsefulQueryLength.current > + invalidQueries + ) { + closeMenu(); + } + }, [closeMenu, invalidQueries, items.length, usedQuery]); +} diff --git a/packages/react/src/components/SuggestionMenu/hooks/useLoadSuggestionMenuItems.ts b/packages/react/src/components/SuggestionMenu/hooks/useLoadSuggestionMenuItems.ts new file mode 100644 index 0000000000..7c205eb43b --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/hooks/useLoadSuggestionMenuItems.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef, useState } from "react"; + +// Hook which loads the items for a suggestion menu and returns them along with +// information whether the current query is still being processed, and the +// query that was used to retrieve the last set of items. +export function useLoadSuggestionMenuItems( + query: string, + getItems: (query: string) => Promise +): { + items: T[]; + usedQuery: string | undefined; + loadingState: "loading-initial" | "loading" | "loaded"; +} { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + + const currentQuery = useRef(); + const usedQuery = useRef(); + + useEffect(() => { + const thisQuery = query; + currentQuery.current = query; + + setLoading(true); + + getItems(query).then((items) => { + if (currentQuery.current !== thisQuery) { + // outdated query returned, ignore the result + return; + } + + setItems(items); + setLoading(false); + usedQuery.current = thisQuery; + }); + }, [query, getItems]); + + return { + items: items || [], + // The query that was used to retrieve the last set of items may not be the + // same as the current query as the items from the current query may not + // have been retrieved yet. This is useful when using the returns of this + // hook in other hooks. + usedQuery: usedQuery.current, + loadingState: + usedQuery.current === undefined + ? "loading-initial" + : loading + ? "loading" + : "loaded", + }; +} diff --git a/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts b/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts new file mode 100644 index 0000000000..36d020f97d --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts @@ -0,0 +1,74 @@ +import { BlockNoteEditor } from "@blocknote/core"; +import { useEffect, useState } from "react"; + +// Hook which handles keyboard navigation of a suggestion menu. Arrow keys are +// used to select a menu item, enter to execute it, and escape to close the +// menu. +export function useSuggestionMenuKeyboardNavigation( + editor: BlockNoteEditor, + items: Item[], + closeMenu: () => void, + onItemClick?: (item: Item) => void +) { + const [selectedIndex, setSelectedIndex] = useState(0); + + useEffect(() => { + const handleMenuNavigationKeys = (event: KeyboardEvent) => { + if (event.key === "ArrowUp") { + event.preventDefault(); + + if (items.length) { + setSelectedIndex((selectedIndex - 1 + items!.length) % items!.length); + } + + return true; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + + if (items.length) { + setSelectedIndex((selectedIndex + 1) % items!.length); + } + + return true; + } + + if (event.key === "Enter") { + event.preventDefault(); + + if (items.length) { + onItemClick?.(items[selectedIndex]); + } + + return true; + } + + if (event.key === "Escape") { + event.preventDefault(); + + closeMenu(); + + return true; + } + + return false; + }; + + editor.domElement.addEventListener( + "keydown", + handleMenuNavigationKeys, + true + ); + + return () => { + editor.domElement.removeEventListener( + "keydown", + handleMenuNavigationKeys, + true + ); + }; + }, [closeMenu, editor.domElement, items, selectedIndex, onItemClick]); + + return selectedIndex; +} diff --git a/packages/react/src/components/SuggestionMenu/mantine/MantineSuggestionMenu.tsx b/packages/react/src/components/SuggestionMenu/mantine/MantineSuggestionMenu.tsx new file mode 100644 index 0000000000..2030edebee --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/mantine/MantineSuggestionMenu.tsx @@ -0,0 +1,68 @@ +import { Loader, Menu } from "@mantine/core"; +import { Children, useMemo } from "react"; +import { DefaultSuggestionItem, SuggestionMenuProps } from "../types"; +import { MantineSuggestionMenuItem } from "./MantineSuggestionMenuItem"; + +export function MantineSuggestionMenu( + props: SuggestionMenuProps +) { + const { items, loadingState, selectedIndex, onItemClick } = props; + + const loader = + loadingState === "loading-initial" || loadingState === "loading" ? ( + + ) : null; + + const renderedItems = useMemo(() => { + let currentGroup: string | undefined = undefined; + const renderedItems = []; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.group !== currentGroup) { + currentGroup = item.group; + renderedItems.push( + {currentGroup} + ); + } + + renderedItems.push( + onItemClick?.(item)} + /> + ); + } + + return renderedItems; + }, [items, selectedIndex, onItemClick]); + + return ( + + event.preventDefault()} + className={"bn-slash-menu"}> + {renderedItems} + {Children.count(renderedItems) === 0 && + (props.loadingState === "loading" || + props.loadingState === "loaded") && ( + No match found + )} + {loader} + + + ); +} diff --git a/packages/react/src/components/SlashMenu/SlashMenuItem.tsx b/packages/react/src/components/SuggestionMenu/mantine/MantineSuggestionMenuItem.tsx similarity index 83% rename from packages/react/src/components/SlashMenu/SlashMenuItem.tsx rename to packages/react/src/components/SuggestionMenu/mantine/MantineSuggestionMenuItem.tsx index c5a5e499aa..2dac1583ab 100644 --- a/packages/react/src/components/SlashMenu/SlashMenuItem.tsx +++ b/packages/react/src/components/SuggestionMenu/mantine/MantineSuggestionMenuItem.tsx @@ -3,16 +3,14 @@ import { useEffect, useRef } from "react"; const MIN_LEFT_MARGIN = 5; -export type SlashMenuItemProps = { - name: string; - icon: JSX.Element; - hint: string | undefined; - shortcut?: string; - isSelected: boolean; - set: () => void; -}; - -export function SlashMenuItem(props: SlashMenuItemProps) { +export function MantineSuggestionMenuItem(props: { + title: string; + onClick: () => void; + subtext?: string; + icon?: JSX.Element; + badge?: string; + isSelected?: boolean; +}) { const itemRef = useRef(null); function isSelected() { @@ -52,7 +50,7 @@ export function SlashMenuItem(props: SlashMenuItemProps) { return ( { @@ -61,17 +59,15 @@ export function SlashMenuItem(props: SlashMenuItemProps) { }, 1); }} leftSection={props.icon} - rightSection={ - props.shortcut && {props.shortcut} - } + rightSection={props.badge && {props.badge}} ref={itemRef}> {/*Might need separate classes.*/} - {props.name} + {props.title} - {props.hint} + {props.subtext} diff --git a/packages/react/src/components/SuggestionMenu/types.tsx b/packages/react/src/components/SuggestionMenu/types.tsx new file mode 100644 index 0000000000..5e0f0efbb2 --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/types.tsx @@ -0,0 +1,21 @@ +/** + * Although any arbitrary data can be passed as suggestion items, the built-in + * UI components such as `MantineSuggestionMenu` expect a shape that conforms to DefaultSuggestionItem + */ +export type DefaultSuggestionItem = { + title: string; + group?: string; + subtext?: string; + icon?: JSX.Element; + badge?: string; +}; + +/** + * Props passed to a suggestion menu component + */ +export type SuggestionMenuProps = { + items: T[]; + loadingState: "loading-initial" | "loading" | "loaded"; + selectedIndex: number; + onItemClick?: (item: T) => void; +}; diff --git a/packages/react/src/components/TableHandles/BlockSchemaWithTable.ts b/packages/react/src/components/TableHandles/BlockSchemaWithTable.ts new file mode 100644 index 0000000000..92b2cf9fa4 --- /dev/null +++ b/packages/react/src/components/TableHandles/BlockSchemaWithTable.ts @@ -0,0 +1,6 @@ +import { BlockSchemaWithBlock, DefaultBlockSchema } from "@blocknote/core"; + +export type BlockSchemaWithTable = BlockSchemaWithBlock< + "table", + DefaultBlockSchema["table"] +>; diff --git a/packages/react/src/components/TableHandles/DefaultPositionedTableHandles.tsx b/packages/react/src/components/TableHandles/DefaultPositionedTableHandles.tsx new file mode 100644 index 0000000000..e7882af7c1 --- /dev/null +++ b/packages/react/src/components/TableHandles/DefaultPositionedTableHandles.tsx @@ -0,0 +1,115 @@ +import { + BlockNoteEditor, + DefaultInlineContentSchema, + DefaultStyleSchema, + InlineContentSchema, + StyleSchema, + TableHandlesState, +} from "@blocknote/core"; +import { DragEvent, FC, useState } from "react"; + +import { DragHandleMenuProps } from "../SideMenu/DragHandleMenu/DragHandleMenu"; +import { useUIPluginState } from "../../hooks/useUIPluginState"; +import { useTableHandlesPositioning } from "./hooks/useTableHandlesPositioning"; +import { DefaultTableHandle } from "./DefaultTableHandle"; +import { BlockSchemaWithTable } from "./BlockSchemaWithTable"; + +type NonUndefined = T extends undefined ? never : T; + +export type TableHandleProps< + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema +> = { + editor: BlockNoteEditor; + orientation: "row" | "column"; + index: number; + dragStart: (e: DragEvent) => void; + showOtherSide: () => void; + hideOtherSide: () => void; + tableHandleMenu?: FC>; +} & Pick, "block"> & + Pick< + NonUndefined["tableHandles"]>, + "dragEnd" | "freezeHandles" | "unfreezeHandles" + >; + +export const DefaultPositionedTableHandles = < + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema +>(props: { + editor: BlockNoteEditor; + tableHandle?: FC>; +}) => { + const callbacks = { + rowDragStart: props.editor.tableHandles!.rowDragStart, + colDragStart: props.editor.tableHandles!.colDragStart, + dragEnd: props.editor.tableHandles!.dragEnd, + freezeHandles: props.editor.tableHandles!.freezeHandles, + unfreezeHandles: props.editor.tableHandles!.unfreezeHandles, + }; + + const state = useUIPluginState( + props.editor.tableHandles!.onUpdate.bind(props.editor.tableHandles) + ); + const { rowHandle, colHandle } = useTableHandlesPositioning( + state?.show || false, + state?.referencePosCell || null, + state?.referencePosTable || null, + state?.draggingState + ? { + draggedCellOrientation: state?.draggingState?.draggedCellOrientation, + mousePos: state?.draggingState?.mousePos, + } + : undefined + ); + console.log(state); + console.log(rowHandle); + console.log(colHandle); + + const [hideRow, setHideRow] = useState(false); + const [hideCol, setHideCol] = useState(false); + + if (!rowHandle.isMounted || !colHandle.isMounted || !state) { + return null; + } + + const TableHandle = props.tableHandle || DefaultTableHandle; + + return ( + <> + {!hideRow && ( +
+ setHideCol(false)} + hideOtherSide={() => setHideCol(true)} + index={state.rowIndex} + block={state.block} + dragStart={callbacks.rowDragStart} + dragEnd={callbacks.dragEnd} + freezeHandles={callbacks.freezeHandles} + unfreezeHandles={callbacks.unfreezeHandles} + /> +
+ )} + {!hideCol && ( +
+ setHideRow(false)} + hideOtherSide={() => setHideRow(true)} + index={state.colIndex} + block={state.block} + dragStart={callbacks.colDragStart} + dragEnd={callbacks.dragEnd} + freezeHandles={callbacks.freezeHandles} + unfreezeHandles={callbacks.unfreezeHandles} + /> +
+ )} + + ); +}; diff --git a/packages/react/src/components/TableHandles/DefaultTableHandle.tsx b/packages/react/src/components/TableHandles/DefaultTableHandle.tsx index f0050ea81e..c59aa7e031 100644 --- a/packages/react/src/components/TableHandles/DefaultTableHandle.tsx +++ b/packages/react/src/components/TableHandles/DefaultTableHandle.tsx @@ -1,12 +1,18 @@ -import { BlockSchemaWithBlock, DefaultBlockSchema } from "@blocknote/core"; +import { + DefaultInlineContentSchema, + DefaultStyleSchema, + InlineContentSchema, + StyleSchema, +} from "@blocknote/core"; import { MdDragIndicator } from "react-icons/md"; import { TableHandle } from "./TableHandle"; -import type { TableHandleProps } from "./TableHandlePositioner"; +import type { TableHandleProps } from "./DefaultPositionedTableHandles"; export const DefaultTableHandle = < - BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]> + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema >( - props: TableHandleProps + props: TableHandleProps ) => ( diff --git a/packages/react/src/components/TableHandles/TableHandle.tsx b/packages/react/src/components/TableHandles/TableHandle.tsx index 1bd948e3e2..555226ad7b 100644 --- a/packages/react/src/components/TableHandles/TableHandle.tsx +++ b/packages/react/src/components/TableHandles/TableHandle.tsx @@ -1,17 +1,21 @@ import { - BlockSchemaWithBlock, - DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, + InlineContentSchema, mergeCSSClasses, + StyleSchema, } from "@blocknote/core"; import { Menu } from "@mantine/core"; import { ReactNode, useState } from "react"; + +import type { TableHandleProps } from "./DefaultPositionedTableHandles"; import { DefaultTableHandleMenu } from "./TableHandleMenu/DefaultTableHandleMenu"; -import type { TableHandleProps } from "./TableHandlePositioner"; export const TableHandle = < - BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]> + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema >( - props: TableHandleProps & { children: ReactNode } + props: TableHandleProps & { children: ReactNode } ) => { const TableHandleMenu = props.tableHandleMenu || DefaultTableHandleMenu; diff --git a/packages/react/src/components/TableHandles/TableHandlePositioner.tsx b/packages/react/src/components/TableHandles/TableHandlePositioner.tsx deleted file mode 100644 index 66ca7b5143..0000000000 --- a/packages/react/src/components/TableHandles/TableHandlePositioner.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { - BlockFromConfigNoChildren, - BlockNoteEditor, - BlockSchemaWithBlock, - DefaultBlockSchema, - InlineContentSchema, - StyleSchema, - TableHandlesProsemirrorPlugin, - TableHandlesState, -} from "@blocknote/core"; -import { offset, useFloating, useTransitionStyles } from "@floating-ui/react"; -import { DragEvent, FC, useEffect, useRef, useState } from "react"; - -import { DragHandleMenuProps } from "../SideMenu/DragHandleMenu/DragHandleMenu"; -import { DefaultTableHandle } from "./DefaultTableHandle"; - -export type TableHandleProps< - BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, - I extends InlineContentSchema, - S extends StyleSchema -> = Pick< - TableHandlesProsemirrorPlugin, - "dragEnd" | "freezeHandles" | "unfreezeHandles" -> & - Omit< - TableHandlesState, - | "rowIndex" - | "colIndex" - | "referencePosCell" - | "referencePosTable" - | "show" - | "draggingState" - > & { - orientation: "row" | "column"; - editor: BlockNoteEditor< - BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]> - >; - tableHandleMenu?: FC>; - dragStart: (e: DragEvent) => void; - index: number; - // TODO: document this, explain why we need it - showOtherSide: () => void; - hideOtherSide: () => void; - }; - -export const TableHandlesPositioner = < - BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, - I extends InlineContentSchema, - S extends StyleSchema ->(props: { - editor: BlockNoteEditor; - tableHandle?: FC>; -}) => { - const [show, setShow] = useState(false); - const [hideRow, setHideRow] = useState(false); - const [hideCol, setHideCol] = useState(false); - const [block, setBlock] = - useState>(); - - const [rowIndex, setRowIndex] = useState(); - const [colIndex, setColIndex] = useState(); - - const [draggedCellOrientation, setDraggedCellOrientation] = useState< - "row" | "col" | undefined - >(undefined); - const [mousePos, setMousePos] = useState(); - - const [, setForceUpdate] = useState(0); - - const referencePosCell = useRef(); - const referencePosTable = useRef(); - - const rowFloating = useFloating({ - open: show, - placement: "left", - middleware: [offset(-10)], - }); - const colFloating = useFloating({ - open: show, - placement: "top", - middleware: [offset(-12)], - }); - - const rowTransitionStyles = useTransitionStyles(rowFloating.context); - const colTransitionStyles = useTransitionStyles(colFloating.context); - - useEffect(() => { - return props.editor.tableHandles!.onUpdate((state) => { - // console.log("update", state.draggingState); - setShow(state.show); - setBlock(state.block); - setRowIndex(state.rowIndex); - setColIndex(state.colIndex); - - if (state.draggingState) { - setDraggedCellOrientation(state.draggingState.draggedCellOrientation); - setMousePos(state.draggingState.mousePos); - } else { - setDraggedCellOrientation(undefined); - setMousePos(undefined); - } - - setForceUpdate(Math.random()); - - referencePosCell.current = state.referencePosCell; - referencePosTable.current = state.referencePosTable; - - rowFloating.update(); - colFloating.update(); - }); - }, [colFloating, props.editor, rowFloating]); - - useEffect(() => { - rowFloating.refs.setReference({ - getBoundingClientRect: () => { - if (draggedCellOrientation === "row") { - return new DOMRect( - referencePosTable.current!.x, - mousePos!, - referencePosTable.current!.width, - 0 - ); - } - - return new DOMRect( - referencePosTable.current!.x, - referencePosCell.current!.y, - referencePosTable.current!.width, - referencePosCell.current!.height - ); - }, - }); - }, [draggedCellOrientation, mousePos, rowFloating.refs]); - - useEffect(() => { - colFloating.refs.setReference({ - getBoundingClientRect: () => { - if (draggedCellOrientation === "col") { - return new DOMRect( - mousePos!, - referencePosTable.current!.y, - 0, - referencePosTable.current!.height - ); - } - - return new DOMRect( - referencePosCell.current!.x, - referencePosTable.current!.y, - referencePosCell.current!.width, - referencePosTable.current!.height - ); - }, - }); - }, [colFloating.refs, draggedCellOrientation, mousePos]); - - const TableHandle = props.tableHandle || DefaultTableHandle; - - if ( - !rowTransitionStyles.isMounted || - !colTransitionStyles.isMounted || - (hideRow && hideCol) - ) { - return null; - } - - return ( - <> - {!hideRow && ( -
- setHideCol(false)} - hideOtherSide={() => setHideCol(true)} - /> -
- )} - {!hideCol && ( -
- setHideRow(false)} - hideOtherSide={() => setHideRow(true)} - /> -
- )} - - ); -}; diff --git a/packages/react/src/components/TableHandles/hooks/useTableHandlesPositioning.ts b/packages/react/src/components/TableHandles/hooks/useTableHandlesPositioning.ts new file mode 100644 index 0000000000..4f1406a81e --- /dev/null +++ b/packages/react/src/components/TableHandles/hooks/useTableHandlesPositioning.ts @@ -0,0 +1,144 @@ +import { offset, useFloating, useTransitionStyles } from "@floating-ui/react"; +import { useEffect, useMemo } from "react"; + +import { UiComponentPosition } from "../../../components-shared/UiComponentTypes"; + +function getBoundingClientRectRow( + referencePosCell: DOMRect | null, + referencePosTable: DOMRect | null, + draggingState?: { + draggedCellOrientation: "row" | "col"; + mousePos: number; + } +) { + if (draggingState && draggingState.draggedCellOrientation === "row") { + return new DOMRect( + referencePosTable!.x, + draggingState.mousePos, + referencePosTable!.width, + 0 + ); + } + + return new DOMRect( + referencePosTable!.x, + referencePosCell!.y, + referencePosTable!.width, + referencePosCell!.height + ); +} + +function getBoundingClientRectCol( + referencePosCell: DOMRect | null, + referencePosTable: DOMRect | null, + draggingState?: { + draggedCellOrientation: "row" | "col"; + mousePos: number; + } +) { + if (draggingState && draggingState.draggedCellOrientation === "col") { + return new DOMRect( + draggingState.mousePos, + referencePosTable!.y, + 0, + referencePosTable!.height + ); + } + + return new DOMRect( + referencePosCell!.x, + referencePosTable!.y, + referencePosCell!.width, + referencePosTable!.height + ); +} + +function useTableHandlePosition( + orientation: "row" | "col", + show: boolean, + referencePosCell: DOMRect | null, + referencePosTable: DOMRect | null, + draggingState?: { + draggedCellOrientation: "row" | "col"; + mousePos: number; + } +): UiComponentPosition { + const { refs, update, context, floatingStyles } = useFloating({ + open: show, + placement: orientation === "row" ? "left" : "top", + middleware: [offset(orientation === "row" ? -10 : -12)], + }); + + const { isMounted, styles } = useTransitionStyles(context); + + useEffect(() => { + update(); + }, [referencePosCell, referencePosTable, update]); + + useEffect(() => { + // TODO: Maybe throw error instead if null + if (referencePosCell === null || referencePosTable === null) { + return; + } + + refs.setReference({ + getBoundingClientRect: () => { + const fn = + orientation === "row" + ? getBoundingClientRectRow + : getBoundingClientRectCol; + return fn(referencePosCell, referencePosTable, draggingState); + }, + }); + }, [draggingState, orientation, referencePosCell, referencePosTable, refs]); + + return useMemo( + () => ({ + isMounted: isMounted, + ref: refs.setFloating, + style: { + display: "flex", + ...styles, + ...floatingStyles, + zIndex: 10000, + }, + }), + [floatingStyles, isMounted, refs.setFloating, styles] + ); +} + +export function useTableHandlesPositioning( + show: boolean, + referencePosCell: DOMRect | null, + referencePosTable: DOMRect | null, + draggingState?: { + draggedCellOrientation: "row" | "col"; + mousePos: number; + } +): { + rowHandle: UiComponentPosition; + colHandle: UiComponentPosition; +} { + const rowHandle = useTableHandlePosition( + "row", + show, + referencePosCell, + referencePosTable, + draggingState + ); + const colHandle = useTableHandlePosition( + "col", + show, + referencePosCell, + referencePosTable, + draggingState + ); + + return useMemo( + () => ({ + rowHandle, + colHandle, + }), + [colHandle, rowHandle] + ); +} diff --git a/packages/react/src/editor/BlockNoteDefaultUI.tsx b/packages/react/src/editor/BlockNoteDefaultUI.tsx new file mode 100644 index 0000000000..7358eeacfe --- /dev/null +++ b/packages/react/src/editor/BlockNoteDefaultUI.tsx @@ -0,0 +1,61 @@ +import { + BlockNoteEditor, + BlockSchema, + filterSuggestionItems, + InlineContentSchema, + StyleSchema, +} from "@blocknote/core"; +import { DefaultPositionedFormattingToolbar } from "../components/FormattingToolbar/DefaultPositionedFormattingToolbar"; +import { DefaultPositionedHyperlinkToolbar } from "../components/HyperlinkToolbar/DefaultPositionedHyperlinkToolbar"; +import { DefaultPositionedImageToolbar } from "../components/ImageToolbar/DefaultPositionedImageToolbar"; +import { DefaultPositionedSideMenu } from "../components/SideMenu/DefaultPositionedSideMenu"; +import { DefaultPositionedSuggestionMenu } from "../components/SuggestionMenu/DefaultPositionedSuggestionMenu"; +import { getDefaultReactSlashMenuItems } from "../components/SuggestionMenu/defaultReactSlashMenuItems"; +import { DefaultPositionedTableHandles } from "../components/TableHandles/DefaultPositionedTableHandles"; + +export function BlockNoteDefaultUI< + BSchema extends BlockSchema, + ISchema extends InlineContentSchema, + SSchema extends StyleSchema +>(props: { + editor: BlockNoteEditor; + formattingToolbar?: boolean; + hyperlinkToolbar?: boolean; + slashMenu?: boolean; + sideMenu?: boolean; + imageToolbar?: boolean; + tableHandles?: boolean; +}) { + return ( + <> + {props.formattingToolbar !== false && ( + + )} + {props.hyperlinkToolbar !== false && ( + + )} + {props.slashMenu !== false && ( + + filterSuggestionItems(getDefaultReactSlashMenuItems(), query) + } + // suggestionMenuComponent={MantineSuggestionMenu} + onItemClick={(item) => { + item.onItemClick(props.editor); + }} + triggerCharacter="/" + /> + )} + {props.sideMenu !== false && ( + + )} + {props.imageToolbar !== false && ( + + )} + {props.editor.blockSchema.table && props.tableHandles !== false && ( + + )} + + ); +} diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx index 451a7749e7..ee43cab368 100644 --- a/packages/react/src/editor/BlockNoteView.tsx +++ b/packages/react/src/editor/BlockNoteView.tsx @@ -17,16 +17,11 @@ import React, { useState, } from "react"; import usePrefersColorScheme from "use-prefers-color-scheme"; -import { FormattingToolbarPositioner } from "../components/FormattingToolbar/FormattingToolbarPositioner"; -import { HyperlinkToolbarPositioner } from "../components/HyperlinkToolbar/HyperlinkToolbarPositioner"; -import { ImageToolbarPositioner } from "../components/ImageToolbar/ImageToolbarPositioner"; -import { SideMenuPositioner } from "../components/SideMenu/SideMenuPositioner"; -import { SlashMenuPositioner } from "../components/SlashMenu/SlashMenuPositioner"; -import { TableHandlesPositioner } from "../components/TableHandles/TableHandlePositioner"; import { useEditorChange } from "../hooks/useEditorChange"; import { useEditorSelectionChange } from "../hooks/useEditorSelectionChange"; import { mergeRefs } from "../util/mergeRefs"; import { BlockNoteContext, useBlockNoteContext } from "./BlockNoteContext"; +import { BlockNoteDefaultUI } from "./BlockNoteDefaultUI"; import { Theme, applyBlockNoteCSSVariablesFromTheme, @@ -159,20 +154,7 @@ function BlockNoteViewComponent< }, [editable, editor]); const renderChildren = useMemo(() => { - return ( - children || ( - <> - - - - - - {editor.blockSchema.table && ( - - )} - - ) - ); + return children || ; }, [editor, children]); const context = useMemo(() => { diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index 29e6deba74..82805a38b4 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -478,6 +478,15 @@ gap: 0; } +.bn-container .bn-slash-menu .bn-slash-menu-loader { + height: 20px; + width: 100%; +} + +.bn-container .bn-slash-menu .bn-slash-menu-loader span { + background-color: var(--bn-colors-side-menu); +} + /* Side Menu & Drag Handle styling */ .bn-container .bn-side-menu { background-color: transparent; diff --git a/packages/react/src/hooks/useBlockNote.ts b/packages/react/src/hooks/useBlockNote.ts index a1f32437ee..1a1608ab1f 100644 --- a/packages/react/src/hooks/useBlockNote.ts +++ b/packages/react/src/hooks/useBlockNote.ts @@ -7,10 +7,8 @@ import { defaultBlockSpecs, defaultInlineContentSpecs, defaultStyleSpecs, - getBlockSchemaFromSpecs, } from "@blocknote/core"; import { DependencyList, useMemo } from "react"; -import { getDefaultReactSlashMenuItems } from "../slashMenuItems/defaultReactSlashMenuItems"; // TODO: document in docs export const createBlockNoteEditor = < @@ -19,13 +17,7 @@ export const createBlockNoteEditor = < SSpecs extends StyleSpecs >( options: Partial> -) => - BlockNoteEditor.create({ - slashMenuItems: getDefaultReactSlashMenuItems( - getBlockSchemaFromSpecs(options.blockSpecs || defaultBlockSpecs) - ), - ...options, - }); +) => BlockNoteEditor.create(options); /** * Main hook for importing a BlockNote editor into a React project diff --git a/packages/react/src/hooks/useUIElementPositioning.ts b/packages/react/src/hooks/useUIElementPositioning.ts new file mode 100644 index 0000000000..d691063120 --- /dev/null +++ b/packages/react/src/hooks/useUIElementPositioning.ts @@ -0,0 +1,51 @@ +import { + useFloating, + UseFloatingOptions, + useTransitionStyles, +} from "@floating-ui/react"; +import { useEffect, useMemo } from "react"; + +import { UiComponentPosition } from "../components-shared/UiComponentTypes"; + +export function useUIElementPositioning( + show: boolean, + referencePos: DOMRect | null, + zIndex: number, + options?: Partial +): UiComponentPosition { + const { refs, update, context, floatingStyles } = useFloating({ + open: show, + ...options, + }); + + const { isMounted, styles } = useTransitionStyles(context); + + useEffect(() => { + update(); + }, [referencePos, update]); + + useEffect(() => { + // TODO: Maybe throw error instead if null + if (referencePos === null) { + return; + } + + refs.setReference({ + getBoundingClientRect: () => referencePos, + }); + }, [referencePos, refs]); + + return useMemo( + () => ({ + isMounted, + ref: refs.setFloating, + style: { + display: "flex", + ...styles, + ...floatingStyles, + zIndex: zIndex, + }, + }), + [floatingStyles, isMounted, refs.setFloating, styles, zIndex] + ); +} diff --git a/packages/react/src/hooks/useUIPluginState.ts b/packages/react/src/hooks/useUIPluginState.ts new file mode 100644 index 0000000000..c49ae72ea1 --- /dev/null +++ b/packages/react/src/hooks/useUIPluginState.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; + +export function useUIPluginState( + onUpdate: (callback: (state: State) => void) => void +): State | undefined { + const [state, setState] = useState(); + + useEffect(() => { + return onUpdate((state) => { + setState({ ...state }); + }); + }, [onUpdate]); + + return state; +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 1cd5d0e958..ef21b5544a 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,5 +1,6 @@ // TODO: review directories export * from "./editor/BlockNoteContext"; +export * from "./editor/BlockNoteDefaultUI"; export * from "./editor/BlockNoteTheme"; export * from "./editor/BlockNoteView"; export * from "./editor/defaultThemes"; @@ -13,16 +14,17 @@ export * from "./components/FormattingToolbar/DefaultButtons/TextAlignButton"; export * from "./components/FormattingToolbar/DefaultButtons/ToggledStyleButton"; export * from "./components/FormattingToolbar/DefaultDropdowns/BlockTypeDropdown"; export * from "./components/FormattingToolbar/DefaultFormattingToolbar"; -export * from "./components/FormattingToolbar/FormattingToolbarPositioner"; +export * from "./components/FormattingToolbar/DefaultPositionedFormattingToolbar"; -export * from "./components/HyperlinkToolbar/HyperlinkToolbarPositioner"; +export * from "./components/HyperlinkToolbar/DefaultHyperlinkToolbar"; +export * from "./components/HyperlinkToolbar/DefaultPositionedHyperlinkToolbar"; export * from "./components/SideMenu/DefaultButtons/AddBlockButton"; export * from "./components/SideMenu/DefaultButtons/DragHandle"; +export * from "./components/SideMenu/DefaultPositionedSideMenu"; export * from "./components/SideMenu/DefaultSideMenu"; export * from "./components/SideMenu/SideMenu"; export * from "./components/SideMenu/SideMenuButton"; -export * from "./components/SideMenu/SideMenuPositioner"; export * from "./components/SideMenu/DragHandleMenu/DefaultButtons/BlockColorsButton"; export * from "./components/SideMenu/DragHandleMenu/DefaultButtons/RemoveBlockButton"; @@ -30,17 +32,21 @@ export * from "./components/SideMenu/DragHandleMenu/DefaultDragHandleMenu"; export * from "./components/SideMenu/DragHandleMenu/DragHandleMenu"; export * from "./components/SideMenu/DragHandleMenu/DragHandleMenuItem"; -export * from "./components/SlashMenu/DefaultSlashMenu"; -export * from "./components/SlashMenu/SlashMenuItem"; -export * from "./components/SlashMenu/SlashMenuPositioner"; -export * from "./slashMenuItems/ReactSlashMenuItem"; -export * from "./slashMenuItems/defaultReactSlashMenuItems"; +export * from "./components/SuggestionMenu/DefaultPositionedSuggestionMenu"; +export * from "./components/SuggestionMenu/DefaultSuggestionMenu"; +export * from "./components/SuggestionMenu/defaultReactSlashMenuItems"; +export * from "./components/SuggestionMenu/hooks/useCloseSuggestionMenuNoItems"; +export * from "./components/SuggestionMenu/hooks/useLoadSuggestionMenuItems"; +export * from "./components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation"; +export * from "./components/SuggestionMenu/mantine/MantineSuggestionMenu"; +export * from "./components/SuggestionMenu/mantine/MantineSuggestionMenuItem"; export * from "./components/ImageToolbar/DefaultImageToolbar"; -export * from "./components/ImageToolbar/ImageToolbarPositioner"; +export * from "./components/ImageToolbar/DefaultPositionedImageToolbar"; +export * from "./components/TableHandles/DefaultPositionedTableHandles"; export * from "./components/TableHandles/DefaultTableHandle"; -export * from "./components/TableHandles/TableHandlePositioner"; +export * from "./components/TableHandles/hooks/useTableHandlesPositioning"; export * from "./components-shared/Toolbar/Toolbar"; export * from "./components-shared/Toolbar/ToolbarButton"; diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index ed447edefe..ce85ef0bb2 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -101,9 +101,9 @@ export function BlockContentWrapper< // A function to create custom block for API consumers // we want to hide the tiptap node from API consumers and provide a simpler API surface instead export function createReactBlockSpec< - T extends CustomBlockConfig, - I extends InlineContentSchema, - S extends StyleSchema + const T extends CustomBlockConfig, + const I extends InlineContentSchema, + const S extends StyleSchema >( blockConfig: T, blockImplementation: ReactCustomBlockImplementation diff --git a/packages/react/src/slashMenuItems/ReactSlashMenuItem.ts b/packages/react/src/slashMenuItems/ReactSlashMenuItem.ts deleted file mode 100644 index 65ceb044f7..0000000000 --- a/packages/react/src/slashMenuItems/ReactSlashMenuItem.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { - BaseSlashMenuItem, - BlockSchema, - DefaultBlockSchema, - DefaultInlineContentSchema, - DefaultStyleSchema, - InlineContentSchema, - StyleSchema, -} from "@blocknote/core"; - -export type ReactSlashMenuItem< - BSchema extends BlockSchema = DefaultBlockSchema, - I extends InlineContentSchema = DefaultInlineContentSchema, - S extends StyleSchema = DefaultStyleSchema -> = BaseSlashMenuItem & { - group: string; - icon: JSX.Element; - hint?: string; - shortcut?: string; -}; diff --git a/packages/react/src/slashMenuItems/defaultReactSlashMenuItems.tsx b/packages/react/src/slashMenuItems/defaultReactSlashMenuItems.tsx deleted file mode 100644 index d72ec3e0d9..0000000000 --- a/packages/react/src/slashMenuItems/defaultReactSlashMenuItems.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { - BaseSlashMenuItem, - BlockSchema, - defaultBlockSchema, - DefaultBlockSchema, - getDefaultSlashMenuItems, - InlineContentSchema, - StyleSchema, -} from "@blocknote/core"; -import { - RiH1, - RiH2, - RiH3, - RiImage2Fill, - RiListOrdered, - RiListUnordered, - RiTable2, - RiText, -} from "react-icons/ri"; -import { formatKeyboardShortcut } from "@blocknote/core"; -import { ReactSlashMenuItem } from "./ReactSlashMenuItem"; - -const extraFields: Record< - string, - Omit< - ReactSlashMenuItem, - keyof BaseSlashMenuItem - > -> = { - Heading: { - group: "Headings", - icon: , - hint: "Used for a top-level heading", - shortcut: formatKeyboardShortcut("Mod-Alt-1"), - }, - "Heading 2": { - group: "Headings", - icon: , - hint: "Used for key sections", - shortcut: formatKeyboardShortcut("Mod-Alt-2"), - }, - "Heading 3": { - group: "Headings", - icon: , - hint: "Used for subsections and group headings", - shortcut: formatKeyboardShortcut("Mod-Alt-3"), - }, - "Numbered List": { - group: "Basic blocks", - icon: , - hint: "Used to display a numbered list", - shortcut: formatKeyboardShortcut("Mod-Shift-7"), - }, - "Bullet List": { - group: "Basic blocks", - icon: , - hint: "Used to display an unordered list", - shortcut: formatKeyboardShortcut("Mod-Shift-8"), - }, - Paragraph: { - group: "Basic blocks", - icon: , - hint: "Used for the body of your document", - shortcut: formatKeyboardShortcut("Mod-Alt-0"), - }, - Table: { - group: "Advanced", - icon: , - hint: "Used for for tables", - // shortcut: formatKeyboardShortcut("Mod-Alt-0"), - }, - Image: { - group: "Media", - icon: , - hint: "Insert an image", - }, -}; - -export function getDefaultReactSlashMenuItems< - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema ->( - // 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 any as BSchema -): ReactSlashMenuItem[] { - const slashMenuItems: BaseSlashMenuItem[] = - getDefaultSlashMenuItems(schema); - - return slashMenuItems.map((item) => ({ - ...item, - ...extraFields[item.name], - })); -} diff --git a/tests/src/end-to-end/draghandle/draghandle.test.ts b/tests/src/end-to-end/draghandle/draghandle.test.ts index e240831af1..a30050fc01 100644 --- a/tests/src/end-to-end/draghandle/draghandle.test.ts +++ b/tests/src/end-to-end/draghandle/draghandle.test.ts @@ -2,7 +2,6 @@ import { expect, Page } from "@playwright/test"; import { test } from "../../setup/setupScript"; import { BASE_URL, - BLOCK_CONTAINER_SELECTOR, DRAG_HANDLE_SELECTOR, DRAG_HANDLE_ADD_SELECTOR, DRAG_HANDLE_MENU_SELECTOR, @@ -97,21 +96,6 @@ test.describe("Check Draghandle functionality", () => { await compareDocToSnapshot(page, "draghandleadd"); }); - test("Clicking add button should show filter message", async () => { - const block = await page.locator(BLOCK_CONTAINER_SELECTOR); - await moveMouseOverElement(page, block); - await page.click(DRAG_HANDLE_ADD_SELECTOR); - const content = await page.waitForSelector(PARAGRAPH_SELECTOR); - // Get text in :before - const text = await content.evaluate((el) => - window - .getComputedStyle(el.children[0], ":before") - .getPropertyValue("content") - ); - - expect(text).toBe('"Type to filter"'); - }); - test("Clicking add button should open menu", async () => { await executeSlashCommand(page, "h1"); await page.keyboard.type("Hover over this text"); diff --git a/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-chromium-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-chromium-linux.png index b55be6257d..c1732a0a5a 100644 Binary files a/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-chromium-linux.png and b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-chromium-linux.png differ diff --git a/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-firefox-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-firefox-linux.png index 8f572c2d0a..4cb02f9c0a 100644 Binary files a/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-firefox-linux.png and b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-firefox-linux.png differ diff --git a/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-webkit-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-webkit-linux.png index 54cfa955ea..406d570690 100644 Binary files a/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-webkit-linux.png and b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-webkit-linux.png differ diff --git a/tests/src/utils/components/Editor.tsx b/tests/src/utils/components/Editor.tsx index 39c072d177..61dfb6dd0b 100644 --- a/tests/src/utils/components/Editor.tsx +++ b/tests/src/utils/components/Editor.tsx @@ -1,45 +1,63 @@ -import { defaultBlockSpecs } from "@blocknote/core"; +import { filterSuggestionItems } from "@blocknote/core"; import "@blocknote/core/style.css"; import { + BlockNoteDefaultUI, BlockNoteView, + DefaultPositionedSuggestionMenu, getDefaultReactSlashMenuItems, useBlockNote, } from "@blocknote/react"; import { Alert, insertAlert } from "../customblocks/Alert"; -import { Button, insertButton } from "../customblocks/Button"; -import { Embed, insertEmbed } from "../customblocks/Embed"; -import { Image, insertImage } from "../customblocks/Image"; -import { Separator, insertSeparator } from "../customblocks/Separator"; +import { Button } from "../customblocks/Button"; -export default function Editor() { - const blockSpecs = { - ...defaultBlockSpecs, - alert: Alert, - button: Button, - embed: Embed, - image: Image, - separator: Separator, - // toc: TableOfContents, - }; - - const slashMenuItems = [ - insertAlert, - insertButton, - insertEmbed, - insertImage, - insertSeparator, - // insertTableOfContents, - ]; +type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; + +const blockSpecs = { + // ...defaultBlockSpecs, + alert: Alert, + button: Button, + // embed: Embed, + // image: Image, + // separator: Separator, + // toc: TableOfContents, +}; + +const defaultItems = getDefaultReactSlashMenuItems(); + +const customItems = [ + insertAlert, + // insertButton, + // insertEmbed, + // insertImage, + // insertSeparator, + // insertTableOfContents, +]; +const allItems = [...defaultItems, ...customItems]; + +export default function Editor() { const editor = useBlockNote({ blockSpecs, - slashMenuItems: [...getDefaultReactSlashMenuItems(), ...slashMenuItems], }); console.log(editor); // Give tests a way to get prosemirror instance - (window as any).ProseMirror = editor?._tiptapEditor; - - return ; + (window as WindowWithProseMirror).ProseMirror = editor?._tiptapEditor; + // editor.insertBlocks([{ + // type:"" + // }]) + // TODO: how to customize slashmenu + return ( + + + filterSuggestionItems(allItems, query)} + onItemClick={(i) => i.onItemClick(editor)} + // suggestionMenuComponent={MantineSuggestionMenu} + triggerCharacter="/" + /> + + ); } diff --git a/tests/src/utils/customblocks/Alert.tsx b/tests/src/utils/customblocks/Alert.tsx index 1585f81de3..fcf4608c65 100644 --- a/tests/src/utils/customblocks/Alert.tsx +++ b/tests/src/utils/customblocks/Alert.tsx @@ -1,5 +1,11 @@ -import { BlockSchema, createBlockSpec, defaultProps } from "@blocknote/core"; -import { ReactSlashMenuItem } from "@blocknote/react"; +import { + BlockNoteEditor, + BlockSchemaWithBlock, + PartialBlock, + createBlockSpec, + defaultProps, +} from "@blocknote/core"; + import { RiAlertFill } from "react-icons/ri"; const values = { warning: { @@ -120,20 +126,21 @@ export const Alert = createBlockSpec( } ); -export const insertAlert: ReactSlashMenuItem = { - name: "Insert Alert", - execute: (editor) => { - // editor.topLevelBlocks[0] - editor.insertBlocks( - [ - { - type: "alert", - }, - ], - editor.getTextCursorPosition().block, - "after" - ); +export const insertAlert = { + title: "Insert Alert", + onItemClick: (editor: BlockNoteEditor) => { + const block: PartialBlock< + BlockSchemaWithBlock<"alert", (typeof Alert)["config"]>, + any, + any + > = { + type: "alert", + }; + + editor.insertBlocks([block], editor.getTextCursorPosition().block, "after"); }, + subtext: "Insert an alert block to emphasize text", + icon: , aliases: [ "alert", "notification", @@ -143,7 +150,5 @@ export const insertAlert: ReactSlashMenuItem = { "info", "success", ], - group: "Media", - icon: , - hint: "Insert an alert block to emphasize text", + group: "Other", }; diff --git a/tests/src/utils/customblocks/Button.tsx b/tests/src/utils/customblocks/Button.tsx index 7e2a46d5ac..e0e50507b5 100644 --- a/tests/src/utils/customblocks/Button.tsx +++ b/tests/src/utils/customblocks/Button.tsx @@ -1,9 +1,8 @@ import { - BlockSchemaWithBlock, + BlockNoteEditor, createBlockSpec, defaultProps, } from "@blocknote/core"; -import { ReactSlashMenuItem } from "@blocknote/react"; import { RiRadioButtonFill } from "react-icons/ri"; export const Button = createBlockSpec( @@ -38,13 +37,9 @@ export const Button = createBlockSpec( } ); -export const insertButton: ReactSlashMenuItem< - BlockSchemaWithBlock<"button", typeof Button.config>, - any, - any -> = { - name: "Insert Button", - execute: (editor) => { +export const insertButton = { + title: "Insert Button", + onItemClick: (editor: BlockNoteEditor) => { editor.insertBlocks( [ { @@ -55,8 +50,8 @@ export const insertButton: ReactSlashMenuItem< "after" ); }, - aliases: ["button", "click", "action"], - group: "Media", + subtext: "Insert a button which inserts a block below it", icon: , - hint: "Insert a button which inserts a block below it", + aliases: ["button", "click", "action"], + group: "Other", }; diff --git a/tests/src/utils/customblocks/Embed.tsx b/tests/src/utils/customblocks/Embed.tsx index d63d022122..5016237235 100644 --- a/tests/src/utils/customblocks/Embed.tsx +++ b/tests/src/utils/customblocks/Embed.tsx @@ -1,5 +1,5 @@ -import { BlockSchemaWithBlock, createBlockSpec } from "@blocknote/core"; -import { ReactSlashMenuItem } from "@blocknote/react"; +import { BlockNoteEditor, createBlockSpec } from "@blocknote/core"; + import { RiLayout5Fill } from "react-icons/ri"; export const Embed = createBlockSpec( @@ -28,13 +28,9 @@ export const Embed = createBlockSpec( } ); -export const insertEmbed: ReactSlashMenuItem< - BlockSchemaWithBlock<"embed", typeof Embed.config>, - any, - any -> = { - name: "Insert Embedded Website", - execute: (editor) => { +export const insertEmbed = { + title: "Insert Embedded Website", + onItemClick: (editor: BlockNoteEditor) => { const src = prompt("Enter website URL"); editor.insertBlocks( [ @@ -49,8 +45,8 @@ export const insertEmbed: ReactSlashMenuItem< "after" ); }, - aliases: ["embedded", "website", "site", "link", "url"], - group: "Media", + subtext: "Insert an embedded website", icon: , - hint: "Insert an embedded website", + aliases: ["embedded", "website", "site", "link", "url"], + group: "Other", }; diff --git a/tests/src/utils/customblocks/Image.tsx b/tests/src/utils/customblocks/Image.tsx index 789a1900d5..36e0008f4c 100644 --- a/tests/src/utils/customblocks/Image.tsx +++ b/tests/src/utils/customblocks/Image.tsx @@ -1,9 +1,8 @@ import { - BlockSchemaWithBlock, + BlockNoteEditor, createBlockSpec, defaultProps, } from "@blocknote/core"; -import { ReactSlashMenuItem } from "@blocknote/react"; import { RiImage2Fill } from "react-icons/ri"; export const Image = createBlockSpec( { @@ -49,13 +48,9 @@ export const Image = createBlockSpec( } ); -export const insertImage: ReactSlashMenuItem< - BlockSchemaWithBlock<"image", typeof Image.config>, - any, - any -> = { - name: "Insert Image", - execute: (editor) => { +export const insertImage = { + title: "Insert Image", + onItemClick: (editor: BlockNoteEditor) => { const src = prompt("Enter image URL") || "https://via.placeholder.com/1000"; editor.insertBlocks( [ @@ -70,8 +65,8 @@ export const insertImage: ReactSlashMenuItem< "after" ); }, - aliases: ["image", "img", "picture", "media"], - group: "Media", + subtext: "Insert an image", icon: , - hint: "Insert an image", + aliases: ["image", "img", "picture", "media"], + group: "Other", }; diff --git a/tests/src/utils/customblocks/ReactAlert.tsx b/tests/src/utils/customblocks/ReactAlert.tsx index 263281a32e..d2d3175cb4 100644 --- a/tests/src/utils/customblocks/ReactAlert.tsx +++ b/tests/src/utils/customblocks/ReactAlert.tsx @@ -1,5 +1,5 @@ -import { BlockSchemaWithBlock, defaultProps } from "@blocknote/core"; -import { ReactSlashMenuItem, createReactBlockSpec } from "@blocknote/react"; +import { BlockNoteEditor, defaultProps } from "@blocknote/core"; +import { createReactBlockSpec } from "@blocknote/react"; import { useEffect, useState } from "react"; import { RiAlertFill } from "react-icons/ri"; @@ -113,11 +113,9 @@ export const ReactAlert = createReactBlockSpec( }, } ); -export const insertReactAler: ReactSlashMenuItem< - BlockSchemaWithBlock<"reactAlert", typeof ReactAlert.config> -> = { - name: "Insert React Alert", - execute: (editor) => { +export const insertReactAlert = { + title: "Insert React Alert", + onItemClick: (editor: BlockNoteEditor) => { editor.insertBlocks( [ { @@ -128,6 +126,8 @@ export const insertReactAler: ReactSlashMenuItem< "after" ); }, + subtext: "Insert an alert block to emphasize text", + icon: , aliases: [ "react", "reactAlert", @@ -140,7 +140,5 @@ export const insertReactAler: ReactSlashMenuItem< "info", "success", ], - group: "Media", - icon: , - hint: "Insert an alert block to emphasize text", + group: "Other", }; diff --git a/tests/src/utils/customblocks/ReactImage.tsx b/tests/src/utils/customblocks/ReactImage.tsx index 5de8b4f56f..afd1d0facb 100644 --- a/tests/src/utils/customblocks/ReactImage.tsx +++ b/tests/src/utils/customblocks/ReactImage.tsx @@ -1,5 +1,5 @@ -import { BlockSchemaWithBlock, defaultProps } from "@blocknote/core"; -import { ReactSlashMenuItem, createReactBlockSpec } from "@blocknote/react"; +import { BlockNoteEditor, defaultProps } from "@blocknote/core"; +import { createReactBlockSpec } from "@blocknote/react"; import { RiImage2Fill } from "react-icons/ri"; export const ReactImage = createReactBlockSpec( @@ -36,11 +36,9 @@ export const ReactImage = createReactBlockSpec( } ); -export const insertReactImage: ReactSlashMenuItem< - BlockSchemaWithBlock<"reactImage", typeof ReactImage.config> -> = { - name: "Insert React Image", - execute: (editor) => { +export const insertReactImage = { + title: "Insert React Image", + onItemClick: (editor: BlockNoteEditor) => { const src = prompt("Enter image URL") || "https://via.placeholder.com/1000"; editor.insertBlocks( [ @@ -55,6 +53,8 @@ export const insertReactImage: ReactSlashMenuItem< "after" ); }, + subtext: "Insert an image", + icon: , aliases: [ "react", "reactImage", @@ -65,6 +65,4 @@ export const insertReactImage: ReactSlashMenuItem< "media", ], group: "Media", - icon: , - hint: "Insert an image", }; diff --git a/tests/src/utils/customblocks/Separator.tsx b/tests/src/utils/customblocks/Separator.tsx index d01e6c5ca7..d1936abcd0 100644 --- a/tests/src/utils/customblocks/Separator.tsx +++ b/tests/src/utils/customblocks/Separator.tsx @@ -1,5 +1,5 @@ -import { BlockSchemaWithBlock, createBlockSpec } from "@blocknote/core"; -import { ReactSlashMenuItem } from "@blocknote/react"; +import { BlockNoteEditor, createBlockSpec } from "@blocknote/core"; + import { RiSeparator } from "react-icons/ri"; export const Separator = createBlockSpec( @@ -30,11 +30,9 @@ export const Separator = createBlockSpec( } ); -export const insertSeparator: ReactSlashMenuItem< - BlockSchemaWithBlock<"separator", typeof Separator.config> -> = { - name: "Insert Separator", - execute: (editor) => { +export const insertSeparator = { + title: "Insert Separator", + onItemClick: (editor: BlockNoteEditor) => { editor.insertBlocks( [ { @@ -45,9 +43,8 @@ export const insertSeparator: ReactSlashMenuItem< "after" ); }, - - aliases: ["separator", "horizontal", "line", "rule"], - group: "Media", + subtext: "Insert a button which inserts a block below it", icon: , - hint: "Insert a button which inserts a block below it", + aliases: ["separator", "horizontal", "line", "rule"], + group: "Other", }; diff --git a/tests/src/utils/slashmenu.ts b/tests/src/utils/slashmenu.ts index ec611d71a6..b6a5129b1e 100644 --- a/tests/src/utils/slashmenu.ts +++ b/tests/src/utils/slashmenu.ts @@ -10,4 +10,5 @@ export async function executeSlashCommand(page: Page, command: string) { await openSlashMenu(page); await page.keyboard.type(command); await page.keyboard.press("Enter"); + await page.waitForTimeout(500); }