Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
136 changes: 136 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,128 @@ 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: 500,
offsetLeft: 0,
offsetTop: 300,
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('anchors compact avatar tool bubbles to the fan origin instead of the rotating tool item', async () => {
vi.useFakeTimers();
try {
Expand Down
93 changes: 93 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,58 @@ 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 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 +3169,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 +3241,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 +4949,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
45 changes: 45 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,51 @@ 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.


.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"]::after,
.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"] > img,
.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"] > svg,
.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"] > .composer-galgame-btn-glyph,
.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"] > .composer-tool-btn,
.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"]::after,
.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"] > img,
.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"] > svg,
.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"] > .composer-galgame-btn-glyph,
.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"] > .composer-tool-btn {
-webkit-mask-image: none;
mask-image: none;
}
}

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