diff --git a/docs/pages/docs/advanced/grid-suggestion-menus.mdx b/docs/pages/docs/advanced/grid-suggestion-menus.mdx new file mode 100644 index 0000000000..75d67b2c19 --- /dev/null +++ b/docs/pages/docs/advanced/grid-suggestion-menus.mdx @@ -0,0 +1,51 @@ +--- +title: Grid Suggestion Menus +description: In addition to displaying Suggestion Menus as stacks, BlockNote also supports displaying them as grids. +imageTitle: Grid Suggestion Menus +--- + +import { Example } from "@/components/example"; + +# Grid Suggestion Menus + +Grid Suggestion Menus appear when the user enters a trigger character, and text after the character is used to filter the menu items. + +Grid Suggestion Menus are similar to regular Suggestion Menus, but results are organized in a grid, and users can use all arrow keys (including left, right) on their keyboard to navigate the results. + +## Emoji Picker + +The Emoji Picker is a Grid Suggestion Menu that opens with the `:` character (or when selecting emoji item in the Slash Menu). + +It only displays once the user types 2 non-whitespace characters a query, to minimize cases where the user only wants to enter the `:` character. + +image + +### Changing Emoji Picker Columns + +By default, the Emoji Picker is rendered with 10 columns, but you can change this to any amount. In the demo below, the Emoji Picker is changed to only display 5 columns. + + + +Passing `emojiPicker={false}` to `BlockNoteView` tells BlockNote not to show the default Emoji Picker. Adding the `GridSuggestionMenuController` with `triggerCharacter={":"}` and `columns={5}` tells BlockNote to show one with 5 columns instead. + +### Replacing the Emoji Picker Component + +You can replace the React component used for the Emoji Picker with your own, as you can see in the demo below. + + + +Again, we add a `GridSuggestionMenuController` component with `triggerCharacter={":"}` and set `emojiPicker={false}` to replace the default Emoji Picker. + +Now, we also pass a component to its `gridSuggestionMenuComponent` prop. The `gridSuggestionMenuComponent` we pass is responsible for rendering the filtered items. The `GridSuggestionMenuController` controls its position and visibility (below the trigger character), and it also determines which items should be shown. Since we don't specify which items to show (the `getItems` prop isn't defined), it will use the default items for a grid, which are the emojis. + +## Creating additional Grid Suggestion Menus + +You can add additional Grid Suggestion Menus to the editor, which can use any trigger character. The demo below adds an example Grid Suggestion Menu for mentions, where each item is the first character of the user's name, and opens with the `@` character. + + + +Changing the column count in the new Grid Suggestion Menu, or the component used to render it, is done the same way as for the [Emoji Picker](/docs/advanced/grid-suggestion-menus#emoji-picker). For more information about how the mentions elements work, see [Custom Inline Content](/docs/custom-schemas/custom-inline-content). diff --git a/docs/pages/docs/editor-basics/setup.mdx b/docs/pages/docs/editor-basics/setup.mdx index e16960f469..e172f5b6cc 100644 --- a/docs/pages/docs/editor-basics/setup.mdx +++ b/docs/pages/docs/editor-basics/setup.mdx @@ -96,6 +96,7 @@ export type BlockNoteViewProps = { linkToolbar?: boolean; sideMenu?: boolean; slashMenu?: boolean; + emojiPicker?: boolean; filePanel?: boolean; tableHandles?: boolean; children?: @@ -120,6 +121,8 @@ export type BlockNoteViewProps = { `slashMenu`: Whether the [Slash Menu](/docs/ui-components/suggestion-menus#slash-menu) should be enabled. +`emojiPicker`: Whether the [Emoji Picker](/docs/ui-components/suggestion-menus#emoji-picker) should be enabled. + `filePanel`: Whether the File Toolbar should be enabled. `tableHandles`: Whether the Table Handles should be enabled. diff --git a/docs/pages/docs/ui-components/suggestion-menus.mdx b/docs/pages/docs/ui-components/suggestion-menus.mdx index e6ed27d4d8..e37c9f4bde 100644 --- a/docs/pages/docs/ui-components/suggestion-menus.mdx +++ b/docs/pages/docs/ui-components/suggestion-menus.mdx @@ -31,9 +31,9 @@ Passing `slashMenu={false}` to `BlockNoteView` tells BlockNote not to show the d `getItems` should return the items that need to be shown in the Slash Menu, based on a `query` entered by the user (anything the user types after the `triggerCharacter`). -### Replacing the Suggestion Menu Component +### Replacing the Slash Menu Component -You can replace the React component used for the Suggestion Menu with your own, as you can see in the demo below. +You can replace the React component used for the Slash Menu with your own, as you can see in the demo below. @@ -48,3 +48,24 @@ You can add additional Suggestion Menus to the editor, which can use any trigger Changing the items in the new Suggestion Menu, or the component used to render it, is done the same way as for the [Slash Menu](/docs/ui-components/suggestion-menus#slash-menu). For more information about how the mentions elements work, see [Custom Inline Content](/docs/custom-schemas/custom-inline-content). + +## Additional Features + +BlockNote offers a few other features for working with Suggestion Menus which may fit your use case. + +### Opening Suggestion Menus Programmatically + +While suggestion menus are generally meant to be opened when the user presses a trigger character, you may also want to open them from code. To do this, you can use the following editor method: + +```typescript +openSuggestionMenu(triggerCharacter: string): void; + +// Usage +editor.openSuggestionMenu("/"); +``` + +### Waiting for a Query + +You may want to hold off displaying a Suggestion Menu unless you're certain that the user actually wants to open the menu and not just enter the trigger character. In this case, you should use the `minQueryLength` prop for `SuggestionMenuController`, which takes a number. + +The number indicates how many characters the user query needs to have before the menu is shown. When greater than 0, it also prevents the menu from displaying if the user enters a space immediately after the trigger character. \ No newline at end of file diff --git a/docs/public/img/screenshots/emoji_picker.png b/docs/public/img/screenshots/emoji_picker.png new file mode 100644 index 0000000000..d912a299de Binary files /dev/null and b/docs/public/img/screenshots/emoji_picker.png differ diff --git a/docs/public/img/screenshots/emoji_picker_dark.png b/docs/public/img/screenshots/emoji_picker_dark.png new file mode 100644 index 0000000000..eb55c7a0b8 Binary files /dev/null and b/docs/public/img/screenshots/emoji_picker_dark.png differ diff --git a/examples/03-ui-components/07-suggestion-menus-slash-menu-component/styles.css b/examples/03-ui-components/07-suggestion-menus-slash-menu-component/styles.css index 61a6e95323..31bc1cf256 100644 --- a/examples/03-ui-components/07-suggestion-menus-slash-menu-component/styles.css +++ b/examples/03-ui-components/07-suggestion-menus-slash-menu-component/styles.css @@ -9,6 +9,10 @@ display: flex; flex-direction: column; gap: 8px; + height: fit-content; + max-height: 100%; + + overflow: auto; padding: 8px; diff --git a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/.bnexample.json b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/.bnexample.json new file mode 100644 index 0000000000..3dfb6cf780 --- /dev/null +++ b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/.bnexample.json @@ -0,0 +1,12 @@ +{ + "playground": true, + "docs": true, + "author": "yousefed", + "tags": [ + "Intermediate", + "Blocks", + "UI Components", + "Suggestion Menus", + "Emoji Picker" + ] +} diff --git a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/App.tsx b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/App.tsx new file mode 100644 index 0000000000..10e0b7f43b --- /dev/null +++ b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/App.tsx @@ -0,0 +1,42 @@ +import "@blocknote/core/fonts/inter.css"; +import { + GridSuggestionMenuController, + useCreateBlockNote, +} from "@blocknote/react"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/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: "Press the ':' key to open the Emoji Picker", + }, + { + type: "paragraph", + content: "There are now 5 columns instead of 10", + }, + { + type: "paragraph", + }, + ], + }); + + // Renders the editor instance. + return ( + + + + ); +} diff --git a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/README.md b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/README.md new file mode 100644 index 0000000000..36487f8070 --- /dev/null +++ b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/README.md @@ -0,0 +1,10 @@ +# Changing Emoji Picker Columns + +In this example, we change the Emoji Picker to display 5 columns instead of 10. + +**Try it out:** Press the ":" key to open the Emoji Picker! + +**Relevant Docs:** + +- [Changing Emoji Picker Columns](/docs/ui-components/suggestion-menus#changing-emoji-picker-columns) +- [Editor Setup](/docs/editor-basics/setup) \ No newline at end of file diff --git a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/index.html b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/index.html new file mode 100644 index 0000000000..83b0d80957 --- /dev/null +++ b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/index.html @@ -0,0 +1,14 @@ + + + + + + Changing Emoji Picker Columns + + +
+ + + diff --git a/examples/03-ui-components/08-uppy-file-panel/main.tsx b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/main.tsx similarity index 100% rename from examples/03-ui-components/08-uppy-file-panel/main.tsx rename to examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/main.tsx diff --git a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/package.json b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/package.json new file mode 100644 index 0000000000..aa0685ea6c --- /dev/null +++ b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/package.json @@ -0,0 +1,37 @@ +{ + "name": "@blocknote/example-suggestion-menus-emoji-picker-columns", + "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.0.4", + "eslint": "^8.10.0", + "vite": "^4.4.8" + }, + "eslintConfig": { + "extends": [ + "../../../.eslintrc.js" + ] + }, + "eslintIgnore": [ + "dist" + ] +} \ No newline at end of file diff --git a/examples/03-ui-components/08-uppy-file-panel/tsconfig.json b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/tsconfig.json similarity index 100% rename from examples/03-ui-components/08-uppy-file-panel/tsconfig.json rename to examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/tsconfig.json diff --git a/examples/03-ui-components/08-uppy-file-panel/vite.config.ts b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/vite.config.ts similarity index 100% rename from examples/03-ui-components/08-uppy-file-panel/vite.config.ts rename to examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/vite.config.ts diff --git a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/.bnexample.json b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/.bnexample.json new file mode 100644 index 0000000000..7bfc6d29e1 --- /dev/null +++ b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/.bnexample.json @@ -0,0 +1,12 @@ +{ + "playground": true, + "docs": true, + "author": "yousefed", + "tags": [ + "Intermediate", + "UI Components", + "Suggestion Menus", + "Emoji Picker", + "Appearance & Styling" + ] +} diff --git a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/App.tsx b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/App.tsx new file mode 100644 index 0000000000..b895c0a5d6 --- /dev/null +++ b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/App.tsx @@ -0,0 +1,71 @@ +import "@blocknote/core/fonts/inter.css"; +import { + DefaultReactGridSuggestionItem, + GridSuggestionMenuController, + GridSuggestionMenuProps, + useCreateBlockNote, +} from "@blocknote/react"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; + +import "./styles.css"; + +// Custom component to replace the default Emoji Picker. +function CustomEmojiPicker( + props: GridSuggestionMenuProps +) { + return ( +
+ {props.items.map((item, index) => ( +
{ + props.onItemClick?.(item); + }}> + {item.icon} +
+ ))} +
+ ); +} + +export default function App() { + // Creates a new editor instance. + const editor = useCreateBlockNote({ + initialContent: [ + { + type: "paragraph", + content: "Welcome to this demo!", + }, + { + type: "paragraph", + content: "Press the ':' key to open the Emoji Picker", + }, + { + type: "paragraph", + content: "It's been replaced with a custom component", + }, + { + type: "paragraph", + }, + ], + }); + + // Renders the editor instance. + return ( + + + + ); +} diff --git a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/README.md b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/README.md new file mode 100644 index 0000000000..f77d36e003 --- /dev/null +++ b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/README.md @@ -0,0 +1,10 @@ +# Replacing Emoji Picker Component + +In this example, we replace the default Emoji Picker component with a basic custom one. + +**Try it out:** Press the ":" key to see the new Emoji Picker! + +**Relevant Docs:** + +- [Replacing the Emoji Picker Component](/docs/ui-components/suggestion-menus#replacing-the-emoji-picker-component) +- [Editor Setup](/docs/editor-basics/setup) \ No newline at end of file diff --git a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/index.html b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/index.html new file mode 100644 index 0000000000..4a239b4812 --- /dev/null +++ b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/index.html @@ -0,0 +1,14 @@ + + + + + + Replacing Emoji Picker Component + + +
+ + + diff --git a/examples/03-ui-components/09-custom-ui/main.tsx b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/main.tsx similarity index 100% rename from examples/03-ui-components/09-custom-ui/main.tsx rename to examples/03-ui-components/09-suggestion-menus-emoji-picker-component/main.tsx diff --git a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/package.json b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/package.json new file mode 100644 index 0000000000..aeebe0d7fd --- /dev/null +++ b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/package.json @@ -0,0 +1,37 @@ +{ + "name": "@blocknote/example-suggestion-menus-emoji-picker-component", + "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.0.4", + "eslint": "^8.10.0", + "vite": "^4.4.8" + }, + "eslintConfig": { + "extends": [ + "../../../.eslintrc.js" + ] + }, + "eslintIgnore": [ + "dist" + ] +} \ No newline at end of file diff --git a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/styles.css b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/styles.css new file mode 100644 index 0000000000..440e910d24 --- /dev/null +++ b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/styles.css @@ -0,0 +1,41 @@ +.emoji-picker { + z-index: 9999; + + background-color: white; + border: 1px solid lightgray; + border-radius: 2px; + box-shadow: 0 0 8px #dddddd; + + display: grid; + flex-direction: column; + gap: 8px; + height: fit-content; + max-height: 100%; + + overflow: auto; + + padding: 8px; + + top: 8px; +} + +.emoji-picker-item { + background-color: white; + border: 1px solid lightgray; + border-radius: 2px; + box-shadow: 0 0 4px #dddddd; + + cursor: pointer; + + font-size: 16px; + + align-items: center; + display: flex; + flex-direction: row; + + padding: 8px; +} + +.emoji-picker-item:hover, .emoji-picker-item.selected { + background-color: lightgray; +} \ No newline at end of file diff --git a/examples/03-ui-components/09-custom-ui/tsconfig.json b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/tsconfig.json similarity index 100% rename from examples/03-ui-components/09-custom-ui/tsconfig.json rename to examples/03-ui-components/09-suggestion-menus-emoji-picker-component/tsconfig.json diff --git a/examples/03-ui-components/09-custom-ui/vite.config.ts b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/vite.config.ts similarity index 100% rename from examples/03-ui-components/09-custom-ui/vite.config.ts rename to examples/03-ui-components/09-suggestion-menus-emoji-picker-component/vite.config.ts diff --git a/examples/03-ui-components/10-suggestion-menus-grid-mentions/.bnexample.json b/examples/03-ui-components/10-suggestion-menus-grid-mentions/.bnexample.json new file mode 100644 index 0000000000..f347671a56 --- /dev/null +++ b/examples/03-ui-components/10-suggestion-menus-grid-mentions/.bnexample.json @@ -0,0 +1,11 @@ +{ + "playground": true, + "docs": true, + "author": "yousefed", + "tags": [ + "Intermediate", + "Inline Content", + "Custom Schemas", + "Suggestion Menus" + ] +} diff --git a/examples/03-ui-components/10-suggestion-menus-grid-mentions/App.tsx b/examples/03-ui-components/10-suggestion-menus-grid-mentions/App.tsx new file mode 100644 index 0000000000..a8f0d748cb --- /dev/null +++ b/examples/03-ui-components/10-suggestion-menus-grid-mentions/App.tsx @@ -0,0 +1,108 @@ +import { + BlockNoteSchema, + defaultInlineContentSpecs, + filterSuggestionItems, +} from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { + DefaultReactGridSuggestionItem, + GridSuggestionMenuController, + useCreateBlockNote, +} from "@blocknote/react"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; + +import { Mention } from "./Mention"; + +// Our schema with inline content specs, which contain the configs and +// implementations for inline content that we want our editor to use. +const schema = BlockNoteSchema.create({ + inlineContentSpecs: { + // Adds all default inline content. + ...defaultInlineContentSpecs, + // Adds the mention tag. + mention: Mention, + }, +}); + +// Function which gets all users for the mentions menu. +const getMentionMenuItems = ( + editor: typeof schema.BlockNoteEditor +): DefaultReactGridSuggestionItem[] => { + const users = ["Steve", "Bob", "Joe", "Mike"]; + + return users.map((user) => ({ + id: user, + onItemClick: () => { + editor.insertInlineContent([ + { + type: "mention", + props: { + user, + }, + }, + " ", // add a space after the mention + ]); + }, + icon:

{user.substring(0, 1)}

, + })); +}; + +export function App() { + const editor = useCreateBlockNote({ + schema, + initialContent: [ + { + type: "paragraph", + content: "Welcome to this demo!", + }, + { + type: "paragraph", + content: [ + { + type: "mention", + props: { + user: "Steve", + }, + }, + { + type: "text", + text: " <- This is an example mention", + styles: {}, + }, + ], + }, + { + type: "paragraph", + content: "Press the '@' key to open the mentions menu and add another", + }, + { + type: "paragraph", + }, + ], + }); + + return ( + + {/* Adds a mentions menu which opens with the "@" key */} + + // Gets the mentions menu items + // TODO: Fix map/type cast + filterSuggestionItems( + getMentionMenuItems(editor).map((item) => ({ + ...item, + title: item.id, + })), + query + ) as DefaultReactGridSuggestionItem[] + } + columns={2} + minQueryLength={2} + /> + + ); +} + +export default App; diff --git a/examples/03-ui-components/10-suggestion-menus-grid-mentions/Mention.tsx b/examples/03-ui-components/10-suggestion-menus-grid-mentions/Mention.tsx new file mode 100644 index 0000000000..fdcae6b8c9 --- /dev/null +++ b/examples/03-ui-components/10-suggestion-menus-grid-mentions/Mention.tsx @@ -0,0 +1,21 @@ +import { createReactInlineContentSpec } from "@blocknote/react"; + +// The Mention inline content. +export const Mention = createReactInlineContentSpec( + { + type: "mention", + propSchema: { + user: { + default: "Unknown", + }, + }, + content: "none", + }, + { + render: (props) => ( + + @{props.inlineContent.props.user} + + ), + } +); diff --git a/examples/03-ui-components/10-suggestion-menus-grid-mentions/README.md b/examples/03-ui-components/10-suggestion-menus-grid-mentions/README.md new file mode 100644 index 0000000000..1696d491f5 --- /dev/null +++ b/examples/03-ui-components/10-suggestion-menus-grid-mentions/README.md @@ -0,0 +1,11 @@ +# Grid Mentions Menu + +In this example, we create a custom `Mention` inline content type which is used to tag people. In addition, we create a new Suggestion Menu for mentions which opens with the "@" character. This Suggestion Menu is displayed as a grid of 2 columns, where each item is the first letter of the person's name. + +**Try it out:** Press the "@" key to open the mentions menu and insert a mention! + +**Relevant Docs:** + +- [Custom Inline Content Types](/docs/custom-schemas/custom-inline-content) +- [Creating Suggestion Menus](/docs/ui-components/suggestion-menus#creating-additional-suggestion-menus) +- [Editor Setup](/docs/editor-basics/setup) \ No newline at end of file diff --git a/examples/03-ui-components/10-suggestion-menus-grid-mentions/index.html b/examples/03-ui-components/10-suggestion-menus-grid-mentions/index.html new file mode 100644 index 0000000000..98fa204a25 --- /dev/null +++ b/examples/03-ui-components/10-suggestion-menus-grid-mentions/index.html @@ -0,0 +1,14 @@ + + + + + + Grid Mentions Menu + + +
+ + + diff --git a/examples/03-ui-components/10-suggestion-menus-grid-mentions/main.tsx b/examples/03-ui-components/10-suggestion-menus-grid-mentions/main.tsx new file mode 100644 index 0000000000..f88b490fbd --- /dev/null +++ b/examples/03-ui-components/10-suggestion-menus-grid-mentions/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/10-suggestion-menus-grid-mentions/package.json b/examples/03-ui-components/10-suggestion-menus-grid-mentions/package.json new file mode 100644 index 0000000000..43bd3102d6 --- /dev/null +++ b/examples/03-ui-components/10-suggestion-menus-grid-mentions/package.json @@ -0,0 +1,37 @@ +{ + "name": "@blocknote/example-suggestion-menus-grid-mentions", + "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.0.4", + "eslint": "^8.10.0", + "vite": "^4.4.8" + }, + "eslintConfig": { + "extends": [ + "../../../.eslintrc.js" + ] + }, + "eslintIgnore": [ + "dist" + ] +} \ No newline at end of file diff --git a/examples/03-ui-components/10-suggestion-menus-grid-mentions/tsconfig.json b/examples/03-ui-components/10-suggestion-menus-grid-mentions/tsconfig.json new file mode 100644 index 0000000000..1bd8ab3c57 --- /dev/null +++ b/examples/03-ui-components/10-suggestion-menus-grid-mentions/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/10-suggestion-menus-grid-mentions/vite.config.ts b/examples/03-ui-components/10-suggestion-menus-grid-mentions/vite.config.ts new file mode 100644 index 0000000000..f62ab20bc2 --- /dev/null +++ b/examples/03-ui-components/10-suggestion-menus-grid-mentions/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/examples/03-ui-components/08-uppy-file-panel/.bnexample.json b/examples/03-ui-components/11-uppy-file-panel/.bnexample.json similarity index 100% rename from examples/03-ui-components/08-uppy-file-panel/.bnexample.json rename to examples/03-ui-components/11-uppy-file-panel/.bnexample.json diff --git a/examples/03-ui-components/08-uppy-file-panel/App.tsx b/examples/03-ui-components/11-uppy-file-panel/App.tsx similarity index 100% rename from examples/03-ui-components/08-uppy-file-panel/App.tsx rename to examples/03-ui-components/11-uppy-file-panel/App.tsx diff --git a/examples/03-ui-components/08-uppy-file-panel/FileReplaceButton.tsx b/examples/03-ui-components/11-uppy-file-panel/FileReplaceButton.tsx similarity index 100% rename from examples/03-ui-components/08-uppy-file-panel/FileReplaceButton.tsx rename to examples/03-ui-components/11-uppy-file-panel/FileReplaceButton.tsx diff --git a/examples/03-ui-components/08-uppy-file-panel/README.md b/examples/03-ui-components/11-uppy-file-panel/README.md similarity index 100% rename from examples/03-ui-components/08-uppy-file-panel/README.md rename to examples/03-ui-components/11-uppy-file-panel/README.md diff --git a/examples/03-ui-components/08-uppy-file-panel/UppyFilePanel.tsx b/examples/03-ui-components/11-uppy-file-panel/UppyFilePanel.tsx similarity index 100% rename from examples/03-ui-components/08-uppy-file-panel/UppyFilePanel.tsx rename to examples/03-ui-components/11-uppy-file-panel/UppyFilePanel.tsx diff --git a/examples/03-ui-components/08-uppy-file-panel/index.html b/examples/03-ui-components/11-uppy-file-panel/index.html similarity index 100% rename from examples/03-ui-components/08-uppy-file-panel/index.html rename to examples/03-ui-components/11-uppy-file-panel/index.html diff --git a/examples/03-ui-components/11-uppy-file-panel/main.tsx b/examples/03-ui-components/11-uppy-file-panel/main.tsx new file mode 100644 index 0000000000..f88b490fbd --- /dev/null +++ b/examples/03-ui-components/11-uppy-file-panel/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/08-uppy-file-panel/package.json b/examples/03-ui-components/11-uppy-file-panel/package.json similarity index 100% rename from examples/03-ui-components/08-uppy-file-panel/package.json rename to examples/03-ui-components/11-uppy-file-panel/package.json diff --git a/examples/03-ui-components/11-uppy-file-panel/tsconfig.json b/examples/03-ui-components/11-uppy-file-panel/tsconfig.json new file mode 100644 index 0000000000..1bd8ab3c57 --- /dev/null +++ b/examples/03-ui-components/11-uppy-file-panel/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/11-uppy-file-panel/vite.config.ts b/examples/03-ui-components/11-uppy-file-panel/vite.config.ts new file mode 100644 index 0000000000..f62ab20bc2 --- /dev/null +++ b/examples/03-ui-components/11-uppy-file-panel/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/examples/03-ui-components/09-custom-ui/.bnexample.json b/examples/03-ui-components/12-custom-ui/.bnexample.json similarity index 99% rename from examples/03-ui-components/09-custom-ui/.bnexample.json rename to examples/03-ui-components/12-custom-ui/.bnexample.json index 9ded90803d..6c50d66ec7 100644 --- a/examples/03-ui-components/09-custom-ui/.bnexample.json +++ b/examples/03-ui-components/12-custom-ui/.bnexample.json @@ -7,4 +7,4 @@ "react-icons": "^5.2.1" }, "pro": true -} +} \ No newline at end of file diff --git a/examples/03-ui-components/09-custom-ui/App.tsx b/examples/03-ui-components/12-custom-ui/App.tsx similarity index 100% rename from examples/03-ui-components/09-custom-ui/App.tsx rename to examples/03-ui-components/12-custom-ui/App.tsx diff --git a/examples/03-ui-components/09-custom-ui/ColorMenu.tsx b/examples/03-ui-components/12-custom-ui/ColorMenu.tsx similarity index 100% rename from examples/03-ui-components/09-custom-ui/ColorMenu.tsx rename to examples/03-ui-components/12-custom-ui/ColorMenu.tsx diff --git a/examples/03-ui-components/09-custom-ui/CustomFormattingToolbar.tsx b/examples/03-ui-components/12-custom-ui/CustomFormattingToolbar.tsx similarity index 100% rename from examples/03-ui-components/09-custom-ui/CustomFormattingToolbar.tsx rename to examples/03-ui-components/12-custom-ui/CustomFormattingToolbar.tsx diff --git a/examples/03-ui-components/09-custom-ui/CustomSideMenu.tsx b/examples/03-ui-components/12-custom-ui/CustomSideMenu.tsx similarity index 100% rename from examples/03-ui-components/09-custom-ui/CustomSideMenu.tsx rename to examples/03-ui-components/12-custom-ui/CustomSideMenu.tsx diff --git a/examples/03-ui-components/09-custom-ui/CustomSlashMenu.tsx b/examples/03-ui-components/12-custom-ui/CustomSlashMenu.tsx similarity index 100% rename from examples/03-ui-components/09-custom-ui/CustomSlashMenu.tsx rename to examples/03-ui-components/12-custom-ui/CustomSlashMenu.tsx diff --git a/examples/03-ui-components/09-custom-ui/LinkMenu.tsx b/examples/03-ui-components/12-custom-ui/LinkMenu.tsx similarity index 100% rename from examples/03-ui-components/09-custom-ui/LinkMenu.tsx rename to examples/03-ui-components/12-custom-ui/LinkMenu.tsx diff --git a/examples/03-ui-components/09-custom-ui/README.md b/examples/03-ui-components/12-custom-ui/README.md similarity index 100% rename from examples/03-ui-components/09-custom-ui/README.md rename to examples/03-ui-components/12-custom-ui/README.md diff --git a/examples/03-ui-components/09-custom-ui/index.html b/examples/03-ui-components/12-custom-ui/index.html similarity index 100% rename from examples/03-ui-components/09-custom-ui/index.html rename to examples/03-ui-components/12-custom-ui/index.html diff --git a/examples/03-ui-components/12-custom-ui/main.tsx b/examples/03-ui-components/12-custom-ui/main.tsx new file mode 100644 index 0000000000..f88b490fbd --- /dev/null +++ b/examples/03-ui-components/12-custom-ui/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/09-custom-ui/package.json b/examples/03-ui-components/12-custom-ui/package.json similarity index 100% rename from examples/03-ui-components/09-custom-ui/package.json rename to examples/03-ui-components/12-custom-ui/package.json diff --git a/examples/03-ui-components/09-custom-ui/styles.css b/examples/03-ui-components/12-custom-ui/styles.css similarity index 100% rename from examples/03-ui-components/09-custom-ui/styles.css rename to examples/03-ui-components/12-custom-ui/styles.css diff --git a/examples/03-ui-components/12-custom-ui/tsconfig.json b/examples/03-ui-components/12-custom-ui/tsconfig.json new file mode 100644 index 0000000000..1bd8ab3c57 --- /dev/null +++ b/examples/03-ui-components/12-custom-ui/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/12-custom-ui/vite.config.ts b/examples/03-ui-components/12-custom-ui/vite.config.ts new file mode 100644 index 0000000000..f62ab20bc2 --- /dev/null +++ b/examples/03-ui-components/12-custom-ui/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 38d60815d5..8797c8db13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3487,6 +3487,11 @@ "node": ">=10.0.0" } }, + "node_modules/@emoji-mart/data": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz", + "integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==" + }, "node_modules/@emotion/is-prop-valid": { "version": "0.8.8", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", @@ -9308,6 +9313,15 @@ "@types/ms": "*" } }, + "node_modules/@types/emoji-mart": { + "version": "3.0.14", + "resolved": "https://registry.npmjs.org/@types/emoji-mart/-/emoji-mart-3.0.14.tgz", + "integrity": "sha512-/vMkVnet466bK37ugf2jry9ldCZklFPXYMB2m+qNo3vkP2I7L0cvtNFPKAjfcHgPg9Z8pbYqVqZn7AgsC0qf+g==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/eslint": { "version": "8.56.10", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz", @@ -13434,6 +13448,11 @@ "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==" }, + "node_modules/emoji-mart": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz", + "integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -29070,6 +29089,7 @@ "version": "0.14.5", "license": "MPL-2.0", "dependencies": { + "@emoji-mart/data": "^1.2.1", "@tiptap/core": "^2.4.0", "@tiptap/extension-bold": "^2.4.0", "@tiptap/extension-code": "^2.4.0", @@ -29090,6 +29110,7 @@ "@tiptap/extension-text": "^2.4.0", "@tiptap/extension-underline": "^2.4.0", "@tiptap/pm": "^2.4.0", + "emoji-mart": "^5.6.0", "hast-util-from-dom": "^4.2.0", "prosemirror-model": "^1.21.0", "prosemirror-state": "^1.4.3", @@ -29111,6 +29132,7 @@ "yjs": "^13.6.15" }, "devDependencies": { + "@types/emoji-mart": "^3.0.14", "@types/hast": "^2.3.4", "@types/uuid": "^8.3.4", "eslint": "^8.10.0", diff --git a/packages/ariakit/src/index.tsx b/packages/ariakit/src/index.tsx index a4d7465562..e766e2c658 100644 --- a/packages/ariakit/src/index.tsx +++ b/packages/ariakit/src/index.tsx @@ -12,6 +12,10 @@ import { import { ComponentProps } from "react"; import { Form } from "./input/Form"; +import { GridSuggestionMenu } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenu"; +import { GridSuggestionMenuEmptyItem } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem"; +import { GridSuggestionMenuItem } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem"; +import { GridSuggestionMenuLoader } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader"; import { TextInput } from "./input/TextInput"; import { Menu, @@ -54,6 +58,12 @@ export const components: Components = { TabPanel: PanelTab, TextInput: PanelTextInput, }, + GridSuggestionMenu: { + Root: GridSuggestionMenu, + Item: GridSuggestionMenuItem, + EmptyItem: GridSuggestionMenuEmptyItem, + Loader: GridSuggestionMenuLoader, + }, LinkToolbar: { Root: Toolbar, Button: ToolbarButton, diff --git a/packages/ariakit/src/style.css b/packages/ariakit/src/style.css index ee3878107d..213f4d5cd9 100644 --- a/packages/ariakit/src/style.css +++ b/packages/ariakit/src/style.css @@ -6,128 +6,181 @@ @import "./ariakitStyles.css"; .bn-ak-input-wrapper { - align-items: center; - display: flex; - gap: 0.5rem; + align-items: center; + display: flex; + gap: 0.5rem; } .bn-toolbar .bn-ak-button { - width: unset; + width: unset; } .bn-toolbar .bn-ak-button[data-selected] { - padding-top: 0.125rem; - box-shadow: inset 0 0 0 1px var(--border), inset 0 2px 0 var(--border); + padding-top: 0.125rem; + box-shadow: inset 0 0 0 1px var(--border), inset 0 2px 0 var(--border); } .bn-toolbar .bn-ak-button[data-selected]:where(.dark, .dark *) { - box-shadow: inset 0 0 0 1px var(--border), inset 0 1px 1px 1px var(--shadow); + box-shadow: inset 0 0 0 1px var(--border), inset 0 1px 1px 1px var(--shadow); } .bn-toolbar .bn-ak-popover { - gap: 0.5rem; + gap: 0.5rem; } .bn-ariakit .bn-tab-panel { - align-items: center; - display: flex; - flex-direction: column; - gap: 0.5rem; + align-items: center; + display: flex; + flex-direction: column; + gap: 0.5rem; } .bn-ariakit .bn-file-input { - max-width: 100%; + max-width: 100%; } .bn-ak-button { - outline-style: none; + outline-style: none; } -.bn-ak-menu-item[aria-selected="true"], .bn-ak-menu-item:hover { - background-color: hsl(204 100% 40%); - color: hsl(204 20% 100%); +.bn-ak-menu-item[aria-selected="true"], +.bn-ak-menu-item:hover { + background-color: hsl(204 100% 40%); + color: hsl(204 20% 100%); } .bn-ak-menu-item { - display: flex; + display: flex; } .bn-ariakit .bn-dropdown { - overflow: visible; + overflow: visible; } -.bn-ariakit .bn-suggestion-menu, .bn-ariakit .bn-color-picker-dropdown { - overflow: scroll; +.bn-ariakit .bn-suggestion-menu { + height: fit-content; + max-height: 100%; +} + +.bn-ariakit .bn-color-picker-dropdown { + overflow: scroll; } .bn-ak-suggestion-menu-item-body { - flex: 1; + flex: 1; } .bn-ak-suggestion-menu-item-subtitle { - font-size: 0.7rem; + font-size: 0.7rem; } .bn-ak-suggestion-menu-item-section[data-position="left"] { - padding: 8px; + padding: 8px; } .bn-ak-suggestion-menu-item-section[data-position="right"] { - --border: rgb(0 0 0/13%); - --highlight: rgb(255 255 255/20%); - --shadow: rgb(0 0 0/10%); - box-shadow: inset 0 0 0 1px var(--border), inset 0 2px 0 var(--highlight), + --border: rgb(0 0 0/13%); + --highlight: rgb(255 255 255/20%); + --shadow: rgb(0 0 0/10%); + box-shadow: inset 0 0 0 1px var(--border), inset 0 2px 0 var(--highlight), inset 0 -1px 0 var(--shadow), 0 1px 1px var(--shadow); - font-size: 0.7rem; - border-radius: 4px; - padding-inline: 4px; + font-size: 0.7rem; + border-radius: 4px; + padding-inline: 4px; +} + +.bn-ariakit .bn-grid-suggestion-menu { + background: var(--bn-colors-menu-background); + border-radius: var(--bn-border-radius-large); + box-shadow: var(--bn-shadow-medium); + display: grid; + gap: 7px; + height: fit-content; + justify-items: center; + max-height: min(500px, 100%); + overflow-y: auto; + padding: 20px; +} + +.bn-ariakit .bn-grid-suggestion-menu-item { + align-items: center; + border-radius: var(--bn-border-radius-large); + cursor: pointer; + display: flex; + font-size: 24px; + height: 32px; + justify-content: center; + margin: 2px; + padding: 4px; + width: 32px; +} + +.bn-ariakit .bn-grid-suggestion-menu-item[aria-selected="true"], +.bn-ariakit .bn-grid-suggestion-menu-item:hover { + background-color: var(--bn-colors-hovered-background); +} + +.bn-ariakit .bn-grid-suggestion-menu-empty-item, +.bn-ariakit .bn-grid-suggestion-menu-loader { + align-items: center; + color: var(--bn-colors-menu-text); + display: flex; + font-size: 14px; + font-weight: 500; + height: 32px; + justify-content: center; +} + +.bn-ariakit .bn-grid-suggestion-menu-loader span { + background-color: var(--bn-colors-side-menu); } .bn-ariakit .bn-side-menu { - align-items: center; - display: flex; - justify-content: center; + align-items: center; + display: flex; + justify-content: center; } .bn-side-menu .bn-ak-button { - height: fit-content; - padding: 0; - width: fit-content; + height: fit-content; + padding: 0; + width: fit-content; } .bn-ariakit .bn-panel-popover { - background-color: transparent; - border: none; - box-shadow: none; + background-color: transparent; + border: none; + box-shadow: none; } .bn-ariakit .bn-table-handle { - height: fit-content; - padding: 0; - width: fit-content; + height: fit-content; + padding: 0; + width: fit-content; } .bn-ariakit .bn-side-menu, .bn-ariakit .bn-table-handle { - color: gray; + color: gray; } .bn-ak-button:where(.dark, .dark *) { - color: hsl(204 20% 100%); + color: hsl(204 20% 100%); } -.bn-ak-tab, .bn-ariakit .bn-file-input { - background-color: transparent; - color: black; +.bn-ak-tab, +.bn-ariakit .bn-file-input { + background-color: transparent; + color: black; } .bn-ak-tab:where(.dark, .dark *), .bn-ariakit .bn-file-input:where(.dark, .dark *) { - color: white; + color: white; } .bn-ak-tooltip { - align-items: center; - display: flex; - flex-direction: column; + align-items: center; + display: flex; + flex-direction: column; } diff --git a/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenu.tsx b/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenu.tsx new file mode 100644 index 0000000000..b61454efe3 --- /dev/null +++ b/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenu.tsx @@ -0,0 +1,23 @@ +import { assertEmpty } from "@blocknote/core"; +import { ComponentProps } from "@blocknote/react"; +import { forwardRef } from "react"; + +export const GridSuggestionMenu = forwardRef< + HTMLDivElement, + ComponentProps["GridSuggestionMenu"]["Root"] +>((props, ref) => { + const { className, children, id, columns, ...rest } = props; + + assertEmpty(rest); + + return ( +
+ {children} +
+ ); +}); diff --git a/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem.tsx b/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem.tsx new file mode 100644 index 0000000000..390cbb80e7 --- /dev/null +++ b/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem.tsx @@ -0,0 +1,21 @@ +import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; +import { ComponentProps } from "@blocknote/react"; +import { forwardRef } from "react"; + +export const GridSuggestionMenuEmptyItem = forwardRef< + HTMLDivElement, + ComponentProps["GridSuggestionMenu"]["EmptyItem"] +>((props, ref) => { + const { className, children, columns, ...rest } = props; + + assertEmpty(rest); + + return ( +
+
{children}
+
+ ); +}); diff --git a/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem.tsx b/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem.tsx new file mode 100644 index 0000000000..6db77fda62 --- /dev/null +++ b/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem.tsx @@ -0,0 +1,40 @@ +import { assertEmpty } from "@blocknote/core"; +import { ComponentProps, elementOverflow, mergeRefs } from "@blocknote/react"; +import { forwardRef, useEffect, useRef } from "react"; + +export const GridSuggestionMenuItem = forwardRef< + HTMLDivElement, + ComponentProps["GridSuggestionMenu"]["Item"] +>((props, ref) => { + const { className, isSelected, onClick, item, id, ...rest } = props; + + assertEmpty(rest); + + const itemRef = useRef(null); + + useEffect(() => { + if (!itemRef.current || !isSelected) { + return; + } + + const overflow = elementOverflow(itemRef.current); + + if (overflow === "top") { + itemRef.current.scrollIntoView(true); + } else if (overflow === "bottom") { + itemRef.current.scrollIntoView(false); + } + }, [isSelected]); + + return ( +
+ {item.icon} +
+ ); +}); diff --git a/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader.tsx b/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader.tsx new file mode 100644 index 0000000000..e78431e2ab --- /dev/null +++ b/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader.tsx @@ -0,0 +1,26 @@ +import { assertEmpty } from "@blocknote/core"; +import { ComponentProps } from "@blocknote/react"; +import { forwardRef } from "react"; + +export const GridSuggestionMenuLoader = forwardRef< + HTMLDivElement, + ComponentProps["GridSuggestionMenu"]["Loader"] +>((props, ref) => { + const { + className, + children, // unused, using "dots" instead + columns, + ...rest + } = props; + + assertEmpty(rest); + + return ( +
+ {children} +
+ ); +}); diff --git a/packages/core/package.json b/packages/core/package.json index f367cdbd4c..7378f4bdd2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -54,6 +54,7 @@ "clean": "rimraf dist && rimraf types" }, "dependencies": { + "@emoji-mart/data": "^1.2.1", "@tiptap/core": "^2.4.0", "@tiptap/extension-bold": "^2.4.0", "@tiptap/extension-code": "^2.4.0", @@ -74,6 +75,7 @@ "@tiptap/extension-text": "^2.4.0", "@tiptap/extension-underline": "^2.4.0", "@tiptap/pm": "^2.4.0", + "emoji-mart": "^5.6.0", "hast-util-from-dom": "^4.2.0", "prosemirror-model": "^1.21.0", "prosemirror-state": "^1.4.3", @@ -95,6 +97,7 @@ "yjs": "^13.6.15" }, "devDependencies": { + "@types/emoji-mart": "^3.0.14", "@types/hast": "^2.3.4", "@types/uuid": "^8.3.4", "eslint": "^8.10.0", diff --git a/packages/core/src/blocks/defaultBlockTypeGuards.ts b/packages/core/src/blocks/defaultBlockTypeGuards.ts index 09c93cb89e..d16bddd67f 100644 --- a/packages/core/src/blocks/defaultBlockTypeGuards.ts +++ b/packages/core/src/blocks/defaultBlockTypeGuards.ts @@ -6,7 +6,13 @@ import { InlineContentSchema, StyleSchema, } from "../schema"; -import { Block, DefaultBlockSchema, defaultBlockSchema } from "./defaultBlocks"; +import { + Block, + DefaultBlockSchema, + defaultBlockSchema, + defaultInlineContentSchema, + DefaultInlineContentSchema, +} from "./defaultBlocks"; import { defaultProps } from "./defaultProps"; export function checkDefaultBlockTypeInSchema< @@ -23,6 +29,25 @@ export function checkDefaultBlockTypeInSchema< ); } +export function checkDefaultInlineContentTypeInSchema< + InlineContentType extends keyof DefaultInlineContentSchema, + B extends BlockSchema, + S extends StyleSchema +>( + inlineContentType: InlineContentType, + editor: BlockNoteEditor +): editor is BlockNoteEditor< + B, + { Type: DefaultInlineContentSchema[InlineContentType] }, + S +> { + return ( + inlineContentType in editor.schema.inlineContentSchema && + editor.schema.inlineContentSchema[inlineContentType] === + defaultInlineContentSchema[inlineContentType] + ); +} + export function checkBlockIsDefaultType< BlockType extends keyof DefaultBlockSchema, I extends InlineContentSchema, diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 8232db27d3..57e0a0be89 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -1038,4 +1038,16 @@ export class BlockNoteEditor< this._tiptapEditor.off("selectionUpdate", cb); }; } + + public openSelectionMenu(triggerCharacter: string) { + this.prosemirrorView.focus(); + this.prosemirrorView.dispatch( + this.prosemirrorView.state.tr + .scrollIntoView() + .setMeta(this.suggestionMenus.plugin, { + triggerCharacter: triggerCharacter, + fromUserInput: false, + }) + ); + } } diff --git a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts index 7ee7eacb9e..b1084cd773 100644 --- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts +++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts @@ -12,7 +12,6 @@ import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; import { UiElementPosition } from "../../extensions-shared/UiElementPosition"; import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; import { EventEmitter } from "../../util/EventEmitter"; -import { suggestionMenuPluginKey } from "../SuggestionMenu/SuggestionPlugin"; import { MultipleNodeSelection } from "./MultipleNodeSelection"; let dragImageElement: Element | undefined; @@ -649,14 +648,8 @@ export class SideMenuView< this.editor._tiptapEditor.commands.setTextSelection(startPos + 1); } - // Focuses and activates the suggestion menu. - this.pmView.focus(); - this.pmView.dispatch( - this.pmView.state.tr.scrollIntoView().setMeta(suggestionMenuPluginKey, { - triggerCharacter: "/", - fromUserInput: false, - }) - ); + // Focuses and activates the slash menu. + this.editor.openSelectionMenu("/"); } } diff --git a/packages/core/src/extensions/SuggestionMenu/DefaultGridSuggestionItem.ts b/packages/core/src/extensions/SuggestionMenu/DefaultGridSuggestionItem.ts new file mode 100644 index 0000000000..01d5aea707 --- /dev/null +++ b/packages/core/src/extensions/SuggestionMenu/DefaultGridSuggestionItem.ts @@ -0,0 +1,4 @@ +export type DefaultGridSuggestionItem = { + id: string; + onItemClick: () => void; +}; diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts index d64bd83984..7729108732 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts @@ -139,7 +139,7 @@ type SuggestionPluginState = } | undefined; -export const suggestionMenuPluginKey = new PluginKey("SuggestionMenuPlugin"); +const suggestionMenuPluginKey = new PluginKey("SuggestionMenuPlugin"); /** * A ProseMirror plugin for suggestions, designed to make '/'-commands possible as well as mentions. diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts new file mode 100644 index 0000000000..9f725f76e9 --- /dev/null +++ b/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts @@ -0,0 +1,38 @@ +import data, { Emoji, EmojiMartData } from "@emoji-mart/data"; +import { init, SearchIndex } from "emoji-mart"; + +import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { checkDefaultInlineContentTypeInSchema } from "../../blocks/defaultBlockTypeGuards"; +import { DefaultGridSuggestionItem } from "./DefaultGridSuggestionItem"; + +let dataInitialized = false; +const emojiMartData = data as EmojiMartData; + +export async function getDefaultEmojiPickerItems< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + editor: BlockNoteEditor, + query: string +): Promise { + if (!checkDefaultInlineContentTypeInSchema("text", editor)) { + return []; + } + + if (!dataInitialized) { + dataInitialized = true; + await init({ emojiMartData }); + } + + const emojisToShow = + query.trim() === "" + ? Object.values(emojiMartData.emojis) + : ((await SearchIndex.search(query)) as Emoji[]); + + return emojisToShow.map((emoji: Emoji) => ({ + id: emoji.skins[0].native, + onItemClick: () => editor.insertInlineContent(emoji.skins[0].native + " "), + })); +} diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts index e482370c44..72994f5d04 100644 --- a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts @@ -1,5 +1,6 @@ import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; import { Block, PartialBlock } from "../../blocks/defaultBlocks"; + import { checkDefaultBlockTypeInSchema } from "../../blocks/defaultBlockTypeGuards"; import { BlockSchema, @@ -270,6 +271,12 @@ export function getDefaultSlashMenuItems< }); } + items.push({ + onItemClick: () => editor.openSelectionMenu(":"), + key: "emoji", + ...editor.dictionary.slash_menu.emoji, + }); + return items; } diff --git a/packages/core/src/i18n/locales/ar.ts b/packages/core/src/i18n/locales/ar.ts index 525d56b2a1..7c81fd8e73 100644 --- a/packages/core/src/i18n/locales/ar.ts +++ b/packages/core/src/i18n/locales/ar.ts @@ -90,6 +90,12 @@ export const ar: Dictionary = { aliases: ["ملف", "تحميل", "تضمين", "وسائط", "رابط"], group: "وسائط", }, + emoji: { + title: "الرموز التعبيرية", + subtext: "تُستخدم لإدراج رمز تعبيري", + aliases: ["رمز تعبيري", "إيموجي", "إيموت", "عاطفة", "وجه"], + group: "آخرون", + }, }, placeholders: { default: "أدخل نصًا أو اكتب '/' للأوامر", diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts index 9edbb6df20..60ebf8a927 100644 --- a/packages/core/src/i18n/locales/en.ts +++ b/packages/core/src/i18n/locales/en.ts @@ -2,37 +2,37 @@ export const en = { slash_menu: { heading: { title: "Heading 1", - subtext: "Used for a top-level heading", + subtext: "Top-level heading", aliases: ["h", "heading1", "h1"], group: "Headings", }, heading_2: { title: "Heading 2", - subtext: "Used for key sections", + subtext: "Key section heading", aliases: ["h2", "heading2", "subheading"], group: "Headings", }, heading_3: { title: "Heading 3", - subtext: "Used for subsections and group headings", + subtext: "Subsection and group heading", aliases: ["h3", "heading3", "subheading"], group: "Headings", }, numbered_list: { title: "Numbered List", - subtext: "Used to display a numbered list", + subtext: "List with ordered items", aliases: ["ol", "li", "list", "numberedlist", "numbered list"], group: "Basic blocks", }, bullet_list: { title: "Bullet List", - subtext: "Used to display an unordered list", + subtext: "List with unordered items", aliases: ["ul", "li", "list", "bulletlist", "bullet list"], group: "Basic blocks", }, check_list: { title: "Check List", - subtext: "Used to display a list with checkboxes", + subtext: "List with checkboxes", aliases: [ "ul", "li", @@ -46,19 +46,19 @@ export const en = { }, paragraph: { title: "Paragraph", - subtext: "Used for the body of your document", + subtext: "The body of your document", aliases: ["p", "paragraph"], group: "Basic blocks", }, table: { title: "Table", - subtext: "Used for tables", + subtext: "Table with editable cells", aliases: ["table"], group: "Advanced", }, image: { title: "Image", - subtext: "Insert an image", + subtext: "Resizable image with caption", aliases: [ "image", "imageUpload", @@ -72,7 +72,7 @@ export const en = { }, video: { title: "Video", - subtext: "Insert a video", + subtext: "Resizable video with caption", aliases: [ "video", "videoUpload", @@ -86,7 +86,7 @@ export const en = { }, audio: { title: "Audio", - subtext: "Insert audio", + subtext: "Embedded audio with caption", aliases: [ "audio", "audioUpload", @@ -100,10 +100,16 @@ export const en = { }, file: { title: "File", - subtext: "Insert a file", + subtext: "Embedded file", aliases: ["file", "upload", "embed", "media", "url"], group: "Media", }, + emoji: { + title: "Emoji", + subtext: "Search for and insert an emoji", + aliases: ["emoji", "emote", "emotion", "face"], + group: "Others", + }, }, placeholders: { default: "Enter text or type '/' for commands", diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts index c4345c6631..bd9e073aa0 100644 --- a/packages/core/src/i18n/locales/fr.ts +++ b/packages/core/src/i18n/locales/fr.ts @@ -105,6 +105,12 @@ export const fr: Dictionary = { aliases: ["fichier", "téléverser", "intégrer", "média", "url"], group: "Média", }, + emoji: { + title: "Emoji", + subtext: "Utilisé pour insérer un emoji", + aliases: ["emoji", "émoticône", "émotion", "visage"], + group: "Autres", + }, }, placeholders: { default: "Entrez du texte ou tapez '/' pour les commandes", diff --git a/packages/core/src/i18n/locales/is.ts b/packages/core/src/i18n/locales/is.ts index 729dc91ddc..4b4ee544d7 100644 --- a/packages/core/src/i18n/locales/is.ts +++ b/packages/core/src/i18n/locales/is.ts @@ -98,6 +98,12 @@ export const is: Dictionary = { aliases: ["skrá", "hlaða upp", "fella inn", "miðill", "url"], group: "Miðlar", }, + emoji: { + title: "Emoji", + subtext: "Notað til að setja inn smámynd", + aliases: ["emoji", "andlitsávísun", "tilfinningar", "andlit"], + group: "Annað", + }, }, placeholders: { default: "Sláðu inn texta eða skrifaðu '/' fyrir skipanir", diff --git a/packages/core/src/i18n/locales/ja.ts b/packages/core/src/i18n/locales/ja.ts index 59dbc267ad..91c7d983de 100644 --- a/packages/core/src/i18n/locales/ja.ts +++ b/packages/core/src/i18n/locales/ja.ts @@ -125,6 +125,12 @@ export const ja: Dictionary = { aliases: ["file", "upload", "embed", "media", "url", "ファイル"], group: "メディア", }, + emoji: { + title: "絵文字", + subtext: "絵文字を挿入するために使用します", + aliases: ["絵文字", "顔文字", "感情表現", "顔"], + group: "その他", + }, }, placeholders: { default: "テキストを入力するか'/' を入力してコマンド選択", diff --git a/packages/core/src/i18n/locales/ko.ts b/packages/core/src/i18n/locales/ko.ts index 20127fc976..9c7f6bf92a 100644 --- a/packages/core/src/i18n/locales/ko.ts +++ b/packages/core/src/i18n/locales/ko.ts @@ -109,6 +109,21 @@ export const ko: Dictionary = { aliases: ["file", "upload", "embed", "media", "파일", "url"], group: "미디어", }, + emoji: { + title: "이모지", + subtext: "이모지 삽입용으로 사용됩니다", + aliases: [ + "이모지", + "emoji", + "감정 표현", + "emotion expression", + "표정", + "face expression", + "얼굴", + "face", + ], + group: "기타", + }, }, placeholders: { default: "텍스트를 입력하거나 /를 입력하여 명령을 입력하세요.", diff --git a/packages/core/src/i18n/locales/nl.ts b/packages/core/src/i18n/locales/nl.ts index 1cd8cf747a..782645c1a1 100644 --- a/packages/core/src/i18n/locales/nl.ts +++ b/packages/core/src/i18n/locales/nl.ts @@ -100,6 +100,17 @@ export const nl: Dictionary = { aliases: ["bestand", "upload", "insluiten", "media", "url"], group: "Media", }, + emoji: { + title: "Emoji", + subtext: "Gebruikt voor het invoegen van een emoji", + aliases: [ + "emoji", + "emotie-uitdrukking", + "gezichtsuitdrukking", + "gezicht", + ], + group: "Overig", + }, }, placeholders: { default: "Voer tekst in of type '/' voor commando's", diff --git a/packages/core/src/i18n/locales/pl.ts b/packages/core/src/i18n/locales/pl.ts index e5647c1b31..89aedc0a54 100644 --- a/packages/core/src/i18n/locales/pl.ts +++ b/packages/core/src/i18n/locales/pl.ts @@ -90,6 +90,12 @@ export const pl: Dictionary = { aliases: ["plik", "wrzuć", "wstaw", "media", "url"], group: "Media", }, + emoji: { + title: "Emoji", + subtext: "Używane do wstawiania emoji", + aliases: ["emoji", "emotka", "wyrażenie emocji", "twarz"], + group: "Inne", + }, }, placeholders: { default: "Wprowadź tekst lub wpisz '/' aby użyć poleceń", diff --git a/packages/core/src/i18n/locales/pt.ts b/packages/core/src/i18n/locales/pt.ts index 946489553d..6ee76d1666 100644 --- a/packages/core/src/i18n/locales/pt.ts +++ b/packages/core/src/i18n/locales/pt.ts @@ -97,6 +97,12 @@ export const pt: Dictionary = { aliases: ["arquivo", "upload", "incorporar", "mídia", "url"], group: "Mídia", }, + emoji: { + title: "Emoji", + subtext: "Usado para inserir um emoji", + aliases: ["emoji", "emoticon", "expressão emocional", "rosto"], + group: "Outros", + }, }, placeholders: { default: "Digite texto ou use '/' para comandos", diff --git a/packages/core/src/i18n/locales/ru.ts b/packages/core/src/i18n/locales/ru.ts index d1ae8d9fb9..51a8f2653f 100644 --- a/packages/core/src/i18n/locales/ru.ts +++ b/packages/core/src/i18n/locales/ru.ts @@ -1,323 +1,336 @@ import { Dictionary } from "../dictionary"; - export const ru: Dictionary = { - slash_menu: { - heading: { - title: "Заголовок 1 уровня", - subtext: "Используется для заголовка верхнего уровня", - aliases: ["h", "heading1", "h1", "заголовок1"], - group: "Заголовки", - }, - heading_2: { - title: "Заголовок 2 уровня", - subtext: "Используется для ключевых разделов", - aliases: ["h2", "heading2", "subheading", "заголовок2", "подзаголовок"], - group: "Заголовки", - }, - heading_3: { - title: "Заголовок 3 уровня", - subtext: "Используется для подразделов и групп", - aliases: ["h3", "heading3", "subheading", "заголовок3", "подзаголовок"], - group: "Заголовки", - }, - numbered_list: { - title: "Нумерованный список", - subtext: "Используется для отображения нумерованного списка", - aliases: [ - "ol", - "li", - "list", - "numberedlist", - "numbered list", - "список", - "нумерованный список", - ], - group: "Базовые блоки", - }, - bullet_list: { - title: "Маркированный список", - subtext: "Для отображения неупорядоченного списка.", - aliases: ["ul", "li", "list", "bulletlist", "bullet list", "список", "маркированный список"], - group: "Базовые блоки", - }, - check_list: { - title: "Контрольный список", - subtext: "Для отображения списка с флажками", - aliases: [ - "ul", - "li", - "list", - "checklist", - "check list", - "checked list", - "checkbox", - "список", - ], - group: "Базовые блоки", - }, - paragraph: { - title: "Параграф", - subtext: "Основной текст", - aliases: ["p", "paragraph", "параграф"], - group: "Базовые блоки", - }, - table: { - title: "Таблица", - subtext: "Используется для таблиц", - aliases: ["table", "таблица"], - group: "Продвинутый", - }, - image: { - title: "Картинка", - subtext: "Вставить изображение", - aliases: [ - "image", - "imageUpload", - "upload", - "img", - "picture", - "media", - "url", - "загрузка", - "картинка", - "рисунок", - ], - group: "Медиа", - }, - video: { - title: "Видео", - subtext: "Вставить видео", - aliases: [ - "video", - "videoUpload", - "upload", - "mp4", - "film", - "media", - "url", - "загрузка", - "видео", - ], - group: "Медиа", - }, - audio: { - title: "Аудио", - subtext: "Вставить аудио", - aliases: [ - "audio", - "audioUpload", - "upload", - "mp3", - "sound", - "media", - "url", - "загрузка", - "аудио", - "звук", - "музыка", - ], - group: "Медиа", - }, - file: { - title: "Файл", - subtext: "Вставить файл", - aliases: ["file", "upload", "embed", "media", "url", "загрузка", "файл"], - group: "Медиа", - }, +export const ru: Dictionary = { + slash_menu: { + heading: { + title: "Заголовок 1 уровня", + subtext: "Используется для заголовка верхнего уровня", + aliases: ["h", "heading1", "h1", "заголовок1"], + group: "Заголовки", }, - placeholders: { - default: "Ведите текст или введите «/» для команд", - heading: "Заголовок", - bulletListItem: "Список", - numberedListItem: "Список", - checkListItem: "Список", + heading_2: { + title: "Заголовок 2 уровня", + subtext: "Используется для ключевых разделов", + aliases: ["h2", "heading2", "subheading", "заголовок2", "подзаголовок"], + group: "Заголовки", }, - file_blocks: { - image: { - add_button_text: "Добавить изображение", - }, - video: { - add_button_text: "Добавить видео", - }, - audio: { - add_button_text: "Добавить аудио", - }, - file: { - add_button_text: "Добавить файл", - }, + heading_3: { + title: "Заголовок 3 уровня", + subtext: "Используется для подразделов и групп", + aliases: ["h3", "heading3", "subheading", "заголовок3", "подзаголовок"], + group: "Заголовки", }, - // from react package: - side_menu: { - add_block_label: "Добавить блок", - drag_handle_label: "Открыть меню блока", - }, - drag_handle: { - delete_menuitem: "Удалить", - colors_menuitem: "Цвета", - }, - table_handle: { - delete_column_menuitem: "Удалить столбец", - delete_row_menuitem: "Удалить строку", - add_left_menuitem: "Добавить столбец слева", - add_right_menuitem: "Добавить столбец справа", - add_above_menuitem: "Добавить строку выше", - add_below_menuitem: "Добавить строку ниже", - }, - suggestion_menu: { - no_items_title: "ничего не найдено", - loading: "Загрузка…", - }, - color_picker: { - text_title: "Текст", - background_title: "Задний фон", - colors: { - default: "По умолчинию", - gray: "Серый", - brown: "Коричневый", - red: "Красный", - orange: "Оранжевый", - yellow: "Жёлтый", - green: "Зелёный", - blue: "Голубой", - purple: "Фиолетовый", - pink: "Розовый", - }, + numbered_list: { + title: "Нумерованный список", + subtext: "Используется для отображения нумерованного списка", + aliases: [ + "ol", + "li", + "list", + "numberedlist", + "numbered list", + "список", + "нумерованный список", + ], + group: "Базовые блоки", }, - - formatting_toolbar: { - bold: { - tooltip: "Жирный", - secondary_tooltip: "Mod+B", - }, - italic: { - tooltip: "Курсив", - secondary_tooltip: "Mod+I", - }, - underline: { - tooltip: "Подчёркнутый", - secondary_tooltip: "Mod+U", - }, - strike: { - tooltip: "Зачёркнутый", - secondary_tooltip: "Mod+Shift+X", - }, - code: { - tooltip: "Код", - secondary_tooltip: "", - }, - colors: { - tooltip: "Цвета", - }, - link: { - tooltip: "Создать ссылку", - secondary_tooltip: "Mod+K", - }, - file_caption: { - tooltip: "Изменить подпись", - input_placeholder: "Изменить подпись", - }, - file_replace: { - tooltip: { - image: "Заменить изображение", - video: "Заменить видео", - audio: "Заменить аудио", - file: "Заменить файл", - }, - }, - file_rename: { - tooltip: { - image: "Переименовать изображение", - video: "Переименовать видео", - audio: "Переименовать аудио", - file: "Переименовать файл", - }, - input_placeholder: { - image: "Переименовать изображение", - video: "Переименовать видео", - audio: "Переименовать аудио", - file: "Переименовать файл", - }, - }, - file_download: { - tooltip: { - image: "Скачать картинку", - video: "Скачать видео", - audio: "Скачать аудио", - file: "Скачать файл", - }, - }, - file_delete: { - tooltip: { - image: "Удалить картинку", - video: "Удалить видео", - audio: "Удалить аудио", - file: "Скачать файл", - }, - }, - file_preview_toggle: { - tooltip: "Переключить предварительный просмотр", - }, - nest: { - tooltip: "Сдвинуть вправо", - secondary_tooltip: "Tab", - }, - unnest: { - tooltip: "Сдвинуть влево", - secondary_tooltip: "Shift+Tab", - }, - align_left: { - tooltip: "Текст по левому краю", - }, - align_center: { - tooltip: "Текст по середине", - }, - align_right: { - tooltip: "Текст по правому краю", - }, - align_justify: { - tooltip: "По середине текст", - }, + bullet_list: { + title: "Маркированный список", + subtext: "Для отображения неупорядоченного списка.", + aliases: [ + "ul", + "li", + "list", + "bulletlist", + "bullet list", + "список", + "маркированный список", + ], + group: "Базовые блоки", }, - file_panel: { - upload: { - title: "Загрузить", - file_placeholder: { - image: "Загрузить картинки", - video: "Загрузить видео", - audio: "Загрузить аудио", - file: "Загрузить файл", - }, - upload_error: "Ошибка: не удалось загрузить", - }, - embed: { - title: "Вставить", - embed_button: { - image: "Вставить картинку", - video: "Вставить видео", - audio: "Вставить аудио", - file: "Вставить файл", - }, - url_placeholder: "Введите URL", - }, + check_list: { + title: "Контрольный список", + subtext: "Для отображения списка с флажками", + aliases: [ + "ul", + "li", + "list", + "checklist", + "check list", + "checked list", + "checkbox", + "список", + ], + group: "Базовые блоки", + }, + paragraph: { + title: "Параграф", + subtext: "Основной текст", + aliases: ["p", "paragraph", "параграф"], + group: "Базовые блоки", + }, + table: { + title: "Таблица", + subtext: "Используется для таблиц", + aliases: ["table", "таблица"], + group: "Продвинутый", + }, + image: { + title: "Картинка", + subtext: "Вставить изображение", + aliases: [ + "image", + "imageUpload", + "upload", + "img", + "picture", + "media", + "url", + "загрузка", + "картинка", + "рисунок", + ], + group: "Медиа", + }, + video: { + title: "Видео", + subtext: "Вставить видео", + aliases: [ + "video", + "videoUpload", + "upload", + "mp4", + "film", + "media", + "url", + "загрузка", + "видео", + ], + group: "Медиа", + }, + audio: { + title: "Аудио", + subtext: "Вставить аудио", + aliases: [ + "audio", + "audioUpload", + "upload", + "mp3", + "sound", + "media", + "url", + "загрузка", + "аудио", + "звук", + "музыка", + ], + group: "Медиа", + }, + file: { + title: "Файл", + subtext: "Вставить файл", + aliases: ["file", "upload", "embed", "media", "url", "загрузка", "файл"], + group: "Медиа", + }, + emoji: { + title: "Эмодзи", + subtext: "Используется для вставки эмодзи", + aliases: ["эмодзи", "смайлик", "выражение эмоций", "лицо"], + group: "Прочее", + }, + }, + placeholders: { + default: "Ведите текст или введите «/» для команд", + heading: "Заголовок", + bulletListItem: "Список", + numberedListItem: "Список", + checkListItem: "Список", + }, + file_blocks: { + image: { + add_button_text: "Добавить изображение", + }, + video: { + add_button_text: "Добавить видео", + }, + audio: { + add_button_text: "Добавить аудио", + }, + file: { + add_button_text: "Добавить файл", + }, + }, + // from react package: + side_menu: { + add_block_label: "Добавить блок", + drag_handle_label: "Открыть меню блока", + }, + drag_handle: { + delete_menuitem: "Удалить", + colors_menuitem: "Цвета", + }, + table_handle: { + delete_column_menuitem: "Удалить столбец", + delete_row_menuitem: "Удалить строку", + add_left_menuitem: "Добавить столбец слева", + add_right_menuitem: "Добавить столбец справа", + add_above_menuitem: "Добавить строку выше", + add_below_menuitem: "Добавить строку ниже", + }, + suggestion_menu: { + no_items_title: "ничего не найдено", + loading: "Загрузка…", + }, + color_picker: { + text_title: "Текст", + background_title: "Задний фон", + colors: { + default: "По умолчинию", + gray: "Серый", + brown: "Коричневый", + red: "Красный", + orange: "Оранжевый", + yellow: "Жёлтый", + green: "Зелёный", + blue: "Голубой", + purple: "Фиолетовый", + pink: "Розовый", + }, + }, + + formatting_toolbar: { + bold: { + tooltip: "Жирный", + secondary_tooltip: "Mod+B", + }, + italic: { + tooltip: "Курсив", + secondary_tooltip: "Mod+I", + }, + underline: { + tooltip: "Подчёркнутый", + secondary_tooltip: "Mod+U", + }, + strike: { + tooltip: "Зачёркнутый", + secondary_tooltip: "Mod+Shift+X", + }, + code: { + tooltip: "Код", + secondary_tooltip: "", }, - link_toolbar: { - delete: { - tooltip: "Удалить ссылку", + colors: { + tooltip: "Цвета", + }, + link: { + tooltip: "Создать ссылку", + secondary_tooltip: "Mod+K", + }, + file_caption: { + tooltip: "Изменить подпись", + input_placeholder: "Изменить подпись", + }, + file_replace: { + tooltip: { + image: "Заменить изображение", + video: "Заменить видео", + audio: "Заменить аудио", + file: "Заменить файл", }, - edit: { - text: "Изменить ссылку", - tooltip: "Редактировать", + }, + file_rename: { + tooltip: { + image: "Переименовать изображение", + video: "Переименовать видео", + audio: "Переименовать аудио", + file: "Переименовать файл", + }, + input_placeholder: { + image: "Переименовать изображение", + video: "Переименовать видео", + audio: "Переименовать аудио", + file: "Переименовать файл", }, - open: { - tooltip: "Открыть в новой вкладке", + }, + file_download: { + tooltip: { + image: "Скачать картинку", + video: "Скачать видео", + audio: "Скачать аудио", + file: "Скачать файл", }, - form: { - title_placeholder: "Изменить заголовок", - url_placeholder: "Изменить URL", + }, + file_delete: { + tooltip: { + image: "Удалить картинку", + video: "Удалить видео", + audio: "Удалить аудио", + file: "Скачать файл", }, }, - generic: { - ctrl_shortcut: "Ctrl", + file_preview_toggle: { + tooltip: "Переключить предварительный просмотр", + }, + nest: { + tooltip: "Сдвинуть вправо", + secondary_tooltip: "Tab", + }, + unnest: { + tooltip: "Сдвинуть влево", + secondary_tooltip: "Shift+Tab", + }, + align_left: { + tooltip: "Текст по левому краю", + }, + align_center: { + tooltip: "Текст по середине", + }, + align_right: { + tooltip: "Текст по правому краю", + }, + align_justify: { + tooltip: "По середине текст", + }, + }, + file_panel: { + upload: { + title: "Загрузить", + file_placeholder: { + image: "Загрузить картинки", + video: "Загрузить видео", + audio: "Загрузить аудио", + file: "Загрузить файл", + }, + upload_error: "Ошибка: не удалось загрузить", + }, + embed: { + title: "Вставить", + embed_button: { + image: "Вставить картинку", + video: "Вставить видео", + audio: "Вставить аудио", + file: "Вставить файл", + }, + url_placeholder: "Введите URL", + }, + }, + link_toolbar: { + delete: { + tooltip: "Удалить ссылку", + }, + edit: { + text: "Изменить ссылку", + tooltip: "Редактировать", + }, + open: { + tooltip: "Открыть в новой вкладке", + }, + form: { + title_placeholder: "Изменить заголовок", + url_placeholder: "Изменить URL", }, - }; - \ No newline at end of file + }, + generic: { + ctrl_shortcut: "Ctrl", + }, +}; diff --git a/packages/core/src/i18n/locales/vi.ts b/packages/core/src/i18n/locales/vi.ts index 054076f321..f188196ddf 100644 --- a/packages/core/src/i18n/locales/vi.ts +++ b/packages/core/src/i18n/locales/vi.ts @@ -97,6 +97,19 @@ export const vi: Dictionary = { aliases: ["tep", "tai-len", "nhung", "media", "url"], group: "Phương tiện", }, + emoji: { + title: "Biểu tượng cảm xúc", + subtext: "Dùng để chèn biểu tượng cảm xúc", + aliases: [ + "biểu tượng cảm xúc", + "emoji", + "emoticon", + "cảm xúc expression", + "khuôn mặt", + "face", + ], + group: "Khác", + }, }, placeholders: { default: "Nhập văn bản hoặc gõ '/' để thêm định dạng", diff --git a/packages/core/src/i18n/locales/zh.ts b/packages/core/src/i18n/locales/zh.ts index eb9364a89d..6c28356597 100644 --- a/packages/core/src/i18n/locales/zh.ts +++ b/packages/core/src/i18n/locales/zh.ts @@ -130,6 +130,20 @@ export const zh: Dictionary = { aliases: ["文件", "上传", "file", "嵌入", "媒体", "url"], group: "媒体", }, + emoji: { + title: "表情符号", + subtext: "用于插入表情符号", + aliases: [ + "表情符号", + "emoji", + "face", + "emote", + "表情", + "表情表达", + "表情", + ], + group: "其他", + }, }, placeholders: { default: "输入 '/' 以使用命令", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 88b0f6186a..730d5ba8df 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -22,8 +22,10 @@ export * from "./extensions/FormattingToolbar/FormattingToolbarPlugin"; export * from "./extensions/LinkToolbar/LinkToolbarPlugin"; export * from "./extensions/SideMenu/SideMenuPlugin"; export * from "./extensions/SuggestionMenu/DefaultSuggestionItem"; +export * from "./extensions/SuggestionMenu/DefaultGridSuggestionItem"; export * from "./extensions/SuggestionMenu/SuggestionPlugin"; export * from "./extensions/SuggestionMenu/getDefaultSlashMenuItems"; +export * from "./extensions/SuggestionMenu/getDefaultEmojiPickerItems"; export * from "./extensions/TableHandles/TableHandlesPlugin"; export * from "./schema"; export * from "./util/browser"; diff --git a/packages/mantine/src/index.tsx b/packages/mantine/src/index.tsx index 142a2db836..94c423ff2b 100644 --- a/packages/mantine/src/index.tsx +++ b/packages/mantine/src/index.tsx @@ -19,7 +19,10 @@ import { applyBlockNoteCSSVariablesFromTheme, removeBlockNoteCSSVariables, } from "./BlockNoteTheme"; -import { TextInput } from "./form/TextInput"; +import { GridSuggestionMenu } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenu"; +import { GridSuggestionMenuEmptyItem } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem"; +import { GridSuggestionMenuItem } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem"; +import { GridSuggestionMenuLoader } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader"; import { Menu, MenuDivider, @@ -42,10 +45,10 @@ import { SuggestionMenuItem } from "./suggestionMenu/SuggestionMenuItem"; import { SuggestionMenuLabel } from "./suggestionMenu/SuggestionMenuLabel"; import { SuggestionMenuLoader } from "./suggestionMenu/SuggestionMenuLoader"; import { TableHandle } from "./tableHandle/TableHandle"; +import { TextInput } from "./form/TextInput"; import { Toolbar } from "./toolbar/Toolbar"; import { ToolbarButton } from "./toolbar/ToolbarButton"; import { ToolbarSelect } from "./toolbar/ToolbarSelect"; - import "./style.css"; export * from "./BlockNoteTheme"; @@ -64,6 +67,12 @@ export const components: Components = { TabPanel: PanelTab, TextInput: PanelTextInput, }, + GridSuggestionMenu: { + Root: GridSuggestionMenu, + Item: GridSuggestionMenuItem, + EmptyItem: GridSuggestionMenuEmptyItem, + Loader: GridSuggestionMenuLoader, + }, LinkToolbar: { Root: Toolbar, Button: ToolbarButton, diff --git a/packages/mantine/src/style.css b/packages/mantine/src/style.css index 92a1f2b0ac..a436a81ba4 100644 --- a/packages/mantine/src/style.css +++ b/packages/mantine/src/style.css @@ -299,6 +299,7 @@ box-shadow: var(--bn-shadow-medium); box-sizing: border-box; color: var(--bn-colors-menu-text); + height: fit-content; overflow-y: auto; padding: 2px; } @@ -362,6 +363,52 @@ background-color: var(--bn-colors-side-menu); } +.bn-mantine .bn-grid-suggestion-menu { + background: var(--bn-colors-menu-background); + border-radius: var(--bn-border-radius-large); + box-shadow: var(--bn-shadow-medium); + display: grid; + gap: 7px; + height: fit-content; + justify-items: center; + max-height: min(500px, 100%); + overflow-y: auto; + padding: 20px; +} + +.bn-mantine .bn-grid-suggestion-menu-item { + align-items: center; + border-radius: var(--bn-border-radius-large); + cursor: pointer; + display: flex; + font-size: 24px; + height: 32px; + justify-content: center; + margin: 2px; + padding: 4px; + width: 32px; +} + +.bn-mantine .bn-grid-suggestion-menu-item[aria-selected="true"], +.bn-mantine .bn-grid-suggestion-menu-item:hover { + background-color: var(--bn-colors-hovered-background); +} + +.bn-mantine .bn-grid-suggestion-menu-empty-item, +.bn-mantine .bn-grid-suggestion-menu-loader{ + align-items: center; + color: var(--bn-colors-menu-text); + display: flex; + font-size: 14px; + font-weight: 500; + height: 32px; + justify-content: center; +} + +.bn-mantine .bn-grid-suggestion-menu-loader span { + background-color: var(--bn-colors-side-menu); +} + /* Side Menu styling */ .bn-mantine .bn-side-menu { background-color: transparent; diff --git a/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenu.tsx b/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenu.tsx new file mode 100644 index 0000000000..b61454efe3 --- /dev/null +++ b/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenu.tsx @@ -0,0 +1,23 @@ +import { assertEmpty } from "@blocknote/core"; +import { ComponentProps } from "@blocknote/react"; +import { forwardRef } from "react"; + +export const GridSuggestionMenu = forwardRef< + HTMLDivElement, + ComponentProps["GridSuggestionMenu"]["Root"] +>((props, ref) => { + const { className, children, id, columns, ...rest } = props; + + assertEmpty(rest); + + return ( +
+ {children} +
+ ); +}); diff --git a/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem.tsx b/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem.tsx new file mode 100644 index 0000000000..fafb9fa1d0 --- /dev/null +++ b/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem.tsx @@ -0,0 +1,25 @@ +import { Group as MantineGroup } from "@mantine/core"; + +import { assertEmpty } from "@blocknote/core"; +import { ComponentProps } from "@blocknote/react"; +import { forwardRef } from "react"; + +export const GridSuggestionMenuEmptyItem = forwardRef< + HTMLDivElement, + ComponentProps["GridSuggestionMenu"]["EmptyItem"] +>((props, ref) => { + const { className, children, columns, ...rest } = props; + + assertEmpty(rest); + + return ( + + + {children} + + + ); +}); diff --git a/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem.tsx b/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem.tsx new file mode 100644 index 0000000000..ded689a9cb --- /dev/null +++ b/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem.tsx @@ -0,0 +1,42 @@ +import { mergeRefs } from "@mantine/hooks"; + +import { assertEmpty } from "@blocknote/core"; +import { ComponentProps, elementOverflow } from "@blocknote/react"; +import { forwardRef, useEffect, useRef } from "react"; + +export const GridSuggestionMenuItem = forwardRef< + HTMLDivElement, + ComponentProps["GridSuggestionMenu"]["Item"] +>((props, ref) => { + const { className, isSelected, onClick, item, id, ...rest } = props; + + assertEmpty(rest); + + const itemRef = useRef(null); + + useEffect(() => { + if (!itemRef.current || !isSelected) { + return; + } + + const overflow = elementOverflow(itemRef.current); + + if (overflow === "top") { + itemRef.current.scrollIntoView(true); + } else if (overflow === "bottom") { + itemRef.current.scrollIntoView(false); + } + }, [isSelected]); + + return ( +
+ {item.icon} +
+ ); +}); diff --git a/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader.tsx b/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader.tsx new file mode 100644 index 0000000000..42dda5d583 --- /dev/null +++ b/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader.tsx @@ -0,0 +1,28 @@ +import { Loader as MantineLoader } from "@mantine/core"; + +import { assertEmpty } from "@blocknote/core"; +import { ComponentProps } from "@blocknote/react"; +import { forwardRef } from "react"; + +export const GridSuggestionMenuLoader = forwardRef< + HTMLDivElement, + ComponentProps["GridSuggestionMenu"]["Loader"] +>((props, ref) => { + const { + className, + children, // unused, using "dots" instead + columns, + ...rest + } = props; + + assertEmpty(rest); + + return ( + + ); +}); diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenu.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenu.tsx new file mode 100644 index 0000000000..12a816eef4 --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenu.tsx @@ -0,0 +1,75 @@ +import { useMemo } from "react"; + +import { useComponentsContext } from "../../../editor/ComponentsContext"; +import { useDictionary } from "../../../i18n/dictionary"; +import { + DefaultReactGridSuggestionItem, + GridSuggestionMenuProps, +} from "./types"; + +export function GridSuggestionMenu( + props: GridSuggestionMenuProps +) { + const Components = useComponentsContext()!; + const dict = useDictionary(); + + const { items, loadingState, selectedIndex, onItemClick, columns } = props; + + const loader = + loadingState === "loading-initial" || loadingState === "loading" ? ( + + {dict.suggestion_menu.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; + }, [Components, items, onItemClick, selectedIndex]); + + return ( + + {loader} + {renderedItems} + {renderedItems.length === 0 && props.loadingState === "loaded" && ( + + {dict.suggestion_menu.no_items_title} + + )} + + ); +} diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx new file mode 100644 index 0000000000..2bfd0dac77 --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx @@ -0,0 +1,154 @@ +import { + BlockSchema, + InlineContentSchema, + StyleSchema, + SuggestionMenuState, +} from "@blocknote/core"; +import { flip, offset, size } from "@floating-ui/react"; +import { FC, useCallback, useMemo } from "react"; + +import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor"; +import { useUIElementPositioning } from "../../../hooks/useUIElementPositioning"; +import { useUIPluginState } from "../../../hooks/useUIPluginState"; +import { getDefaultReactEmojiPickerItems } from "./getDefaultReactEmojiPickerItems"; +import { GridSuggestionMenu } from "./GridSuggestionMenu"; +import { GridSuggestionMenuWrapper } from "./GridSuggestionMenuWrapper"; +import { + DefaultReactGridSuggestionItem, + GridSuggestionMenuProps, +} from "./types"; + +type ArrayElement = A extends readonly (infer T)[] ? T : never; + +type ItemType Promise> = + ArrayElement>>; + +export function GridSuggestionMenuController< + // 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 = ( + query: string + ) => Promise +>( + props: { + triggerCharacter: string; + getItems?: GetItemsType; + columns: number; + minQueryLength?: number; + } & (ItemType extends DefaultReactGridSuggestionItem + ? { + // can be undefined + gridSuggestionMenuComponent?: FC< + GridSuggestionMenuProps> + >; + onItemClick?: (item: ItemType) => void; + } + : { + // getItems doesn't return DefaultSuggestionItem, so suggestionMenuComponent is required + gridSuggestionMenuComponent: FC< + GridSuggestionMenuProps> + >; + onItemClick: (item: ItemType) => void; + }) +) { + const editor = useBlockNoteEditor< + BlockSchema, + InlineContentSchema, + StyleSchema + >(); + + const { + triggerCharacter, + gridSuggestionMenuComponent, + columns, + minQueryLength, + onItemClick, + getItems, + } = props; + + const onItemClickOrDefault = useMemo(() => { + return ( + onItemClick || + ((item: ItemType) => { + item.onItemClick(editor); + }) + ); + }, [editor, onItemClick]); + + const getItemsOrDefault = useMemo(() => { + return ( + getItems || + ((async (query: string) => + await getDefaultReactEmojiPickerItems( + editor, + query + )) as any as typeof getItems) + ); + }, [editor, getItems])!; + + const callbacks = { + closeMenu: editor.suggestionMenus.closeMenu, + clearQuery: editor.suggestionMenus.clearQuery, + }; + + const cb = useCallback( + (callback: (state: SuggestionMenuState) => void) => { + return editor.suggestionMenus.onUpdate(triggerCharacter, callback); + }, + [editor.suggestionMenus, triggerCharacter] + ); + + const state = useUIPluginState(cb); + + const { isMounted, ref, style, getFloatingProps } = 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, { + height: `${availableHeight - 10}px`, + }); + }, + }), + ], + onOpenChange(open) { + if (!open) { + editor.suggestionMenus.closeMenu(); + } + }, + } + ); + + if ( + !isMounted || + !state || + (minQueryLength && + (state.query.startsWith(" ") || state.query.length < minQueryLength)) + ) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuWrapper.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuWrapper.tsx new file mode 100644 index 0000000000..56b27b4005 --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuWrapper.tsx @@ -0,0 +1,105 @@ +import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core"; +import { FC, useCallback, useEffect } from "react"; + +import { useBlockNoteContext } from "../../../editor/BlockNoteContext"; +import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor"; +import { useCloseSuggestionMenuNoItems } from "../hooks/useCloseSuggestionMenuNoItems"; +import { useGridSuggestionMenuKeyboardNavigation } from "./hooks/useGridSuggestionMenuKeyboardNavigation"; +import { useLoadSuggestionMenuItems } from "../hooks/useLoadSuggestionMenuItems"; +import { GridSuggestionMenuProps } from "./types"; + +export function GridSuggestionMenuWrapper(props: { + query: string; + closeMenu: () => void; + clearQuery: () => void; + getItems: (query: string) => Promise; + columns: number; + onItemClick?: (item: Item) => void; + gridSuggestionMenuComponent: FC>; +}) { + const ctx = useBlockNoteContext(); + const setContentEditableProps = ctx!.setContentEditableProps!; + const editor = useBlockNoteEditor< + BlockSchema, + InlineContentSchema, + StyleSchema + >(); + + const { + getItems, + gridSuggestionMenuComponent, + query, + clearQuery, + closeMenu, + onItemClick, + columns, + } = props; + + const onItemClickCloseMenu = useCallback( + (item: Item) => { + closeMenu(); + clearQuery(); + onItemClick?.(item); + }, + [onItemClick, closeMenu, clearQuery] + ); + + const { items, usedQuery, loadingState } = useLoadSuggestionMenuItems( + query, + getItems + ); + + useCloseSuggestionMenuNoItems(items, usedQuery, closeMenu); + + const { selectedIndex } = useGridSuggestionMenuKeyboardNavigation( + editor, + query, + items, + columns, + onItemClickCloseMenu + ); + + // set basic aria attributes when the menu is open + useEffect(() => { + setContentEditableProps((p) => ({ + ...p, + "aria-expanded": true, + "aria-controls": "bn-suggestion-menu", + })); + return () => { + setContentEditableProps((p) => ({ + ...p, + "aria-expanded": false, + "aria-controls": undefined, + })); + }; + }, [setContentEditableProps]); + + // set selected item (activedescendent) attributes when selected item changes + useEffect(() => { + setContentEditableProps((p) => ({ + ...p, + "aria-activedescendant": selectedIndex + ? "bn-suggestion-menu-item-" + selectedIndex + : undefined, + })); + return () => { + setContentEditableProps((p) => ({ + ...p, + "aria-activedescendant": undefined, + })); + }; + }, [setContentEditableProps, selectedIndex]); + + const Component = gridSuggestionMenuComponent; + + return ( + + ); +} diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/getDefaultReactEmojiPickerItems.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/getDefaultReactEmojiPickerItems.tsx new file mode 100644 index 0000000000..85ec2e53a2 --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/getDefaultReactEmojiPickerItems.tsx @@ -0,0 +1,25 @@ +import { + BlockNoteEditor, + BlockSchema, + getDefaultEmojiPickerItems, + InlineContentSchema, + StyleSchema, +} from "@blocknote/core"; +import { DefaultReactGridSuggestionItem } from "./types"; + +export async function getDefaultReactEmojiPickerItems< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + editor: BlockNoteEditor, + query: string +): Promise { + return (await getDefaultEmojiPickerItems(editor, query)).map( + ({ id, onItemClick }) => ({ + id, + onItemClick, + icon: id as any, + }) + ); +} diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation.ts b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation.ts new file mode 100644 index 0000000000..739dbdec7e --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation.ts @@ -0,0 +1,91 @@ +import { BlockNoteEditor } from "@blocknote/core"; +import { useEffect, useState } from "react"; + +// Hook which handles keyboard navigation of a grid suggestion menu. Arrow keys +// are used to select a menu item, enter is used to execute it. +export function useGridSuggestionMenuKeyboardNavigation( + editor: BlockNoteEditor, + query: string, + items: Item[], + columns: number, + onItemClick?: (item: Item) => void +) { + const [selectedIndex, setSelectedIndex] = useState(0); + + const isGrid = columns !== undefined && columns > 1; + + useEffect(() => { + const handleMenuNavigationKeys = (event: KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + if (items.length) { + setSelectedIndex((selectedIndex - 1 + items!.length) % items!.length); + } + } + + if (event.key === "ArrowRight") { + event.preventDefault(); + if (items.length) { + setSelectedIndex((selectedIndex + 1 + items!.length) % items!.length); + } + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + + if (items.length) { + setSelectedIndex( + (selectedIndex - columns + items!.length) % items!.length + ); + } + + return true; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + + if (items.length) { + setSelectedIndex((selectedIndex + columns) % items!.length); + } + + return true; + } + + if (event.key === "Enter") { + event.preventDefault(); + + if (items.length) { + onItemClick?.(items[selectedIndex]); + } + + return true; + } + + return false; + }; + + editor.domElement.addEventListener( + "keydown", + handleMenuNavigationKeys, + true + ); + + return () => { + editor.domElement.removeEventListener( + "keydown", + handleMenuNavigationKeys, + true + ); + }; + }, [editor.domElement, items, selectedIndex, onItemClick, columns, isGrid]); + + // Resets index when items change + useEffect(() => { + setSelectedIndex(0); + }, [query]); + + return { + selectedIndex: items.length === 0 ? undefined : selectedIndex, + }; +} diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/types.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/types.tsx new file mode 100644 index 0000000000..e71f37995c --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/types.tsx @@ -0,0 +1,11 @@ +import { DefaultGridSuggestionItem } from "@blocknote/core"; + +import { SuggestionMenuProps } from "../types"; + +export type DefaultReactGridSuggestionItem = DefaultGridSuggestionItem & { + icon?: JSX.Element; +}; + +export type GridSuggestionMenuProps = SuggestionMenuProps & { + columns: number; +}; diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenu.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenu.tsx index cff67da17f..b5750e825a 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenu.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenu.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; + import { useComponentsContext } from "../../editor/ComponentsContext"; import { useDictionary } from "../../i18n/dictionary"; import { DefaultReactSuggestionItem, SuggestionMenuProps } from "./types"; diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx index 6a6feafd54..b6e1caeb97 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx @@ -31,6 +31,7 @@ export function SuggestionMenuController< props: { triggerCharacter: string; getItems?: GetItemsType; + minQueryLength?: number; } & (ItemType extends DefaultReactSuggestionItem ? { // can be undefined @@ -53,9 +54,13 @@ export function SuggestionMenuController< StyleSchema >(); - const { triggerCharacter, suggestionMenuComponent } = props; - - const { onItemClick, getItems } = props; + const { + triggerCharacter, + suggestionMenuComponent, + minQueryLength, + onItemClick, + getItems, + } = props; const onItemClickOrDefault = useMemo(() => { return ( @@ -105,7 +110,7 @@ export function SuggestionMenuController< size({ apply({ availableHeight, elements }) { Object.assign(elements.floating.style, { - maxHeight: `${availableHeight - 10}px`, + height: `${availableHeight - 10}px`, }); }, }), @@ -118,7 +123,12 @@ export function SuggestionMenuController< } ); - if (!isMounted || !state) { + if ( + !isMounted || + !state || + (minQueryLength && + (state.query.startsWith(" ") || state.query.length < minQueryLength)) + ) { return null; } diff --git a/packages/react/src/components/SuggestionMenu/getDefaultReactSlashMenuItems.tsx b/packages/react/src/components/SuggestionMenu/getDefaultReactSlashMenuItems.tsx index 88fbe29bab..e17a247591 100644 --- a/packages/react/src/components/SuggestionMenu/getDefaultReactSlashMenuItems.tsx +++ b/packages/react/src/components/SuggestionMenu/getDefaultReactSlashMenuItems.tsx @@ -6,6 +6,7 @@ import { StyleSchema, } from "@blocknote/core"; import { + RiEmotionFill, RiH1, RiH2, RiH3, @@ -34,6 +35,7 @@ const icons = { video: RiFilmLine, audio: RiVolumeUpFill, file: RiFile2Line, + emoji: RiEmotionFill, }; export function getDefaultReactSlashMenuItems< diff --git a/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts b/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts index 0306914c5e..f618c8f309 100644 --- a/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts +++ b/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts @@ -1,8 +1,8 @@ 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 +// Hook which handles keyboard navigation of a suggestion menu. Up & down arrow +// keys are used to select a menu item, enter is used to execute it. export function useSuggestionMenuKeyboardNavigation( editor: BlockNoteEditor, query: string, diff --git a/packages/react/src/editor/BlockNoteDefaultUI.tsx b/packages/react/src/editor/BlockNoteDefaultUI.tsx index 8df2624cb3..237c843bba 100644 --- a/packages/react/src/editor/BlockNoteDefaultUI.tsx +++ b/packages/react/src/editor/BlockNoteDefaultUI.tsx @@ -3,6 +3,7 @@ import { LinkToolbarController } from "../components/LinkToolbar/LinkToolbarCont import { FilePanelController } from "../components/FilePanel/FilePanelController"; import { SideMenuController } from "../components/SideMenu/SideMenuController"; import { SuggestionMenuController } from "../components/SuggestionMenu/SuggestionMenuController"; +import { GridSuggestionMenuController } from "../components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController"; import { TableHandlesController } from "../components/TableHandles/TableHandlesController"; import { useBlockNoteEditor } from "../hooks/useBlockNoteEditor"; @@ -13,6 +14,7 @@ export type BlockNoteDefaultUIProps = { sideMenu?: boolean; filePanel?: boolean; tableHandles?: boolean; + emojiPicker?: boolean; }; export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) { @@ -31,6 +33,13 @@ export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) { {props.slashMenu !== false && ( )} + {props.emojiPicker !== false && ( + + )} {props.sideMenu !== false && } {editor.filePanel && props.filePanel !== false && } {editor.tableHandles && props.tableHandles !== false && ( diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx index d76d37240f..584ad1f257 100644 --- a/packages/react/src/editor/BlockNoteView.tsx +++ b/packages/react/src/editor/BlockNoteView.tsx @@ -81,6 +81,7 @@ function BlockNoteViewComponent< formattingToolbar, linkToolbar, slashMenu, + emojiPicker, sideMenu, filePanel, tableHandles, @@ -115,6 +116,7 @@ function BlockNoteViewComponent< formattingToolbar={formattingToolbar} linkToolbar={linkToolbar} slashMenu={slashMenu} + emojiPicker={emojiPicker} sideMenu={sideMenu} filePanel={filePanel} tableHandles={tableHandles} @@ -125,9 +127,10 @@ function BlockNoteViewComponent< children, formattingToolbar, linkToolbar, - filePanel, - sideMenu, slashMenu, + emojiPicker, + sideMenu, + filePanel, tableHandles, ]); diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index d6ce638f11..7a46df4549 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -10,6 +10,7 @@ import { } from "react"; import { DefaultReactSuggestionItem } from "../components/SuggestionMenu/types"; +import { DefaultReactGridSuggestionItem } from "../components/SuggestionMenu/GridSuggestionMenu/types"; export type ComponentProps = { FormattingToolbar: { @@ -142,6 +143,35 @@ export type ComponentProps = { children?: ReactNode; }; }; + GridSuggestionMenu: { + Root: { + id: string; + columns: number; + className?: string; + children?: ReactNode; + }; + EmptyItem: { + columns: number; + className?: string; + children?: ReactNode; + }; + Item: { + className?: string; + id: string; + isSelected: boolean; + onClick: () => void; + item: DefaultReactGridSuggestionItem; + }; + // Label: { + // className?: string; + // children?: ReactNode; + // }; + Loader: { + columns: number; + className?: string; + children?: ReactNode; + }; + }; TableHandle: { Root: { className?: string; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 8f50414f25..10e2659117 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -51,6 +51,12 @@ export * from "./components/SuggestionMenu/hooks/useLoadSuggestionMenuItems"; export * from "./components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation"; export * from "./components/SuggestionMenu/types"; +export * from "./components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController"; +export * from "./components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuWrapper"; +export * from "./components/SuggestionMenu/GridSuggestionMenu/getDefaultReactEmojiPickerItems"; +export * from "./components/SuggestionMenu/GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation"; +export * from "./components/SuggestionMenu/GridSuggestionMenu/types"; + export * from "./components/FilePanel/DefaultTabs/EmbedTab"; export * from "./components/FilePanel/DefaultTabs/UploadTab"; export * from "./components/FilePanel/FilePanel"; diff --git a/packages/shadcn/src/index.tsx b/packages/shadcn/src/index.tsx index d69d05c86a..cb40f75d0f 100644 --- a/packages/shadcn/src/index.tsx +++ b/packages/shadcn/src/index.tsx @@ -17,6 +17,10 @@ import { ShadCNDefaultComponents, } from "./ShadCNComponentsContext"; import { Form } from "./form/Form"; +import { GridSuggestionMenu } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenu"; +import { GridSuggestionMenuEmptyItem } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem"; +import { GridSuggestionMenuItem } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem"; +import { GridSuggestionMenuLoader } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader"; import { Menu, MenuDivider, @@ -72,6 +76,12 @@ export const components: Components = { Label: SuggestionMenuLabel, Loader: SuggestionMenuLoader, }, + GridSuggestionMenu: { + Root: GridSuggestionMenu, + Item: GridSuggestionMenuItem, + EmptyItem: GridSuggestionMenuEmptyItem, + Loader: GridSuggestionMenuLoader, + }, TableHandle: { Root: TableHandle, }, diff --git a/packages/shadcn/src/style.css b/packages/shadcn/src/style.css index b0e9cc4593..bd507e628f 100644 --- a/packages/shadcn/src/style.css +++ b/packages/shadcn/src/style.css @@ -105,7 +105,58 @@ overflow: auto; } +.bn-shadcn .bn-suggestion-menu { + height: fit-content; + max-height: 100%; +} + .bn-shadcn .bn-suggestion-menu-item[aria-selected="true"], .bn-shadcn .bn-suggestion-menu-item:hover { background-color: hsl(var(--accent)); } + +.bn-shadcn .bn-grid-suggestion-menu { + background: var(--bn-colors-menu-background); + border-radius: var(--bn-border-radius-large); + box-shadow: var(--bn-shadow-medium); + display: grid; + gap: 7px; + height: fit-content; + justify-items: center; + max-height: min(500px, 100%); + overflow-y: auto; + padding: 20px; +} + +.bn-shadcn .bn-grid-suggestion-menu-item { + align-items: center; + border-radius: var(--bn-border-radius-large); + cursor: pointer; + display: flex; + font-size: 24px; + height: 32px; + justify-content: center; + margin: 2px; + padding: 4px; + width: 32px; +} + +.bn-shadcn .bn-grid-suggestion-menu-item[aria-selected="true"], +.bn-shadcn .bn-grid-suggestion-menu-item:hover { + background-color: var(--bn-colors-hovered-background); +} + +.bn-shadcn .bn-grid-suggestion-menu-empty-item, +.bn-shadcn .bn-grid-suggestion-menu-loader{ + align-items: center; + color: var(--bn-colors-menu-text); + display: flex; + font-size: 14px; + font-weight: 500; + height: 32px; + justify-content: center; +} + +.bn-shadcn .bn-grid-suggestion-menu-loader span { + background-color: hsl(var(--accent)); +} diff --git a/packages/shadcn/src/suggestionMenu/SuggestionMenuEmptyItem.tsx b/packages/shadcn/src/suggestionMenu/SuggestionMenuEmptyItem.tsx index 8ec2dcd780..09f51e1868 100644 --- a/packages/shadcn/src/suggestionMenu/SuggestionMenuEmptyItem.tsx +++ b/packages/shadcn/src/suggestionMenu/SuggestionMenuEmptyItem.tsx @@ -1,8 +1,8 @@ +import { assertEmpty } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; import { forwardRef } from "react"; import { cn } from "../lib/utils"; -import { assertEmpty } from "@blocknote/core"; export const SuggestionMenuEmptyItem = forwardRef< HTMLDivElement, diff --git a/packages/shadcn/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenu.tsx b/packages/shadcn/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenu.tsx new file mode 100644 index 0000000000..b61454efe3 --- /dev/null +++ b/packages/shadcn/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenu.tsx @@ -0,0 +1,23 @@ +import { assertEmpty } from "@blocknote/core"; +import { ComponentProps } from "@blocknote/react"; +import { forwardRef } from "react"; + +export const GridSuggestionMenu = forwardRef< + HTMLDivElement, + ComponentProps["GridSuggestionMenu"]["Root"] +>((props, ref) => { + const { className, children, id, columns, ...rest } = props; + + assertEmpty(rest); + + return ( +
+ {children} +
+ ); +}); diff --git a/packages/shadcn/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem.tsx b/packages/shadcn/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem.tsx new file mode 100644 index 0000000000..e189d938b5 --- /dev/null +++ b/packages/shadcn/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem.tsx @@ -0,0 +1,21 @@ +import { assertEmpty } from "@blocknote/core"; +import { ComponentProps } from "@blocknote/react"; +import { forwardRef } from "react"; + +export const GridSuggestionMenuEmptyItem = forwardRef< + HTMLDivElement, + ComponentProps["GridSuggestionMenu"]["EmptyItem"] +>((props, ref) => { + const { className, children, columns, ...rest } = props; + + assertEmpty(rest); + + return ( +
+ {children} +
+ ); +}); diff --git a/packages/shadcn/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem.tsx b/packages/shadcn/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem.tsx new file mode 100644 index 0000000000..6db77fda62 --- /dev/null +++ b/packages/shadcn/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem.tsx @@ -0,0 +1,40 @@ +import { assertEmpty } from "@blocknote/core"; +import { ComponentProps, elementOverflow, mergeRefs } from "@blocknote/react"; +import { forwardRef, useEffect, useRef } from "react"; + +export const GridSuggestionMenuItem = forwardRef< + HTMLDivElement, + ComponentProps["GridSuggestionMenu"]["Item"] +>((props, ref) => { + const { className, isSelected, onClick, item, id, ...rest } = props; + + assertEmpty(rest); + + const itemRef = useRef(null); + + useEffect(() => { + if (!itemRef.current || !isSelected) { + return; + } + + const overflow = elementOverflow(itemRef.current); + + if (overflow === "top") { + itemRef.current.scrollIntoView(true); + } else if (overflow === "bottom") { + itemRef.current.scrollIntoView(false); + } + }, [isSelected]); + + return ( +
+ {item.icon} +
+ ); +}); diff --git a/packages/shadcn/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader.tsx b/packages/shadcn/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader.tsx new file mode 100644 index 0000000000..e78431e2ab --- /dev/null +++ b/packages/shadcn/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader.tsx @@ -0,0 +1,26 @@ +import { assertEmpty } from "@blocknote/core"; +import { ComponentProps } from "@blocknote/react"; +import { forwardRef } from "react"; + +export const GridSuggestionMenuLoader = forwardRef< + HTMLDivElement, + ComponentProps["GridSuggestionMenu"]["Loader"] +>((props, ref) => { + const { + className, + children, // unused, using "dots" instead + columns, + ...rest + } = props; + + assertEmpty(rest); + + return ( +
+ {children} +
+ ); +}); diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 845bb98e20..2e800f189d 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -405,10 +405,75 @@ "slug": "ui-components" } }, + { + "projectSlug": "suggestion-menus-emoji-picker-columns", + "fullSlug": "ui-components/suggestion-menus-emoji-picker-columns", + "pathFromRoot": "examples/03-ui-components/08-suggestion-menus-emoji-picker-columns", + "config": { + "playground": true, + "docs": true, + "author": "yousefed", + "tags": [ + "Intermediate", + "Blocks", + "UI Components", + "Suggestion Menus", + "Emoji Picker" + ] + }, + "title": "Changing Emoji Picker Columns", + "group": { + "pathFromRoot": "examples/03-ui-components", + "slug": "ui-components" + } + }, + { + "projectSlug": "suggestion-menus-emoji-picker-component", + "fullSlug": "ui-components/suggestion-menus-emoji-picker-component", + "pathFromRoot": "examples/03-ui-components/09-suggestion-menus-emoji-picker-component", + "config": { + "playground": true, + "docs": true, + "author": "yousefed", + "tags": [ + "Intermediate", + "UI Components", + "Suggestion Menus", + "Emoji Picker", + "Appearance & Styling" + ] + }, + "title": "Replacing Emoji Picker Component", + "group": { + "pathFromRoot": "examples/03-ui-components", + "slug": "ui-components" + } + }, + { + "projectSlug": "suggestion-menus-grid-mentions", + "fullSlug": "ui-components/suggestion-menus-grid-mentions", + "pathFromRoot": "examples/03-ui-components/10-suggestion-menus-grid-mentions", + "config": { + "playground": true, + "docs": true, + "author": "yousefed", + "tags": [ + "Intermediate", + "Inline Content", + "Custom Schemas", + "Suggestion Menus" + ] + }, + "title": "Grid Mentions Menu", + "group": { + "pathFromRoot": "examples/03-ui-components", + "slug": "ui-components" + } + }, { "projectSlug": "uppy-file-panel", "fullSlug": "ui-components/uppy-file-panel", - "pathFromRoot": "examples/03-ui-components/08-uppy-file-panel", + "pathFromRoot": "examples/03-ui-components/11-uppy-file-panel", "config": { "playground": true, "docs": true, @@ -442,7 +507,7 @@ { "projectSlug": "custom-ui", "fullSlug": "ui-components/custom-ui", - "pathFromRoot": "examples/03-ui-components/09-custom-ui", + "pathFromRoot": "examples/03-ui-components/12-custom-ui", "config": { "playground": true, "docs": true, diff --git a/tests/src/end-to-end/ariakit/ariakit.test.ts b/tests/src/end-to-end/ariakit/ariakit.test.ts index 8261d6cf18..34449d3a10 100644 --- a/tests/src/end-to-end/ariakit/ariakit.test.ts +++ b/tests/src/end-to-end/ariakit/ariakit.test.ts @@ -46,6 +46,14 @@ test.describe("Check Ariakit UI", () => { await page.waitForTimeout(500); expect(await page.screenshot()).toMatchSnapshot("ariakit-slash-menu.png"); }); + test("Check emoji picker", async ({ page }) => { + await focusOnEditor(page); + await page.keyboard.press(":"); + await page.keyboard.type("sm"); + + await page.waitForTimeout(500); + expect(await page.screenshot()).toMatchSnapshot("ariakit-emoji-picker.png"); + }); test("Check side menu", async ({ page }) => { await focusOnEditor(page); await page.waitForSelector(PARAGRAPH_SELECTOR); diff --git a/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-chromium-linux.png b/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-chromium-linux.png new file mode 100644 index 0000000000..5e2715b039 Binary files /dev/null and b/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-chromium-linux.png differ diff --git a/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-firefox-linux.png b/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-firefox-linux.png new file mode 100644 index 0000000000..0b0ec27da9 Binary files /dev/null and b/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-firefox-linux.png differ diff --git a/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-webkit-linux.png b/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-webkit-linux.png new file mode 100644 index 0000000000..f11b18f818 Binary files /dev/null and b/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-webkit-linux.png differ diff --git a/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-slash-menu-chromium-linux.png b/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-slash-menu-chromium-linux.png index e0d5d27b4f..1742d9b9db 100644 Binary files a/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-slash-menu-chromium-linux.png and b/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-slash-menu-chromium-linux.png differ diff --git a/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-slash-menu-firefox-linux.png b/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-slash-menu-firefox-linux.png index ba8673918e..1b6d9b61c2 100644 Binary files a/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-slash-menu-firefox-linux.png and b/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-slash-menu-firefox-linux.png differ diff --git a/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-slash-menu-webkit-linux.png b/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-slash-menu-webkit-linux.png index ef24c8fee2..d000df6e52 100644 Binary files a/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-slash-menu-webkit-linux.png and b/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-slash-menu-webkit-linux.png differ diff --git a/tests/src/end-to-end/emojipicker/emojipicker.test.ts b/tests/src/end-to-end/emojipicker/emojipicker.test.ts new file mode 100644 index 0000000000..9477bc8b21 --- /dev/null +++ b/tests/src/end-to-end/emojipicker/emojipicker.test.ts @@ -0,0 +1,51 @@ +import { expect } from "@playwright/test"; +import { test } from "../../setup/setupScript"; +import { BASE_URL, EMOJI_PICKER_SELECTOR } from "../../utils/const"; +import { focusOnEditor, waitForTextInEditor } from "../../utils/editor"; +import { executeEmojiCommand, openEmojiPicker } from "../../utils/emojipicker"; +import { executeSlashCommand } from "../../utils/slashmenu"; + +test.beforeEach(async ({ page }) => { + await page.goto(BASE_URL); +}); + +test.describe("Check Emoji Picker Functionality", () => { + test("should not show emoji picker when : is typed", async ({ page }) => { + await focusOnEditor(page); + await openEmojiPicker(page); + await expect(page.locator(EMOJI_PICKER_SELECTOR)).toHaveCount(0); + }); + test("should show emoji picker when : and query is typed", async ({ + page, + }) => { + await focusOnEditor(page); + await openEmojiPicker(page); + await page.keyboard.type("sm"); + await page.waitForSelector(EMOJI_PICKER_SELECTOR); + }); + test("Should be able to insert emoji", async ({ page }) => { + await focusOnEditor(page); + await executeEmojiCommand(page, "sm"); + await page.pause(); + await waitForTextInEditor(page, "🛩️ "); + }); + test("Should be able to open emoji picker from slash menu", async ({ + page, + }) => { + await focusOnEditor(page); + await executeSlashCommand(page, "emoji"); + await page.keyboard.type("sm"); + await page.waitForSelector(EMOJI_PICKER_SELECTOR); + }); + test("Should be able to insert emoji after slash command", async ({ + page, + }) => { + await focusOnEditor(page); + await executeSlashCommand(page, "emoji"); + await page.keyboard.type("sm"); + await page.waitForSelector(EMOJI_PICKER_SELECTOR); + await page.waitForTimeout(500); + await page.keyboard.press("Enter"); + await waitForTextInEditor(page, "🛩️ "); + }); +}); diff --git a/tests/src/end-to-end/shadcn/shadcn.test.ts b/tests/src/end-to-end/shadcn/shadcn.test.ts index 63f7872308..04d5ec196f 100644 --- a/tests/src/end-to-end/shadcn/shadcn.test.ts +++ b/tests/src/end-to-end/shadcn/shadcn.test.ts @@ -46,6 +46,14 @@ test.describe("Check ShadCN UI", () => { await page.waitForTimeout(500); expect(await page.screenshot()).toMatchSnapshot("shadcn-slash-menu.png"); }); + test("Check emoji picker", async ({ page }) => { + await focusOnEditor(page); + await page.keyboard.press(":"); + await page.keyboard.type("sm"); + + await page.waitForTimeout(500); + expect(await page.screenshot()).toMatchSnapshot("shadcn-emoji-picker.png"); + }); test("Check side menu", async ({ page }) => { await focusOnEditor(page); await page.waitForSelector(PARAGRAPH_SELECTOR); diff --git a/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-chromium-linux.png b/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-chromium-linux.png new file mode 100644 index 0000000000..c959fd661d Binary files /dev/null and b/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-chromium-linux.png differ diff --git a/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-firefox-linux.png b/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-firefox-linux.png new file mode 100644 index 0000000000..5f539f99de Binary files /dev/null and b/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-firefox-linux.png differ diff --git a/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-webkit-linux.png b/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-webkit-linux.png new file mode 100644 index 0000000000..faf8eb3ca3 Binary files /dev/null and b/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-webkit-linux.png differ diff --git a/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-slash-menu-chromium-linux.png b/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-slash-menu-chromium-linux.png index cb008535d1..c1f9d6f64b 100644 Binary files a/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-slash-menu-chromium-linux.png and b/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-slash-menu-chromium-linux.png differ diff --git a/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-slash-menu-firefox-linux.png b/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-slash-menu-firefox-linux.png index e380edf1d8..aeec08eb5f 100644 Binary files a/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-slash-menu-firefox-linux.png and b/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-slash-menu-firefox-linux.png differ diff --git a/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-slash-menu-webkit-linux.png b/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-slash-menu-webkit-linux.png index c7b9713555..2e2fb007c7 100644 Binary files a/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-slash-menu-webkit-linux.png and b/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-slash-menu-webkit-linux.png differ diff --git a/tests/src/end-to-end/theming/theming.test.ts b/tests/src/end-to-end/theming/theming.test.ts index b346ea6617..372a056627 100644 --- a/tests/src/end-to-end/theming/theming.test.ts +++ b/tests/src/end-to-end/theming/theming.test.ts @@ -56,6 +56,14 @@ test.describe("Check Dark Theme is Automatically Applied", () => { await page.waitForTimeout(500); expect(await page.screenshot()).toMatchSnapshot("dark-slash-menu.png"); }); + test("Should show dark emoji picker", async ({ page }) => { + await focusOnEditor(page); + await page.keyboard.press(":"); + await page.keyboard.type("sm"); + + await page.waitForTimeout(500); + expect(await page.screenshot()).toMatchSnapshot("dark-emoji-picker.png"); + }); test("Should show dark side menu", async ({ page }) => { await focusOnEditor(page); await page.waitForSelector(PARAGRAPH_SELECTOR); diff --git a/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-chromium-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-chromium-linux.png new file mode 100644 index 0000000000..6aebeb8009 Binary files /dev/null and b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-chromium-linux.png differ diff --git a/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-firefox-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-firefox-linux.png new file mode 100644 index 0000000000..a5257a4771 Binary files /dev/null and b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-firefox-linux.png differ diff --git a/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-webkit-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-webkit-linux.png new file mode 100644 index 0000000000..57fab97138 Binary files /dev/null and b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-webkit-linux.png differ 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 68927f404e..c08950e9d8 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 78837a6c7d..624d5c5ba1 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 66f5a2b403..0a0350cc0c 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/const.ts b/tests/src/utils/const.ts index 1dbf90ca21..e3cbeb163c 100644 --- a/tests/src/utils/const.ts +++ b/tests/src/utils/const.ts @@ -30,6 +30,7 @@ export const DRAG_HANDLE_ADD_SELECTOR = `[data-test="dragHandleAdd"]`; export const DRAG_HANDLE_MENU_SELECTOR = `.bn-side-menu > .bn-menu-dropdown`; export const SLASH_MENU_SELECTOR = `.bn-suggestion-menu`; +export const EMOJI_PICKER_SELECTOR = `.bn-grid-suggestion-menu`; export const ITALIC_BUTTON_SELECTOR = `[data-test="italic"]`; export const COLORS_BUTTON_SELECTOR = `[data-test="colors"]`; diff --git a/tests/src/utils/editor.ts b/tests/src/utils/editor.ts index 168a229c39..8c317ec6b5 100644 --- a/tests/src/utils/editor.ts +++ b/tests/src/utils/editor.ts @@ -14,6 +14,14 @@ export async function waitForSelectorInEditor(page: Page, selector: string) { }); } +export async function waitForTextInEditor(page: Page, text: string) { + const editor = page.locator(EDITOR_SELECTOR); + await editor.getByText(text).waitFor({ + state: "attached", + timeout: 1000, + }); +} + export async function getDoc(page: Page) { const window = await page.evaluateHandle("window"); const doc = await window.evaluate((win) => @@ -23,7 +31,9 @@ export async function getDoc(page: Page) { } export function removeAttFromDoc(doc: any, att: string) { - if (typeof doc !== "object" || doc === null) {return;} + if (typeof doc !== "object" || doc === null) { + return; + } if (Object.keys(doc).includes(att)) { delete doc[att]; } diff --git a/tests/src/utils/emojipicker.ts b/tests/src/utils/emojipicker.ts new file mode 100644 index 0000000000..ac6a876cbd --- /dev/null +++ b/tests/src/utils/emojipicker.ts @@ -0,0 +1,16 @@ +import { Page } from "@playwright/test"; +import { EMOJI_PICKER_SELECTOR } from "./const"; + +export async function openEmojiPicker(page: Page) { + await page.keyboard.press(":"); +} + +export async function executeEmojiCommand(page: Page, command: string) { + await openEmojiPicker(page); + await page.waitForTimeout(100); + await page.keyboard.type(command); + await page.waitForSelector(EMOJI_PICKER_SELECTOR); + await page.waitForTimeout(500); + await page.keyboard.press("Enter"); + await page.waitForTimeout(500); +} diff --git a/tests/src/utils/slashmenu.ts b/tests/src/utils/slashmenu.ts index 5b78d3ac17..a1fc6c89b2 100644 --- a/tests/src/utils/slashmenu.ts +++ b/tests/src/utils/slashmenu.ts @@ -8,8 +8,8 @@ export async function openSlashMenu(page: Page) { export async function executeSlashCommand(page: Page, command: string) { await openSlashMenu(page); + await page.waitForTimeout(100); await page.keyboard.type(command); - await page.keyboard.press("ArrowDown"); await page.keyboard.press("Enter"); await page.waitForTimeout(500); }