diff --git a/examples/03-theming/03-theming-css/styles.css b/examples/03-theming/03-theming-css/styles.css index fb44e1babf..14f6e1382e 100644 --- a/examples/03-theming/03-theming-css/styles.css +++ b/examples/03-theming/03-theming-css/styles.css @@ -4,6 +4,9 @@ } /* Makes slash menu hovered items blue */ -.bn-container[data-theming-css-demo] .bn-slash-menu .mantine-Menu-item[data-hovered] { +.bn-container[data-theming-css-demo] + .bn-slash-menu + .mantine-Menu-item[aria-selected="true"], +.bn-container[data-theming-css-demo] .bn-slash-menu .mantine-Menu-item:hover { background-color: blue; } diff --git a/package-lock.json b/package-lock.json index 2a71ba0847..7e4dd64cd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2361,6 +2361,10 @@ "node": ">=6.9.0" } }, + "node_modules/@blocknote/ariakit": { + "resolved": "packages/ariakit", + "link": true + }, "node_modules/@blocknote/core": { "resolved": "packages/core", "link": true @@ -2373,6 +2377,10 @@ "resolved": "playground", "link": true }, + "node_modules/@blocknote/mantine": { + "resolved": "packages/mantine", + "link": true + }, "node_modules/@blocknote/react": { "resolved": "packages/react", "link": true @@ -22969,6 +22977,54 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/ariakit": { + "name": "@blocknote/ariakit", + "version": "0.12.2", + "license": "MPL-2.0", + "dependencies": { + "@ariakit/react": "^0.4.3", + "@blocknote/core": "*", + "@blocknote/react": "*", + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@types/react": "^18.0.25", + "@types/react-dom": "^18.0.9", + "@vitejs/plugin-react": "^4.0.4", + "eslint": "^8.10.0", + "prettier": "^2.7.1", + "rimraf": "^5.0.5", + "rollup-plugin-webpack-stats": "^0.2.2", + "typescript": "^5.3.3", + "vite": "^4.4.8", + "vite-plugin-eslint": "^1.8.1", + "vite-plugin-externalize-deps": "^0.7.0", + "vitest": "^0.34.1" + }, + "peerDependencies": { + "react": "^18", + "react-dom": "^18" + } + }, + "packages/ariakit/node_modules/rimraf": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz", + "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==", + "dev": true, + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "packages/core": { "name": "@blocknote/core", "version": "0.12.1", @@ -23081,17 +23137,63 @@ "typescript": "^5.3.3" } }, + "packages/mantine": { + "name": "@blocknote/mantine", + "version": "0.12.2", + "license": "MPL-2.0", + "dependencies": { + "@blocknote/core": "*", + "@blocknote/react": "*", + "@mantine/core": "^7.5.0", + "@mantine/hooks": "^7.5.0", + "@mantine/utils": "^6.0.21", + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@types/react": "^18.0.25", + "@types/react-dom": "^18.0.9", + "@vitejs/plugin-react": "^4.0.4", + "eslint": "^8.10.0", + "prettier": "^2.7.1", + "rimraf": "^5.0.5", + "rollup-plugin-webpack-stats": "^0.2.2", + "typescript": "^5.3.3", + "vite": "^4.4.8", + "vite-plugin-eslint": "^1.8.1", + "vite-plugin-externalize-deps": "^0.7.0", + "vitest": "^0.34.1" + }, + "peerDependencies": { + "react": "^18", + "react-dom": "^18" + } + }, + "packages/mantine/node_modules/rimraf": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz", + "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==", + "dev": true, + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "packages/react": { "name": "@blocknote/react", "version": "0.12.2", "license": "MPL-2.0", "dependencies": { - "@ariakit/react": "^0.4.3", "@blocknote/core": "^0.12.1", "@floating-ui/react": "^0.26.4", - "@mantine/core": "^7.5.0", - "@mantine/hooks": "^7.5.0", - "@mantine/utils": "^6.0.21", "@tiptap/core": "^2.0.3", "@tiptap/react": "^2.0.3", "lodash.merge": "^4.6.2", diff --git a/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts index bdcbbb5eab..4823885be6 100644 --- a/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts +++ b/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts @@ -1,4 +1,5 @@ import { InputRule } from "@tiptap/core"; +import { getCurrentBlockContentType } from "../../../api/getCurrentBlockContentType"; import { PropSchema, createBlockSpecFromStronglyTypedTiptapNode, @@ -7,7 +8,6 @@ import { import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers"; import { defaultProps } from "../../defaultProps"; import { handleEnter } from "../ListItemKeyboardShortcuts"; -import { getCurrentBlockContentType } from "../../../api/getCurrentBlockContentType"; export const bulletListItemPropSchema = { ...defaultProps, @@ -124,6 +124,7 @@ const BulletListItemBlockContent = createStronglyTypedTiptapNode({ { ...(this.options.domAttributes?.blockContent || {}), ...HTMLAttributes, + role: "listitem", }, this.options.domAttributes?.inlineContent || {} ); diff --git a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts index a2fd3b4142..8b2ec44662 100644 --- a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts +++ b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts @@ -1,4 +1,5 @@ import { InputRule } from "@tiptap/core"; +import { getCurrentBlockContentType } from "../../../api/getCurrentBlockContentType"; import { PropSchema, createBlockSpecFromStronglyTypedTiptapNode, @@ -8,7 +9,6 @@ import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers"; import { defaultProps } from "../../defaultProps"; import { handleEnter } from "../ListItemKeyboardShortcuts"; import { NumberedListIndexingPlugin } from "./NumberedListIndexingPlugin"; -import { getCurrentBlockContentType } from "../../../api/getCurrentBlockContentType"; export const numberedListItemPropSchema = { ...defaultProps, @@ -145,6 +145,7 @@ const NumberedListItemBlockContent = createStronglyTypedTiptapNode({ { ...(this.options.domAttributes?.blockContent || {}), ...HTMLAttributes, + role: "listitem", }, this.options.domAttributes?.inlineContent || {} ); diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 001782123b..3484c0b6ea 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -27,10 +27,10 @@ import { PartialBlock, } from "../blocks/defaultBlocks"; import { FormattingToolbarProsemirrorPlugin } from "../extensions/FormattingToolbar/FormattingToolbarPlugin"; +import { ImagePanelProsemirrorPlugin } from "../extensions/ImagePanel/ImageToolbarPlugin"; import { LinkToolbarProsemirrorPlugin } from "../extensions/LinkToolbar/LinkToolbarPlugin"; import { SideMenuProsemirrorPlugin } from "../extensions/SideMenu/SideMenuPlugin"; import { SuggestionMenuProseMirrorPlugin } from "../extensions/SuggestionMenu/SuggestionPlugin"; -import { ImagePanelProsemirrorPlugin } from "../extensions/ImagePanel/ImageToolbarPlugin"; import { TableHandlesProsemirrorPlugin } from "../extensions/TableHandles/TableHandlesPlugin"; import { UniqueID } from "../extensions/UniqueID/UniqueID"; import { @@ -781,7 +781,7 @@ export class BlockNoteEditor< * @param styles The styles to remove. */ public removeStyles(styles: Styles) { - this._tiptapEditor.view.focus(); + // this._tiptapEditor.view.focus(); for (const style of Object.keys(styles)) { this._tiptapEditor.commands.unsetMark(style); @@ -793,7 +793,7 @@ export class BlockNoteEditor< * @param styles The styles to toggle. */ public toggleStyles(styles: Styles) { - this._tiptapEditor.view.focus(); + // this._tiptapEditor.view.focus(); for (const [style, value] of Object.entries(styles)) { const config = this.schema.styleSchema[style]; diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index 2cf41f98f8..d651739e86 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -1,4 +1,4 @@ -import { Extensions, extensions } from "@tiptap/core"; +import { Extension, Extensions, extensions } from "@tiptap/core"; import type { BlockNoteEditor } from "./BlockNoteEditor"; @@ -92,10 +92,22 @@ export const getBlockNoteExtensions = < BackgroundColorExtension, TextAlignmentExtension, + // make sure escape blurs editor, so that we can tab to other elements in the host page (accessibility) + Extension.create({ + name: "OverrideEscape", + addKeyboardShortcuts() { + return { + Escape: () => { + return this.editor.commands.blur(); + }, + }; + }, + }), + // nodes Doc, BlockContainer.configure({ - editor: opts.editor as any, + editor: opts.editor, domAttributes: opts.domAttributes, }), BlockGroup.configure({ diff --git a/packages/core/src/editor/BlockNoteTipTapEditor.ts b/packages/core/src/editor/BlockNoteTipTapEditor.ts index 8aee5c7f75..b40e690b9d 100644 --- a/packages/core/src/editor/BlockNoteTipTapEditor.ts +++ b/packages/core/src/editor/BlockNoteTipTapEditor.ts @@ -126,12 +126,15 @@ export class BlockNoteTipTapEditor extends TiptapEditor { private createViewAlternative() { // Without queueMicrotask, custom IC / styles will give a React FlushSync error queueMicrotask(() => { - this.view = new EditorView(this.options.element, { - ...this.options.editorProps, - // @ts-ignore - dispatchTransaction: this.dispatchTransaction.bind(this), - state: this.state, - }); + this.view = new EditorView( + { mount: this.options.element as any }, + { + ...this.options.editorProps, + // @ts-ignore + dispatchTransaction: this.dispatchTransaction.bind(this), + state: this.state, + } + ); // `editor.view` is not yet available at this time. // Therefore we will add all plugins and node views directly afterwards. diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts index b1b329081a..01f151d546 100644 --- a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts @@ -1,5 +1,5 @@ import { isNodeSelection, posToDOMRect } from "@tiptap/core"; -import { EditorState, Plugin, PluginKey } from "prosemirror-state"; +import { EditorState, Plugin, PluginKey, PluginView } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; @@ -9,7 +9,7 @@ import { EventEmitter } from "../../util/EventEmitter"; export type FormattingToolbarState = UiElementPosition; -export class FormattingToolbarView { +export class FormattingToolbarView implements PluginView { public state?: FormattingToolbarState; public emitUpdate: () => void; @@ -73,38 +73,34 @@ export class FormattingToolbarView { focusHandler = () => { // we use `setTimeout` to make sure `selection` is already updated - setTimeout(() => this.update(this.pmView)); + // setTimeout(() => this.update(this.pmView)); }; blurHandler = (event: FocusEvent) => { - if (this.preventHide) { - this.preventHide = false; - - return; - } - - const editorWrapper = this.pmView.dom.parentElement!; - - // Checks if the focus is moving to an element outside the editor. If it is, - // the toolbar is hidden. - if ( - // An element is clicked. - event && - event.relatedTarget && - // Element is inside the editor. - (editorWrapper === (event.relatedTarget as Node) || - editorWrapper.contains(event.relatedTarget as Node) || - (event.relatedTarget as HTMLElement).matches( - ".bn-ui-container, .bn-ui-container *" - )) - ) { - return; - } - - if (this.state?.show) { - this.state.show = false; - this.emitUpdate(); - } + // if (this.preventHide) { + // this.preventHide = false; + // return; + // } + // const editorWrapper = this.pmView.dom.parentElement!; + // // Checks if the focus is moving to an element outside the editor. If it is, + // // the toolbar is hidden. + // if ( + // // An element is clicked. + // event && + // event.relatedTarget && + // // Element is inside the editor. + // (editorWrapper === (event.relatedTarget as Node) || + // editorWrapper.contains(event.relatedTarget as Node) || + // (event.relatedTarget as HTMLElement).matches( + // ".bn-ui-container, .bn-ui-container *" + // )) + // ) { + // return; + // } + // if (this.state?.show) { + // this.state.show = false; + // this.emitUpdate(); + // } }; scrollHandler = () => { @@ -183,6 +179,13 @@ export class FormattingToolbarView { document.removeEventListener("scroll", this.scrollHandler); } + closeMenu = () => { + if (this.state?.show) { + this.state.show = false; + this.emitUpdate(); + } + }; + getSelectionBoundingBox() { const { state } = this.pmView; const { selection } = state; @@ -222,10 +225,25 @@ export class FormattingToolbarProsemirrorPlugin extends EventEmitter { }); return this.view; }, + props: { + handleKeyDown: (_view, event: KeyboardEvent) => { + if (event.key === "Escape" && this.shown) { + this.view!.closeMenu(); + return true; + } + return false; + }, + }, }); } + public get shown() { + return this.view?.state?.show || false; + } + public onUpdate(callback: (state: FormattingToolbarState) => void) { return this.on("update", callback); } + + public closeMenu = () => this.view!.closeMenu(); } diff --git a/packages/core/src/extensions/ImagePanel/ImageToolbarPlugin.ts b/packages/core/src/extensions/ImagePanel/ImageToolbarPlugin.ts index 516f7f2852..9dc07ab9e4 100644 --- a/packages/core/src/extensions/ImagePanel/ImageToolbarPlugin.ts +++ b/packages/core/src/extensions/ImagePanel/ImageToolbarPlugin.ts @@ -1,15 +1,15 @@ import { EditorState, Plugin, PluginKey } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; +import { DefaultBlockSchema } from "../../blocks/defaultBlocks"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { UiElementPosition } from "../../extensions-shared/UiElementPosition"; import type { BlockFromConfig, InlineContentSchema, StyleSchema, } from "../../schema"; -import { UiElementPosition } from "../../extensions-shared/UiElementPosition"; import { EventEmitter } from "../../util/EventEmitter"; -import { DefaultBlockSchema } from "../../blocks/defaultBlocks"; export type ImagePanelState< I extends InlineContentSchema, @@ -140,6 +140,13 @@ export class ImagePanelView< document.removeEventListener("scroll", this.scrollHandler); } + + closeMenu = () => { + if (this.state?.show) { + this.state.show = false; + this.emitUpdate(); + } + }; } const imagePanelPluginKey = new PluginKey("ImagePanelPlugin"); @@ -170,6 +177,15 @@ export class ImagePanelProsemirrorPlugin< ); return this.view; }, + props: { + handleKeyDown: (_view, event: KeyboardEvent) => { + if (event.key === "Escape" && this.shown) { + this.view!.closeMenu(); + return true; + } + return false; + }, + }, state: { init: () => { return { @@ -189,7 +205,13 @@ export class ImagePanelProsemirrorPlugin< }); } + public get shown() { + return this.view?.state?.show || false; + } + public onUpdate(callback: (state: ImagePanelState) => void) { return this.on("update", callback); } + + public closeMenu = () => this.view!.closeMenu(); } diff --git a/packages/core/src/extensions/LinkToolbar/LinkToolbarPlugin.ts b/packages/core/src/extensions/LinkToolbar/LinkToolbarPlugin.ts index 7fd9cde3a4..69ac03c025 100644 --- a/packages/core/src/extensions/LinkToolbar/LinkToolbarPlugin.ts +++ b/packages/core/src/extensions/LinkToolbar/LinkToolbarPlugin.ts @@ -4,8 +4,8 @@ import { Mark } from "prosemirror-model"; import { Plugin, PluginKey } from "prosemirror-state"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; -import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; import { UiElementPosition } from "../../extensions-shared/UiElementPosition"; +import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; import { EventEmitter } from "../../util/EventEmitter"; export type LinkToolbarState = UiElementPosition & { @@ -263,6 +263,13 @@ class LinkToolbarView { document.removeEventListener("scroll", this.scrollHandler); document.removeEventListener("click", this.clickHandler, true); } + + closeMenu = () => { + if (this.state?.show) { + this.state.show = false; + this.emitUpdate(); + } + }; } export const linkToolbarPluginKey = new PluginKey("LinkToolbarPlugin"); @@ -285,9 +292,22 @@ export class LinkToolbarProsemirrorPlugin< }); return this.view; }, + props: { + handleKeyDown: (_view, event: KeyboardEvent) => { + if (event.key === "Escape" && this.shown) { + this.view!.closeMenu(); + return true; + } + return false; + }, + }, }); } + public get shown() { + return this.view?.state?.show || false; + } + public onUpdate(callback: (state: LinkToolbarState) => void) { return this.on("update", callback); } @@ -327,4 +347,6 @@ export class LinkToolbarProsemirrorPlugin< public stopHideTimer = () => { this.view!.stopMenuUpdateTimer(); }; + + public closeMenu = () => this.view!.closeMenu(); } diff --git a/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts b/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts index b25e6d4c5a..813c45e22b 100644 --- a/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts +++ b/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts @@ -113,6 +113,7 @@ export const Placeholder = Extension.create({ const dec = Decoration.node(before, before + node.nodeSize, { "data-is-empty-and-focused": "true", + // "aria-placeholder": this.options.placeholders.default, }); return DecorationSet.create(doc, [dec]); diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts index 74f4e11572..25a1b4bda9 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts @@ -3,8 +3,8 @@ import { EditorState, Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; -import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; import { UiElementPosition } from "../../extensions-shared/UiElementPosition"; +import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; import { EventEmitter } from "../../util/EventEmitter"; const findBlock = findParentNode((node) => node.type.name === "blockContainer"); diff --git a/packages/core/src/pm-nodes/BlockContainer.ts b/packages/core/src/pm-nodes/BlockContainer.ts index 73015220b8..ab9446e13c 100644 --- a/packages/core/src/pm-nodes/BlockContainer.ts +++ b/packages/core/src/pm-nodes/BlockContainer.ts @@ -62,7 +62,7 @@ declare module "@tiptap/core" { */ export const BlockContainer = Node.create<{ domAttributes?: BlockNoteDOMAttributes; - editor: BlockNoteEditor; + editor: BlockNoteEditor; }>({ name: "blockContainer", group: "blockContainer", @@ -115,6 +115,7 @@ export const BlockContainer = Node.create<{ const block = document.createElement("div"); block.className = mergeCSSClasses("bn-block", blockHTMLAttributes.class); block.setAttribute("data-node-type", this.name); + for (const [attribute, value] of Object.entries(blockHTMLAttributes)) { if (attribute !== "class") { block.setAttribute(attribute, value); @@ -686,10 +687,26 @@ export const BlockContainer = Node.create<{ // Always returning true for tab key presses ensures they're not captured by the browser. Otherwise, they blur the // editor since the browser will try to use tab for keyboard navigation. Tab: () => { + if ( + this.options.editor.formattingToolbar?.shown || + this.options.editor.linkToolbar?.shown || + this.options.editor.imagePanel?.shown + ) { + // don't handle tabs if a toolbar is shown, so we can tab into / out of it + return false; + } this.editor.commands.sinkListItem("blockContainer"); return true; }, "Shift-Tab": () => { + if ( + this.options.editor.formattingToolbar?.shown || + this.options.editor.linkToolbar?.shown || + this.options.editor.imagePanel?.shown + ) { + // don't handle tabs if the formatting toolbar is shown, so we can tab into / out of it + return false; + } this.editor.commands.liftListItem("blockContainer"); return true; }, diff --git a/packages/mantine/src/toolbar/Toolbar.tsx b/packages/mantine/src/toolbar/Toolbar.tsx index bfacaf6fdc..67c9c28901 100644 --- a/packages/mantine/src/toolbar/Toolbar.tsx +++ b/packages/mantine/src/toolbar/Toolbar.tsx @@ -1,7 +1,8 @@ import * as Mantine from "@mantine/core"; import { mergeCSSClasses } from "@blocknote/core"; -import { forwardRef, HTMLAttributes } from "react"; +import { mergeRefs, useFocusTrap, useFocusWithin } from "@mantine/hooks"; +import { HTMLAttributes, forwardRef } from "react"; export const Toolbar = forwardRef< HTMLDivElement, @@ -9,10 +10,20 @@ export const Toolbar = forwardRef< >((props, ref) => { const { className, children, ...rest } = props; + // use a focus trap so that tab cycles through toolbar buttons, but only if focus is within the toolbar + const { ref: focusRef, focused } = useFocusWithin(); + + const trapRef = useFocusTrap(focused); + + const combinedRef = mergeRefs(ref, focusRef, trapRef); + return ( {children} diff --git a/packages/mantine/src/toolbar/ToolbarButton.tsx b/packages/mantine/src/toolbar/ToolbarButton.tsx index 9ac1aa02d3..695b7f0ec4 100644 --- a/packages/mantine/src/toolbar/ToolbarButton.tsx +++ b/packages/mantine/src/toolbar/ToolbarButton.tsx @@ -46,6 +46,8 @@ export const ToolbarButton = forwardRef( props.mainTooltip.slice(0, 1).toLowerCase() + props.mainTooltip.replace(/\s+/g, "").slice(1) } + aria-label={props.mainTooltip} + aria-pressed={props.isSelected} size={"xs"} disabled={props.isDisabled || false} ref={ref}> @@ -67,6 +69,8 @@ export const ToolbarButton = forwardRef( props.mainTooltip.slice(0, 1).toLowerCase() + props.mainTooltip.replace(/\s+/g, "").slice(1) } + aria-label={props.mainTooltip} + aria-pressed={props.isSelected} size={30} disabled={props.isDisabled || false} ref={ref}> diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx index 7f5034cd5b..d42bff5696 100644 --- a/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx @@ -72,16 +72,36 @@ export const FormattingToolbarController = (props: { const state = useUIPluginState( editor.formattingToolbar.onUpdate.bind(editor.formattingToolbar) ); - const { isMounted, ref, style } = useUIElementPositioning( + + const { + isMounted, + ref, + style, + getReferenceProps, + getFloatingProps, + context, + } = useUIElementPositioning( state?.show || false, state?.referencePos || null, 3000, { placement, middleware: [offset(10), flip()], + onOpenChange: (open) => { + if (!open) { + editor.formattingToolbar.closeMenu(); + editor.focus(); + } + }, } ); + // const ctx = useBlockNoteContext()!; + + // useEffect(() => { + // ctx.setProps(getReferenceProps()); + // }, [ctx, getReferenceProps]); + const combinedRef = useMemo(() => mergeRefs([divRef, ref]), [divRef, ref]); if (!isMounted || !state) { @@ -103,7 +123,7 @@ export const FormattingToolbarController = (props: { const Component = props.formattingToolbar || FormattingToolbar; return ( -
+
); diff --git a/packages/react/src/components/ImagePanel/ImagePanelController.tsx b/packages/react/src/components/ImagePanel/ImagePanelController.tsx index aee8039b7b..bf1f5cd14a 100644 --- a/packages/react/src/components/ImagePanel/ImagePanelController.tsx +++ b/packages/react/src/components/ImagePanel/ImagePanelController.tsx @@ -35,13 +35,19 @@ export const ImagePanelController = < const state = useUIPluginState( editor.imagePanel.onUpdate.bind(editor.imagePanel) ); - const { isMounted, ref, style } = useUIElementPositioning( + const { isMounted, ref, style, getFloatingProps } = useUIElementPositioning( state?.show || false, state?.referencePos || null, 5000, { placement: "bottom", middleware: [offset(10), flip()], + onOpenChange: (open) => { + if (!open) { + editor.imagePanel?.closeMenu(); + editor.focus(); + } + }, } ); @@ -54,7 +60,7 @@ export const ImagePanelController = < const Component = props.imageToolbar || ImagePanel; return ( -
+
); diff --git a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx index 6d59c79caf..8a9079de12 100644 --- a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx +++ b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx @@ -34,13 +34,19 @@ export const LinkToolbarController = < const state = useUIPluginState( editor.linkToolbar.onUpdate.bind(editor.linkToolbar) ); - const { isMounted, ref, style } = useUIElementPositioning( + const { isMounted, ref, style, getFloatingProps } = useUIElementPositioning( state?.show || false, state?.referencePos || null, 4000, { placement: "top-start", middleware: [offset(10), flip()], + onOpenChange: (open) => { + if (!open) { + editor.linkToolbar.closeMenu(); + editor.focus(); + } + }, } ); @@ -53,7 +59,7 @@ export const LinkToolbarController = < const Component = props.linkToolbar || LinkToolbar; return ( -
+
); diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuWrapper.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuWrapper.tsx index cce2a086a2..2435bc5e88 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuWrapper.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuWrapper.tsx @@ -1,6 +1,7 @@ import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core"; -import { FC, useCallback } from "react"; +import { FC, useCallback, useEffect } from "react"; +import { useBlockNotePropsContext } from "../../editor/BlockNotePropsContext"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor"; import { useCloseSuggestionMenuNoItems } from "./hooks/useCloseSuggestionMenuNoItems"; import { useLoadSuggestionMenuItems } from "./hooks/useLoadSuggestionMenuItems"; @@ -15,6 +16,9 @@ export function SuggestionMenuWrapper(props: { onItemClick?: (item: Item) => void; suggestionMenuComponent: FC>; }) { + const ctx = useBlockNotePropsContext()!; + const setEditorProps = ctx.setProps; + const editor = useBlockNoteEditor< BlockSchema, InlineContentSchema, @@ -46,16 +50,50 @@ export function SuggestionMenuWrapper(props: { useCloseSuggestionMenuNoItems(items, usedQuery, closeMenu); - const { selectedIndex, setSelectedIndex } = - useSuggestionMenuKeyboardNavigation( - editor, - items, - closeMenu, - onItemClickCloseMenu - ); + const selInfo = useSuggestionMenuKeyboardNavigation( + editor, + items, + closeMenu, + onItemClickCloseMenu + ); + + let selectedIndex = selInfo.selectedIndex; + if (items.length === 0) { + selectedIndex = -1; + } + + useEffect(() => { + setEditorProps((p) => ({ + ...p, + "aria-expanded": true, + "aria-controls": "bn-suggestion-box", + })); + return () => { + setEditorProps((p) => ({ + ...p, + "aria-expanded": false, + "aria-controls": undefined, + })); + }; + }, [setEditorProps]); + + useEffect(() => { + setEditorProps((p) => ({ + ...p, + "aria-activedescendant": + selectedIndex > -1 ? "bn-suggestion-item-" + selectedIndex : undefined, + })); + return () => { + setEditorProps((p) => ({ + ...p, + "aria-activedescendant": undefined, + })); + }; + }, [setEditorProps, selectedIndex]); // TODO: reset selectionIndex when items change? // TODO: changes to suggestionmenu need extensive testing + // TODO: set correct ids on items etc const Component = suggestionMenuComponent; @@ -65,7 +103,7 @@ export function SuggestionMenuWrapper(props: { onItemClick={onItemClickCloseMenu} loadingState={loadingState} selectedIndex={selectedIndex} - setSelectedIndex={setSelectedIndex} + setSelectedIndex={selInfo.setSelectedIndex} /> ); } diff --git a/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts b/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts index 783e0605e3..b04949fb59 100644 --- a/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts +++ b/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts @@ -10,7 +10,7 @@ export function useSuggestionMenuKeyboardNavigation( closeMenu: () => void, onItemClick?: (item: Item) => void ) { - const [selectedIndex, setSelectedIndex] = useState(-1); + const [selectedIndex, setSelectedIndex] = useState(0); useEffect(() => { const handleMenuNavigationKeys = (event: KeyboardEvent) => { diff --git a/packages/react/src/components/SuggestionMenu/implementation/SuggestionMenu.tsx b/packages/react/src/components/SuggestionMenu/implementation/SuggestionMenu.tsx index 14fa72c7a0..a209f7d9d8 100644 --- a/packages/react/src/components/SuggestionMenu/implementation/SuggestionMenu.tsx +++ b/packages/react/src/components/SuggestionMenu/implementation/SuggestionMenu.tsx @@ -50,7 +50,10 @@ export function SuggestionMenu( return (
event.preventDefault()} // TODO: needed? - className={"bn-slash-menu"}> + className={"bn-slash-menu"} + id="bn-slash-menu" + role="listbox" + tabIndex={-1}> {renderedItems} {Children.count(renderedItems) === 0 && (props.loadingState === "loading" || diff --git a/packages/react/src/components/SuggestionMenu/implementation/SuggestionMenuItem.tsx b/packages/react/src/components/SuggestionMenu/implementation/SuggestionMenuItem.tsx index 6b8a640c72..4bf77026c2 100644 --- a/packages/react/src/components/SuggestionMenu/implementation/SuggestionMenuItem.tsx +++ b/packages/react/src/components/SuggestionMenu/implementation/SuggestionMenuItem.tsx @@ -1,29 +1,19 @@ -import { useCallback, useRef } from "react"; +import { useRef } from "react"; import type { SuggestionMenuItemProps } from "../../../editor/ComponentsContext"; export function SuggestionMenuItem(props: SuggestionMenuItemProps) { - const { setSelected } = props; - const itemRef = useRef(null); - const handleMouseLeave = useCallback(() => { - setSelected(false); - }, [setSelected]); - - const handleMouseEnter = useCallback(() => { - setSelected(true); - }, [setSelected]); - // TODO: remove mantine classnames and clean up styles return (
{props.icon && (
diff --git a/packages/react/src/editor/BlockNoteContext.ts b/packages/react/src/editor/BlockNoteContext.tsx similarity index 100% rename from packages/react/src/editor/BlockNoteContext.ts rename to packages/react/src/editor/BlockNoteContext.tsx diff --git a/packages/react/src/editor/BlockNoteDefaultUI.tsx b/packages/react/src/editor/BlockNoteDefaultUI.tsx index a4872335df..2f89339079 100644 --- a/packages/react/src/editor/BlockNoteDefaultUI.tsx +++ b/packages/react/src/editor/BlockNoteDefaultUI.tsx @@ -1,10 +1,8 @@ -import { filterSuggestionItems } from "@blocknote/core"; import { FormattingToolbarController } from "../components/FormattingToolbar/FormattingToolbarController"; import { ImagePanelController } from "../components/ImagePanel/ImagePanelController"; import { LinkToolbarController } from "../components/LinkToolbar/LinkToolbarController"; import { SideMenuController } from "../components/SideMenu/SideMenuController"; import { SuggestionMenuController } from "../components/SuggestionMenu/SuggestionMenuController"; -import { getDefaultReactSlashMenuItems } from "../components/SuggestionMenu/getDefaultReactSlashMenuItems"; import { TableHandlesController } from "../components/TableHandles/TableHandlesController"; import { useBlockNoteEditor } from "../hooks/useBlockNoteEditor"; @@ -31,16 +29,7 @@ export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) { {props.formattingToolbar !== false && } {props.linkToolbar !== false && } {props.slashMenu !== false && ( - - filterSuggestionItems(getDefaultReactSlashMenuItems(editor), query) - } - // suggestionMenuComponent={MantineSuggestionMenu} - onItemClick={(item) => { - item.onItemClick(); - }} - triggerCharacter="/" - /> + )} {props.sideMenu !== false && } {editor.imagePanel && props.imageToolbar !== false && ( diff --git a/packages/react/src/editor/BlockNotePropsContext.tsx b/packages/react/src/editor/BlockNotePropsContext.tsx new file mode 100644 index 0000000000..63029c2d4e --- /dev/null +++ b/packages/react/src/editor/BlockNotePropsContext.tsx @@ -0,0 +1,18 @@ +import { createContext, useContext, useState } from "react"; + +type BlockNotePropsContextValue = { + props: ReturnType>>[0]; + setProps: ReturnType>>[1]; +}; + +export const BlockNotePropsContext = createContext< + BlockNotePropsContextValue | undefined +>(undefined); + +export function useBlockNotePropsContext(): + | BlockNotePropsContextValue + | undefined { + const context = useContext(BlockNotePropsContext) as any; + + return context; +} diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx index 580e68ad5c..d07d2492ea 100644 --- a/packages/react/src/editor/BlockNoteView.tsx +++ b/packages/react/src/editor/BlockNoteView.tsx @@ -25,6 +25,10 @@ import { BlockNoteDefaultUI, BlockNoteDefaultUIProps, } from "./BlockNoteDefaultUI"; +import { + BlockNotePropsContext, + useBlockNotePropsContext, +} from "./BlockNotePropsContext"; import { Theme, applyBlockNoteCSSVariablesFromTheme, @@ -181,6 +185,14 @@ function BlockNoteViewComponent< tableHandles, ]); + const [cprops, setCprops] = useState>(); + const propsContext = useMemo(() => { + return { + props: cprops, + setProps: setCprops, + }; + }, [editor, cprops, setCprops]); + const context = useMemo(() => { return { ...existingContext, @@ -189,28 +201,46 @@ function BlockNoteViewComponent< }, [existingContext, editor]); const refs = useMemo(() => { - return mergeRefs([containerRef, editor._tiptapEditor.mount, ref]); - }, [containerRef, editor._tiptapEditor.mount, ref]); + return mergeRefs([containerRef, ref]); + }, [containerRef, ref]); return ( // `cssVariablesSelector` scopes Mantine CSS variables to only the editor, // as proposed here: https://github.com/orgs/mantinedev/discussions/5685 - -
- {renderChildren} -
-
+ + +
+ + {renderChildren} +
+
+
); } +export const BlockNoteContentEditable = React.forwardRef( + ({}, ref) => { + const ctx = useBlockNotePropsContext()!; + return ( +
+ ); + } +); + export const BlockNoteViewRaw = React.forwardRef( BlockNoteViewComponent ) as typeof BlockNoteViewComponent; // need hack to get types working with generics diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index 2feb2ece9d..6b2d451e42 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -146,7 +146,8 @@ color: var(--bn-colors-menu-text); } -.bn-container .mantine-Menu-item[data-hovered] { +.bn-container .mantine-Menu-item[aria-selected="true"], +.bn-container .mantine-Menu-item:hover { background-color: var(--bn-colors-hovered-background); border: none; color: var(--bn-colors-hovered-text); diff --git a/packages/react/src/hooks/useUIElementPositioning.ts b/packages/react/src/hooks/useUIElementPositioning.ts index 01e3e6a3e3..bc666404b4 100644 --- a/packages/react/src/hooks/useUIElementPositioning.ts +++ b/packages/react/src/hooks/useUIElementPositioning.ts @@ -1,18 +1,18 @@ import { + useDismiss, useFloating, UseFloatingOptions, + useInteractions, useTransitionStyles, } from "@floating-ui/react"; import { useEffect, useMemo } from "react"; -import { UiComponentPosition } from "./UiComponentPosition"; - export function useUIElementPositioning( show: boolean, referencePos: DOMRect | null, zIndex: number, options?: Partial -): UiComponentPosition { +) { const { refs, update, context, floatingStyles } = useFloating({ open: show, ...options, @@ -20,6 +20,10 @@ export function useUIElementPositioning( const { isMounted, styles } = useTransitionStyles(context); + const dismiss = useDismiss(context, {}); + + const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]); + useEffect(() => { update(); }, [referencePos, update]); @@ -45,7 +49,19 @@ export function useUIElementPositioning( ...floatingStyles, zIndex: zIndex, }, + getReferenceProps, + getFloatingProps, + context, }), - [floatingStyles, isMounted, refs.setFloating, styles, zIndex] + [ + context, + floatingStyles, + getFloatingProps, + getReferenceProps, + isMounted, + refs.setFloating, + styles, + zIndex, + ] ); }