Skip to content

Commit 8887880

Browse files
committed
feat(components): support appendTo for template select
1 parent e8440c0 commit 8887880

9 files changed

Lines changed: 153 additions & 9 deletions

File tree

docs/src/components/sender.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ TrSender.mention(mentions, '@')
7878
TrSender.suggestion(suggestions) // 不过滤
7979
TrSender.suggestion(suggestions, { filterFn: customFilter }) // 自定义过滤
8080
TrSender.template(templates)
81+
TrSender.template(templates, { appendTo: '.chat-window' })
8182

8283
// 标准配置(用于复杂场景)
8384
TrSender.Mention.configure({ items: mentions, char: '@', allowSpaces: false })
@@ -310,14 +311,16 @@ Sender 提供了多个插槽位置,方便扩展功能:
310311
```typescript
311312
// 便捷函数
312313
TrSender.template(templates)
314+
TrSender.template(templates, { appendTo: '.chat-window' })
313315

314316
// 标准配置
315-
TrSender.Template.configure({ items: templates })
317+
TrSender.Template.configure({ items: templates, appendTo: '.chat-window' })
316318
```
317319

318320
| 配置项 | 类型 | 说明 |
319321
| ------- | ----------------------------------------- | ------------ |
320322
| `items` | `TemplateItem[]` \| `Ref<TemplateItem[]>` | 模板数据列表 |
323+
| `appendTo` | `string` \| `HTMLElement` | Template Select 下拉菜单的挂载目标,默认挂载到 `body` |
321324

322325
#### Mention
323326

