diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/.bnexample.json b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/.bnexample.json new file mode 100644 index 0000000000..16f9aea065 --- /dev/null +++ b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/.bnexample.json @@ -0,0 +1,11 @@ +{ + "playground": true, + "docs": true, + "author": "areknawo", + "tags": [ + "Intermediate", + "UI Components", + "Formatting Toolbar", + "Appearance & Styling" + ] +} diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/App.tsx b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/App.tsx new file mode 100644 index 0000000000..62c82ddfe8 --- /dev/null +++ b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/App.tsx @@ -0,0 +1,42 @@ +import "@blocknote/core/fonts/inter.css"; +import { + ExperimentalMobileFormattingToolbarController, + useCreateBlockNote, +} from "@blocknote/react"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; + +import "./style.css"; + +export default function App() { + // Creates a new editor instance. + const editor = useCreateBlockNote({ + initialContent: [ + { + type: "paragraph", + content: "Welcome to this demo!", + }, + { + type: "paragraph", + content: + "Check out the experimental mobile formatting toolbar by selecting some text (best experienced on a mobile device).", + }, + { + type: "paragraph", + }, + ], + }); + + // Renders the editor instance using a React component. + return ( + // Disables the default formatting toolbar and re-adds it without the + // `FormattingToolbarController` component. You may have seen + // `FormattingToolbarController` used in other examples, but we omit it here + // as we want to control the position and visibility ourselves. BlockNote + // also uses the `FormattingToolbarController` when displaying the + // Formatting Toolbar by default. + + + + ); +} diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/README.md b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/README.md new file mode 100644 index 0000000000..7998433781 --- /dev/null +++ b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/README.md @@ -0,0 +1,10 @@ +# Experimental Mobile Formatting Toolbar + +This example shows how to use the experimental mobile formatting toolbar, which uses [Visual Viewport API](https://developer.mozilla.org/en-US/docs/Web/API/Visual_Viewport_API) to position the toolbar right above the virtual keyboard on mobile devices. + +Controller is currently marked **experimental** due to the flickering issue with positioning (caused by delays of the Visual Viewport API) + +**Relevant Docs:** + +- [Changing the Formatting Toolbar](/docs/ui-components/formatting-toolbar#changing-the-formatting-toolbar) +- [Editor Setup](/docs/editor-basics/setup) diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/index.html b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/index.html new file mode 100644 index 0000000000..6914836688 --- /dev/null +++ b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/index.html @@ -0,0 +1,14 @@ + + + + + + Experimental Mobile Formatting Toolbar + + +
+ + + diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/main.tsx b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/main.tsx new file mode 100644 index 0000000000..f88b490fbd --- /dev/null +++ b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + +); diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/package.json b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/package.json new file mode 100644 index 0000000000..e6377e75b1 --- /dev/null +++ b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/package.json @@ -0,0 +1,37 @@ +{ + "name": "@blocknote/example-experimental-mobile-formatting-toolbar", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint . --max-warnings 0" + }, + "dependencies": { + "@blocknote/core": "latest", + "@blocknote/react": "latest", + "@blocknote/ariakit": "latest", + "@blocknote/mantine": "latest", + "@blocknote/shadcn": "latest", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.0.25", + "@types/react-dom": "^18.0.9", + "@vitejs/plugin-react": "^4.3.1", + "eslint": "^8.10.0", + "vite": "^5.3.4" + }, + "eslintConfig": { + "extends": [ + "../../../.eslintrc.js" + ] + }, + "eslintIgnore": [ + "dist" + ] +} \ No newline at end of file diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/style.css b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/style.css new file mode 100644 index 0000000000..839dd7baa0 --- /dev/null +++ b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/style.css @@ -0,0 +1,9 @@ +.bn-container { + display: flex; + flex-direction: column-reverse; + gap: 8px; +} + +.bn-formatting-toolbar { + margin-inline: auto; +} \ No newline at end of file diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/tsconfig.json b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/tsconfig.json new file mode 100644 index 0000000000..1bd8ab3c57 --- /dev/null +++ b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/tsconfig.json @@ -0,0 +1,36 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true + }, + "include": [ + "." + ], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} \ No newline at end of file diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/vite.config.ts b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/vite.config.ts new file mode 100644 index 0000000000..f62ab20bc2 --- /dev/null +++ b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/vite.config.ts @@ -0,0 +1,32 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite"; +// import eslintPlugin from "vite-plugin-eslint"; +// https://vitejs.dev/config/ +export default defineConfig((conf) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/" + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/" + ), + } as any), + }, +})); diff --git a/package-lock.json b/package-lock.json index 2addfdcf33..fc84d7aa47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24769,9 +24769,9 @@ } }, "node_modules/prosemirror-view": { - "version": "1.34.2", - "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.34.2.tgz", - "integrity": "sha512-tPX/V2Xd70vrAGQ/V9CppJtPKnQyQMypJGlLylvdI94k6JaG+4P6fVmXPR1zc1eVTW0gq3c6zsfqwJKCRLaG9Q==", + "version": "1.38.0", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.38.0.tgz", + "integrity": "sha512-O45kxXQTaP9wPdXhp8TKqCR+/unS/gnfg9Q93svQcB3j0mlp2XSPAmsPefxHADwzC+fbNS404jqRxm3UQaGvgw==", "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -30151,7 +30151,7 @@ "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.6.1", "prosemirror-transform": "^1.9.0", - "prosemirror-view": "^1.33.7", + "prosemirror-view": "^1.38.0", "rehype-format": "^5.0.0", "rehype-parse": "^8.0.4", "rehype-remark": "^9.1.2", @@ -30544,7 +30544,7 @@ "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.3.7", "prosemirror-transform": "^1.9.0", - "prosemirror-view": "^1.33.7", + "prosemirror-view": "^1.38.0", "react-icons": "^5.2.1" }, "devDependencies": { diff --git a/packages/ariakit/src/style.css b/packages/ariakit/src/style.css index bf1dcd4ced..f966bee877 100644 --- a/packages/ariakit/src/style.css +++ b/packages/ariakit/src/style.css @@ -11,6 +11,10 @@ gap: 0.5rem; } +.bn-toolbar.bn-ak-toolbar { + overflow-x: auto; + max-width: 100vw; +} .bn-toolbar .bn-ak-button { width: unset; } diff --git a/packages/core/package.json b/packages/core/package.json index f7ea47f06a..cda0a94fb3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -85,7 +85,7 @@ "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.6.1", "prosemirror-transform": "^1.9.0", - "prosemirror-view": "^1.33.7", + "prosemirror-view": "^1.38.0", "rehype-format": "^5.0.0", "rehype-parse": "^8.0.4", "rehype-remark": "^9.1.2", diff --git a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts index 87e2c7c81e..ed26ccb8c6 100644 --- a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts +++ b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts @@ -125,8 +125,7 @@ export function selectedFragmentToHTML< } // Uses default ProseMirror clipboard serialization. - const clipboardHTML: string = (pmView as any).__serializeForClipboard( - view, + const clipboardHTML: string = view.serializeForClipboard( view.state.selection.content() ).dom.innerHTML; diff --git a/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts b/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts index 174cf0654a..48c7c41b24 100644 --- a/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts +++ b/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts @@ -5,6 +5,7 @@ import { TableRow } from "@tiptap/extension-table-row"; import { Node as PMNode } from "prosemirror-model"; import { TableView } from "prosemirror-tables"; +import { NodeView } from "prosemirror-view"; import { createBlockSpecFromStronglyTypedTiptapNode, createStronglyTypedTiptapNode, @@ -101,7 +102,7 @@ export const TableBlockContent = createStronglyTypedTiptapNode({ return new BlockNoteTableView(node, EMPTY_CELL_WIDTH, { ...(this.options.domAttributes?.blockContent || {}), ...HTMLAttributes, - }); + }) as NodeView; }; }, }); diff --git a/packages/core/src/extensions/SideMenu/dragging.ts b/packages/core/src/extensions/SideMenu/dragging.ts index 285e5fba68..581ed81bd3 100644 --- a/packages/core/src/extensions/SideMenu/dragging.ts +++ b/packages/core/src/extensions/SideMenu/dragging.ts @@ -1,6 +1,5 @@ import { Node } from "prosemirror-model"; import { NodeSelection, Selection } from "prosemirror-state"; -import * as pmView from "prosemirror-view"; import { EditorView } from "prosemirror-view"; import { createExternalHTMLExporter } from "../../api/exporters/html/externalHTMLExporter.js"; @@ -177,10 +176,8 @@ export function dragStart< const selectedSlice = view.state.selection.content(); const schema = editor.pmSchema; - const clipboardHTML = (pmView as any).__serializeForClipboard( - view, - selectedSlice - ).dom.innerHTML; + const clipboardHTML = + view.serializeForClipboard(selectedSlice).dom.innerHTML; const externalHTMLExporter = createExternalHTMLExporter(schema, editor); diff --git a/packages/mantine/src/style.css b/packages/mantine/src/style.css index 55b5301fb0..9fe02136fa 100644 --- a/packages/mantine/src/style.css +++ b/packages/mantine/src/style.css @@ -134,6 +134,10 @@ overflow: auto; } +.bn-mantine .mantine-Button-root[aria-controls*="dropdown"] { + min-width: fit-content; +} + /* Toolbar styling */ .bn-mantine .bn-toolbar { background-color: var(--bn-colors-menu-background); @@ -144,6 +148,8 @@ gap: 2px; padding: 2px; width: fit-content; + overflow-x: auto; + max-width: 100vw; } .bn-mantine .bn-toolbar:empty { diff --git a/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx new file mode 100644 index 0000000000..7110ecdf88 --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx @@ -0,0 +1,89 @@ +import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core"; +import { UseFloatingOptions } from "@floating-ui/react"; +import { FC, CSSProperties, useMemo, useRef, useState, useEffect } from "react"; +import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; +import { useUIPluginState } from "../../hooks/useUIPluginState.js"; +import { FormattingToolbar } from "./FormattingToolbar.js"; +import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; + +/** + * Experimental formatting toolbar controller for mobile devices. + * Uses Visual Viewport API to position the toolbar above the virtual keyboard. + * + * Currently marked experimental due to the flickering issue with positioning cause by the use of the API (and likely a delay in its updates). + */ +export const ExperimentalMobileFormattingToolbarController = (props: { + formattingToolbar?: FC; + floatingOptions?: Partial; +}) => { + const [transform, setTransform] = useState("none"); + const divRef = useRef(null); + const editor = useBlockNoteEditor< + BlockSchema, + InlineContentSchema, + StyleSchema + >(); + const state = useUIPluginState( + editor.formattingToolbar.onUpdate.bind(editor.formattingToolbar) + ); + const style = useMemo(() => { + return { + display: "flex", + position: "fixed", + bottom: 0, + zIndex: 3000, + transform, + }; + }, [transform]); + + useEffect(() => { + const viewport = window.visualViewport!; + function viewportHandler() { + // Calculate the offset necessary to set the toolbar above the virtual keyboard (using the offset info from the visualViewport) + const layoutViewport = document.body; + const offsetLeft = viewport.offsetLeft; + const offsetTop = + viewport.height - + layoutViewport.getBoundingClientRect().height + + viewport.offsetTop; + + setTransform( + `translate(${offsetLeft}px, ${offsetTop}px) scale(${ + 1 / viewport.scale + })` + ); + } + window.visualViewport!.addEventListener("scroll", viewportHandler); + window.visualViewport!.addEventListener("resize", viewportHandler); + viewportHandler(); + + return () => { + window.visualViewport!.removeEventListener("scroll", viewportHandler); + window.visualViewport!.removeEventListener("resize", viewportHandler); + }; + }, []); + + if (!state) { + return null; + } + + if (!state.show && divRef.current) { + // The component is fading out. Use the previous state to render the toolbar with innerHTML, + // because otherwise the toolbar will quickly flickr (i.e.: show a different state) while fading out, + // which looks weird + return ( +
+ ); + } + + const Component = props.formattingToolbar || FormattingToolbar; + + return ( +
+ +
+ ); +}; diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx index f4bab49b83..43937bc3f7 100644 --- a/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx @@ -4,7 +4,7 @@ import { InlineContentSchema, StyleSchema, } from "@blocknote/core"; -import { UseFloatingOptions, flip, offset } from "@floating-ui/react"; +import { UseFloatingOptions, flip, offset, shift } from "@floating-ui/react"; import { FC, useMemo, useRef, useState } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; @@ -80,7 +80,7 @@ export const FormattingToolbarController = (props: { 3000, { placement, - middleware: [offset(10), flip()], + middleware: [offset(10), shift(), flip()], onOpenChange: (open, _event) => { // console.log("change", event); if (!open) { diff --git a/packages/react/src/hooks/useUIElementPositioning.ts b/packages/react/src/hooks/useUIElementPositioning.ts index 7c2f47d56e..328e481eb9 100644 --- a/packages/react/src/hooks/useUIElementPositioning.ts +++ b/packages/react/src/hooks/useUIElementPositioning.ts @@ -17,7 +17,6 @@ export function useUIElementPositioning( open: show, ...options, }); - const { isMounted, styles } = useTransitionStyles(context); // handle "escape" and other dismiss events, these will add some listeners to @@ -35,14 +34,13 @@ export function useUIElementPositioning( if (referencePos === null) { return; } - refs.setReference({ getBoundingClientRect: () => referencePos, }); }, [referencePos, refs]); - return useMemo( - () => ({ + return useMemo(() => { + return { isMounted, ref: refs.setFloating, style: { @@ -53,15 +51,14 @@ export function useUIElementPositioning( }, getFloatingProps, getReferenceProps, - }), - [ - floatingStyles, - isMounted, - refs.setFloating, - styles, - zIndex, - getFloatingProps, - getReferenceProps, - ] - ); + }; + }, [ + floatingStyles, + isMounted, + refs.setFloating, + styles, + zIndex, + getFloatingProps, + getReferenceProps, + ]); } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 713325abfa..1ae092b267 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -26,6 +26,7 @@ export * from "./components/FormattingToolbar/DefaultButtons/TextAlignButton.js" export * from "./components/FormattingToolbar/DefaultSelects/BlockTypeSelect.js"; export * from "./components/FormattingToolbar/FormattingToolbar.js"; export * from "./components/FormattingToolbar/FormattingToolbarController.js"; +export * from "./components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.js"; export * from "./components/FormattingToolbar/FormattingToolbarProps.js"; export * from "./components/LinkToolbar/DefaultButtons/DeleteLinkButton.js"; diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index 03c75c7039..91bac94d0e 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -20,7 +20,6 @@ import { wrapInBlockStructure, } from "@blocknote/core"; import { - NodeView, NodeViewContent, NodeViewProps, NodeViewWrapper, @@ -190,7 +189,7 @@ export function createReactBlockSpec< { className: "bn-react-node-view-renderer", } - )(props) as NodeView; + )(props); if (blockConfig.isSelectable === false) { applyNonSelectableBlockFix(nodeView, this.editor); diff --git a/packages/shadcn/src/style.css b/packages/shadcn/src/style.css index 102e80d8b9..f353cccd33 100644 --- a/packages/shadcn/src/style.css +++ b/packages/shadcn/src/style.css @@ -169,3 +169,7 @@ .bn-shadcn .bn-extend-button-add-remove-rows { cursor: row-resize; } +.bn-shadcn .bn-toolbar { + overflow-x: auto; + max-width: 100vw; +} diff --git a/packages/xl-multi-column/package.json b/packages/xl-multi-column/package.json index a702384c07..9d100fcf36 100644 --- a/packages/xl-multi-column/package.json +++ b/packages/xl-multi-column/package.json @@ -52,7 +52,7 @@ "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.3.7", "prosemirror-transform": "^1.9.0", - "prosemirror-view": "^1.33.7", + "prosemirror-view": "^1.38.0", "react-icons": "^5.2.1" }, "devDependencies": { diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 67e6090d2e..c95bd0a46b 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -621,6 +621,27 @@ "slug": "ui-components" } }, + { + "projectSlug": "experimental-mobile-formatting-toolbar", + "fullSlug": "ui-components/experimental-mobile-formatting-toolbar", + "pathFromRoot": "examples/03-ui-components/14-experimental-mobile-formatting-toolbar", + "config": { + "playground": true, + "docs": true, + "author": "areknawo", + "tags": [ + "Intermediate", + "UI Components", + "Formatting Toolbar", + "Appearance & Styling" + ] + }, + "title": "Experimental Mobile Formatting Toolbar", + "group": { + "pathFromRoot": "examples/03-ui-components", + "slug": "ui-components" + } + }, { "projectSlug": "link-toolbar-buttons", "fullSlug": "ui-components/link-toolbar-buttons", diff --git a/playground/src/style.css b/playground/src/style.css index de46ca3abe..7e716f2b47 100644 --- a/playground/src/style.css +++ b/playground/src/style.css @@ -13,3 +13,13 @@ body { .mantine-AppShell-navbar { background-color: #f7f7f5; } + +@media (max-width: 767px) { + .mantine-AppShell-navbar { + display: none !important; + } + + .mantine-AppShell-main { + padding: 0 !important; + } +}