Skip to content

Commit 6804728

Browse files
committed
fix(chat): use visual viewport for compact tool wheel fit
1 parent f4fdd37 commit 6804728

2 files changed

Lines changed: 59 additions & 6 deletions

File tree

frontend/react-neko-chat/src/App.test.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4403,6 +4403,56 @@ describe('App', () => {
44034403
}
44044404
});
44054405

4406+
it('uses the visual viewport when checking compact tool wheel clipping on mobile', () => {
4407+
const originalMatchMedia = window.matchMedia;
4408+
const originalInnerWidth = window.innerWidth;
4409+
const originalInnerHeight = window.innerHeight;
4410+
const originalVisualViewport = window.visualViewport;
4411+
mockMobileMatchMedia();
4412+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 });
4413+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 });
4414+
Object.defineProperty(window, 'visualViewport', {
4415+
configurable: true,
4416+
value: {
4417+
width: 390,
4418+
height: 500,
4419+
offsetLeft: 0,
4420+
offsetTop: 300,
4421+
addEventListener: vi.fn(),
4422+
removeEventListener: vi.fn(),
4423+
dispatchEvent: vi.fn(),
4424+
},
4425+
});
4426+
4427+
try {
4428+
const { container } = render(<App chatSurfaceMode="compact" compactChatState="input" />);
4429+
const fan = container.querySelector('.compact-input-tool-fan') as HTMLDivElement;
4430+
vi.spyOn(fan, 'getBoundingClientRect').mockReturnValue({
4431+
left: 10,
4432+
top: 10,
4433+
right: 242,
4434+
bottom: 242,
4435+
width: 232,
4436+
height: 232,
4437+
x: 10,
4438+
y: 10,
4439+
toJSON: () => ({}),
4440+
});
4441+
4442+
const actionButton = container.querySelector('.compact-input-tool-toggle') as HTMLButtonElement;
4443+
expect(actionButton).not.toBeNull();
4444+
fireEvent.click(actionButton);
4445+
4446+
expect(fan).toHaveAttribute('data-compact-input-tool-fan-open', 'true');
4447+
expect(fan).toHaveAttribute('data-compact-tool-wheel-layout', 'viewport-fit');
4448+
} finally {
4449+
window.matchMedia = originalMatchMedia;
4450+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
4451+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight });
4452+
Object.defineProperty(window, 'visualViewport', { configurable: true, value: originalVisualViewport });
4453+
}
4454+
});
4455+
44064456
it('anchors compact avatar tool bubbles to the fan origin instead of the rotating tool item', async () => {
44074457
vi.useFakeTimers();
44084458
try {

frontend/react-neko-chat/src/App.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3107,8 +3107,11 @@ export default function App({
31073107
const fanRect = fanElement?.getBoundingClientRect();
31083108
if (!fanElement || !fanRect || fanRect.width <= 0 || fanRect.height <= 0) return 'default';
31093109

3110-
const viewportWidth = window.innerWidth;
3111-
const viewportHeight = window.innerHeight;
3110+
const visualViewport = window.visualViewport;
3111+
const viewportLeft = visualViewport?.offsetLeft ?? 0;
3112+
const viewportTop = visualViewport?.offsetTop ?? 0;
3113+
const viewportWidth = visualViewport?.width ?? window.innerWidth;
3114+
const viewportHeight = visualViewport?.height ?? window.innerHeight;
31123115
if (!Number.isFinite(viewportWidth) || viewportWidth <= 0 || !Number.isFinite(viewportHeight) || viewportHeight <= 0) {
31133116
return 'default';
31143117
}
@@ -3124,10 +3127,10 @@ export default function App({
31243127
const centerY = fanRect.top + readFanPixelVar('--compact-tool-wheel-center-y', COMPACT_INPUT_TOOL_WHEEL_CENTER_Y);
31253128
const orbitRadius = readFanPixelVar('--compact-tool-wheel-orbit-radius', 80);
31263129
const buttonSize = readFanPixelVar('--compact-tool-button-size', 38);
3127-
const minX = COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN;
3128-
const minY = COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN;
3129-
const maxX = viewportWidth - COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN;
3130-
const maxY = viewportHeight - COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN;
3130+
const minX = viewportLeft + COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN;
3131+
const minY = viewportTop + COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN;
3132+
const maxX = viewportLeft + viewportWidth - COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN;
3133+
const maxY = viewportTop + viewportHeight - COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN;
31313134

31323135
const overflowsViewport = compactInputToolWheelDefaultVisibleSlots.some(({ angleDeg, scale }) => {
31333136
const angle = angleDeg * (Math.PI / 180);

0 commit comments

Comments
 (0)