packages/components/src/sender/extensions/template/extension.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export const Template = Extension.create<TemplateOptions>({
1616
name: EXTENSION_NAMES.TEMPLATE,
1717

1818
addExtensions() {
19-
return [TemplateBlock.configure(this.options), TemplateSelect]
19+
return [TemplateBlock.configure(this.options), TemplateSelect.configure(this.options)]
2020
},
2121

2222
// 添加命令(统一命令入口)

packages/components/src/sender/extensions/template/select/extension.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44

55
import { Node, mergeAttributes } from '@tiptap/core'
66
import { VueNodeViewRenderer } from '@tiptap/vue-3'
7-
import type { TemplateSelectAttrs } from '../types'
7+
import type { TemplateOptions, TemplateSelectAttrs } from '../types'
88
import TemplateSelectView from './template-select-view.vue'
99
import { selectDropdownStatePlugin, selectZeroWidthPlugin, selectKeyboardPlugin } from './plugins'
1010
import { NODE_TYPE_NAMES } from '../../constants'
1111

1212
/**
1313
* TemplateSelect 节点定义
1414
*/
15-
export const TemplateSelect = Node.create<Record<string, unknown>>({
15+
export const TemplateSelect = Node.create<TemplateOptions>({
1616
name: NODE_TYPE_NAMES.TEMPLATE_SELECT,
1717

1818
// 节点配置

packages/components/src/sender/extensions/template/select/template-select-view.vue

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { IconArrowDown } from '@opentiny/tiny-robot-svgs'
77
import { TemplateSelectDropdownPluginKey } from './plugins'
88
import type { SelectOption } from '../types'
99
import { closeAllDropdowns, setupClickOutside } from './dropdown-manager'
10+
import { useTeleportTarget } from '../../../../shared/composables'
1011
1112
interface NodeAttrs {
1213
id: string
@@ -21,6 +22,11 @@ interface Props {
2122
}
2223
updateAttributes: (attrs: Record<string, unknown>) => void
2324
editor: Editor
25+
extension: {
26+
options: {
27+
appendTo?: string | HTMLElement
28+
}
29+
}
2430
}
2531
2632
const props = defineProps<Props>()
@@ -32,6 +38,11 @@ const triggerRef = ref<HTMLElement>()
3238
const dropdownRef = ref<HTMLElement>()
3339
let cleanupClickOutside: (() => void) | null = null
3440
let cleanupAutoUpdate: (() => void) | null = null
41+
const teleportTarget = useTeleportTarget(triggerRef, props.extension.options.appendTo)
42+
const isCustomTeleportTarget = computed(() => {
43+
const target = teleportTarget.value
44+
return target instanceof HTMLElement && target !== document.body
45+
})
3546
3647
// 计算属性
3748
const selectedOption = computed(() => {
@@ -136,7 +147,8 @@ const updatePosition = () => {
136147
137148
computePosition(triggerRef.value, dropdownRef.value, {
138149
placement: 'bottom-start',
139-
strategy: 'fixed', // 使用 fixed 定位策略,相对于视口
150+
// 自定义挂载目标使用 absolute 定位
151+
strategy: isCustomTeleportTarget.value ? 'absolute' : 'fixed', // 使用 fixed 定位策略,相对于视口
140152
middleware: [offset(4), flip(), shift({ padding: 8 })],
141153
}).then(({ x, y }) => {
142154
if (dropdownRef.value) {
@@ -252,8 +264,13 @@ onUnmounted(() => {
252264
</span>
253265
<span contenteditable="false" class="template-select__suffix">&#8203;</span>
254266

255-
<Teleport to="body">
256-
<div v-if="showDropdown" ref="dropdownRef" class="template-select__dropdown">
267+
<Teleport :to="teleportTarget">
268+
<div
269+
v-if="showDropdown"
270+
ref="dropdownRef"
271+
class="template-select__dropdown"
272+
:class="{ 'is-absolute': isCustomTeleportTarget }"
273+
>
257274
<div
258275
v-for="(option, index) in node.attrs.options"
259276
:key="option.value"
@@ -338,6 +355,10 @@ onUnmounted(() => {
338355
box-shadow: var(--tr-sender-template-select-dropdown-shadow);
339356
padding: 6px;
340357
358+
&.is-absolute {
359+
position: absolute;
360+
}
361+
341362
&::-webkit-scrollbar {
342363
width: 8px;
343364
}

packages/components/src/sender/extensions/template/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ export interface TemplateOptions {
8585
* HTML 属性
8686
*/
8787
HTMLAttributes?: Record<string, unknown>
88+
89+
/**
90+
* 下拉菜单挂载目标
91+
*/
92+
appendTo?: string | HTMLElement
8893
}
8994

9095
// ===== 模块扩展声明 =====

packages/test/src/sender/helpers/template-helper.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,29 @@ export function createTemplateTestHelper(page: Page) {
8080
await expect(this.getTemplates()).toHaveCount(3)
8181
},
8282

83+
/**
84+
* 设置模板选择器
85+
*/
86+
async setSelectTemplate() {
87+
await page.click(selectors.setTemplateSelectBtn)
88+
await expect(page.locator(selectors.templateSelect)).toBeVisible()
89+
},
90+
91+
/**
92+
* 获取模板选择器
93+
*/
94+
getTemplateSelect() {
95+
return page.locator(selectors.templateSelect)
96+
},
97+
98+
/**
99+
* 打开模板选择器
100+
*/
101+
async openTemplateSelect() {
102+
await this.getTemplateSelect().locator(selectors.templateSelectTrigger).click()
103+
await expect(page.locator(selectors.templateSelectDropdown)).toBeVisible()
104+
},
105+
83106
/**
84107
* 清空模板
85108
*/

packages/test/src/sender/index.vue

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const enableMention = ref(false)
4040
const enableTemplate = ref(false)
4141
const enableSuggestion = ref(false)
4242
const templateData = ref<TemplateItem[]>([])
43+
const templateAppendTo = ref<string | undefined>()
4344
const attachmentsSourceMounted = ref(false)
4445
const senderAttachmentItems = ref<Attachment[]>([
4546
{
@@ -54,7 +55,7 @@ const mode = computed(() => (isMultipleMode.value ? 'multiple' : 'single'))
5455
const size = computed(() => (isSmallSize.value ? 'small' : 'normal'))
5556
5657
const componentKey = computed(() => {
57-
return `${enableMention.value}-${enableTemplate.value}-${enableSuggestion.value}`
58+
return `${enableMention.value}-${enableTemplate.value}-${enableSuggestion.value}-${templateAppendTo.value || 'body'}`
5859
})
5960
6061
const handleModeChange = () => {
@@ -154,6 +155,27 @@ const clearTemplate = () => {
154155
result.value = '已清空模板'
155156
}
156157
158+
const setTemplateSelect = () => {
159+
templateData.value = [
160+
{ type: 'text', content: '模型:' },
161+
{
162+
type: 'select',
163+
content: '',
164+
placeholder: '请选择模型',
165+
options: [
166+
{ label: 'GPT-4', value: 'gpt-4' },
167+
{ label: 'DeepSeek', value: 'deepseek' },
168+
],
169+
},
170+
]
171+
result.value = '已设置模板选择器'
172+
}
173+
174+
const toggleTemplateAppendTo = () => {
175+
templateAppendTo.value = templateAppendTo.value ? undefined : '#template-select-teleport-target'
176+
result.value = templateAppendTo.value ? '模板选择器挂载到自定义容器' : '模板选择器挂载到 body'
177+
}
178+
157179
const mentions = ref<MentionItem[]>([
158180
{
159181
label: '小小画家',
@@ -188,7 +210,7 @@ const extensions = computed(() => {
188210
exts.push(Sender.mention(mentions))
189211
}
190212
if (enableTemplate.value) {
191-
exts.push(Sender.template(templateData))
213+
exts.push(Sender.template(templateData, { appendTo: templateAppendTo.value }))
192214
}
193215
if (enableSuggestion.value) {
194216
exts.push(Sender.suggestion(suggestions))
@@ -331,6 +353,10 @@ onBeforeUnmount(() => {
331353
<label>template:</label>
332354
<tiny-switch data-testid="toggle-template-btn" v-model="enableTemplate"></tiny-switch>
333355
</div>
356+
<div class="control-item">
357+
<label>template appendTo:</label>
358+
<button data-testid="toggle-template-append-to-btn" @click="toggleTemplateAppendTo">切换</button>
359+
</div>
334360
<div class="control-item">
335361
<label>suggestion:</label>
336362
<tiny-switch data-testid="toggle-suggestion-btn" v-model="enableSuggestion"></tiny-switch>
@@ -356,6 +382,7 @@ onBeforeUnmount(() => {
356382
<button data-testid="set-template-simple-btn" @click="setTemplateSimple">简单模板</button>
357383
<button data-testid="set-template-empty-btn" @click="setTemplateEmpty">空模板块</button>
358384
<button data-testid="set-template-multiple-btn" @click="setTemplateMultiple">多模板块</button>
385+
<button data-testid="set-template-select-btn" @click="setTemplateSelect">模板选择器</button>
359386
<button data-testid="clear-template-btn" @click="clearTemplate">清空模板</button>
360387
</div>
361388
</fieldset>
@@ -396,6 +423,8 @@ onBeforeUnmount(() => {
396423
<button data-testid="custom-footer-btn" @click="handleCustomAction">自定义按钮</button>
397424
</template>
398425
</Sender>
426+
427+
<div id="template-select-teleport-target" data-testid="template-select-teleport-target"></div>
399428
</div>
400429
</template>
401430

@@ -443,4 +472,8 @@ onBeforeUnmount(() => {
443472
margin: 0;
444473
word-break: break-all;
445474
}
475+
476+
#template-select-teleport-target {
477+
position: relative;
478+
}
446479
</style>

packages/test/src/sender/selectors.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const SENDER_SELECTORS = {
2525

2626
toggleMentionBtn: '[data-testid="toggle-mention-btn"]',
2727
toggleTemplateBtn: '[data-testid="toggle-template-btn"]',
28+
toggleTemplateAppendToBtn: '[data-testid="toggle-template-append-to-btn"]',
2829
toggleSuggestionBtn: '[data-testid="toggle-suggestion-btn"]',
2930

3031
sender: '[data-testid="test-sender"]',
@@ -55,8 +56,14 @@ export const SENDER_SELECTORS = {
5556
setTemplateSimpleBtn: '[data-testid="set-template-simple-btn"]',
5657
setTemplateEmptyBtn: '[data-testid="set-template-empty-btn"]',
5758
setTemplateMultipleBtn: '[data-testid="set-template-multiple-btn"]',
59+
setTemplateSelectBtn: '[data-testid="set-template-select-btn"]',
5860
clearTemplateBtn: '[data-testid="clear-template-btn"]',
5961

62+
templateSelect: '.template-select',
63+
templateSelectTrigger: '.template-select__trigger',
64+
templateSelectDropdown: '.template-select__dropdown',
65+
templateSelectOption: '.template-select__option',
66+
6067
suggestionList: '.suggestion-list',
6168
suggestionItem: '.suggestion-list__item',
6269
suggestionHighlighted: '.suggestion-list__item.highlighted',
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { test, expect, type Page } from '@playwright/test'
2+
import { createSenderTestHelper } from '../../helpers'
3+
import { createTemplateTestHelper } from '../../helpers/template-helper'
4+
5+
test.describe('Template Select - appendTo', () => {
6+
test.describe.configure({ mode: 'serial' })
7+
8+
let page: Page
9+
let helper: ReturnType<typeof createSenderTestHelper>
10+
let templateHelper: ReturnType<typeof createTemplateTestHelper>
11+
12+
test.beforeEach(async ({ page: currentPage }) => {
13+
page = currentPage
14+
await page.goto('/')
15+
await page.click('text=Sender 组件')
16+
helper = createSenderTestHelper(page)
17+
templateHelper = createTemplateTestHelper(page)
18+
19+
await helper.toggleTemplate()
20+
await helper.wait(300)
21+
await helper.clearContent()
22+
await templateHelper.clearTemplates()
23+
})
24+
25+
test('默认挂载到 body,并支持选择和关闭', async () => {
26+
await templateHelper.setSelectTemplate()
27+
await templateHelper.openTemplateSelect()
28+
29+
const dropdown = page.locator(templateHelper.selectors.templateSelectDropdown)
30+
await expect(dropdown.evaluate((element) => element.parentElement?.tagName)).resolves.toBe('BODY')
31+
32+
await dropdown.locator(templateHelper.selectors.templateSelectOption).first().click()
33+
await expect(templateHelper.getTemplateSelect()).toContainText('GPT-4')
34+
await expect(dropdown).toHaveCount(0)
35+
})
36+
37+
test('指定选择器后挂载到目标容器,并使用 absolute 定位', async () => {
38+
await page.click(templateHelper.selectors.toggleTemplateAppendToBtn)
39+
await helper.wait(300)
40+
await templateHelper.setSelectTemplate()
41+
await templateHelper.openTemplateSelect()
42+
43+
const dropdown = page.locator(templateHelper.selectors.templateSelectDropdown)
44+
await expect(dropdown).toHaveClass(/is-absolute/)
45+
await expect(dropdown.evaluate((element) => element.parentElement?.id)).resolves.toBe(
46+
'template-select-teleport-target',
47+
)
48+
49+
await page.keyboard.press('Escape')
50+
await expect(dropdown).toHaveCount(0)
51+
})
52+
})

0 commit comments

Comments
 (0)