-
Notifications
You must be signed in to change notification settings - Fork 159
Refactor the item shortcut bar and add a new item warehouse management panel. #1600
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 6 commits
fc82e0b
2e3d900
d0495f3
85d295b
023f5e8
b0c2089
8506000
56798bf
e43859c
3f5bf47
45bdb6a
a2c4410
7af52ba
6559c88
41791ba
8640ee8
6e17c3d
7f543b0
f547c12
c622900
5dee50a
2c54642
33c0ca7
2035461
3e9b2d6
5b31a23
58face7
942cb87
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| import type { MouseEvent as ReactMouseEvent } from 'react'; | ||
| import { i18n } from './i18n'; | ||
| import { | ||
| type AvatarToolId, | ||
| type AvatarToolItem, | ||
| type CursorVariant, | ||
| resolveAvatarToolMenuIconVisual, | ||
| } from './avatarTools'; | ||
|
|
||
| type AvatarToolQuickbarProps = { | ||
| activeToolIds: AvatarToolId[]; | ||
| activeCursorToolId: string | null; | ||
| availableTools: AvatarToolItem[]; | ||
| disabled?: boolean; | ||
| getToolVariant: (toolId: AvatarToolId) => CursorVariant; | ||
| onToolClick: (tool: AvatarToolItem, event: ReactMouseEvent<HTMLButtonElement>) => void; | ||
| onEditClick: (event: ReactMouseEvent<HTMLButtonElement>) => void; | ||
| }; | ||
|
|
||
| function getToolLabel(tool: AvatarToolItem): string { | ||
| return i18n(tool.labelKey, tool.labelFallback); | ||
| } | ||
|
|
||
| export default function AvatarToolQuickbar({ | ||
| activeToolIds, | ||
| activeCursorToolId, | ||
| availableTools, | ||
| disabled = false, | ||
| getToolVariant, | ||
| onToolClick, | ||
| onEditClick, | ||
| }: AvatarToolQuickbarProps) { | ||
| const availableById = new Map(availableTools.map(tool => [tool.id, tool])); | ||
| const activeTools = activeToolIds | ||
| .map(toolId => availableById.get(toolId)) | ||
| .filter((tool): tool is AvatarToolItem => !!tool); | ||
|
|
||
| return ( | ||
| <div | ||
| id="composer-avatar-tool-quickbar" | ||
| className="avatar-tool-quickbar" | ||
| role="group" | ||
| aria-label={i18n('chat.avatarToolQuickbarAriaLabel', 'Avatar quick tools')} | ||
| data-avatar-tool-quickbar-empty={activeTools.length === 0 ? 'true' : 'false'} | ||
| > | ||
| <div className="avatar-tool-quickbar-scroll"> | ||
| {activeTools.length > 0 ? activeTools.map((tool) => { | ||
| const label = getToolLabel(tool); | ||
| const visual = resolveAvatarToolMenuIconVisual(tool, getToolVariant(tool.id)); | ||
| return ( | ||
| <button | ||
| key={tool.id} | ||
| className={`composer-icon-button avatar-tool-quickbar-button${activeCursorToolId === tool.id ? ' is-active' : ''}`} | ||
| type="button" | ||
| aria-label={label} | ||
| aria-pressed={activeCursorToolId === tool.id} | ||
| title={label} | ||
| disabled={disabled} | ||
| onClick={(event) => onToolClick(tool, event)} | ||
| > | ||
| <img | ||
| className={`composer-icon-button-image avatar-tool-quickbar-image avatar-tool-icon avatar-tool-icon-${tool.id}`} | ||
| src={visual.imagePath} | ||
| style={{ | ||
| transform: `translate(${visual.offsetX}px, ${visual.offsetY}px) scale(${tool.menuIconScale ?? 1})`, | ||
| }} | ||
| alt="" | ||
| aria-hidden="true" | ||
| /> | ||
| </button> | ||
| ); | ||
| }) : ( | ||
| <span className="avatar-tool-quickbar-empty"> | ||
| {i18n('chat.avatarToolQuickbarEmpty', 'No quick tools')} | ||
| </span> | ||
| )} | ||
| </div> | ||
| <button | ||
| className="avatar-tool-quickbar-edit" | ||
| type="button" | ||
| aria-label={i18n('chat.avatarToolEdit', 'Edit quick tools')} | ||
| title={i18n('chat.avatarToolEdit', 'Edit quick tools')} | ||
| disabled={disabled} | ||
| onClick={onEditClick} | ||
| > | ||
| <img | ||
| className="avatar-tool-quickbar-edit-image" | ||
| src="/static/icons/edit_tool_unified.png" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This quickbar button loads Useful? React with 👍 / 👎. |
||
| alt="" | ||
| aria-hidden="true" | ||
| /> | ||
| </button> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export type { AvatarToolQuickbarProps }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| import type { AvatarInteractionPayload } from './message-schema'; | ||
|
|
||
| export type AvatarToolId = AvatarInteractionPayload['toolId']; | ||
|
|
||
| export type CursorVariant = 'primary' | 'secondary' | 'tertiary'; | ||
|
|
||
| export type AvatarToolItem = { | ||
| id: AvatarToolId; | ||
| labelKey: string; | ||
| labelFallback: string; | ||
| iconImagePath: string; | ||
| iconImagePathAlt?: string; | ||
| iconImagePathAlt2?: string; | ||
| menuIconScale?: number; | ||
| menuIconOffsetX?: number; | ||
| menuIconOffsetY?: number; | ||
| menuIconOffsetXAlt?: number; | ||
| menuIconOffsetYAlt?: number; | ||
| menuIconOffsetXAlt2?: number; | ||
| menuIconOffsetYAlt2?: number; | ||
| cursorImagePath: string; | ||
| cursorImagePathAlt?: string; | ||
| cursorImagePathAlt2?: string; | ||
| cursorHotspotX?: number; | ||
| cursorHotspotY?: number; | ||
| }; | ||
|
|
||
| export const ACTIVE_AVATAR_TOOLS_STORAGE_KEY = 'neko.reactChatWindow.activeAvatarTools'; | ||
| export const MAX_ACTIVE_AVATAR_TOOLS = 3; | ||
| export const DEFAULT_ACTIVE_AVATAR_TOOL_IDS: AvatarToolId[] = ['lollipop', 'fist', 'hammer']; | ||
|
|
||
| export const AVAILABLE_AVATAR_TOOLS: AvatarToolItem[] = [ | ||
| { | ||
| id: 'lollipop', | ||
| labelKey: 'chat.toolLollipop', | ||
| labelFallback: '棒棒糖', | ||
| iconImagePath: '/static/icons/chat_sugar1.png', | ||
| iconImagePathAlt: '/static/icons/chat_sugar2.png', | ||
| iconImagePathAlt2: '/static/icons/chat_sugar3.png', | ||
| cursorImagePath: '/static/icons/chat_sugar1_cursor.png', | ||
| cursorImagePathAlt: '/static/icons/chat_sugar2_cursor.png', | ||
| menuIconScale: 1.18, | ||
| cursorHotspotX: 27, | ||
| cursorHotspotY: 46, | ||
| }, | ||
| { | ||
| id: 'fist', | ||
| labelKey: 'chat.toolFist', | ||
| labelFallback: '猫爪', | ||
| iconImagePath: '/static/icons/cat_claw1.png', | ||
| iconImagePathAlt: '/static/icons/cat_claw2.png', | ||
| cursorImagePath: '/static/icons/cat_claw1_cursor.png', | ||
| cursorImagePathAlt: '/static/icons/cat_claw2_cursor.png', | ||
| cursorHotspotX: 39, | ||
| cursorHotspotY: 46, | ||
| }, | ||
| { | ||
| id: 'hammer', | ||
| labelKey: 'chat.toolHammer', | ||
| labelFallback: '锤子', | ||
| iconImagePath: '/static/icons/chat_hammer1.png', | ||
| iconImagePathAlt: '/static/icons/chat_hammer2.png', | ||
| cursorImagePath: '/static/icons/chat_hammer1_cursor.png', | ||
| cursorImagePathAlt: '/static/icons/chat_hammer2_cursor.png', | ||
| menuIconScale: 1.52, | ||
| menuIconOffsetX: -8, | ||
| menuIconOffsetY: 4, | ||
| menuIconOffsetXAlt: 1, | ||
| menuIconOffsetYAlt: -1, | ||
| cursorHotspotX: 50, | ||
| cursorHotspotY: 54, | ||
| }, | ||
| ]; | ||
|
|
||
| const AVAILABLE_AVATAR_TOOL_IDS = new Set<AvatarToolId>(AVAILABLE_AVATAR_TOOLS.map(item => item.id)); | ||
|
|
||
| export function isAvatarToolId(value: unknown): value is AvatarToolId { | ||
| return typeof value === 'string' && AVAILABLE_AVATAR_TOOL_IDS.has(value as AvatarToolId); | ||
| } | ||
|
|
||
| export function sanitizeAvatarToolIds(value: unknown): AvatarToolId[] { | ||
| if (!Array.isArray(value)) { | ||
| return [...DEFAULT_ACTIVE_AVATAR_TOOL_IDS]; | ||
| } | ||
|
|
||
| const next: AvatarToolId[] = []; | ||
| value.forEach((candidate) => { | ||
| if (!isAvatarToolId(candidate)) return; | ||
| if (next.includes(candidate)) return; | ||
| if (next.length >= MAX_ACTIVE_AVATAR_TOOLS) return; | ||
| next.push(candidate); | ||
| }); | ||
| return next; | ||
| } | ||
|
|
||
| export function readPersistedActiveAvatarToolIds(): AvatarToolId[] { | ||
| if (typeof window === 'undefined') { | ||
| return [...DEFAULT_ACTIVE_AVATAR_TOOL_IDS]; | ||
| } | ||
|
|
||
| try { | ||
| const rawValue = window.localStorage?.getItem(ACTIVE_AVATAR_TOOLS_STORAGE_KEY); | ||
| if (rawValue === null || typeof rawValue === 'undefined') { | ||
| return [...DEFAULT_ACTIVE_AVATAR_TOOL_IDS]; | ||
| } | ||
| return sanitizeAvatarToolIds(JSON.parse(rawValue)); | ||
| } catch { | ||
| return [...DEFAULT_ACTIVE_AVATAR_TOOL_IDS]; | ||
| } | ||
| } | ||
|
|
||
| export function persistActiveAvatarToolIds(ids: AvatarToolId[]) { | ||
| if (typeof window === 'undefined') return; | ||
| try { | ||
| window.localStorage?.setItem( | ||
| ACTIVE_AVATAR_TOOLS_STORAGE_KEY, | ||
| JSON.stringify(sanitizeAvatarToolIds(ids)), | ||
| ); | ||
| } catch { | ||
| // Keep in-memory state when localStorage is unavailable. | ||
| } | ||
| } | ||
|
|
||
| export function resolveAvatarToolImagePaths(item: AvatarToolItem, variant: CursorVariant) { | ||
| return { | ||
| iconImagePath: variant === 'tertiary' && item.iconImagePathAlt2 | ||
| ? item.iconImagePathAlt2 | ||
| : variant === 'secondary' && item.iconImagePathAlt | ||
| ? item.iconImagePathAlt | ||
| : item.iconImagePath, | ||
| cursorImagePath: variant === 'tertiary' && item.cursorImagePathAlt2 | ||
| ? item.cursorImagePathAlt2 | ||
| : variant === 'secondary' && item.cursorImagePathAlt | ||
| ? item.cursorImagePathAlt | ||
| : variant === 'tertiary' && item.cursorImagePathAlt | ||
| ? item.cursorImagePathAlt | ||
| : item.cursorImagePath, | ||
| }; | ||
| } | ||
|
|
||
| export function resolveAvatarToolMenuIconVisual(item: AvatarToolItem, variant: CursorVariant) { | ||
| const imagePath = variant === 'tertiary' && item.iconImagePathAlt2 | ||
| ? item.iconImagePathAlt2 | ||
| : variant === 'secondary' && item.iconImagePathAlt | ||
| ? item.iconImagePathAlt | ||
| : item.iconImagePath; | ||
| const offsetX = variant === 'tertiary' | ||
| ? (item.menuIconOffsetXAlt2 ?? item.menuIconOffsetXAlt ?? item.menuIconOffsetX ?? 0) | ||
| : variant === 'secondary' | ||
| ? (item.menuIconOffsetXAlt ?? item.menuIconOffsetX ?? 0) | ||
| : (item.menuIconOffsetX ?? 0); | ||
| const offsetY = variant === 'tertiary' | ||
| ? (item.menuIconOffsetYAlt2 ?? item.menuIconOffsetYAlt ?? item.menuIconOffsetY ?? 0) | ||
| : variant === 'secondary' | ||
| ? (item.menuIconOffsetYAlt ?? item.menuIconOffsetY ?? 0) | ||
| : (item.menuIconOffsetY ?? 0); | ||
|
|
||
| return { | ||
| imagePath, | ||
| offsetX, | ||
| offsetY, | ||
| }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the Electron compact/minimize-ball host,
static/app-react-chat-window.jsbuilds native/hit rects for the tool fan from.compact-input-tool-itemand the old.composer-icon-popover .composer-icon-buttonselector. This new quickbar is absolutely positioned outside the fan’s own rect but uses only.avatar-tool-quickbar, so the quick tool and edit buttons no longer contribute native hit regions; in that compact desktop context, the visible controls outside the base fan can be missed by click-through/hit testing unless the host geometry selector is updated or the quickbar keeps a collected selector.Useful? React with 👍 / 👎.