Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/src/components/sender.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ TrSender.mention(mentions, '@')
TrSender.suggestion(suggestions) // 不过滤
TrSender.suggestion(suggestions, { filterFn: customFilter }) // 自定义过滤
TrSender.template(templates)
TrSender.template(templates, { appendTo: '.chat-window' })

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

// 标准配置
TrSender.Template.configure({ items: templates })
TrSender.Template.configure({ items: templates, appendTo: '.chat-window' })
```

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

#### Mention

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const Template = Extension.create<TemplateOptions>({
name: EXTENSION_NAMES.TEMPLATE,

addExtensions() {
return [TemplateBlock.configure(this.options), TemplateSelect]
return [TemplateBlock.configure(this.options), TemplateSelect.configure(this.options)]
},

// 添加命令(统一命令入口)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@

import { Node, mergeAttributes } from '@tiptap/core'
import { VueNodeViewRenderer } from '@tiptap/vue-3'
import type { TemplateSelectAttrs } from '../types'
import type { TemplateOptions, TemplateSelectAttrs } from '../types'
import TemplateSelectView from './template-select-view.vue'
import { selectDropdownStatePlugin, selectZeroWidthPlugin, selectKeyboardPlugin } from './plugins'
import { NODE_TYPE_NAMES } from '../../constants'

/**
* TemplateSelect 节点定义
*/
export const TemplateSelect = Node.create<Record<string, unknown>>({
export const TemplateSelect = Node.create<TemplateOptions>({
name: NODE_TYPE_NAMES.TEMPLATE_SELECT,

// 节点配置
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom'
import { IconArrowDown } from '@opentiny/tiny-robot-svgs'
import { TemplateSelectDropdownPluginKey } from './plugins'
import type { SelectOption } from '../types'
import type { SelectOption, TemplateOptions } from '../types'
import { closeAllDropdowns, setupClickOutside } from './dropdown-manager'
import { useTeleportTarget } from '../../../../shared/composables'

interface NodeAttrs {
id: string
Expand All @@ -21,6 +22,9 @@ interface Props {
}
updateAttributes: (attrs: Record<string, unknown>) => void
editor: Editor
extension: {
options: Pick<TemplateOptions, 'appendTo'>
}
Comment thread
gene9831 marked this conversation as resolved.
}

const props = defineProps<Props>()
Expand All @@ -32,6 +36,11 @@ const triggerRef = ref<HTMLElement>()
const dropdownRef = ref<HTMLElement>()
let cleanupClickOutside: (() => void) | null = null
let cleanupAutoUpdate: (() => void) | null = null
const teleportTarget = useTeleportTarget(triggerRef, props.extension.options.appendTo, { fallback: 'body' })
const isCustomTeleportTarget = computed(() => {
const target = teleportTarget.value
return target instanceof HTMLElement && target !== document.body
})

// 计算属性
const selectedOption = computed(() => {
Expand Down Expand Up @@ -136,7 +145,8 @@ const updatePosition = () => {

computePosition(triggerRef.value, dropdownRef.value, {
placement: 'bottom-start',
strategy: 'fixed', // 使用 fixed 定位策略,相对于视口
// 自定义挂载目标使用 absolute 定位
strategy: isCustomTeleportTarget.value ? 'absolute' : 'fixed', // 使用 fixed 定位策略,相对于视口
middleware: [offset(4), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
if (dropdownRef.value) {
Expand Down Expand Up @@ -252,8 +262,13 @@ onUnmounted(() => {
</span>
<span contenteditable="false" class="template-select__suffix">&#8203;</span>

<Teleport to="body">
<div v-if="showDropdown" ref="dropdownRef" class="template-select__dropdown">
<Teleport :to="teleportTarget">
Comment thread
gene9831 marked this conversation as resolved.
<div
v-if="showDropdown"
ref="dropdownRef"
class="template-select__dropdown"
:class="{ 'is-absolute': isCustomTeleportTarget }"
>
<div
v-for="(option, index) in node.attrs.options"
:key="option.value"
Expand Down Expand Up @@ -338,6 +353,10 @@ onUnmounted(() => {
box-shadow: var(--tr-sender-template-select-dropdown-shadow);
padding: 6px;

&.is-absolute {
position: absolute;
}

&::-webkit-scrollbar {
width: 8px;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/components/src/sender/extensions/template/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ export interface TemplateOptions {
* HTML 属性
*/
HTMLAttributes?: Record<string, unknown>

/**
* 下拉菜单挂载目标
*/
appendTo?: string | HTMLElement
}

// ===== 模块扩展声明 =====
Expand Down
19 changes: 14 additions & 5 deletions packages/components/src/shared/composables/useTeleportTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { computed } from 'vue'
* - 否则在 document.body 内查找 target(如果 target 为 'body',直接返回 document.body)。
* - 如果查找失败,则返回查找的根节点(ShadowRoot 或 body)。
*/
export function useTeleportTarget(reference?: MaybeElementRef, target?: string | HTMLElement) {
export function useTeleportTarget(
reference?: MaybeElementRef,
target?: string | HTMLElement,
options: { fallback?: 'root' | 'body' } = {},
) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return computed(() => {
if (target instanceof HTMLElement) {
return target
Expand All @@ -22,17 +26,22 @@ export function useTeleportTarget(reference?: MaybeElementRef, target?: string |
const rootNode = referentceEl?.getRootNode?.()
const inShadowDOM = rootNode instanceof ShadowRoot
const searchRoot = inShadowDOM ? rootNode : document.body
const fallbackTarget = options.fallback === 'body' ? document.body : searchRoot

if (selector) {
if (!inShadowDOM && selector === 'body') {
if (selector === 'body' && (!inShadowDOM || options.fallback === 'body')) {
// 特殊处理,直接返回 <body>
return document.body
}

const found = searchRoot.querySelector(selector)
if (found instanceof Node) return found
try {
const found = searchRoot.querySelector(selector)
if (found instanceof Node) return found
} catch {
return fallbackTarget
}
}

return searchRoot
return fallbackTarget
})
}
23 changes: 23 additions & 0 deletions packages/test/src/sender/helpers/template-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,29 @@ export function createTemplateTestHelper(page: Page) {
await expect(this.getTemplates()).toHaveCount(3)
},

/**
* 设置模板选择器
*/
async setSelectTemplate() {
await page.click(selectors.setTemplateSelectBtn)
await expect(page.locator(selectors.templateSelect)).toBeVisible()
},

/**
* 获取模板选择器
*/
getTemplateSelect() {
return page.locator(selectors.templateSelect)
},

/**
* 打开模板选择器
*/
async openTemplateSelect() {
await this.getTemplateSelect().locator(selectors.templateSelectTrigger).click()
await expect(page.locator(selectors.templateSelectDropdown)).toBeVisible()
},

/**
* 清空模板
*/
Expand Down
43 changes: 41 additions & 2 deletions packages/test/src/sender/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ declare global {
__senderTestApi?: {
moveCursorBeforeTemplate: (index: number) => boolean
pressDeleteBeforeTemplate: (index: number) => boolean
setTemplateAppendTo: (value: string | undefined) => void
}
}
}
Expand All @@ -40,6 +41,7 @@ const enableMention = ref(false)
const enableTemplate = ref(false)
const enableSuggestion = ref(false)
const templateData = ref<TemplateItem[]>([])
const templateAppendTo = ref<string | undefined>()
const attachmentsSourceMounted = ref(false)
const senderAttachmentItems = ref<Attachment[]>([
{
Expand All @@ -54,7 +56,7 @@ const mode = computed(() => (isMultipleMode.value ? 'multiple' : 'single'))
const size = computed(() => (isSmallSize.value ? 'small' : 'normal'))

const componentKey = computed(() => {
return `${enableMention.value}-${enableTemplate.value}-${enableSuggestion.value}`
return `${enableMention.value}-${enableTemplate.value}-${enableSuggestion.value}-${templateAppendTo.value || 'body'}`
})

const handleModeChange = () => {
Expand Down Expand Up @@ -154,6 +156,31 @@ const clearTemplate = () => {
result.value = '已清空模板'
}

const setTemplateSelect = () => {
templateData.value = [
{ type: 'text', content: '模型:' },
{
type: 'select',
content: '',
placeholder: '请选择模型',
options: [
{ label: 'GPT-4', value: 'gpt-4' },
{ label: 'DeepSeek', value: 'deepseek' },
],
},
]
result.value = '已设置模板选择器'
}

const toggleTemplateAppendTo = () => {
templateAppendTo.value = templateAppendTo.value ? undefined : '#template-select-teleport-target'
result.value = templateAppendTo.value ? '模板选择器挂载到自定义容器' : '模板选择器挂载到 body'
}

const setTemplateAppendTo = (value: string | undefined) => {
templateAppendTo.value = value
}

const mentions = ref<MentionItem[]>([
{
label: '小小画家',
Expand Down Expand Up @@ -188,7 +215,7 @@ const extensions = computed(() => {
exts.push(Sender.mention(mentions))
}
if (enableTemplate.value) {
exts.push(Sender.template(templateData))
exts.push(Sender.template(templateData, { appendTo: templateAppendTo.value }))
}
if (enableSuggestion.value) {
exts.push(Sender.suggestion(suggestions))
Expand Down Expand Up @@ -241,6 +268,7 @@ onMounted(() => {
window.__senderTestApi = {
moveCursorBeforeTemplate,
pressDeleteBeforeTemplate,
setTemplateAppendTo,
}
})

Expand Down Expand Up @@ -331,6 +359,10 @@ onBeforeUnmount(() => {
<label>template:</label>
<tiny-switch data-testid="toggle-template-btn" v-model="enableTemplate"></tiny-switch>
</div>
<div class="control-item">
<label>template appendTo:</label>
<button data-testid="toggle-template-append-to-btn" @click="toggleTemplateAppendTo">切换</button>
</div>
<div class="control-item">
<label>suggestion:</label>
<tiny-switch data-testid="toggle-suggestion-btn" v-model="enableSuggestion"></tiny-switch>
Expand All @@ -356,6 +388,7 @@ onBeforeUnmount(() => {
<button data-testid="set-template-simple-btn" @click="setTemplateSimple">简单模板</button>
<button data-testid="set-template-empty-btn" @click="setTemplateEmpty">空模板块</button>
<button data-testid="set-template-multiple-btn" @click="setTemplateMultiple">多模板块</button>
<button data-testid="set-template-select-btn" @click="setTemplateSelect">模板选择器</button>
<button data-testid="clear-template-btn" @click="clearTemplate">清空模板</button>
</div>
</fieldset>
Expand Down Expand Up @@ -396,6 +429,8 @@ onBeforeUnmount(() => {
<button data-testid="custom-footer-btn" @click="handleCustomAction">自定义按钮</button>
</template>
</Sender>

<div id="template-select-teleport-target" data-testid="template-select-teleport-target"></div>
</div>
</template>

Expand Down Expand Up @@ -443,4 +478,8 @@ onBeforeUnmount(() => {
margin: 0;
word-break: break-all;
}

#template-select-teleport-target {
position: relative;
}
</style>
7 changes: 7 additions & 0 deletions packages/test/src/sender/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const SENDER_SELECTORS = {

toggleMentionBtn: '[data-testid="toggle-mention-btn"]',
toggleTemplateBtn: '[data-testid="toggle-template-btn"]',
toggleTemplateAppendToBtn: '[data-testid="toggle-template-append-to-btn"]',
toggleSuggestionBtn: '[data-testid="toggle-suggestion-btn"]',

sender: '[data-testid="test-sender"]',
Expand Down Expand Up @@ -55,8 +56,14 @@ export const SENDER_SELECTORS = {
setTemplateSimpleBtn: '[data-testid="set-template-simple-btn"]',
setTemplateEmptyBtn: '[data-testid="set-template-empty-btn"]',
setTemplateMultipleBtn: '[data-testid="set-template-multiple-btn"]',
setTemplateSelectBtn: '[data-testid="set-template-select-btn"]',
clearTemplateBtn: '[data-testid="clear-template-btn"]',

templateSelect: '.template-select',
templateSelectTrigger: '.template-select__trigger',
templateSelectDropdown: '.template-select__dropdown',
templateSelectOption: '.template-select__option',

suggestionList: '.suggestion-list',
suggestionItem: '.suggestion-list__item',
suggestionHighlighted: '.suggestion-list__item.highlighted',
Expand Down
Loading
Loading