Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
31 changes: 20 additions & 11 deletions packages/components/src/shared/composables/useTeleportTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,38 @@ 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
}

const selector = target

const referentceEl = unrefElement(reference)
const rootNode = referentceEl?.getRootNode?.()
const inShadowDOM = rootNode instanceof ShadowRoot
const searchRoot = inShadowDOM ? rootNode : document.body
const fallbackTarget = options.fallback === 'body' ? document.body : searchRoot

if (target instanceof HTMLElement) {
return target.isConnected ? target : fallbackTarget
}

const selector = target

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 | HTMLElement | 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 | HTMLElement | 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 | HTMLElement | 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