Skip to content

Commit 5a43f26

Browse files
areknawomatthewlipskiYousefED
authored
feat: Mobile-optimized formatting toolbar (#1284)
* fix: Import only from a single Shiki bundle * fix: Select options styling on Chrome, Linux * fix: Detect proper language ID when parsing from HTML * fix: Support "typescriptreact" alias for tsx * minor code change * remove setting cursor position when inserting code block * experiment: Visual Viewport API * experiment: Directly setting transform * feat: Experimental mobile formatting toolbar controller * feat: Optimize formatting toolbar controller for mobile * fix: Move overflow-x to bn-toolbar * chore: Describe the experimental mobile formatting toolbar controller * chore: Add an example for the experimental mobile formatting toolbar controller * fix: Experimental toolbar positioning --------- Co-authored-by: matthewlipski <[email protected]> Co-authored-by: yousefed <[email protected]>
1 parent 71792c2 commit 5a43f26

File tree

18 files changed

+351
-17
lines changed

18 files changed

+351
-17
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"playground": true,
3+
"docs": true,
4+
"author": "areknawo",
5+
"tags": [
6+
"Intermediate",
7+
"UI Components",
8+
"Formatting Toolbar",
9+
"Appearance & Styling"
10+
]
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import "@blocknote/core/fonts/inter.css";
2+
import {
3+
ExperimentalMobileFormattingToolbarController,
4+
useCreateBlockNote,
5+
} from "@blocknote/react";
6+
import { BlockNoteView } from "@blocknote/mantine";
7+
import "@blocknote/mantine/style.css";
8+
9+
import "./style.css";
10+
11+
export default function App() {
12+
// Creates a new editor instance.
13+
const editor = useCreateBlockNote({
14+
initialContent: [
15+
{
16+
type: "paragraph",
17+
content: "Welcome to this demo!",
18+
},
19+
{
20+
type: "paragraph",
21+
content:
22+
"Check out the experimental mobile formatting toolbar by selecting some text (best experienced on a mobile device).",
23+
},
24+
{
25+
type: "paragraph",
26+
},
27+
],
28+
});
29+
30+
// Renders the editor instance using a React component.
31+
return (
32+
// Disables the default formatting toolbar and re-adds it without the
33+
// `FormattingToolbarController` component. You may have seen
34+
// `FormattingToolbarController` used in other examples, but we omit it here
35+
// as we want to control the position and visibility ourselves. BlockNote
36+
// also uses the `FormattingToolbarController` when displaying the
37+
// Formatting Toolbar by default.
38+
<BlockNoteView editor={editor} formattingToolbar={false}>
39+
<ExperimentalMobileFormattingToolbarController />
40+
</BlockNoteView>
41+
);
42+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Experimental Mobile Formatting Toolbar
2+
3+
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.
4+
5+
Controller is currently marked **experimental** due to the flickering issue with positioning (caused by delays of the Visual Viewport API)
6+
7+
**Relevant Docs:**
8+
9+
- [Changing the Formatting Toolbar](/docs/ui-components/formatting-toolbar#changing-the-formatting-toolbar)
10+
- [Editor Setup](/docs/editor-basics/setup)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<html lang="en">
2+
<head>
3+
<script>
4+
<!-- AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -->
5+
</script>
6+
<meta charset="UTF-8" />
7+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
8+
<title>Experimental Mobile Formatting Toolbar</title>
9+
</head>
10+
<body>
11+
<div id="root"></div>
12+
<script type="module" src="./main.tsx"></script>
13+
</body>
14+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
2+
import React from "react";
3+
import { createRoot } from "react-dom/client";
4+
import App from "./App";
5+
6+
const root = createRoot(document.getElementById("root")!);
7+
root.render(
8+
<React.StrictMode>
9+
<App />
10+
</React.StrictMode>
11+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "@blocknote/example-experimental-mobile-formatting-toolbar",
3+
"description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
4+
"private": true,
5+
"version": "0.12.4",
6+
"scripts": {
7+
"start": "vite",
8+
"dev": "vite",
9+
"build": "tsc && vite build",
10+
"preview": "vite preview",
11+
"lint": "eslint . --max-warnings 0"
12+
},
13+
"dependencies": {
14+
"@blocknote/core": "latest",
15+
"@blocknote/react": "latest",
16+
"@blocknote/ariakit": "latest",
17+
"@blocknote/mantine": "latest",
18+
"@blocknote/shadcn": "latest",
19+
"react": "^18.3.1",
20+
"react-dom": "^18.3.1"
21+
},
22+
"devDependencies": {
23+
"@types/react": "^18.0.25",
24+
"@types/react-dom": "^18.0.9",
25+
"@vitejs/plugin-react": "^4.3.1",
26+
"eslint": "^8.10.0",
27+
"vite": "^5.3.4"
28+
},
29+
"eslintConfig": {
30+
"extends": [
31+
"../../../.eslintrc.js"
32+
]
33+
},
34+
"eslintIgnore": [
35+
"dist"
36+
]
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
.bn-container {
2+
display: flex;
3+
flex-direction: column-reverse;
4+
gap: 8px;
5+
}
6+
7+
.bn-formatting-toolbar {
8+
margin-inline: auto;
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
3+
"compilerOptions": {
4+
"target": "ESNext",
5+
"useDefineForClassFields": true,
6+
"lib": [
7+
"DOM",
8+
"DOM.Iterable",
9+
"ESNext"
10+
],
11+
"allowJs": false,
12+
"skipLibCheck": true,
13+
"esModuleInterop": false,
14+
"allowSyntheticDefaultImports": true,
15+
"strict": true,
16+
"forceConsistentCasingInFileNames": true,
17+
"module": "ESNext",
18+
"moduleResolution": "Node",
19+
"resolveJsonModule": true,
20+
"isolatedModules": true,
21+
"noEmit": true,
22+
"jsx": "react-jsx",
23+
"composite": true
24+
},
25+
"include": [
26+
"."
27+
],
28+
"__ADD_FOR_LOCAL_DEV_references": [
29+
{
30+
"path": "../../../packages/core/"
31+
},
32+
{
33+
"path": "../../../packages/react/"
34+
}
35+
]
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
2+
import react from "@vitejs/plugin-react";
3+
import * as fs from "fs";
4+
import * as path from "path";
5+
import { defineConfig } from "vite";
6+
// import eslintPlugin from "vite-plugin-eslint";
7+
// https://vitejs.dev/config/
8+
export default defineConfig((conf) => ({
9+
plugins: [react()],
10+
optimizeDeps: {},
11+
build: {
12+
sourcemap: true,
13+
},
14+
resolve: {
15+
alias:
16+
conf.command === "build" ||
17+
!fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
18+
? {}
19+
: ({
20+
// Comment out the lines below to load a built version of blocknote
21+
// or, keep as is to load live from sources with live reload working
22+
"@blocknote/core": path.resolve(
23+
__dirname,
24+
"../../packages/core/src/"
25+
),
26+
"@blocknote/react": path.resolve(
27+
__dirname,
28+
"../../packages/react/src/"
29+
),
30+
} as any),
31+
},
32+
}));

packages/ariakit/src/style.css

+4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
gap: 0.5rem;
1212
}
1313

14+
.bn-toolbar.bn-ak-toolbar {
15+
overflow-x: auto;
16+
max-width: 100vw;
17+
}
1418
.bn-toolbar .bn-ak-button {
1519
width: unset;
1620
}

packages/mantine/src/style.css

+6
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@
134134
overflow: auto;
135135
}
136136

137+
.bn-mantine .mantine-Button-root[aria-controls*="dropdown"] {
138+
min-width: fit-content;
139+
}
140+
137141
/* Toolbar styling */
138142
.bn-mantine .bn-toolbar {
139143
background-color: var(--bn-colors-menu-background);
@@ -144,6 +148,8 @@
144148
gap: 2px;
145149
padding: 2px;
146150
width: fit-content;
151+
overflow-x: auto;
152+
max-width: 100vw;
147153
}
148154

149155
.bn-mantine .bn-toolbar:empty {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core";
2+
import { UseFloatingOptions } from "@floating-ui/react";
3+
import { FC, CSSProperties, useMemo, useRef, useState, useEffect } from "react";
4+
import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js";
5+
import { useUIPluginState } from "../../hooks/useUIPluginState.js";
6+
import { FormattingToolbar } from "./FormattingToolbar.js";
7+
import { FormattingToolbarProps } from "./FormattingToolbarProps.js";
8+
9+
/**
10+
* Experimental formatting toolbar controller for mobile devices.
11+
* Uses Visual Viewport API to position the toolbar above the virtual keyboard.
12+
*
13+
* Currently marked experimental due to the flickering issue with positioning cause by the use of the API (and likely a delay in its updates).
14+
*/
15+
export const ExperimentalMobileFormattingToolbarController = (props: {
16+
formattingToolbar?: FC<FormattingToolbarProps>;
17+
floatingOptions?: Partial<UseFloatingOptions>;
18+
}) => {
19+
const [transform, setTransform] = useState<string>("none");
20+
const divRef = useRef<HTMLDivElement>(null);
21+
const editor = useBlockNoteEditor<
22+
BlockSchema,
23+
InlineContentSchema,
24+
StyleSchema
25+
>();
26+
const state = useUIPluginState(
27+
editor.formattingToolbar.onUpdate.bind(editor.formattingToolbar)
28+
);
29+
const style = useMemo<CSSProperties>(() => {
30+
return {
31+
display: "flex",
32+
position: "fixed",
33+
bottom: 0,
34+
zIndex: 3000,
35+
transform,
36+
};
37+
}, [transform]);
38+
39+
useEffect(() => {
40+
const viewport = window.visualViewport!;
41+
function viewportHandler() {
42+
// Calculate the offset necessary to set the toolbar above the virtual keyboard (using the offset info from the visualViewport)
43+
const layoutViewport = document.body;
44+
const offsetLeft = viewport.offsetLeft;
45+
const offsetTop =
46+
viewport.height -
47+
layoutViewport.getBoundingClientRect().height +
48+
viewport.offsetTop;
49+
50+
setTransform(
51+
`translate(${offsetLeft}px, ${offsetTop}px) scale(${
52+
1 / viewport.scale
53+
})`
54+
);
55+
}
56+
window.visualViewport!.addEventListener("scroll", viewportHandler);
57+
window.visualViewport!.addEventListener("resize", viewportHandler);
58+
viewportHandler();
59+
60+
return () => {
61+
window.visualViewport!.removeEventListener("scroll", viewportHandler);
62+
window.visualViewport!.removeEventListener("resize", viewportHandler);
63+
};
64+
}, []);
65+
66+
if (!state) {
67+
return null;
68+
}
69+
70+
if (!state.show && divRef.current) {
71+
// The component is fading out. Use the previous state to render the toolbar with innerHTML,
72+
// because otherwise the toolbar will quickly flickr (i.e.: show a different state) while fading out,
73+
// which looks weird
74+
return (
75+
<div
76+
ref={divRef}
77+
style={style}
78+
dangerouslySetInnerHTML={{ __html: divRef.current.innerHTML }}></div>
79+
);
80+
}
81+
82+
const Component = props.formattingToolbar || FormattingToolbar;
83+
84+
return (
85+
<div ref={divRef} style={style}>
86+
<Component />
87+
</div>
88+
);
89+
};

packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
InlineContentSchema,
55
StyleSchema,
66
} from "@blocknote/core";
7-
import { UseFloatingOptions, flip, offset } from "@floating-ui/react";
7+
import { UseFloatingOptions, flip, offset, shift } from "@floating-ui/react";
88
import { FC, useMemo, useRef, useState } from "react";
99

1010
import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js";
@@ -80,7 +80,7 @@ export const FormattingToolbarController = (props: {
8080
3000,
8181
{
8282
placement,
83-
middleware: [offset(10), flip()],
83+
middleware: [offset(10), shift(), flip()],
8484
onOpenChange: (open, _event) => {
8585
// console.log("change", event);
8686
if (!open) {

0 commit comments

Comments
 (0)