feat(web): expand slash command menu
This commit is contained in:
parent
39328cf6a9
commit
50310a1bf8
@ -54,30 +54,43 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<transition
|
||||||
v-if="skillSlashMenuOpen"
|
name="skill-slash-menu-motion"
|
||||||
ref="skillSlashList"
|
@before-enter="handleSlashMenuTransitionStart"
|
||||||
class="skill-slash-menu"
|
@before-leave="handleSlashMenuTransitionStart"
|
||||||
role="listbox"
|
@after-enter="handleSlashMenuAfterEnter"
|
||||||
aria-label="可用 Skills"
|
@after-leave="handleSlashMenuAfterLeave"
|
||||||
>
|
>
|
||||||
<button
|
<div
|
||||||
v-for="(skill, index) in filteredSkills"
|
v-if="skillSlashMenuOpen"
|
||||||
:key="skill.path"
|
:key="slashMenuMode"
|
||||||
type="button"
|
ref="skillSlashList"
|
||||||
class="skill-slash-item"
|
class="skill-slash-menu"
|
||||||
:class="{ 'skill-slash-item--active': index === skillSlashActiveIndex }"
|
role="listbox"
|
||||||
role="option"
|
:aria-label="slashMenuAriaLabel"
|
||||||
:aria-selected="index === skillSlashActiveIndex"
|
|
||||||
@mousedown.prevent="selectSkillSlashItem(index)"
|
|
||||||
>
|
>
|
||||||
<span class="skill-slash-item__name">{{ skill.name }}</span>
|
<button
|
||||||
<span class="skill-slash-item__description">{{ skill.description }}</span>
|
v-for="(item, index) in activeSlashMenuItems"
|
||||||
</button>
|
:key="item.id"
|
||||||
<div v-if="!filteredSkills.length" class="skill-slash-empty">
|
type="button"
|
||||||
没有匹配的 skill
|
class="skill-slash-item"
|
||||||
|
:class="{
|
||||||
|
'skill-slash-item--active': index === skillSlashActiveIndex,
|
||||||
|
'skill-slash-item--disabled': item.disabled
|
||||||
|
}"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="index === skillSlashActiveIndex"
|
||||||
|
:disabled="item.disabled"
|
||||||
|
@mousedown.prevent="selectSlashMenuItem(index)"
|
||||||
|
>
|
||||||
|
<span class="skill-slash-item__name">{{ item.label }}</span>
|
||||||
|
<span class="skill-slash-item__description">{{ item.description }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="!activeSlashMenuItems.length" class="skill-slash-empty">
|
||||||
|
{{ slashMenuEmptyText }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</transition>
|
||||||
<div
|
<div
|
||||||
class="stadium-shell"
|
class="stadium-shell"
|
||||||
ref="compactInputShell"
|
ref="compactInputShell"
|
||||||
@ -428,6 +441,16 @@ const stadiumInput = ref<HTMLTextAreaElement | null>(null);
|
|||||||
const fileUploadInput = ref<HTMLInputElement | null>(null);
|
const fileUploadInput = ref<HTMLInputElement | null>(null);
|
||||||
const baselineComposerVisualHeight = ref<number>(0);
|
const baselineComposerVisualHeight = ref<number>(0);
|
||||||
type SkillItem = { name: string; description: string; path: string };
|
type SkillItem = { name: string; description: string; path: string };
|
||||||
|
type SlashMenuMode = 'root' | 'skills' | 'theme';
|
||||||
|
type SlashMenuItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
action?: () => void;
|
||||||
|
mode?: SlashMenuMode;
|
||||||
|
skill?: SkillItem;
|
||||||
|
};
|
||||||
const availableSkills = ref<SkillItem[]>([]);
|
const availableSkills = ref<SkillItem[]>([]);
|
||||||
const selectedSkillRefs = ref<SkillItem[]>([]);
|
const selectedSkillRefs = ref<SkillItem[]>([]);
|
||||||
const skillsLoaded = ref(false);
|
const skillsLoaded = ref(false);
|
||||||
@ -436,6 +459,9 @@ const skillSlashOpen = ref(false);
|
|||||||
const skillSlashQuery = ref('');
|
const skillSlashQuery = ref('');
|
||||||
const skillSlashActiveIndex = ref(0);
|
const skillSlashActiveIndex = ref(0);
|
||||||
const skillSlashList = ref<HTMLElement | null>(null);
|
const skillSlashList = ref<HTMLElement | null>(null);
|
||||||
|
const slashMenuMode = ref<SlashMenuMode>('root');
|
||||||
|
const slashMenuParentIndex = ref(0);
|
||||||
|
const slashMenuTransitioning = ref(false);
|
||||||
let composerResizeObserver: ResizeObserver | null = null;
|
let composerResizeObserver: ResizeObserver | null = null;
|
||||||
let syncingEditorFromProps = false;
|
let syncingEditorFromProps = false;
|
||||||
|
|
||||||
@ -476,7 +502,7 @@ const findSlashToken = () => {
|
|||||||
const { $from } = state.selection;
|
const { $from } = state.selection;
|
||||||
const lineStart = $from.start();
|
const lineStart = $from.start();
|
||||||
const beforeInLine = $from.parent.textBetween(0, $from.parentOffset, '\n', '\n');
|
const beforeInLine = $from.parent.textBetween(0, $from.parentOffset, '\n', '\n');
|
||||||
const match = /(^|[ \n])\/([A-Za-z0-9_-]*)$/.exec(beforeInLine);
|
const match = /(^|[ \n])\/([^\s/]*)$/.exec(beforeInLine);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const query = match[2] || '';
|
const query = match[2] || '';
|
||||||
const start = $from.pos - query.length - 1;
|
const start = $from.pos - query.length - 1;
|
||||||
@ -488,6 +514,30 @@ const findSlashToken = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeSlashMenu = () => {
|
||||||
|
skillSlashOpen.value = false;
|
||||||
|
skillSlashQuery.value = '';
|
||||||
|
skillSlashActiveIndex.value = 0;
|
||||||
|
slashMenuMode.value = 'root';
|
||||||
|
slashMenuParentIndex.value = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSlashToken = () => {
|
||||||
|
const token = findSlashToken();
|
||||||
|
const instance = editor.value;
|
||||||
|
if (!token || !instance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { state, view } = instance;
|
||||||
|
let tr = state.tr.delete(token.start, token.end);
|
||||||
|
tr = tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(0, token.start))));
|
||||||
|
view.dispatch(tr);
|
||||||
|
view.focus();
|
||||||
|
nextTick(() => {
|
||||||
|
adjustTextareaSize();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const scrollSkillSlashSelectionIntoMiddle = () => {
|
const scrollSkillSlashSelectionIntoMiddle = () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const list = skillSlashList.value;
|
const list = skillSlashList.value;
|
||||||
@ -510,7 +560,186 @@ const filteredSkills = computed(() => {
|
|||||||
return filtered.slice(0, 100);
|
return filtered.slice(0, 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
const skillSlashMenuOpen = computed(() => skillSlashOpen.value && (skillsLoading.value || filteredSkills.value.length >= 0));
|
const currentModelSupportsImage = computed(() => {
|
||||||
|
const found = props.modelOptions?.find((m) => m.key === props.currentModelKey) as any;
|
||||||
|
return !!found?.supportsImage;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentModelSupportsVideo = computed(() => {
|
||||||
|
const found = props.modelOptions?.find((m) => m.key === props.currentModelKey) as any;
|
||||||
|
return !!found?.supportsVideo;
|
||||||
|
});
|
||||||
|
|
||||||
|
const applySlashTheme = async (theme: 'classic' | 'light' | 'dark') => {
|
||||||
|
personalizationStore.applyTheme(theme);
|
||||||
|
personalizationStore.updateField({ key: 'theme', value: theme });
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/personalization', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ theme })
|
||||||
|
});
|
||||||
|
const result = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok || !result.success) {
|
||||||
|
console.warn('保存主题到后端失败:', result?.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('保存主题到后端失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeSlashAction = (action: () => void) => {
|
||||||
|
deleteSlashToken();
|
||||||
|
closeSlashMenu();
|
||||||
|
action();
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootSlashMenuItems = computed<SlashMenuItem[]>(() => {
|
||||||
|
const items: SlashMenuItem[] = [
|
||||||
|
{
|
||||||
|
id: 'upload',
|
||||||
|
label: '上传文件',
|
||||||
|
description: props.uploading ? '上传中...' : '选择本地文件上传',
|
||||||
|
disabled: !props.isConnected || props.uploading || props.blockUpload,
|
||||||
|
action: () => emit('quick-upload')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'image',
|
||||||
|
label: '发送图片',
|
||||||
|
description: currentModelSupportsImage.value ? '选择图片并附加到消息' : '当前模型不支持图片',
|
||||||
|
disabled: !props.isConnected || props.streamingMessage || !currentModelSupportsImage.value,
|
||||||
|
action: () => emit('pick-images')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'video',
|
||||||
|
label: '发送视频',
|
||||||
|
description: currentModelSupportsVideo.value ? '选择视频并附加到消息' : '当前模型不支持视频',
|
||||||
|
disabled: !props.isConnected || props.streamingMessage || !currentModelSupportsVideo.value,
|
||||||
|
action: () => emit('pick-video')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'compress',
|
||||||
|
label: '压缩对话',
|
||||||
|
description: props.compressing ? '压缩中...' : '手动压缩当前对话上下文',
|
||||||
|
disabled:
|
||||||
|
!props.isConnected ||
|
||||||
|
props.compressing ||
|
||||||
|
props.streamingMessage ||
|
||||||
|
props.blockCompressConversation,
|
||||||
|
action: () => emit('compress-conversation')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'skills',
|
||||||
|
label: '选择 AgentSkill',
|
||||||
|
description: '插入一个 AgentSkill 引用',
|
||||||
|
mode: 'skills'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theme',
|
||||||
|
label: '切换主题',
|
||||||
|
description: '经典 / 明亮 / 夜间',
|
||||||
|
mode: 'theme'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'review',
|
||||||
|
label: '对话回顾',
|
||||||
|
description: '打开当前对话回顾',
|
||||||
|
disabled: !props.isConnected || props.streamingMessage || props.blockConversationReview,
|
||||||
|
action: () => emit('open-review')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tokens',
|
||||||
|
label: '用量统计',
|
||||||
|
description: '打开上下文与 token 用量面板',
|
||||||
|
disabled: !props.currentConversationId || props.blockTokenPanel,
|
||||||
|
action: () => emit('toggle-token-panel', true)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'goal',
|
||||||
|
label: '目标模式',
|
||||||
|
description: props.goalRunning ? '目标模式运行中' : props.goalModeArmed ? '目标模式已就绪' : '切换目标模式',
|
||||||
|
disabled: !props.isConnected,
|
||||||
|
action: () => emit('toggle-goal-mode')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'personalization',
|
||||||
|
label: '个人设置',
|
||||||
|
description: '打开个人空间设置',
|
||||||
|
action: () => {
|
||||||
|
void personalizationStore.openDrawer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const query = skillSlashQuery.value.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
return items.filter((item) => {
|
||||||
|
const haystack = `${item.label} ${item.description} ${item.id}`.toLowerCase();
|
||||||
|
return haystack.includes(query);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const themeSlashMenuItems = computed<SlashMenuItem[]>(() => {
|
||||||
|
const themes: Array<SlashMenuItem & { theme: 'classic' | 'light' | 'dark' }> = [
|
||||||
|
{
|
||||||
|
id: 'theme-classic',
|
||||||
|
label: '经典',
|
||||||
|
description: '米色质感,柔和高对比',
|
||||||
|
theme: 'classic'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theme-light',
|
||||||
|
label: '明亮',
|
||||||
|
description: '纯白底色 + 优雅灰,简洁清爽',
|
||||||
|
theme: 'light'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theme-dark',
|
||||||
|
label: '夜间',
|
||||||
|
description: '深灰 + 黑,低亮度显示',
|
||||||
|
theme: 'dark'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return themes.map((item) => ({
|
||||||
|
...item,
|
||||||
|
action: () => {
|
||||||
|
void applySlashTheme(item.theme);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const skillSlashMenuItems = computed<SlashMenuItem[]>(() =>
|
||||||
|
filteredSkills.value.map((skill) => ({
|
||||||
|
id: `skill:${skill.path}`,
|
||||||
|
label: skill.name,
|
||||||
|
description: skill.description,
|
||||||
|
skill
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeSlashMenuItems = computed<SlashMenuItem[]>(() => {
|
||||||
|
if (slashMenuMode.value === 'skills') {
|
||||||
|
return skillSlashMenuItems.value;
|
||||||
|
}
|
||||||
|
if (slashMenuMode.value === 'theme') {
|
||||||
|
return themeSlashMenuItems.value;
|
||||||
|
}
|
||||||
|
return rootSlashMenuItems.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const slashMenuAriaLabel = computed(() => {
|
||||||
|
if (slashMenuMode.value === 'skills') return '可用 AgentSkills';
|
||||||
|
if (slashMenuMode.value === 'theme') return '主题选项';
|
||||||
|
return '快捷指令';
|
||||||
|
});
|
||||||
|
|
||||||
|
const slashMenuEmptyText = computed(() => {
|
||||||
|
if (slashMenuMode.value === 'skills') return skillsLoading.value ? '正在加载 skills...' : '没有匹配的 skill';
|
||||||
|
return '没有匹配的指令';
|
||||||
|
});
|
||||||
|
|
||||||
|
const skillSlashMenuOpen = computed(() => skillSlashOpen.value && (skillsLoading.value || activeSlashMenuItems.value.length >= 0));
|
||||||
|
|
||||||
const loadSkills = async () => {
|
const loadSkills = async () => {
|
||||||
if (skillsLoaded.value || skillsLoading.value) {
|
if (skillsLoaded.value || skillsLoading.value) {
|
||||||
@ -545,9 +774,7 @@ const loadSkills = async () => {
|
|||||||
const refreshSkillSlashState = () => {
|
const refreshSkillSlashState = () => {
|
||||||
const token = findSlashToken();
|
const token = findSlashToken();
|
||||||
if (!token || !props.isConnected || props.inputLocked) {
|
if (!token || !props.isConnected || props.inputLocked) {
|
||||||
skillSlashOpen.value = false;
|
closeSlashMenu();
|
||||||
skillSlashQuery.value = '';
|
|
||||||
skillSlashActiveIndex.value = 0;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
skillSlashOpen.value = true;
|
skillSlashOpen.value = true;
|
||||||
@ -555,9 +782,11 @@ const refreshSkillSlashState = () => {
|
|||||||
skillSlashActiveIndex.value = 0;
|
skillSlashActiveIndex.value = 0;
|
||||||
}
|
}
|
||||||
skillSlashQuery.value = token.query;
|
skillSlashQuery.value = token.query;
|
||||||
skillSlashActiveIndex.value = Math.min(skillSlashActiveIndex.value, Math.max(0, filteredSkills.value.length - 1));
|
if (slashMenuMode.value === 'skills') {
|
||||||
|
void loadSkills();
|
||||||
|
}
|
||||||
|
skillSlashActiveIndex.value = Math.min(skillSlashActiveIndex.value, Math.max(0, activeSlashMenuItems.value.length - 1));
|
||||||
scrollSkillSlashSelectionIntoMiddle();
|
scrollSkillSlashSelectionIntoMiddle();
|
||||||
void loadSkills();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectSkillSlashItem = (index = skillSlashActiveIndex.value) => {
|
const selectSkillSlashItem = (index = skillSlashActiveIndex.value) => {
|
||||||
@ -590,14 +819,37 @@ const selectSkillSlashItem = (index = skillSlashActiveIndex.value) => {
|
|||||||
tr = tr.setSelection(TextSelection.near(tr.doc.resolve(afterMention + 1)));
|
tr = tr.setSelection(TextSelection.near(tr.doc.resolve(afterMention + 1)));
|
||||||
view.dispatch(tr);
|
view.dispatch(tr);
|
||||||
view.focus();
|
view.focus();
|
||||||
skillSlashOpen.value = false;
|
closeSlashMenu();
|
||||||
skillSlashQuery.value = '';
|
|
||||||
skillSlashActiveIndex.value = 0;
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
adjustTextareaSize();
|
adjustTextareaSize();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectSlashMenuItem = (index = skillSlashActiveIndex.value) => {
|
||||||
|
const item = activeSlashMenuItems.value[index];
|
||||||
|
if (!item || item.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.mode) {
|
||||||
|
slashMenuParentIndex.value = index;
|
||||||
|
slashMenuMode.value = item.mode;
|
||||||
|
skillSlashActiveIndex.value = 0;
|
||||||
|
if (item.mode === 'skills') {
|
||||||
|
void loadSkills();
|
||||||
|
}
|
||||||
|
scrollSkillSlashSelectionIntoMiddle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.skill) {
|
||||||
|
const skillIndex = filteredSkills.value.findIndex((skill) => skill.path === item.skill?.path);
|
||||||
|
selectSkillSlashItem(skillIndex >= 0 ? skillIndex : index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.action) {
|
||||||
|
executeSlashAction(item.action);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSlashMenuKeydown = (event: KeyboardEvent): boolean => {
|
const handleSlashMenuKeydown = (event: KeyboardEvent): boolean => {
|
||||||
if (event.ctrlKey && event.key === 'Enter') {
|
if (event.ctrlKey && event.key === 'Enter') {
|
||||||
return false;
|
return false;
|
||||||
@ -605,7 +857,7 @@ const handleSlashMenuKeydown = (event: KeyboardEvent): boolean => {
|
|||||||
if (skillSlashOpen.value) {
|
if (skillSlashOpen.value) {
|
||||||
if (event.key === 'ArrowDown') {
|
if (event.key === 'ArrowDown') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const count = filteredSkills.value.length;
|
const count = activeSlashMenuItems.value.length;
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
skillSlashActiveIndex.value = (skillSlashActiveIndex.value + 1) % count;
|
skillSlashActiveIndex.value = (skillSlashActiveIndex.value + 1) % count;
|
||||||
scrollSkillSlashSelectionIntoMiddle();
|
scrollSkillSlashSelectionIntoMiddle();
|
||||||
@ -614,7 +866,7 @@ const handleSlashMenuKeydown = (event: KeyboardEvent): boolean => {
|
|||||||
}
|
}
|
||||||
if (event.key === 'ArrowUp') {
|
if (event.key === 'ArrowUp') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const count = filteredSkills.value.length;
|
const count = activeSlashMenuItems.value.length;
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
skillSlashActiveIndex.value = (skillSlashActiveIndex.value - 1 + count) % count;
|
skillSlashActiveIndex.value = (skillSlashActiveIndex.value - 1 + count) % count;
|
||||||
scrollSkillSlashSelectionIntoMiddle();
|
scrollSkillSlashSelectionIntoMiddle();
|
||||||
@ -622,16 +874,25 @@ const handleSlashMenuKeydown = (event: KeyboardEvent): boolean => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
const count = filteredSkills.value.length;
|
const count = activeSlashMenuItems.value.length;
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
selectSkillSlashItem(skillSlashActiveIndex.value);
|
selectSlashMenuItem(skillSlashActiveIndex.value);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
skillSlashOpen.value = false;
|
if (slashMenuMode.value !== 'root') {
|
||||||
|
slashMenuMode.value = 'root';
|
||||||
|
skillSlashActiveIndex.value = Math.min(
|
||||||
|
slashMenuParentIndex.value,
|
||||||
|
Math.max(0, activeSlashMenuItems.value.length - 1)
|
||||||
|
);
|
||||||
|
scrollSkillSlashSelectionIntoMiddle();
|
||||||
|
} else {
|
||||||
|
closeSlashMenu();
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -649,7 +910,7 @@ const onKeydown = (event: KeyboardEvent): boolean => {
|
|||||||
const onInputBlur = () => {
|
const onInputBlur = () => {
|
||||||
emit('input-blur');
|
emit('input-blur');
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
skillSlashOpen.value = false;
|
closeSlashMenu();
|
||||||
}, 120);
|
}, 120);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -772,7 +1033,9 @@ const runtimeQueuePrevElements = new Map<string, HTMLElement>();
|
|||||||
const runtimeQueueGhosts = new Map<string, HTMLElement>();
|
const runtimeQueueGhosts = new Map<string, HTMLElement>();
|
||||||
const runtimeQueueActiveAnimations = new WeakMap<HTMLElement, Animation>();
|
const runtimeQueueActiveAnimations = new WeakMap<HTMLElement, Animation>();
|
||||||
const runtimeQueueFallbackTimers = new WeakMap<HTMLElement, number>();
|
const runtimeQueueFallbackTimers = new WeakMap<HTMLElement, number>();
|
||||||
|
const runtimeQueueTransitioning = ref(false);
|
||||||
let runtimeQueueAnimationReady = false;
|
let runtimeQueueAnimationReady = false;
|
||||||
|
let runtimeQueueTransitionTimer: number | null = null;
|
||||||
|
|
||||||
const bindRuntimeQueueItemRef = (id: string) => (el: Element | null) => {
|
const bindRuntimeQueueItemRef = (id: string) => (el: Element | null) => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@ -805,6 +1068,18 @@ const stopRuntimeQueueAnimation = (el: HTMLElement) => {
|
|||||||
el.style.removeProperty('pointer-events');
|
el.style.removeProperty('pointer-events');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const keepRuntimeQueueTransitionCollapsed = () => {
|
||||||
|
runtimeQueueTransitioning.value = true;
|
||||||
|
if (runtimeQueueTransitionTimer !== null) {
|
||||||
|
window.clearTimeout(runtimeQueueTransitionTimer);
|
||||||
|
}
|
||||||
|
runtimeQueueTransitionTimer = window.setTimeout(() => {
|
||||||
|
runtimeQueueTransitioning.value = false;
|
||||||
|
runtimeQueueTransitionTimer = null;
|
||||||
|
emitComposerHeight();
|
||||||
|
}, RUNTIME_QUEUE_ANIM_DURATION + 40);
|
||||||
|
};
|
||||||
|
|
||||||
const playRuntimeQueueTransform = (
|
const playRuntimeQueueTransform = (
|
||||||
el: HTMLElement,
|
el: HTMLElement,
|
||||||
fromY: number,
|
fromY: number,
|
||||||
@ -818,6 +1093,7 @@ const playRuntimeQueueTransform = (
|
|||||||
done?.();
|
done?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
keepRuntimeQueueTransitionCollapsed();
|
||||||
stopRuntimeQueueAnimation(el);
|
stopRuntimeQueueAnimation(el);
|
||||||
const from = `translateY(${fromY}px)`;
|
const from = `translateY(${fromY}px)`;
|
||||||
const to = `translateY(${toY}px)`;
|
const to = `translateY(${toY}px)`;
|
||||||
@ -1034,10 +1310,28 @@ const hasRuntimeLayoutExpansion = computed(() => {
|
|||||||
|
|
||||||
const goalCompleted = computed(() => String(props.goalProgress?.status || '').toLowerCase() === 'done');
|
const goalCompleted = computed(() => String(props.goalProgress?.status || '').toLowerCase() === 'done');
|
||||||
|
|
||||||
|
const handleSlashMenuTransitionStart = () => {
|
||||||
|
slashMenuTransitioning.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSlashMenuAfterEnter = () => {
|
||||||
|
slashMenuTransitioning.value = false;
|
||||||
|
emitComposerHeight();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSlashMenuAfterLeave = () => {
|
||||||
|
slashMenuTransitioning.value = false;
|
||||||
|
emitComposerHeight();
|
||||||
|
};
|
||||||
|
|
||||||
// 当消息队列非空或 skill 选择菜单展开时,把目标横幅收起为仅圆点,
|
// 当消息队列非空或 skill 选择菜单展开时,把目标横幅收起为仅圆点,
|
||||||
// 让队列/菜单紧贴输入框,不再被横幅顶高。队列清空且无 skill 选择时自动恢复。
|
// 让队列/菜单紧贴输入框,不再被横幅顶高。队列清空且无 skill 选择时自动恢复。
|
||||||
const goalBannerCollapsed = computed(
|
const goalBannerCollapsed = computed(
|
||||||
() => runtimeQueuedMessagesForRender.value.length > 0 || skillSlashMenuOpen.value
|
() =>
|
||||||
|
runtimeQueuedMessagesForRender.value.length > 0 ||
|
||||||
|
runtimeQueueTransitioning.value ||
|
||||||
|
skillSlashMenuOpen.value ||
|
||||||
|
slashMenuTransitioning.value
|
||||||
);
|
);
|
||||||
|
|
||||||
const goalBannerTitle = computed(() => {
|
const goalBannerTitle = computed(() => {
|
||||||
@ -1342,6 +1636,11 @@ onBeforeUnmount(() => {
|
|||||||
runtimeQueuePrevRects.clear();
|
runtimeQueuePrevRects.clear();
|
||||||
runtimeQueuePrevElements.clear();
|
runtimeQueuePrevElements.clear();
|
||||||
runtimeQueueAnimationReady = false;
|
runtimeQueueAnimationReady = false;
|
||||||
|
if (runtimeQueueTransitionTimer !== null) {
|
||||||
|
window.clearTimeout(runtimeQueueTransitionTimer);
|
||||||
|
runtimeQueueTransitionTimer = null;
|
||||||
|
}
|
||||||
|
runtimeQueueTransitioning.value = false;
|
||||||
if (composerResizeObserver) {
|
if (composerResizeObserver) {
|
||||||
composerResizeObserver.disconnect();
|
composerResizeObserver.disconnect();
|
||||||
composerResizeObserver = null;
|
composerResizeObserver = null;
|
||||||
|
|||||||
@ -28,7 +28,7 @@
|
|||||||
--runtime-queue-bg: var(--theme-surface-soft);
|
--runtime-queue-bg: var(--theme-surface-soft);
|
||||||
--runtime-queue-divider: rgba(15, 23, 42, 0.12);
|
--runtime-queue-divider: rgba(15, 23, 42, 0.12);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: calc(50% - 7px);
|
||||||
bottom: calc(100% - 2px);
|
bottom: calc(100% - 2px);
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@ -125,9 +125,11 @@
|
|||||||
.skill-slash-menu {
|
.skill-slash-menu {
|
||||||
--skill-slash-row-height: 38px;
|
--skill-slash-row-height: 38px;
|
||||||
--skill-slash-gap: 3px;
|
--skill-slash-gap: 3px;
|
||||||
|
--skill-slash-motion-duration: 300ms;
|
||||||
|
--skill-slash-motion-easing: cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||||
--skill-slash-visible-rows: 5;
|
--skill-slash-visible-rows: 5;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: calc(50% - 7px);
|
||||||
bottom: calc(100% - 2px);
|
bottom: calc(100% - 2px);
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@ -152,6 +154,27 @@
|
|||||||
gap: var(--skill-slash-gap);
|
gap: var(--skill-slash-gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skill-slash-menu-motion-enter-active,
|
||||||
|
.skill-slash-menu-motion-leave-active {
|
||||||
|
transition:
|
||||||
|
transform var(--skill-slash-motion-duration, 300ms) var(--skill-slash-motion-easing, cubic-bezier(0.25, 0.8, 0.25, 1)),
|
||||||
|
clip-path var(--skill-slash-motion-duration, 300ms) var(--skill-slash-motion-easing, cubic-bezier(0.25, 0.8, 0.25, 1));
|
||||||
|
transform-origin: bottom center;
|
||||||
|
will-change: transform, clip-path;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-slash-menu-motion-enter-from,
|
||||||
|
.skill-slash-menu-motion-leave-to {
|
||||||
|
transform: translateX(-50%) translateY(100%);
|
||||||
|
clip-path: inset(0 0 100% 0 round 12px 12px 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-slash-menu-motion-enter-to,
|
||||||
|
.skill-slash-menu-motion-leave-from {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
clip-path: inset(0 0 0 0 round 12px 12px 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
.skill-slash-menu::-webkit-scrollbar {
|
.skill-slash-menu::-webkit-scrollbar {
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
@ -180,6 +203,18 @@
|
|||||||
box-shadow: 0 1px 2px rgba(61, 57, 41, 0.08);
|
box-shadow: 0 1px 2px rgba(61, 57, 41, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skill-slash-item--disabled,
|
||||||
|
.skill-slash-item:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-slash-item--disabled:hover,
|
||||||
|
.skill-slash-item:disabled:hover {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.skill-slash-item__name {
|
.skill-slash-item__name {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
max-width: 210px;
|
max-width: 210px;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user