Skip to content

Commit a4624fb

Browse files
authored
fix(chat): adapt compact tool wheel on mobile (#1670)
* fix(chat): adapt compact tool wheel on mobile * fix(chat): use visual viewport for compact tool wheel fit * fix(chat): remove compact tool wheel edge masks in viewport-fit * fix(chat): verify compact tool wheel fallback fits viewport
1 parent 03cbce3 commit a4624fb

3 files changed

Lines changed: 319 additions & 0 deletions

File tree

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

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,19 @@ describe('App', () => {
6363
}));
6464
};
6565

66+
const mockMobileMatchMedia = (mobile = true) => {
67+
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
68+
matches: mobile && query === '(max-width: 820px)',
69+
media: query,
70+
onchange: null,
71+
addListener: vi.fn(),
72+
removeListener: vi.fn(),
73+
addEventListener: vi.fn(),
74+
removeEventListener: vi.fn(),
75+
dispatchEvent: vi.fn(),
76+
}));
77+
};
78+
6679
const setupAvatarDropBounds = () => {
6780
const live2dContainer = document.createElement('div');
6881
live2dContainer.id = 'live2d-container';
@@ -4299,6 +4312,7 @@ describe('App', () => {
42994312
expect(onComposerSubmit).not.toHaveBeenCalled();
43004313
expect(fan).not.toBeNull();
43014314
expect(fan).toHaveAttribute('data-compact-input-tool-fan-open', 'true');
4315+
expect(fan).toHaveAttribute('data-compact-tool-wheel-layout', 'default');
43024316
expect(fan).toHaveAttribute('data-compact-geometry-owner', 'surface');
43034317
expect(fan).toHaveAttribute('data-compact-geometry-item', 'toolFan');
43044318
expect(fan?.parentElement).toBe(shell);
@@ -4317,6 +4331,164 @@ describe('App', () => {
43174331
expect(container.querySelectorAll('.send-button-circle')).toHaveLength(1);
43184332
});
43194333

4334+
it('keeps the default compact tool wheel layout on mobile when the original arc fits', () => {
4335+
const originalMatchMedia = window.matchMedia;
4336+
const originalInnerWidth = window.innerWidth;
4337+
const originalInnerHeight = window.innerHeight;
4338+
mockMobileMatchMedia();
4339+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 });
4340+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 });
4341+
4342+
try {
4343+
const { container } = render(<App chatSurfaceMode="compact" compactChatState="input" />);
4344+
const fan = container.querySelector('.compact-input-tool-fan') as HTMLDivElement;
4345+
vi.spyOn(fan, 'getBoundingClientRect').mockReturnValue({
4346+
left: 10,
4347+
top: 10,
4348+
right: 242,
4349+
bottom: 242,
4350+
width: 232,
4351+
height: 232,
4352+
x: 10,
4353+
y: 10,
4354+
toJSON: () => ({}),
4355+
});
4356+
4357+
const actionButton = container.querySelector('.compact-input-tool-toggle') as HTMLButtonElement;
4358+
expect(actionButton).not.toBeNull();
4359+
fireEvent.click(actionButton);
4360+
4361+
expect(fan).toHaveAttribute('data-compact-input-tool-fan-open', 'true');
4362+
expect(fan).toHaveAttribute('data-compact-tool-wheel-layout', 'default');
4363+
} finally {
4364+
window.matchMedia = originalMatchMedia;
4365+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
4366+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight });
4367+
}
4368+
});
4369+
4370+
it('uses viewport-fit compact tool wheel layout on mobile when the original arc would clip', () => {
4371+
const originalMatchMedia = window.matchMedia;
4372+
const originalInnerWidth = window.innerWidth;
4373+
const originalInnerHeight = window.innerHeight;
4374+
mockMobileMatchMedia();
4375+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 });
4376+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 });
4377+
4378+
try {
4379+
const { container } = render(<App chatSurfaceMode="compact" compactChatState="input" />);
4380+
const fan = container.querySelector('.compact-input-tool-fan') as HTMLDivElement;
4381+
vi.spyOn(fan, 'getBoundingClientRect').mockReturnValue({
4382+
left: 220,
4383+
top: 580,
4384+
right: 452,
4385+
bottom: 812,
4386+
width: 232,
4387+
height: 232,
4388+
x: 220,
4389+
y: 580,
4390+
toJSON: () => ({}),
4391+
});
4392+
4393+
const actionButton = container.querySelector('.compact-input-tool-toggle') as HTMLButtonElement;
4394+
expect(actionButton).not.toBeNull();
4395+
fireEvent.click(actionButton);
4396+
4397+
expect(fan).toHaveAttribute('data-compact-input-tool-fan-open', 'true');
4398+
expect(fan).toHaveAttribute('data-compact-tool-wheel-layout', 'viewport-fit');
4399+
} finally {
4400+
window.matchMedia = originalMatchMedia;
4401+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
4402+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight });
4403+
}
4404+
});
4405+
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: 180,
4419+
offsetLeft: 0,
4420+
offsetTop: 0,
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+
4456+
it('keeps the default compact tool wheel layout when viewport-fit would still clip at the top edge', () => {
4457+
const originalMatchMedia = window.matchMedia;
4458+
const originalInnerWidth = window.innerWidth;
4459+
const originalInnerHeight = window.innerHeight;
4460+
mockMobileMatchMedia();
4461+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 });
4462+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 });
4463+
4464+
try {
4465+
const { container } = render(<App chatSurfaceMode="compact" compactChatState="input" />);
4466+
const fan = container.querySelector('.compact-input-tool-fan') as HTMLDivElement;
4467+
vi.spyOn(fan, 'getBoundingClientRect').mockReturnValue({
4468+
left: 10,
4469+
top: -90,
4470+
right: 242,
4471+
bottom: 142,
4472+
width: 232,
4473+
height: 232,
4474+
x: 10,
4475+
y: -90,
4476+
toJSON: () => ({}),
4477+
});
4478+
4479+
const actionButton = container.querySelector('.compact-input-tool-toggle') as HTMLButtonElement;
4480+
expect(actionButton).not.toBeNull();
4481+
fireEvent.click(actionButton);
4482+
4483+
expect(fan).toHaveAttribute('data-compact-input-tool-fan-open', 'true');
4484+
expect(fan).toHaveAttribute('data-compact-tool-wheel-layout', 'default');
4485+
} finally {
4486+
window.matchMedia = originalMatchMedia;
4487+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
4488+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight });
4489+
}
4490+
});
4491+
43204492
it('anchors compact avatar tool bubbles to the fan origin instead of the rotating tool item', async () => {
43214493
vi.useFakeTimers();
43224494
try {

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

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
useState,
33
useEffect,
4+
useLayoutEffect,
45
useMemo,
56
useRef,
67
useCallback,
@@ -118,6 +119,7 @@ const COMPACT_INPUT_TOOL_WHEEL_CENTER_Y = 116;
118119
const COMPACT_INPUT_TOOL_WHEEL_ORBIT_RADIUS = 91.92;
119120
const COMPACT_INPUT_TOOL_WHEEL_HOVER_RADIUS = 116;
120121
const COMPACT_INPUT_TOOL_WHEEL_ANGLE_MIN_RADIUS = 16;
122+
const COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN = 8;
121123
const COMPACT_INPUT_TOOL_TOGGLE_HOVER_OUTSET = 14;
122124
const COMPACT_INPUT_TOOL_FAN_ORIGIN_CLOSE_SIZE = 48;
123125
// 在工具轮盘中心(toggle / fan 原点)按下后,指针移动超过此像素阈值即视为「拖动文本框」
@@ -126,6 +128,20 @@ const COMPACT_INPUT_TOOL_ORIGIN_DRAG_THRESHOLD = 6;
126128
const COMPACT_INPUT_TOOL_FAN_INTERACTIVE_DELAY_MS = 220;
127129
const COMPACT_INPUT_TOOL_FAN_TRANSIENT_CLOSE_DELAY_MS = 360;
128130
const COMPACT_INPUT_TOOL_FAN_OUTSIDE_CLOSE_DELAY_MS = 650;
131+
const compactInputToolWheelDefaultVisibleSlots = [
132+
{ angleDeg: 107.35, scale: 0.86 },
133+
{ angleDeg: 75.82, scale: 0.98 },
134+
{ angleDeg: 45, scale: 1.04 },
135+
{ angleDeg: 14.18, scale: 0.98 },
136+
{ angleDeg: -17.35, scale: 0.86 },
137+
] as const;
138+
const compactInputToolWheelViewportFitVisibleSlots = [
139+
{ angleDeg: -200, scale: 0.86 },
140+
{ angleDeg: -170, scale: 0.98 },
141+
{ angleDeg: -140, scale: 1.04 },
142+
{ angleDeg: -110, scale: 0.98 },
143+
{ angleDeg: -80, scale: 0.86 },
144+
] as const;
129145
const COMPACT_SURFACE_RESIZE_MIN_WIDTH = 430;
130146
const COMPACT_SURFACE_RESIZE_MOBILE_MIN_WIDTH = 280;
131147
const COMPACT_SURFACE_RESIZE_MAX_WIDTH = 720;
@@ -731,6 +747,7 @@ type ToolCursorVariantState = Record<string, CursorVariant>;
731747
type InteractionIntensity = NonNullable<AvatarInteractionPayload['intensity']>;
732748
type AvatarInteractionToolId = AvatarToolId;
733749
type AvatarTouchZone = 'ear' | 'head' | 'face' | 'body';
750+
type CompactInputToolWheelLayout = 'default' | 'viewport-fit';
734751
type AvatarInteractionPayloadByTool = {
735752
[K in AvatarInteractionToolId]: Extract<AvatarInteractionPayload, { toolId: K }>;
736753
};
@@ -1346,6 +1363,7 @@ export default function App({
13461363
const [compactChoiceLayerPlacement, setCompactChoiceLayerPlacement] = useState<'above' | 'below'>('above');
13471364
const [compactInputToolFanOpen, setCompactInputToolFanOpen] = useState(false);
13481365
const [compactInputToolFanInteractive, setCompactInputToolFanInteractive] = useState(false);
1366+
const [compactInputToolWheelLayout, setCompactInputToolWheelLayout] = useState<CompactInputToolWheelLayout>('default');
13491367
const [compactInputToolWheelIndex, setCompactInputToolWheelIndex] = useState(0);
13501368
const [compactInputToolWheelFastAnimation, setCompactInputToolWheelFastAnimation] = useState(false);
13511369
const [compactInputToolWheelChargeRatio, setCompactInputToolWheelChargeRatio] = useState(0);
@@ -3090,6 +3108,60 @@ export default function App({
30903108
compactInputToolFanSuppressHoverUntilLeaveRef.current = false;
30913109
}, []);
30923110

3111+
const resolveCompactInputToolWheelLayout = useCallback((): CompactInputToolWheelLayout => {
3112+
if (!window.matchMedia?.('(max-width: 820px)').matches) return 'default';
3113+
const fanElement = compactInputToolFanRef.current;
3114+
const fanRect = fanElement?.getBoundingClientRect();
3115+
if (!fanElement || !fanRect || fanRect.width <= 0 || fanRect.height <= 0) return 'default';
3116+
3117+
const visualViewport = window.visualViewport;
3118+
const viewportLeft = visualViewport?.offsetLeft ?? 0;
3119+
const viewportTop = visualViewport?.offsetTop ?? 0;
3120+
const viewportWidth = visualViewport?.width ?? window.innerWidth;
3121+
const viewportHeight = visualViewport?.height ?? window.innerHeight;
3122+
if (!Number.isFinite(viewportWidth) || viewportWidth <= 0 || !Number.isFinite(viewportHeight) || viewportHeight <= 0) {
3123+
return 'default';
3124+
}
3125+
3126+
const fanStyle = window.getComputedStyle ? window.getComputedStyle(fanElement) : null;
3127+
const readFanPixelVar = (name: string, fallback: number) => {
3128+
const rawValue = fanStyle?.getPropertyValue(name).trim() || '';
3129+
const parsedValue = Number.parseFloat(rawValue);
3130+
return Number.isFinite(parsedValue) ? parsedValue : fallback;
3131+
};
3132+
3133+
const centerX = fanRect.left + readFanPixelVar('--compact-tool-wheel-center-x', COMPACT_INPUT_TOOL_WHEEL_CENTER_X);
3134+
const centerY = fanRect.top + readFanPixelVar('--compact-tool-wheel-center-y', COMPACT_INPUT_TOOL_WHEEL_CENTER_Y);
3135+
const orbitRadius = readFanPixelVar('--compact-tool-wheel-orbit-radius', 80);
3136+
const buttonSize = readFanPixelVar('--compact-tool-button-size', 38);
3137+
const minX = viewportLeft + COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN;
3138+
const minY = viewportTop + COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN;
3139+
const maxX = viewportLeft + viewportWidth - COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN;
3140+
const maxY = viewportTop + viewportHeight - COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN;
3141+
3142+
const wheelLayoutFitsViewport = (slots: ReadonlyArray<{ angleDeg: number; scale: number }>) => slots.every(({ angleDeg, scale }) => {
3143+
const angle = angleDeg * (Math.PI / 180);
3144+
const itemCenterX = centerX + (Math.cos(angle) * orbitRadius);
3145+
const itemCenterY = centerY + (Math.sin(angle) * orbitRadius);
3146+
const halfSize = (buttonSize * scale) / 2;
3147+
return itemCenterX - halfSize >= minX
3148+
&& itemCenterX + halfSize <= maxX
3149+
&& itemCenterY - halfSize >= minY
3150+
&& itemCenterY + halfSize <= maxY;
3151+
});
3152+
3153+
if (wheelLayoutFitsViewport(compactInputToolWheelDefaultVisibleSlots)) return 'default';
3154+
if (wheelLayoutFitsViewport(compactInputToolWheelViewportFitVisibleSlots)) return 'viewport-fit';
3155+
return 'default';
3156+
}, []);
3157+
3158+
const syncCompactInputToolWheelLayout = useCallback(() => {
3159+
const nextLayout = resolveCompactInputToolWheelLayout();
3160+
setCompactInputToolWheelLayout(currentLayout => (
3161+
currentLayout === nextLayout ? currentLayout : nextLayout
3162+
));
3163+
}, [resolveCompactInputToolWheelLayout]);
3164+
30933165
const closeCompactInputToolFan = useCallback((options?: {
30943166
afterClose?: () => void;
30953167
deferDesktopAction?: boolean;
@@ -3106,6 +3178,7 @@ export default function App({
31063178
compactInputToolWheelLastRotationAtRef.current = 0;
31073179
resetCompactInputToolWheelCharge();
31083180
setCompactInputToolWheelFastAnimation(false);
3181+
setCompactInputToolWheelLayout('default');
31093182
setCompactInputToolFanInteractiveState(false);
31103183
compactInputToolFanPositionSyncRef.current?.();
31113184
compactInputToolFanOpenRef.current = false;
@@ -3177,11 +3250,39 @@ export default function App({
31773250
toolMenuOpen,
31783251
]);
31793252

3253+
useLayoutEffect(() => {
3254+
if (!compactInputToolFanOpen) {
3255+
setCompactInputToolWheelLayout('default');
3256+
return undefined;
3257+
}
3258+
3259+
syncCompactInputToolWheelLayout();
3260+
const frameId = window.requestAnimationFrame(syncCompactInputToolWheelLayout);
3261+
window.addEventListener('resize', syncCompactInputToolWheelLayout);
3262+
window.addEventListener('neko:compact-interaction-geometry-change', syncCompactInputToolWheelLayout);
3263+
window.visualViewport?.addEventListener('resize', syncCompactInputToolWheelLayout);
3264+
window.visualViewport?.addEventListener('scroll', syncCompactInputToolWheelLayout);
3265+
3266+
return () => {
3267+
window.cancelAnimationFrame(frameId);
3268+
window.removeEventListener('resize', syncCompactInputToolWheelLayout);
3269+
window.removeEventListener('neko:compact-interaction-geometry-change', syncCompactInputToolWheelLayout);
3270+
window.visualViewport?.removeEventListener('resize', syncCompactInputToolWheelLayout);
3271+
window.visualViewport?.removeEventListener('scroll', syncCompactInputToolWheelLayout);
3272+
};
3273+
}, [
3274+
compactInputToolFanOpen,
3275+
compactSurfaceResizeWidth,
3276+
effectiveCompactChatState,
3277+
syncCompactInputToolWheelLayout,
3278+
]);
3279+
31803280
const openCompactInputToolFan = useCallback((intent: 'click' | 'hover') => {
31813281
if (composerDisabled || compactInputHasPayload) return;
31823282
clearCompactInputToolFanCloseTimer();
31833283
clearCompactInputToolFanInteractiveTimer();
31843284
compactInputToolFanOpenIntentRef.current = intent;
3285+
setCompactInputToolWheelLayout('default');
31853286
setCompactInputToolFanInteractiveState(false);
31863287
updateCompactInputToolFanPosition();
31873288
compactInputToolFanOpenRef.current = true;
@@ -4857,6 +4958,7 @@ export default function App({
48574958
data-compact-no-drag="true"
48584959
data-compact-input-tool-fan-open={compactInputToolFanOpen ? 'true' : 'false'}
48594960
data-compact-input-tool-fan-interactive={compactInputToolFanInteractive ? 'true' : 'false'}
4961+
data-compact-tool-wheel-layout={compactInputToolWheelLayout}
48604962
data-compact-tool-wheel-fast-animation={compactInputToolWheelFastAnimation ? 'true' : 'false'}
48614963
data-compact-tool-wheel-charge-active={compactInputToolWheelChargeRatio > 0 ? 'true' : 'false'}
48624964
data-compact-tool-wheel-charge-direction={compactInputToolWheelChargeDirection === 1 ? 'forward' : compactInputToolWheelChargeDirection === -1 ? 'backward' : 'none'}

0 commit comments

Comments
 (0)