11import {
22 useState ,
33 useEffect ,
4+ useLayoutEffect ,
45 useMemo ,
56 useRef ,
67 useCallback ,
@@ -118,6 +119,7 @@ const COMPACT_INPUT_TOOL_WHEEL_CENTER_Y = 116;
118119const COMPACT_INPUT_TOOL_WHEEL_ORBIT_RADIUS = 91.92 ;
119120const COMPACT_INPUT_TOOL_WHEEL_HOVER_RADIUS = 116 ;
120121const COMPACT_INPUT_TOOL_WHEEL_ANGLE_MIN_RADIUS = 16 ;
122+ const COMPACT_INPUT_TOOL_WHEEL_VIEWPORT_MARGIN = 8 ;
121123const COMPACT_INPUT_TOOL_TOGGLE_HOVER_OUTSET = 14 ;
122124const COMPACT_INPUT_TOOL_FAN_ORIGIN_CLOSE_SIZE = 48 ;
123125// 在工具轮盘中心(toggle / fan 原点)按下后,指针移动超过此像素阈值即视为「拖动文本框」
@@ -126,6 +128,20 @@ const COMPACT_INPUT_TOOL_ORIGIN_DRAG_THRESHOLD = 6;
126128const COMPACT_INPUT_TOOL_FAN_INTERACTIVE_DELAY_MS = 220 ;
127129const COMPACT_INPUT_TOOL_FAN_TRANSIENT_CLOSE_DELAY_MS = 360 ;
128130const 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 ;
129145const COMPACT_SURFACE_RESIZE_MIN_WIDTH = 430 ;
130146const COMPACT_SURFACE_RESIZE_MOBILE_MIN_WIDTH = 280 ;
131147const COMPACT_SURFACE_RESIZE_MAX_WIDTH = 720 ;
@@ -731,6 +747,7 @@ type ToolCursorVariantState = Record<string, CursorVariant>;
731747type InteractionIntensity = NonNullable < AvatarInteractionPayload [ 'intensity' ] > ;
732748type AvatarInteractionToolId = AvatarToolId ;
733749type AvatarTouchZone = 'ear' | 'head' | 'face' | 'body' ;
750+ type CompactInputToolWheelLayout = 'default' | 'viewport-fit' ;
734751type 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