Skip to content
Merged
9 changes: 8 additions & 1 deletion docs/demos/sender/input-prefix-placeholder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import { TrSender } from '@opentiny/tiny-robot'

const showInputPrefix = shallowRef(true)
const longPlaceholder =
'这是一段用于观察多行模式换行效果的超长占位文本,输入区域宽度不足时会自动折行,同时可以切换首行前置插槽观察缩进变化。'.repeat(
'这是一段用于观察多行模式换行效果的超长占位文本123123,thisisaintelligentaiframework. 输入区域宽度不足时会自动折行,同时可以切换首行前置插槽观察缩进变化。'.repeat(
2,
)
</script>

<template>
<div class="demo-container">
<p class="demo-description">观察多行 placeholder 中中英文、数字混排时的自动换行;切换前置插槽可查看首行缩进。</p>
<button class="toggle-button" type="button" @click="showInputPrefix = !showInputPrefix">
{{ showInputPrefix ? '移除首行前置插槽' : '添加首行前置插槽' }}
</button>
Expand All @@ -28,6 +29,12 @@ const longPlaceholder =
padding: 20px;
}

.demo-description {
margin: 0 0 12px;
color: #666;
font-size: 14px;
}

.toggle-button {
margin-bottom: 12px;
padding: 5px 12px;
Expand Down
4 changes: 2 additions & 2 deletions docs/src/components/sender.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ Sender 提供了多个插槽位置,方便扩展功能:
`header` 内容为空时不显示顶部留白和分隔线。条件显示时,请在插槽声明或直接子节点上使用 `v-if`;使用 `v-show` 或仅在子组件内部隐藏内容仍可能占位。

:::info 当前插槽作用域
当前版本只有 `content` 插槽提供 `editor`。`actions-inline`、`footer` 和 `footer-right` 只负责放置内容,不提供作用域参数;需要操作输入内容时,请通过 Sender 实例公开的方法接入。
`content` 插槽提供 `editor`。`actions-inline` 在单行和多行模式下均提供 `SenderSlotScope`,包含 `editor`、`focus`、`blur`、`insert`、`append`、`replace`、`disabled`、`loading` 和 `hasContent`;其中 `insert(text)` 在当前光标位置插入文本并补一个空格,然后聚焦编辑器。`footer` 和 `footer-right` 不提供作用域参数,需要操作输入内容时,请通过 Sender 实例公开的方法接入。
:::

<demo vue="../../demos/sender/custom-slots.vue" title="自定义插槽" description="在插槽区域添加自定义按钮,如深度思考、网络搜索等功能。" />
Expand Down Expand Up @@ -494,7 +494,7 @@ Sender Actions 的基础图标按钮,也可以独立用于自定义操作区
| `prefix` | 在编辑器左侧添加内容 | — |
| input-prefix | 多行模式首行前置插槽 | - |
| `content` | 完全替换默认编辑器内容 | `{ editor: unknown }` |
| `actions-inline` | 在单行模式的默认操作按钮前添加内容 | — |
| `actions-inline` | 在默认操作按钮前添加内容,多行模式下移至底部右侧 | `SenderSlotScope` |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| `footer` | 在多行模式底部左侧添加内容 | — |
| `footer-right` | 在多行模式底部默认操作按钮前添加内容 | — |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ watch([editor, mode], () => nextTick(syncPlaceholder), { immediate: true })
pointer-events: none;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-all;
}

