From 194cebdfe0ae906ad0b72280555ce29602763335 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 24 May 2026 10:48:14 +0200 Subject: [PATCH] fix: clamp selection toolbar to viewport --- .../tolariaEditorFormatting.behavior.test.tsx | 23 +++++++++++ src/components/tolariaEditorFormatting.tsx | 41 ++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/components/tolariaEditorFormatting.behavior.test.tsx b/src/components/tolariaEditorFormatting.behavior.test.tsx index cc8853be..4b007c9d 100644 --- a/src/components/tolariaEditorFormatting.behavior.test.tsx +++ b/src/components/tolariaEditorFormatting.behavior.test.tsx @@ -287,6 +287,29 @@ describe('tolariaEditorFormatting behavior', () => { })) }) + it('adds viewport-clamping middleware and preserves caller middleware', () => { + const editor = createMockEditor('paragraph') + useBlockNoteEditorMock.mockReturnValue(editor) + + render( + , + ) + + const floatingOptions = positionPopoverState.lastProps?.useFloatingOptions as { + middleware: Array<{ name: string }> + } + + expect(floatingOptions.middleware.map((middleware) => middleware.name)).toEqual( + expect.arrayContaining(['custom-middleware', 'tolariaViewportClamp']), + ) + }) + it('falls back to top-start and focuses the block type trigger on mouse down', () => { const editor = createMockEditor('paragraph') const focusSpy = vi.spyOn(HTMLButtonElement.prototype, 'focus').mockImplementation(() => {}) diff --git a/src/components/tolariaEditorFormatting.tsx b/src/components/tolariaEditorFormatting.tsx index 4307711c..8c0ace45 100644 --- a/src/components/tolariaEditorFormatting.tsx +++ b/src/components/tolariaEditorFormatting.tsx @@ -67,6 +67,9 @@ import { type TolariaBasicTextStyle = 'bold' | 'italic' | 'strike' | 'code' const FORMATTER_CLOSE_GRACE_MS = 160 +const FORMATTER_VIEWPORT_PADDING_PX = 8 +type TolariaFloatingOptions = NonNullable +type TolariaFloatingMiddleware = NonNullable[number] function isFocusStillWithinToolbar( currentTarget: EventTarget & Element, @@ -230,6 +233,42 @@ function textAlignmentToPlacement( } } +function viewportClampMiddleware(): TolariaFloatingMiddleware { + return { + name: 'tolariaViewportClamp', + fn({ x, rects }: { rects: { floating: { width: number } }; x: number }) { + const viewportWidth = window.visualViewport?.width ?? window.innerWidth + const minX = FORMATTER_VIEWPORT_PADDING_PX + const maxX = Math.max( + minX, + viewportWidth - rects.floating.width - FORMATTER_VIEWPORT_PADDING_PX, + ) + + return { + x: Math.min(Math.max(x, minX), maxX), + } + }, + } +} + +function withViewportSafeMiddleware( + options?: TolariaFloatingOptions, +): TolariaFloatingOptions { + if (!options) { + return { + middleware: [viewportClampMiddleware()], + } + } + + return { + ...options, + middleware: [ + ...(options.middleware ?? []), + viewportClampMiddleware(), + ], + } +} + function editorSupportsTextStyle( style: TolariaBasicTextStyle, editor: BlockNoteEditor, @@ -730,7 +769,7 @@ export function TolariaFormattingToolbarController(props: { } }, placement, - ...props.floatingUIOptions?.useFloatingOptions, + ...withViewportSafeMiddleware(props.floatingUIOptions?.useFloatingOptions), }, elementProps: { style: {