Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions frontend/react-neko-chat/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ describe('App', () => {
}));
};

const mockMobileMatchMedia = (mobile = true) => {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: mobile && query === '(max-width: 820px)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
};

const setupAvatarDropBounds = () => {
const live2dContainer = document.createElement('div');
live2dContainer.id = 'live2d-container';
Expand Down Expand Up @@ -4299,6 +4312,7 @@ describe('App', () => {
expect(onComposerSubmit).not.toHaveBeenCalled();
expect(fan).not.toBeNull();
expect(fan).toHaveAttribute('data-compact-input-tool-fan-open', 'true');
expect(fan).toHaveAttribute('data-compact-tool-wheel-layout', 'default');
expect(fan).toHaveAttribute('data-compact-geometry-owner', 'surface');
expect(fan).toHaveAttribute('data-compact-geometry-item', 'toolFan');
expect(fan?.parentElement).toBe(shell);
Expand All @@ -4317,6 +4331,164 @@ describe('App', () => {
expect(container.querySelectorAll('.send-button-circle')).toHaveLength(1);
});

it('keeps the default compact tool wheel layout on mobile when the original arc fits', () => {
const originalMatchMedia = window.matchMedia;
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
mockMobileMatchMedia();
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 });

try {
const { container } = render(<App chatSurfaceMode="compact" compactChatState="input" />);
const fan = container.querySelector('.compact-input-tool-fan') as HTMLDivElement;
vi.spyOn(fan, 'getBoundingClientRect').mockReturnValue({
left: 10,
top: 10,
right: 242,
bottom: 242,
width: 232,
height: 232,
x: 10,
y: 10,
toJSON: () => ({}),
});

const actionButton = container.querySelector('.compact-input-tool-toggle') as HTMLButtonElement;
expect(actionButton).not.toBeNull();
fireEvent.click(actionButton);

expect(fan).toHaveAttribute('data-compact-input-tool-fan-open', 'true');
expect(fan).toHaveAttribute('data-compact-tool-wheel-layout', 'default');
} finally {
window.matchMedia = originalMatchMedia;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight });
}
});

it('uses viewport-fit compact tool wheel layout on mobile when the original arc would clip', () => {
const originalMatchMedia = window.matchMedia;
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
mockMobileMatchMedia();
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 });

try {
const { container } = render(<App chatSurfaceMode="compact" compactChatState="input" />);
const fan = container.querySelector('.compact-input-tool-fan') as HTMLDivElement;
vi.spyOn(fan, 'getBoundingClientRect').mockReturnValue({
left: 220,
top: 580,
right: 452,
bottom: 812,
width: 232,
height: 232,
x: 220,
y: 580,
toJSON: () => ({}),
});

const actionButton = container.querySelector('.compact-input-tool-toggle') as HTMLButtonElement;
expect(actionButton).not.toBeNull();
fireEvent.click(actionButton);

expect(fan).toHaveAttribute('data-compact-input-tool-fan-open', 'true');
expect(fan).toHaveAttribute('data-compact-tool-wheel-layout', 'viewport-fit');
} finally {
window.matchMedia = originalMatchMedia;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight });
}
});

it('uses the visual viewport when checking compact tool wheel clipping on mobile', () => {
const originalMatchMedia = window.matchMedia;
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
const originalVisualViewport = window.visualViewport;
mockMobileMatchMedia();
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 });
Object.defineProperty(window, 'visualViewport', {
configurable: true,
value: {
width: 390,
height: 180,
offsetLeft: 0,
offsetTop: 0,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
},
});