.tr-sender-editor-content {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ defineSlots<{
prefix?: () => unknown
'input-prefix'?: () => unknown
content?: (props: { editor: unknown }) => unknown
'actions-inline'?: (scope: SenderSlotScope) => unknown
footer?: (scope: SenderSlotScope) => unknown
'footer-right'?: (scope: SenderSlotScope) => unknown
}>()
Expand Down Expand Up @@ -50,8 +51,9 @@ defineSlots<{
<template #footer>
<slot name="footer" v-bind="slotScope" />
</template>
<template #footer-right>
<slot name="footer-right" v-bind="slotScope" />
<template #footer-right="slotProps">
<slot name="actions-inline" v-bind="slotProps" />
<slot name="footer-right" v-bind="slotProps" />
</template>
</Footer>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ defineSlots<{
.tr-sender-container {
display: flex;
align-items: center;
min-width: 0;
}

.tr-sender-main {
Expand Down
39 changes: 39 additions & 0 deletions packages/components/src/sender/composables/useEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { AnyExtension } from '@tiptap/core'
import type { SenderEmits, UseEditorReturn } from '../index.type'
import type { SenderPropsWithDefaults } from '../index.vue'
import { countGraphemes } from '../utils/countGraphemes'
import { ImeGuard } from '../extensions/ime'

/**
* 编辑器 Hook
Expand All @@ -27,6 +28,9 @@ import { countGraphemes } from '../utils/countGraphemes'
*/
export function useEditor(props: SenderPropsWithDefaults, emit: SenderEmits): UseEditorReturn {
const editorRef = ref<HTMLElement | null>(null)
const isComposing = ref(false)
const hasPendingModelValue = ref(false)
let pendingModelTimer: ReturnType<typeof setTimeout> | undefined

// 将 placeholder 转换为响应式引用
const placeholderRef = toRef(props, 'placeholder')
Expand All @@ -42,6 +46,7 @@ export function useEditor(props: SenderPropsWithDefaults, emit: SenderEmits): Us
Paragraph,
Text,
History, // 提供 undo/redo 功能
ImeGuard,
Placeholder.configure({
placeholder: () => placeholderRef.value || '请输入内容...',
}),
Expand All @@ -67,6 +72,34 @@ export function useEditor(props: SenderPropsWithDefaults, emit: SenderEmits): Us
class: 'tr-sender-editor',
enterkeyhint: props.enterkeyhint,
},
handleDOMEvents: {
compositionstart: () => {
clearTimeout(pendingModelTimer)
pendingModelTimer = undefined
isComposing.value = true
return false
},
compositionend: () => {
isComposing.value = false
if (hasPendingModelValue.value) {
clearTimeout(pendingModelTimer)
// 等待 ProseMirror 同步最终输入及父组件回传模型。
pendingModelTimer = setTimeout(() => {
pendingModelTimer = undefined
const currentEditor = editor.value
if (!currentEditor || currentEditor.isDestroyed) return
if (isComposing.value || currentEditor.view.composing) return

hasPendingModelValue.value = false
const externalValue = props.modelValue ?? ''
if (externalValue !== currentEditor.getText()) {
currentEditor.commands.setContent(externalValue, { emitUpdate: false })
}
}, 0)
}
return false
},
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 处理粘贴事件 - 只粘贴纯文本
handlePaste(view, event) {
const text = event.clipboardData?.getData('text/plain')
Expand Down Expand Up @@ -105,6 +138,11 @@ export function useEditor(props: SenderPropsWithDefaults, emit: SenderEmits): Us
watch(
() => props.modelValue,
(newValue) => {
if (isComposing.value || editor.value?.view.composing || pendingModelTimer !== undefined) {
hasPendingModelValue.value = true
return
}

if (editor.value && newValue !== editor.value.getText()) {
editor.value.commands.setContent(newValue ?? '', { emitUpdate: false })
}
Expand Down Expand Up @@ -140,6 +178,7 @@ export function useEditor(props: SenderPropsWithDefaults, emit: SenderEmits): Us

// 清理
onBeforeUnmount(() => {
clearTimeout(pendingModelTimer)
editor.value?.destroy()
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import { isKey } from '../extensions/utils'
import { isImeKeyEvent } from '../extensions/ime'
import type { UseKeyboardShortcutsParams, UseKeyboardShortcutsReturn } from '../index.type'

/**
Expand All @@ -29,6 +30,7 @@ export function useKeyboardShortcuts(params: UseKeyboardShortcutsParams): UseKey
* - 当 submitType 为 'shiftEnter' 时:按 Shift+Enter 提交,单独按 Enter 换行
*/
const checkSubmitShortcut = (event: KeyboardEvent): boolean => {
if (isImeKeyEvent(event)) return false
if (!isKey(event, 'ENTER')) return false

switch (submitType.value) {
Expand Down Expand Up @@ -58,6 +60,7 @@ export function useKeyboardShortcuts(params: UseKeyboardShortcutsParams): UseKey
* - submitType 为 'shiftEnter' 时:Enter(不带修饰键)
*/
const checkNewlineShortcut = (event: KeyboardEvent): boolean => {
if (isImeKeyEvent(event)) return false
if (!isKey(event, 'ENTER')) return false

switch (submitType.value) {
Expand Down
63 changes: 36 additions & 27 deletions packages/components/src/sender/composables/useModeSwitch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
* 支持单行/多行模式的自动和手动切换
*/

import { ref, watch, nextTick, computed, type Ref } from 'vue'
import { useResizeObserver, useTimeoutFn } from '@vueuse/core'
import { ref, watch, nextTick, computed, onScopeDispose, type Ref } from 'vue'
import { useEventListener, useResizeObserver } from '@vueuse/core'
import type { Editor } from '@tiptap/vue-3'
import type { SenderProps, UseModeSwitchReturn, InputMode } from '../index.type'

Expand All @@ -14,16 +14,10 @@ export function useModeSwitch(
editorRef: Ref<HTMLElement | null>,
): UseModeSwitchReturn {
const currentMode = ref<InputMode>(props.mode || 'single')
const isAutoSwitching = ref(false)
const initialMode = ref<InputMode>(props.mode || 'single')

const { start: startAutoSwitchingTimeout, stop: stopAutoSwitchingTimeout } = useTimeoutFn(
() => {
isAutoSwitching.value = false
},
300,
{ immediate: false },
)
const isAutoSwitching = ref(false)
let overflowFrame = 0
let disposed = false

// 获取容器元素
const containerRef = computed(() => {
Expand All @@ -36,25 +30,27 @@ export function useModeSwitch(
*/
const checkOverflow = () => {
if (initialMode.value !== 'single') return
if (isAutoSwitching.value) return
if (!editor.value || !editorRef.value) return
// 组合期间重建布局会使浏览器丢失输入法维护的文本范围。
if (editor.value.view.composing) return

const editorElement = editorRef.value.querySelector('.ProseMirror') as HTMLElement
if (!editorElement) return

const text = editor.value.getText()

if (currentMode.value === 'single') {
// 单行模式:检查是否溢出
// scrollWidth > clientWidth 表示内容超出可见区域
const isOverflowing = editorElement.scrollWidth > editorElement.clientWidth
// 空段落的 scrollWidth 包含占位文本,不能据此切换布局。
if (editor.value.isEmpty) return
// 测量实际段落,避免父级 overflow:hidden 和省略号影响 scrollWidth。
const contentElement = editorElement.querySelector('p') as HTMLElement | null
const contentWidth = contentElement?.scrollWidth ?? editorElement.scrollWidth
const isOverflowing = contentWidth > editorElement.clientWidth + 1

if (isOverflowing) {
setMode('multiple')
}
} else {
// 多行模式:清空文本后切换回单行
if (!text.length) {
if (editor.value.isEmpty) {
setMode('single')
}
}
Expand All @@ -71,22 +67,35 @@ export function useModeSwitch(
currentMode.value = mode

nextTick(() => {
if (editor.value) {
if (disposed) return
if (editor.value && !editor.value.isDestroyed) {
editor.value.commands.focus('end')
}

stopAutoSwitchingTimeout()
startAutoSwitchingTimeout()
isAutoSwitching.value = false
scheduleOverflowCheck()
})
}

const scheduleOverflowCheck = () => {
if (disposed) return
cancelAnimationFrame(overflowFrame)
overflowFrame = requestAnimationFrame(() => checkOverflow())
}

// 即使确认或取消组合输入没有改变文档,也要在原生事件和 DOM 更新后重新测量。
useEventListener(() => editor.value?.view.dom, 'compositionend', scheduleOverflowCheck)

onScopeDispose(() => {
disposed = true
cancelAnimationFrame(overflowFrame)
})

useResizeObserver(containerRef, () => {
// 使用 requestAnimationFrame 避免频繁触发
requestAnimationFrame(() => {
checkOverflow()
})
scheduleOverflowCheck()
})

watch(editor, scheduleOverflowCheck, { flush: 'post' })

watch(
() => props.mode,
(newMode) => {
Expand All @@ -101,6 +110,6 @@ export function useModeSwitch(
currentMode,
isAutoSwitching,
setMode,
checkOverflow,
checkOverflow: scheduleOverflowCheck,
}
}
16 changes: 7 additions & 9 deletions packages/components/src/sender/composables/useSenderCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
getTextWithMentions,
} from '../extensions'
import { EXTENSION_NAMES } from '../extensions/constants'
import { isImeKeyEvent } from '../extensions/ime'
import { SENDER_CONTEXT_KEY, type SenderContext } from '../types/context'
import { setupSenderContentRegistration } from '../../shared/composables'
import { useEditor } from './useEditor'
Expand Down Expand Up @@ -180,6 +181,11 @@ export function useSenderCore(props: SenderPropsWithDefaults, emit: SenderEmits)
editorProps: {
...editorInstance.options.editorProps,
handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
// 组合输入期间交给 ProseMirror/输入法处理,避免误触发提交或换行
if (isImeKeyEvent(event, view)) {
return false
}

// 0. 检查插件状态 - 如果建议面板激活或下拉菜单打开,不拦截键盘事件
const mentionState = MentionPluginKey.getState(view.state)
const suggestionState = SuggestionPluginKey.getState(view.state)
Expand Down Expand Up @@ -235,15 +241,7 @@ export function useSenderCore(props: SenderPropsWithDefaults, emit: SenderEmits)
useAutoSize(currentMode, editorRef, props.autoSize)

// 监听编辑器内容变化,检查是否需要切换模式
watch(
() => editor.value?.state.doc.content,
() => {
setTimeout(() => {
checkOverflow()
}, 0)
},
{ deep: true },
)
watch(() => editor.value?.state.doc.content, checkOverflow, { deep: true })

// ========================================
// 8. 定义其他方法
Expand Down
49 changes: 49 additions & 0 deletions packages/components/src/sender/extensions/ime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import type { Transaction } from '@tiptap/pm/state'
import type { EditorProps, EditorView } from '@tiptap/pm/view'

export function isImeKeyEvent(event: KeyboardEvent, view?: EditorView): boolean {
return Boolean(view?.composing || event.isComposing || event.keyCode === 229)
}

export function hasCompositionTransaction(transactions: readonly Transaction[]): boolean {
return transactions.some((tr) => tr.getMeta('composition') !== undefined)
}

export const ImeGuard = Extension.create({
name: 'senderImeGuard',
priority: 1000,

addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('senderImeGuard'),
props: {
handleDOMEvents: {
// 只跳过编辑器快捷键,保留输入法的原生按键行为。
keydown: (view, event) => isImeKeyEvent(event, view),
},
},
view(view) {
const originalSomeProp = view.someProp
const someProp = originalSomeProp.bind(view)
// 组合期间的合成 Backspace 仍会进入快捷键链,空事务会破坏输入法维护的文本范围。
view.someProp = (<PropName extends keyof EditorProps, Result>(
propName: PropName,
callback?: (value: NonNullable<EditorProps[PropName]>) => Result,
) => {
if (propName === 'handleKeyDown' && callback && view.composing) return undefined
return callback ? someProp(propName, callback) : someProp(propName)
}) as typeof view.someProp

return {
destroy() {
view.someProp = originalSomeProp
},
}
},
}),
]
},
})
Loading
Loading