feat(web): customize composer toolbar visibility (#1298)

This commit is contained in:
Ananovo
2026-08-02 13:14:00 +08:00
committed by GitHub
parent 74ec247b92
commit 68299631f9
5 changed files with 95 additions and 15 deletions
@@ -37,10 +37,10 @@ export function ComposerToolbarLayoutControl() {
const moveDraggedItem = (group: ComposerToolbarGroup, index: number) => {
if (draggedItem) {
const next = layout.mode === 'split'
const next = group === 'hidden' || layout.hidden.includes(draggedItem) || layout.mode === 'split'
? moveComposerToolbarItem(layout, draggedItem, group, index)
: moveComposerToolbarItemInSingleLayout(layout, draggedItem, index)
const unchanged = next.left.join() === layout.left.join() && next.right.join() === layout.right.join()
const unchanged = next.left.join() === layout.left.join() && next.right.join() === layout.right.join() && next.hidden.join() === layout.hidden.join()
if (!unchanged) {
setLayout(next)
}
@@ -51,9 +51,10 @@ export function ComposerToolbarLayoutControl() {
event.preventDefault()
const item = draggedItem ?? event.dataTransfer.getData('text/plain')
if ((COMPOSER_TOOLBAR_ITEM_IDS as readonly string[]).includes(item)) {
const next = layout.mode === 'split'
? moveComposerToolbarItem(layout, item as ComposerToolbarItemId, group, index)
: moveComposerToolbarItemInSingleLayout(layout, item as ComposerToolbarItemId, index)
const typedItem = item as ComposerToolbarItemId
const next = group === 'hidden' || layout.hidden.includes(typedItem) || layout.mode === 'split'
? moveComposerToolbarItem(layout, typedItem, group, index)
: moveComposerToolbarItemInSingleLayout(layout, typedItem, index)
setLayout(next)
}
setDraggedItem(null)
@@ -61,7 +62,7 @@ export function ComposerToolbarLayoutControl() {
const moveItemByOffset = (item: ComposerToolbarItemId, group: ComposerToolbarGroup, index: number, offset: -1 | 1) => {
const targetIndex = Math.max(0, index + offset)
const next = layout.mode === 'split'
const next = group === 'hidden' || layout.mode === 'split'
? moveComposerToolbarItem(layout, item, group, targetIndex)
: moveComposerToolbarItemInSingleLayout(layout, item, targetIndex)
setLayout(next)
@@ -127,8 +128,10 @@ export function ComposerToolbarLayoutControl() {
const singleAlignment = layout.mode === 'center' ? 'justify-center' : layout.mode === 'right' ? 'justify-end' : 'justify-start'
const singleItems = [...layout.left, ...layout.right]
const selectedGroup: ComposerToolbarGroup = selectedItem && layout.right.includes(selectedItem) ? 'right' : 'left'
const selectedItems = layout.mode === 'split' ? layout[selectedGroup] : singleItems
const selectedGroup: ComposerToolbarGroup = selectedItem && layout.hidden.includes(selectedItem)
? 'hidden'
: selectedItem && layout.right.includes(selectedItem) ? 'right' : 'left'
const selectedItems = selectedGroup === 'hidden' ? layout.hidden : layout.mode === 'split' ? layout[selectedGroup] : singleItems
const selectedIndex = selectedItem ? selectedItems.indexOf(selectedItem) : -1
return (
@@ -173,6 +176,22 @@ export function ComposerToolbarLayoutControl() {
<span className="ml-1" title={t('composer.send')}><ComposerSendButtonPreview /></span>
</div>
</div>
<div className="mx-3 mt-3">
<h4 className="text-sm font-medium text-[var(--app-fg)]">{t('settings.chat.composerToolbar.hidden')}</h4>
<div className="mt-2 rounded-xl border border-dashed border-[var(--app-border)] bg-[var(--app-subtle-bg)] px-2 py-1.5">
{layout.hidden.length > 0
? renderGroup('hidden', layout.hidden, 'justify-start', true)
: (
<div
className="flex min-h-10 items-center justify-center text-xs text-[var(--app-hint)]"
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => onDrop(event, 'hidden', 0)}
>
{t('settings.chat.composerToolbar.emptyHidden')}
</div>
)}
</div>
</div>
{selectedItem && selectedIndex >= 0 ? (
<div className="mx-3 mt-2 flex items-center justify-between gap-2 rounded-lg bg-[var(--app-subtle-bg)] px-3 py-2 text-sm">
<span className="min-w-0 truncate text-[var(--app-hint)]">{t(ITEM_LABEL_KEYS[selectedItem])}</span>
@@ -197,7 +216,28 @@ export function ComposerToolbarLayoutControl() {
>
</button>
{layout.mode === 'split' ? (
{selectedGroup !== 'hidden' ? (
<button
type="button"
aria-label={t('settings.chat.composerToolbar.hide')}
title={t('settings.chat.composerToolbar.hide')}
onClick={() => setLayout(moveComposerToolbarItem(layout, selectedItem, 'hidden', layout.hidden.length))}
className="ml-1 rounded-lg px-2.5 py-1.5 text-xs text-[var(--app-link)] hover:bg-[var(--app-bg)]"
>
{t('settings.chat.composerToolbar.hide')}
</button>
) : (
<button
type="button"
aria-label={t('settings.chat.composerToolbar.show')}
title={t('settings.chat.composerToolbar.show')}
onClick={() => setLayout(moveComposerToolbarItem(layout, selectedItem, 'left', layout.left.length))}
className="ml-1 rounded-lg px-2.5 py-1.5 text-xs text-[var(--app-link)] hover:bg-[var(--app-bg)]"
>
{t('settings.chat.composerToolbar.show')}
</button>
)}
{layout.mode === 'split' && selectedGroup !== 'hidden' ? (
<button
type="button"
aria-label={selectedGroup === 'left' ? t('settings.chat.composerToolbar.moveRight') : t('settings.chat.composerToolbar.moveLeft')}
+26 -1
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
DEFAULT_COMPOSER_TOOLBAR_LAYOUT,
moveComposerToolbarItem,
moveComposerToolbarItemInSingleLayout,
normalizeComposerToolbarLayout,
} from './useComposerToolbarLayout'
@@ -19,6 +20,7 @@ describe('DEFAULT_COMPOSER_TOOLBAR_LAYOUT', () => {
'schedule',
'abort',
])
expect(DEFAULT_COMPOSER_TOOLBAR_LAYOUT.hidden).toEqual([])
})
})
@@ -39,7 +41,30 @@ describe('normalizeComposerToolbarLayout', () => {
expect(result.mode).toBe('split')
expect(result.left.slice(0, 2)).toEqual(['settings', 'attachment'])
expect(result.right).toEqual(['abort', 'schedule'])
expect([...result.left, ...result.right]).toHaveLength(DEFAULT_COMPOSER_TOOLBAR_LAYOUT.left.length)
expect([...result.left, ...result.right, ...result.hidden]).toHaveLength(DEFAULT_COMPOSER_TOOLBAR_LAYOUT.left.length)
})
it('preserves hidden tools and keeps newly introduced tools visible', () => {
const result = normalizeComposerToolbarLayout({
mode: 'left',
left: ['attachment'],
right: [],
hidden: ['settings', 'abort', 'settings'],
})
expect(result.hidden).toEqual(['settings', 'abort'])
expect(result.left).toContain('schedule')
expect(result.left).not.toContain('settings')
})
it('moves tools between visible and hidden groups without duplication', () => {
const hidden = moveComposerToolbarItem(DEFAULT_COMPOSER_TOOLBAR_LAYOUT, 'terminal', 'hidden', 0)
expect(hidden.hidden).toEqual(['terminal'])
expect(hidden.left).not.toContain('terminal')
const visible = moveComposerToolbarItem(hidden, 'terminal', 'right', 0)
expect(visible.hidden).toEqual([])
expect(visible.right).toEqual(['terminal'])
})
it('reorders across a hidden split boundary in single-column modes', () => {
+10 -3
View File
@@ -15,12 +15,13 @@ export const COMPOSER_TOOLBAR_ITEM_IDS = [
export type ComposerToolbarItemId = typeof COMPOSER_TOOLBAR_ITEM_IDS[number]
export type ComposerToolbarLayoutMode = 'left' | 'center' | 'right' | 'split'
export type ComposerToolbarGroup = 'left' | 'right'
export type ComposerToolbarGroup = 'left' | 'right' | 'hidden'
export type ComposerToolbarLayout = {
mode: ComposerToolbarLayoutMode
left: ComposerToolbarItemId[]
right: ComposerToolbarItemId[]
hidden: ComposerToolbarItemId[]
}
export const DEFAULT_COMPOSER_TOOLBAR_LAYOUT: ComposerToolbarLayout = {
@@ -30,6 +31,7 @@ export const DEFAULT_COMPOSER_TOOLBAR_LAYOUT: ComposerToolbarLayout = {
'abort',
],
right: [],
hidden: [],
}
export function moveComposerToolbarItem(
@@ -40,9 +42,10 @@ export function moveComposerToolbarItem(
): ComposerToolbarLayout {
const left = layout.left.filter((entry) => entry !== item)
const right = layout.right.filter((entry) => entry !== item)
const target = targetGroup === 'left' ? left : right
const hidden = layout.hidden.filter((entry) => entry !== item)
const target = targetGroup === 'left' ? left : targetGroup === 'right' ? right : hidden
target.splice(Math.max(0, Math.min(targetIndex, target.length)), 0, item)
return { ...layout, left, right }
return { ...layout, left, right, hidden }
}
export function moveComposerToolbarItemInSingleLayout(
@@ -52,11 +55,13 @@ export function moveComposerToolbarItemInSingleLayout(
): ComposerToolbarLayout {
const leftCount = layout.left.length
const items = [...layout.left, ...layout.right].filter((entry) => entry !== item)
const hidden = layout.hidden.filter((entry) => entry !== item)
items.splice(Math.max(0, Math.min(targetIndex, items.length)), 0, item)
return {
...layout,
left: items.slice(0, leftCount),
right: items.slice(leftCount),
hidden,
}
}
@@ -93,6 +98,7 @@ export function normalizeComposerToolbarLayout(value: unknown): ComposerToolbarL
const left = normalizeGroup(candidate.left)
const right = normalizeGroup(candidate.right)
const hidden = normalizeGroup(candidate.hidden)
for (const item of COMPOSER_TOOLBAR_ITEM_IDS) {
if (!seen.has(item)) {
left.push(item)
@@ -103,6 +109,7 @@ export function normalizeComposerToolbarLayout(value: unknown): ComposerToolbarL
mode: isLayoutMode(candidate.mode) ? candidate.mode : DEFAULT_COMPOSER_TOOLBAR_LAYOUT.mode,
left,
right,
hidden,
}
}
+5 -1
View File
@@ -725,12 +725,16 @@ export default {
'settings.chat.composerToolbar.description': 'Choose how toolbar actions are arranged below the message input.',
'settings.chat.composerToolbar.layout': 'Alignment',
'settings.chat.composerToolbar.order': 'Tool order',
'settings.chat.composerToolbar.hidden': 'Hidden tools',
'settings.chat.composerToolbar.emptyHidden': 'Drag tools here to hide them',
'settings.chat.composerToolbar.hide': 'Hide',
'settings.chat.composerToolbar.show': 'Show',
'settings.chat.composerToolbar.layout.left': 'Left',
'settings.chat.composerToolbar.layout.center': 'Center',
'settings.chat.composerToolbar.layout.right': 'Right',
'settings.chat.composerToolbar.layout.split': 'Split',
'settings.chat.composerToolbar.dragHint': 'Drag actions to reorder them. In split layout, drag between the two groups.',
'settings.chat.composerToolbar.previewHint': 'Drag the actual toolbar icons below. Changes are reflected in this preview immediately; hover an icon to see its action.',
'settings.chat.composerToolbar.previewHint': 'Drag the actual toolbar icons below to reorder them, or drag them to Hidden tools. Changes appear immediately; hover an icon to see its action.',
'settings.chat.composerToolbar.reset': 'Reset',
'settings.chat.composerToolbar.leftGroup': 'Left group',
'settings.chat.composerToolbar.rightGroup': 'Right group',
+5 -1
View File
@@ -729,12 +729,16 @@ export default {
'settings.chat.composerToolbar.description': '设置消息输入框底部工具的排列方式。',
'settings.chat.composerToolbar.layout': '对齐方式',
'settings.chat.composerToolbar.order': '工具顺序',
'settings.chat.composerToolbar.hidden': '隐藏工具',
'settings.chat.composerToolbar.emptyHidden': '将工具拖到这里隐藏',
'settings.chat.composerToolbar.hide': '隐藏',
'settings.chat.composerToolbar.show': '显示',
'settings.chat.composerToolbar.layout.left': '左对齐',
'settings.chat.composerToolbar.layout.center': '居中',
'settings.chat.composerToolbar.layout.right': '右对齐',
'settings.chat.composerToolbar.layout.split': '左右分组',
'settings.chat.composerToolbar.dragHint': '拖动操作项可调整顺序;左右分组布局下也可在两组之间拖动。',
'settings.chat.composerToolbar.previewHint': '直接拖动下方的实际工具图标预览会立即更新鼠标悬停可查看功能名称。',
'settings.chat.composerToolbar.previewHint': '直接拖动下方的实际工具图标调整顺序,或拖到隐藏工具;预览会立即更新鼠标悬停可查看功能名称。',
'settings.chat.composerToolbar.reset': '恢复默认',
'settings.chat.composerToolbar.leftGroup': '左侧分组',
'settings.chat.composerToolbar.rightGroup': '右侧分组',