Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
86 changes: 86 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,78 @@ 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('anchors compact avatar tool bubbles to the fan origin instead of the rotating tool item', async () => {
vi.useFakeTimers();
try {
Expand Down
90 changes: 90 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,13 @@ 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 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 +740,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 +1356,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 +3101,55 @@ 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 viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the visual viewport for mobile clipping

On mobile when the soft keyboard or browser chrome shrinks the visible area, window.innerWidth/innerHeight still describe the layout viewport, so this comparison can decide that the default arc fits even though it is clipped or covered in the visual viewport. The effect already listens to visualViewport resize/scroll, but those events cannot change the result while the resolver ignores visualViewport.width/height and offsets.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted and fixed in 6804728. The resolver now uses visualViewport width/height plus offsetLeft/offsetTop when available, while keeping innerWidth/innerHeight as the fallback. Added a mobile visual viewport clipping test. Verified with npm.cmd run build and npm.cmd test -- App.test.tsx.

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 = COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN;
const minY = COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN;
const maxX = viewportWidth - COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN;
const maxY = viewportHeight - COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN;

const overflowsViewport = compactInputToolWheelDefaultVisibleSlots.some(({ 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;
});

return overflowsViewport ? 'viewport-fit' : '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 +3166,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 +3238,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 +4946,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
31 changes: 31 additions & 0 deletions frontend/react-neko-chat/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -4207,6 +4207,37 @@ body.electron-chat-window.subtitle-web-host .compact-export-history-anchor {
.compact-chat-surface-frame[data-compact-chat-state="input"] {
padding: 5px 8px 5px 18px;
}

/* Only use the phone fallback when the original wheel geometry would overflow the viewport. */
.compact-input-tool-fan[data-compact-input-tool-fan-open="true"][data-compact-tool-wheel-layout="viewport-fit"] .compact-input-tool-item[data-compact-tool-wheel-slot="-2"],
.compact-input-tool-fan[data-compact-input-tool-fan-open="true"][data-compact-tool-wheel-layout="viewport-fit"] .compact-input-tool-item[data-compact-tool-wheel-slot="-1"],
.compact-input-tool-fan[data-compact-input-tool-fan-open="true"][data-compact-tool-wheel-layout="viewport-fit"] .compact-input-tool-item[data-compact-tool-wheel-slot="0"],
.compact-input-tool-fan[data-compact-input-tool-fan-open="true"][data-compact-tool-wheel-layout="viewport-fit"] .compact-input-tool-item[data-compact-tool-wheel-slot="1"],
.compact-input-tool-fan[data-compact-input-tool-fan-open="true"][data-compact-tool-wheel-layout="viewport-fit"] .compact-input-tool-item[data-compact-tool-wheel-slot="2"] {
opacity: 1;
pointer-events: auto;
transition: none;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

.compact-input-tool-fan[data-compact-input-tool-fan-open="true"][data-compact-tool-wheel-layout="viewport-fit"] .compact-input-tool-item[data-compact-tool-wheel-slot="-2"] {
transform: translate(var(--compact-tool-wheel-center-x), var(--compact-tool-wheel-center-y)) rotate(-200deg) translateX(var(--compact-tool-wheel-orbit-radius)) rotate(200deg) scale(0.86);
}

.compact-input-tool-fan[data-compact-input-tool-fan-open="true"][data-compact-tool-wheel-layout="viewport-fit"] .compact-input-tool-item[data-compact-tool-wheel-slot="-1"] {
transform: translate(var(--compact-tool-wheel-center-x), var(--compact-tool-wheel-center-y)) rotate(-170deg) translateX(var(--compact-tool-wheel-orbit-radius)) rotate(170deg) scale(0.98);
}

.compact-input-tool-fan[data-compact-input-tool-fan-open="true"][data-compact-tool-wheel-layout="viewport-fit"] .compact-input-tool-item[data-compact-tool-wheel-slot="0"] {
transform: translate(var(--compact-tool-wheel-center-x), var(--compact-tool-wheel-center-y)) rotate(-140deg) translateX(var(--compact-tool-wheel-orbit-radius)) rotate(140deg) scale(1.04);
}

.compact-input-tool-fan[data-compact-input-tool-fan-open="true"][data-compact-tool-wheel-layout="viewport-fit"] .compact-input-tool-item[data-compact-tool-wheel-slot="1"] {
transform: translate(var(--compact-tool-wheel-center-x), var(--compact-tool-wheel-center-y)) rotate(-110deg) translateX(var(--compact-tool-wheel-orbit-radius)) rotate(110deg) scale(0.98);
}

.compact-input-tool-fan[data-compact-input-tool-fan-open="true"][data-compact-tool-wheel-layout="viewport-fit"] .compact-input-tool-item[data-compact-tool-wheel-slot="2"] {
transform: translate(var(--compact-tool-wheel-center-x), var(--compact-tool-wheel-center-y)) rotate(-80deg) translateX(var(--compact-tool-wheel-orbit-radius)) rotate(80deg) scale(0.86);
}
Comment on lines +4238 to +4240
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle top-edge overflows before using phone arc

When the compact surface is positioned near the top of a narrow viewport, the resolver switches to viewport-fit as soon as the default arc clips, but this fallback can make that case worse: the right preview slot moves from the default -17.35deg position to -80deg, which raises it by roughly another 55px at the current 80px radius. Since the compact shell can be fixed via --desktop-compact-surface-top, opening the wheel near the top edge still leaves the item clipped even though the layout is marked viewport-fit; the fallback needs to account for which edge overflowed or use a placement that is verified to fit.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted and fixed in ec6009d. The resolver now verifies both candidate geometries: it keeps default when the default arc fits, switches to viewport-fit only when that fallback also fully fits the visual viewport, and otherwise falls back to default instead of making top-edge clipping worse. Added a top-edge regression test. Verified with npm.cmd run build and npm.cmd test -- App.test.tsx.

}

/* ===================================================================
Expand Down
Loading