try {
const { container } = render(<App chatSurfaceMode="compact" compactChatState="input" />);
const fan = container.querySelector('.compact-input-tool-fan') as HTMLDivElement;
vi.spyOn(fan, 'getBoundingClientRect').mockReturnValue({
left: 10,
top: 10,
right: 242,
bottom: 242,
width: 232,
height: 232,
x: 10,
y: 10,
toJSON: () => ({}),
});

const actionButton = container.querySelector('.compact-input-tool-toggle') as HTMLButtonElement;
expect(actionButton).not.toBeNull();
fireEvent.click(actionButton);

expect(fan).toHaveAttribute('data-compact-input-tool-fan-open', 'true');
expect(fan).toHaveAttribute('data-compact-tool-wheel-layout', 'viewport-fit');
} finally {
window.matchMedia = originalMatchMedia;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight });
Object.defineProperty(window, 'visualViewport', { configurable: true, value: originalVisualViewport });
}
});

it('keeps the default compact tool wheel layout when viewport-fit would still clip at the top edge', () => {
const originalMatchMedia = window.matchMedia;
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
mockMobileMatchMedia();
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 });

try {
const { container } = render(<App chatSurfaceMode="compact" compactChatState="input" />);
const fan = container.querySelector('.compact-input-tool-fan') as HTMLDivElement;
vi.spyOn(fan, 'getBoundingClientRect').mockReturnValue({
left: 10,
top: -90,
right: 242,
bottom: 142,
width: 232,
height: 232,
x: 10,
y: -90,
toJSON: () => ({}),
});

const actionButton = container.querySelector('.compact-input-tool-toggle') as HTMLButtonElement;
expect(actionButton).not.toBeNull();
fireEvent.click(actionButton);

expect(fan).toHaveAttribute('data-compact-input-tool-fan-open', 'true');
expect(fan).toHaveAttribute('data-compact-tool-wheel-layout', 'default');
} finally {
window.matchMedia = originalMatchMedia;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight });
}
});

it('anchors compact avatar tool bubbles to the fan origin instead of the rotating tool item', async () => {
vi.useFakeTimers();
try {
Expand Down
102 changes: 102 additions & 0 deletions frontend/react-neko-chat/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
useState,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useCallback,
Expand Down Expand Up @@ -118,6 +119,7 @@ const COMPACT_INPUT_TOOL_WHEEL_CENTER_Y = 116;
const COMPACT_INPUT_TOOL_WHEEL_ORBIT_RADIUS = 91.92;
const COMPACT_INPUT_TOOL_WHEEL_HOVER_RADIUS = 116;
const COMPACT_INPUT_TOOL_WHEEL_ANGLE_MIN_RADIUS = 16;
const COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN = 8;
const COMPACT_INPUT_TOOL_TOGGLE_HOVER_OUTSET = 14;
const COMPACT_INPUT_TOOL_FAN_ORIGIN_CLOSE_SIZE = 48;
// 在工具轮盘中心(toggle / fan 原点)按下后,指针移动超过此像素阈值即视为「拖动文本框」
Expand All @@ -126,6 +128,20 @@ const COMPACT_INPUT_TOOL_ORIGIN_DRAG_THRESHOLD = 6;
const COMPACT_INPUT_TOOL_FAN_INTERACTIVE_DELAY_MS = 220;
const COMPACT_INPUT_TOOL_FAN_TRANSIENT_CLOSE_DELAY_MS = 360;
const COMPACT_INPUT_TOOL_FAN_OUTSIDE_CLOSE_DELAY_MS = 650;
const compactInputToolWheelDefaultVisibleSlots = [
{ angleDeg: 107.35, scale: 0.86 },
{ angleDeg: 75.82, scale: 0.98 },
{ angleDeg: 45, scale: 1.04 },
{ angleDeg: 14.18, scale: 0.98 },
{ angleDeg: -17.35, scale: 0.86 },
] as const;
const compactInputToolWheelViewportFitVisibleSlots = [
{ angleDeg: -200, scale: 0.86 },
{ angleDeg: -170, scale: 0.98 },
{ angleDeg: -140, scale: 1.04 },
{ angleDeg: -110, scale: 0.98 },
{ angleDeg: -80, scale: 0.86 },
] as const;
const COMPACT_SURFACE_RESIZE_MIN_WIDTH = 430;
const COMPACT_SURFACE_RESIZE_MOBILE_MIN_WIDTH = 280;
const COMPACT_SURFACE_RESIZE_MAX_WIDTH = 720;
Expand Down Expand Up @@ -731,6 +747,7 @@ type ToolCursorVariantState = Record<string, CursorVariant>;
type InteractionIntensity = NonNullable<AvatarInteractionPayload['intensity']>;
type AvatarInteractionToolId = AvatarToolId;
type AvatarTouchZone = 'ear' | 'head' | 'face' | 'body';
type CompactInputToolWheelLayout = 'default' | 'viewport-fit';
type AvatarInteractionPayloadByTool = {
[K in AvatarInteractionToolId]: Extract<AvatarInteractionPayload, { toolId: K }>;
};
Expand Down Expand Up @@ -1346,6 +1363,7 @@ export default function App({
const [compactChoiceLayerPlacement, setCompactChoiceLayerPlacement] = useState<'above' | 'below'>('above');
const [compactInputToolFanOpen, setCompactInputToolFanOpen] = useState(false);
const [compactInputToolFanInteractive, setCompactInputToolFanInteractive] = useState(false);
const [compactInputToolWheelLayout, setCompactInputToolWheelLayout] = useState<CompactInputToolWheelLayout>('default');
const [compactInputToolWheelIndex, setCompactInputToolWheelIndex] = useState(0);
const [compactInputToolWheelFastAnimation, setCompactInputToolWheelFastAnimation] = useState(false);
const [compactInputToolWheelChargeRatio, setCompactInputToolWheelChargeRatio] = useState(0);
Expand Down Expand Up @@ -3090,6 +3108,60 @@ export default function App({
compactInputToolFanSuppressHoverUntilLeaveRef.current = false;
}, []);

const resolveCompactInputToolWheelLayout = useCallback((): CompactInputToolWheelLayout => {
if (!window.matchMedia?.('(max-width: 820px)').matches) return 'default';
const fanElement = compactInputToolFanRef.current;
const fanRect = fanElement?.getBoundingClientRect();
if (!fanElement || !fanRect || fanRect.width <= 0 || fanRect.height <= 0) return 'default';

const visualViewport = window.visualViewport;
const viewportLeft = visualViewport?.offsetLeft ?? 0;
const viewportTop = visualViewport?.offsetTop ?? 0;
const viewportWidth = visualViewport?.width ?? window.innerWidth;
const viewportHeight = visualViewport?.height ?? window.innerHeight;
if (!Number.isFinite(viewportWidth) || viewportWidth <= 0 || !Number.isFinite(viewportHeight) || viewportHeight <= 0) {
return 'default';
}

const fanStyle = window.getComputedStyle ? window.getComputedStyle(fanElement) : null;
const readFanPixelVar = (name: string, fallback: number) => {
const rawValue = fanStyle?.getPropertyValue(name).trim() || '';
const parsedValue = Number.parseFloat(rawValue);
return Number.isFinite(parsedValue) ? parsedValue : fallback;
};

const centerX = fanRect.left + readFanPixelVar('--compact-tool-wheel-center-x', COMPACT_INPUT_TOOL_WHEEL_CENTER_X);
const centerY = fanRect.top + readFanPixelVar('--compact-tool-wheel-center-y', COMPACT_INPUT_TOOL_WHEEL_CENTER_Y);
const orbitRadius = readFanPixelVar('--compact-tool-wheel-orbit-radius', 80);
const buttonSize = readFanPixelVar('--compact-tool-button-size', 38);
const minX = viewportLeft + COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN;
const minY = viewportTop + COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN;
const maxX = viewportLeft + viewportWidth - COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN;
const maxY = viewportTop + viewportHeight - COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN;

const wheelLayoutFitsViewport = (slots: ReadonlyArray<{ angleDeg: number; scale: number }>) => slots.every(({ angleDeg, scale }) => {
const angle = angleDeg * (Math.PI / 180);
const itemCenterX = centerX + (Math.cos(angle) * orbitRadius);
const itemCenterY = centerY + (Math.sin(angle) * orbitRadius);
const halfSize = (buttonSize * scale) / 2;
return itemCenterX - halfSize >= minX
&& itemCenterX + halfSize <= maxX
&& itemCenterY - halfSize >= minY
&& itemCenterY + halfSize <= maxY;
});

if (wheelLayoutFitsViewport(compactInputToolWheelDefaultVisibleSlots)) return 'default';
if (wheelLayoutFitsViewport(compactInputToolWheelViewportFitVisibleSlots)) return 'viewport-fit';
return 'default';
}, []);

const syncCompactInputToolWheelLayout = useCallback(() => {
const nextLayout = resolveCompactInputToolWheelLayout();
setCompactInputToolWheelLayout(currentLayout => (
currentLayout === nextLayout ? currentLayout : nextLayout
));
}, [resolveCompactInputToolWheelLayout]);

const closeCompactInputToolFan = useCallback((options?: {
afterClose?: () => void;
deferDesktopAction?: boolean;
Expand All @@ -3106,6 +3178,7 @@ export default function App({
compactInputToolWheelLastRotationAtRef.current = 0;
resetCompactInputToolWheelCharge();
setCompactInputToolWheelFastAnimation(false);
setCompactInputToolWheelLayout('default');
setCompactInputToolFanInteractiveState(false);
compactInputToolFanPositionSyncRef.current?.();
compactInputToolFanOpenRef.current = false;
Expand Down Expand Up @@ -3177,11 +3250,39 @@ export default function App({
toolMenuOpen,
]);

useLayoutEffect(() => {
if (!compactInputToolFanOpen) {
setCompactInputToolWheelLayout('default');
return undefined;
}

syncCompactInputToolWheelLayout();
const frameId = window.requestAnimationFrame(syncCompactInputToolWheelLayout);
window.addEventListener('resize', syncCompactInputToolWheelLayout);
window.addEventListener('neko:compact-interaction-geometry-change', syncCompactInputToolWheelLayout);
window.visualViewport?.addEventListener('resize', syncCompactInputToolWheelLayout);
window.visualViewport?.addEventListener('scroll', syncCompactInputToolWheelLayout);

return () => {
window.cancelAnimationFrame(frameId);
window.removeEventListener('resize', syncCompactInputToolWheelLayout);
window.removeEventListener('neko:compact-interaction-geometry-change', syncCompactInputToolWheelLayout);
window.visualViewport?.removeEventListener('resize', syncCompactInputToolWheelLayout);
window.visualViewport?.removeEventListener('scroll', syncCompactInputToolWheelLayout);
};
}, [
compactInputToolFanOpen,
compactSurfaceResizeWidth,
effectiveCompactChatState,
syncCompactInputToolWheelLayout,
]);

const openCompactInputToolFan = useCallback((intent: 'click' | 'hover') => {
if (composerDisabled || compactInputHasPayload) return;
clearCompactInputToolFanCloseTimer();
clearCompactInputToolFanInteractiveTimer();
compactInputToolFanOpenIntentRef.current = intent;
setCompactInputToolWheelLayout('default');
setCompactInputToolFanInteractiveState(false);
updateCompactInputToolFanPosition();
compactInputToolFanOpenRef.current = true;
Expand Down Expand Up @@ -4857,6 +4958,7 @@ export default function App({
data-compact-no-drag="true"
data-compact-input-tool-fan-open={compactInputToolFanOpen ? 'true' : 'false'}
data-compact-input-tool-fan-interactive={compactInputToolFanInteractive ? 'true' : 'false'}
data-compact-tool-wheel-layout={compactInputToolWheelLayout}
data-compact-tool-wheel-fast-animation={compactInputToolWheelFastAnimation ? 'true' : 'false'}
data-compact-tool-wheel-charge-active={compactInputToolWheelChargeRatio > 0 ? 'true' : 'false'}
data-compact-tool-wheel-charge-direction={compactInputToolWheelChargeDirection === 1 ? 'forward' : compactInputToolWheelChargeDirection === -1 ? 'backward' : 'none'}
Expand Down
Loading
Loading