diff --git a/packages/components/package.json b/packages/components/package.json index d76b6a1d3..02c5678de 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -19,9 +19,19 @@ "peerDependencies": { "dompurify": "^3.3.1", "markdown-it": "^14.1.0", - "vue": "^3.3.11" + "vue": "^3.3.11", + "@tiptap/core": "^3.11.0", + "@tiptap/vue-3": "^3.11.0", + "@tiptap/pm": "^3.11.0", + "@tiptap/extension-document": "^3.11.0", + "@tiptap/extension-paragraph": "^3.11.0", + "@tiptap/extension-text": "^3.11.0", + "@tiptap/extension-history": "^3.11.0", + "@tiptap/extension-placeholder": "^3.11.0", + "@tiptap/extension-character-count": "^3.11.0" }, "dependencies": { + "@floating-ui/dom": "^1.6.0", "@opentiny/tiny-robot-svgs": "workspace:*", "@opentiny/vue": "^3.20.0", "@vueuse/core": "^13.1.0", diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 3234fa0a5..66844386a 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -13,13 +13,26 @@ import History from './history' import IconButton from './icon-button' import { Prompt, Prompts } from './prompts' import Sender from './sender' +import SenderCompat from './sender-compat' import SuggestionPills, { SuggestionPillButton } from './suggestion-pills' import SuggestionPopover from './suggestion-popover' import ThemeProvider from './theme-provider' import Welcome from './welcome' import McpServerPicker from './mcp-server-picker' import McpAddForm from './mcp-add-form' +import { + ActionButton, + SubmitButton, + ClearButton, + UploadButton, + VoiceButton, + WordCounter, + DefaultActionButtons, +} from './sender-actions' +// ============================================ +// 组件类型导出 +// ============================================ export * from './attachments/index.type' export * from './bubble/index.type' export * from './container/index.type' @@ -30,6 +43,7 @@ export * from './history/index.type' export * from './icon-button/index.type' export * from './prompts/index.type' export * from './sender/index.type' +export * from './sender-actions/index.type' export * from './suggestion-pills/index.type' export * from './suggestion-popover/index.type' export * from './theme-provider/index.type' @@ -47,6 +61,7 @@ export { useOmitMessageFields, } from './bubble' export { useTheme } from './theme-provider/useTheme' +export { useSenderContext } from './sender' export { vDropzone } from './drag-overlay/directives/vDropzone' export { useAutoScroll, useTouchDevice } from './shared/composables' @@ -65,6 +80,7 @@ const components = [ Prompt, Prompts, Sender, + SenderCompat, SuggestionPills, SuggestionPillButton, SuggestionPopover, @@ -72,6 +88,13 @@ const components = [ Welcome, McpServerPicker, McpAddForm, + ActionButton, + SubmitButton, + ClearButton, + UploadButton, + VoiceButton, + WordCounter, + DefaultActionButtons, ] export default { @@ -112,6 +135,8 @@ export { Prompts as TrPrompts, Sender, Sender as TrSender, + SenderCompat, + SenderCompat as TrSenderCompat, SuggestionPillButton, SuggestionPillButton as TrSuggestionPillButton, SuggestionPills, @@ -126,4 +151,18 @@ export { McpServerPicker as TrMcpServerPicker, McpAddForm, McpAddForm as TrMcpAddForm, + ActionButton, + ActionButton as TrActionButton, + SubmitButton, + SubmitButton as TrSubmitButton, + ClearButton, + ClearButton as TrClearButton, + UploadButton, + UploadButton as TrUploadButton, + VoiceButton, + VoiceButton as TrVoiceButton, + WordCounter, + WordCounter as TrWordCounter, + DefaultActionButtons, + DefaultActionButtons as TrDefaultActionButtons, } diff --git a/packages/components/src/sender-actions/action-button/index.vue b/packages/components/src/sender-actions/action-button/index.vue new file mode 100644 index 000000000..8d06f51e8 --- /dev/null +++ b/packages/components/src/sender-actions/action-button/index.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/packages/components/src/sender-actions/clear-button/index.vue b/packages/components/src/sender-actions/clear-button/index.vue new file mode 100644 index 000000000..a5394a63c --- /dev/null +++ b/packages/components/src/sender-actions/clear-button/index.vue @@ -0,0 +1,53 @@ + + + diff --git a/packages/components/src/sender-actions/default-actions/index.vue b/packages/components/src/sender-actions/default-actions/index.vue new file mode 100644 index 000000000..c8684ff5b --- /dev/null +++ b/packages/components/src/sender-actions/default-actions/index.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/packages/components/src/sender-actions/index.ts b/packages/components/src/sender-actions/index.ts new file mode 100644 index 000000000..383b0e993 --- /dev/null +++ b/packages/components/src/sender-actions/index.ts @@ -0,0 +1,23 @@ +/** + * Sender Actions 组件导出 + * + * 包含所有操作按钮组件: + * - ActionButton: 基础按钮 + * - SubmitButton: 提交按钮 + * - ClearButton: 清空按钮 + * - UploadButton: 上传按钮 + * - VoiceButton: 语音输入按钮 + * - WordCounter: 字数统计 + * - DefaultActionButtons: 默认按钮组合 + */ +export { default as ActionButton } from './action-button/index.vue' +export { default as SubmitButton } from './submit-button/index.vue' +export { default as ClearButton } from './clear-button/index.vue' +export { default as UploadButton } from './upload-button/index.vue' +export { default as VoiceButton } from './voice-button/index.vue' +export { default as WordCounter } from './word-counter/index.vue' +export { default as DefaultActionButtons } from './default-actions/index.vue' + +// 导出语音相关 Hook +export { useSpeechHandler } from './voice-button/useSpeechHandler' +export { WebSpeechHandler } from './voice-button/webSpeechHandler' diff --git a/packages/components/src/sender-actions/index.type.ts b/packages/components/src/sender-actions/index.type.ts new file mode 100644 index 000000000..16a8652ed --- /dev/null +++ b/packages/components/src/sender-actions/index.type.ts @@ -0,0 +1,18 @@ +/** + * Sender Actions 类型导出 + */ + +// 导出共享类型 +export type { TooltipPlacement, TooltipContent, ActionButtonProps } from './types/common' + +// 导出组件特有类型 +export type { UploadButtonProps, UploadButtonEmits } from './upload-button/index.type' +export type { VoiceButtonProps, VoiceButtonEmits } from './voice-button/index.type' +export type { + SpeechConfig, + SpeechHandler, + SpeechState, + SpeechCallbacks, + SpeechHookOptions, + SpeechHandlerResult, +} from './voice-button/speech.types' diff --git a/packages/components/src/sender-actions/submit-button/index.vue b/packages/components/src/sender-actions/submit-button/index.vue new file mode 100644 index 000000000..22a346df1 --- /dev/null +++ b/packages/components/src/sender-actions/submit-button/index.vue @@ -0,0 +1,162 @@ + + + + + + + diff --git a/packages/components/src/sender-actions/types/common.ts b/packages/components/src/sender-actions/types/common.ts new file mode 100644 index 000000000..4dc4a519a --- /dev/null +++ b/packages/components/src/sender-actions/types/common.ts @@ -0,0 +1,61 @@ +import type { VNode, Component } from 'vue' +import type { TooltipContent } from './tooltip' + +// 统一从 common 导入 +export type { TooltipContent } from './tooltip' + +/** + * Tooltip 位置 + * + * 支持 TinyTooltip 的所有位置选项 + */ +export type TooltipPlacement = + | 'top' + | 'top-start' + | 'top-end' + | 'bottom' + | 'bottom-start' + | 'bottom-end' + | 'left' + | 'left-start' + | 'left-end' + | 'right' + | 'right-start' + | 'right-end' + +/** + * ActionButton Props + * + * 基础操作按钮的 Props + */ +export interface ActionButtonProps { + /** + * 按钮图标 + */ + icon: VNode | Component + + /** + * 是否禁用 + */ + disabled?: boolean + + /** + * 是否激活状态 + */ + active?: boolean + + /** + * 工具提示 + */ + tooltip?: TooltipContent + + /** + * Tooltip 位置 + */ + tooltipPlacement?: TooltipPlacement + + /** + * 按钮大小 + */ + size?: string | number +} diff --git a/packages/components/src/sender-actions/types/index.ts b/packages/components/src/sender-actions/types/index.ts new file mode 100644 index 000000000..b865b520b --- /dev/null +++ b/packages/components/src/sender-actions/types/index.ts @@ -0,0 +1,6 @@ +/** + * Sender Actions 类型统一导出 + */ + +export * from './tooltip' +export * from './common' diff --git a/packages/components/src/sender-actions/types/tooltip.ts b/packages/components/src/sender-actions/types/tooltip.ts new file mode 100644 index 000000000..8edac22f4 --- /dev/null +++ b/packages/components/src/sender-actions/types/tooltip.ts @@ -0,0 +1,8 @@ +import type { VNode } from 'vue' + +/** + * Tooltip 内容类型 + * - string: 简单文本 + * - () => string | VNode: 渲染函数,支持复杂内容 + */ +export type TooltipContent = string | (() => string | VNode) diff --git a/packages/components/src/sender-actions/upload-button/index.type.ts b/packages/components/src/sender-actions/upload-button/index.type.ts new file mode 100644 index 000000000..96feb13de --- /dev/null +++ b/packages/components/src/sender-actions/upload-button/index.type.ts @@ -0,0 +1,69 @@ +import { Component } from 'vue' +import { TooltipContent, TooltipPlacement } from '../types/common' + +export interface UploadButtonProps { + /** + * 是否禁用 + */ + disabled?: boolean + + /** + * 接受的文件类型 + * @default '*' + */ + accept?: string + + /** + * 是否支持多选 + * @default false + */ + multiple?: boolean + + /** + * 是否在选择文件后重置 input + * @default true + */ + reset?: boolean + + /** + * 文件大小限制(MB) + */ + maxSize?: number + + /** + * 最大文件数量 + */ + maxCount?: number + + /** + * 按钮提示文本 + */ + tooltip?: TooltipContent + + /** + * 按钮尺寸 + */ + size?: number | string + + /** + * 自定义图标 + */ + icon?: Component + + /** + * Tooltip 位置 + */ + tooltipPlacement?: TooltipPlacement +} + +export interface UploadButtonEmits { + /** + * 文件选择 + */ + (e: 'select', files: File[]): void + + /** + * 文件验证失败 + */ + (e: 'error', error: Error, files?: File[]): void +} diff --git a/packages/components/src/sender-actions/upload-button/index.vue b/packages/components/src/sender-actions/upload-button/index.vue new file mode 100644 index 000000000..dd4aff0b7 --- /dev/null +++ b/packages/components/src/sender-actions/upload-button/index.vue @@ -0,0 +1,82 @@ + + + diff --git a/packages/components/src/sender-actions/utils/tooltip.ts b/packages/components/src/sender-actions/utils/tooltip.ts new file mode 100644 index 000000000..6325a2993 --- /dev/null +++ b/packages/components/src/sender-actions/utils/tooltip.ts @@ -0,0 +1,14 @@ +import type { TooltipContent } from '../types/tooltip' + +/** + * 将 TooltipContent 转换为 TinyTooltip 的 render-content 函数 + */ +export function normalizeTooltipContent(tooltip: TooltipContent | undefined) { + if (!tooltip) return undefined + + if (typeof tooltip === 'string') { + return () => tooltip + } + + return tooltip +} diff --git a/packages/components/src/sender-actions/voice-button/index.type.ts b/packages/components/src/sender-actions/voice-button/index.type.ts new file mode 100644 index 000000000..03a766509 --- /dev/null +++ b/packages/components/src/sender-actions/voice-button/index.type.ts @@ -0,0 +1,51 @@ +import type { VNode, Component } from 'vue' +import type { SpeechConfig } from './speech.types' +import type { TooltipPlacement, TooltipContent } from '../types/common' +/** + * VoiceButton 组件 Props + */ +export interface VoiceButtonProps { + /** + * 自定义图标 + */ + icon?: VNode | Component + /** + * 是否禁用(会与 Context 的 disabled 合并) + */ + disabled?: boolean + /** + * 按钮尺寸 + */ + size?: 'small' | 'normal' + /** + * Tooltip 文本 + */ + tooltip?: TooltipContent + /** + * Tooltip 位置 + */ + tooltipPlacement?: TooltipPlacement + /** + * 语音配置 + */ + speechConfig?: SpeechConfig + /** + * 是否自动插入识别结果到编辑器 + * @default true + */ + autoInsert?: boolean + /** + * 按钮点击拦截器(用于自定义 UI) + */ + onButtonClick?: (isRecording: boolean, preventDefault: () => void) => void | Promise +} +/** + * VoiceButton 组件 Emits + */ +export interface VoiceButtonEmits { + (e: 'speech-start'): void + (e: 'speech-interim', transcript: string): void + (e: 'speech-final', transcript: string): void + (e: 'speech-end', transcript?: string): void + (e: 'speech-error', error: Error): void +} diff --git a/packages/components/src/sender-actions/voice-button/index.vue b/packages/components/src/sender-actions/voice-button/index.vue new file mode 100644 index 000000000..5002a2654 --- /dev/null +++ b/packages/components/src/sender-actions/voice-button/index.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/packages/components/src/sender-actions/voice-button/speech.types.ts b/packages/components/src/sender-actions/voice-button/speech.types.ts new file mode 100644 index 000000000..f745f1e66 --- /dev/null +++ b/packages/components/src/sender-actions/voice-button/speech.types.ts @@ -0,0 +1,55 @@ +/** + * 语音识别相关类型定义 + */ +// 语音回调函数集合 +export interface SpeechCallbacks { + onStart: () => void + onInterim: (transcript: string) => void + onFinal: (transcript: string) => void + onEnd: (transcript?: string) => void + onError: (error: Error) => void +} + +// 语音处理器接口(统一接口,支持内置和自定义实现) +// 职责说明: +// - start: 启动语音识别,接收 callbacks 用于通知识别过程中的各种事件 +// - stop: 清理资源 +// - isSupported: 检查当前环境是否支持该语音识别方式 +export interface SpeechHandler { + start: (callbacks: SpeechCallbacks) => Promise | void + stop: () => Promise | void + isSupported: () => boolean +} + +// 语音识别配置 +export interface SpeechConfig { + customHandler?: SpeechHandler // 自定义语音处理器(传入则使用自定义,否则使用内置) + lang?: string // 识别语言,默认浏览器语言 + continuous?: boolean // 是否持续识别 + interimResults?: boolean // 是否返回中间结果 + autoReplace?: boolean // 是否自动替换当前输入内容 + onVoiceButtonClick?: (isRecording: boolean, preventDefault: () => void) => void | Promise // 录音按钮点击拦截器 +} + +// 语音识别状态 +export interface SpeechState { + isRecording: boolean // 是否正在录音 + isSupported: boolean // 是否支持语音识别 + error?: Error // 错误信息 +} + +// 语音识别Hook配置 +export interface SpeechHookOptions extends SpeechConfig { + onStart?: () => void + onEnd?: (transcript?: string) => void + onInterim?: (transcript: string) => void + onFinal?: (transcript: string) => void + onError?: (error: Error) => void +} + +// 语音识别Hook返回类型 +export interface SpeechHandlerResult { + speechState: SpeechState + start: () => void + stop: () => void +} diff --git a/packages/components/src/sender/composables/useSpeechHandler.ts b/packages/components/src/sender-actions/voice-button/useSpeechHandler.ts similarity index 75% rename from packages/components/src/sender/composables/useSpeechHandler.ts rename to packages/components/src/sender-actions/voice-button/useSpeechHandler.ts index 979d3f358..6cc28ea88 100644 --- a/packages/components/src/sender/composables/useSpeechHandler.ts +++ b/packages/components/src/sender-actions/voice-button/useSpeechHandler.ts @@ -1,5 +1,11 @@ -import { reactive, onUnmounted } from 'vue' -import type { SpeechHookOptions, SpeechHandlerResult, SpeechState, SpeechCallbacks, SpeechHandler } from '../index.type' +import { reactive, onUnmounted, ref } from 'vue' +import type { + SpeechHookOptions, + SpeechHandlerResult, + SpeechState, + SpeechCallbacks, + SpeechHandler, +} from './speech.types' import { WebSpeechHandler } from './webSpeechHandler' /** @@ -11,6 +17,9 @@ import { WebSpeechHandler } from './webSpeechHandler' * @returns 语音识别控制器 */ export function useSpeechHandler(options: SpeechHookOptions): SpeechHandlerResult { + // 使用 ref 存储 options,确保能获取最新值 + const optionsRef = ref(options) + // 语音识别状态 const speechState = reactive({ isRecording: false, @@ -18,29 +27,29 @@ export function useSpeechHandler(options: SpeechHookOptions): SpeechHandlerResul error: undefined, }) - // 创建回调函数集合 + // 创建回调函数集合 - 使用函数形式,每次调用时获取最新的 options const callbacks: SpeechCallbacks = { onStart: () => { speechState.isRecording = true speechState.error = undefined - options.onStart?.() + optionsRef.value.onStart?.() }, onInterim: (transcript: string) => { - options.onInterim?.(transcript) + optionsRef.value.onInterim?.(transcript) }, onFinal: (transcript: string) => { - options.onFinal?.(transcript) + optionsRef.value.onFinal?.(transcript) }, onEnd: (transcript?: string) => { if (speechState.isRecording) { speechState.isRecording = false - options.onEnd?.(transcript) + optionsRef.value.onEnd?.(transcript) } }, onError: (error: Error) => { speechState.error = error speechState.isRecording = false - options.onError?.(error) + optionsRef.value.onError?.(error) }, } @@ -57,7 +66,7 @@ export function useSpeechHandler(options: SpeechHookOptions): SpeechHandlerResul if (!speechState.isSupported || !handler) { const error = new Error('语音识别不受支持') speechState.error = error - options.onError?.(error) + optionsRef.value.onError?.(error) return } @@ -72,7 +81,12 @@ export function useSpeechHandler(options: SpeechHookOptions): SpeechHandlerResul return } - handler.start(callbacks) + try { + handler.start(callbacks) + } catch (error) { + speechState.error = error instanceof Error ? error : new Error('启动失败') + optionsRef.value.onError?.(speechState.error) + } } // 停止录音 @@ -82,7 +96,6 @@ export function useSpeechHandler(options: SpeechHookOptions): SpeechHandlerResul } handler.stop() - callbacks.onEnd() } diff --git a/packages/components/src/sender/composables/webSpeechHandler.ts b/packages/components/src/sender-actions/voice-button/webSpeechHandler.ts similarity index 99% rename from packages/components/src/sender/composables/webSpeechHandler.ts rename to packages/components/src/sender-actions/voice-button/webSpeechHandler.ts index ffe8cd8e2..39a61638a 100644 --- a/packages/components/src/sender/composables/webSpeechHandler.ts +++ b/packages/components/src/sender-actions/voice-button/webSpeechHandler.ts @@ -1,4 +1,4 @@ -import type { SpeechCallbacks, SpeechHandler, SpeechConfig } from '../index.type' +import type { SpeechCallbacks, SpeechHandler, SpeechConfig } from './speech.types' /** * 内置 Web Speech API 处理器 @@ -14,7 +14,6 @@ export class WebSpeechHandler implements SpeechHandler { private initialize() { // 创建语音识别实例 this.recognition = new (window.webkitSpeechRecognition || window.SpeechRecognition)() - // 配置识别参数 this.recognition.continuous = this.options.continuous ?? false this.recognition.interimResults = this.options.interimResults ?? true @@ -46,29 +45,23 @@ export class WebSpeechHandler implements SpeechHandler { */ private setupEventHandlers(callbacks: SpeechCallbacks): void { if (!this.recognition || !callbacks) return - this.recognition.onstart = () => { callbacks.onStart() } - this.recognition.onend = () => { callbacks.onEnd() } - this.recognition.onresult = (event: SpeechRecognitionEvent) => { const transcript = Array.from(event.results) .map((result) => result[0].transcript) .join('') - const current = event.results[event.resultIndex] - if (current?.isFinal) { callbacks.onFinal(transcript) } else { callbacks.onInterim(transcript) } } - this.recognition.onerror = (event: SpeechRecognitionErrorEvent) => { callbacks.onError(new Error(event.error)) this.cleanup() @@ -80,7 +73,6 @@ export class WebSpeechHandler implements SpeechHandler { */ private cleanup(): void { if (!this.recognition) return - this.recognition.onstart = null this.recognition.onend = null this.recognition.onresult = null @@ -96,10 +88,8 @@ export class WebSpeechHandler implements SpeechHandler { callbacks.onError(new Error('浏览器不支持语音识别')) return } - // 绑定事件处理器 this.setupEventHandlers(callbacks) - try { this.recognition.start() } catch (error) { @@ -112,9 +102,7 @@ export class WebSpeechHandler implements SpeechHandler { */ stop(): void { if (!this.recognition) return - this.cleanup() - try { this.recognition.stop() } catch (error) { diff --git a/packages/components/src/sender-actions/word-counter/index.vue b/packages/components/src/sender-actions/word-counter/index.vue new file mode 100644 index 000000000..5da5557af --- /dev/null +++ b/packages/components/src/sender-actions/word-counter/index.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/packages/components/src/sender-compat/index.ts b/packages/components/src/sender-compat/index.ts new file mode 100644 index 000000000..6ae366b3f --- /dev/null +++ b/packages/components/src/sender-compat/index.ts @@ -0,0 +1,12 @@ +import { App } from 'vue' +import SenderCompat from './index.vue' + +SenderCompat.name = 'TrSenderCompat' + +const install = function (app: App) { + app.component(SenderCompat.name!, SenderCompat) +} + +SenderCompat.install = install + +export default SenderCompat as typeof SenderCompat & { install: typeof install } diff --git a/packages/components/src/sender-compat/index.type.ts b/packages/components/src/sender-compat/index.type.ts new file mode 100644 index 000000000..cd9570788 --- /dev/null +++ b/packages/components/src/sender-compat/index.type.ts @@ -0,0 +1,137 @@ +/** + * SenderCompat 类型定义(v0.3.0 兼容层) + * 这些是旧版 Sender 的类型定义 + */ + +import type { VNode, Component } from 'vue' +import type { InputMode, SubmitTrigger, AutoSize } from '../sender/types/base' +import type { SpeechConfig } from '../sender-actions/voice-button/speech.types' + +// 主题类型 +export type ThemeType = 'light' | 'dark' + +export type TooltipRender = () => VNode | string + +export interface ControlState { + tooltips?: string | TooltipRender + disabled?: boolean + tooltipPlacement?: + | 'top' + | 'top-start' + | 'top-end' + | 'bottom' + | 'bottom-start' + | 'bottom-end' + | 'left' + | 'left-start' + | 'left-end' + | 'right' + | 'right-start' + | 'right-end' +} + +interface fileUploadConfig { + accept?: string + multiple?: boolean + reset?: boolean +} + +interface VoiceButtonConfig { + icon?: VNode | Component +} + +export interface ButtonGroupConfig { + file?: ControlState & fileUploadConfig + submit?: ControlState + voice?: VoiceButtonConfig +} + +// ============================================ +// 建议项相关类型(旧版兼容层专用) +// 注意:这些类型与新版 sender 的 SuggestionItem 不同,仅用于兼容旧版 API +// ============================================ + +// 高亮片段类型 +export interface SuggestionTextPart { + text: string + isMatch: boolean +} + +// 高亮函数类型 +type HighlightFunction = (suggestionText: string, inputText: string) => SuggestionTextPart[] + +// 建议项类型 +export interface ISuggestionItem { + content: string + highlights?: string[] | HighlightFunction +} + +// Sender组件属性(旧版) +export interface SenderProps { + autofocus?: boolean + autoSize?: AutoSize + allowSpeech?: boolean + allowFiles?: boolean + clearable?: boolean + disabled?: boolean + defaultValue?: string | null + loading?: boolean + modelValue?: string + mode?: InputMode + maxLength?: number + buttonGroup?: ButtonGroupConfig + submitType?: SubmitTrigger + speech?: boolean | SpeechConfig + placeholder?: string + showWordLimit?: boolean + suggestions?: ISuggestionItem[] + suggestionPopupWidth?: string | number + activeSuggestionKeys?: string[] + theme?: ThemeType + templateData?: UserItem[] + stopText?: string +} + +// 组件事件定义(旧版) +export type SenderEmits = { + (e: 'update:modelValue', value: string): void + (e: 'update:templateData', value: UserItem[]): void + (e: 'submit', value: string): void + (e: 'clear'): void + (e: 'speech-start'): void + (e: 'speech-end', transcript?: string): void + (e: 'speech-interim', transcript: string): void + (e: 'speech-error', error: Error): void + (e: 'suggestion-select', value: string): void + (e: 'focus', event: FocusEvent): void + (e: 'blur', event: FocusEvent): void + (e: 'escape-press'): void + (e: 'cancel'): void + (e: 'reset-template'): void + (e: 'files-selected', files: File[]): void +} + +// ============================================ +// UserItem 相关类型(旧版兼容层专用) +// 注意:这些类型与新版 sender 的 TemplateItem 不同,仅用于兼容旧版 API +// ============================================ + +export interface CompatTextItem { + id: string + type: 'text' + content: string +} + +export interface CompatTemplateItem { + id: string + type: 'template' | 'block' + content: string +} + +export type UserTextItem = Omit & { id?: CompatTextItem['id'] } + +export type UserTemplateItem = Omit, 'id'> & { + id?: CompatTemplateItem['id'] +} + +export type UserItem = UserTextItem | UserTemplateItem diff --git a/packages/components/src/sender-compat/index.vue b/packages/components/src/sender-compat/index.vue new file mode 100644 index 000000000..d9cfa96ce --- /dev/null +++ b/packages/components/src/sender-compat/index.vue @@ -0,0 +1,287 @@ + + + diff --git a/packages/components/src/sender/components/ActionButtons.vue b/packages/components/src/sender/components/ActionButtons.vue deleted file mode 100644 index d983369c9..000000000 --- a/packages/components/src/sender/components/ActionButtons.vue +++ /dev/null @@ -1,419 +0,0 @@ - - - - - - - - - diff --git a/packages/components/src/sender/components/Block.vue b/packages/components/src/sender/components/Block.vue deleted file mode 100644 index c471e1287..000000000 --- a/packages/components/src/sender/components/Block.vue +++ /dev/null @@ -1,58 +0,0 @@ - - - - - diff --git a/packages/components/src/sender/components/TemplateEditor.vue b/packages/components/src/sender/components/TemplateEditor.vue deleted file mode 100644 index 3b11c8e65..000000000 --- a/packages/components/src/sender/components/TemplateEditor.vue +++ /dev/null @@ -1,1129 +0,0 @@ - - - - - - - - - diff --git a/packages/components/src/sender/components/editor-content/index.vue b/packages/components/src/sender/components/editor-content/index.vue new file mode 100644 index 000000000..9865a824b --- /dev/null +++ b/packages/components/src/sender/components/editor-content/index.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/packages/components/src/sender/components/footer/index.vue b/packages/components/src/sender/components/footer/index.vue new file mode 100644 index 000000000..5e3dcdd6f --- /dev/null +++ b/packages/components/src/sender/components/footer/index.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/packages/components/src/sender/components/global.d.ts b/packages/components/src/sender/components/global.d.ts deleted file mode 100644 index 1aa7c1fb6..000000000 --- a/packages/components/src/sender/components/global.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -declare global { - interface Selection { - getComposedRanges?: (options?: { shadowRoots: ShadowRoot[] } | ShadowRoot) => StaticRange[] - } - - interface ShadowRoot { - getSelection?: () => Selection - } -} - -export {} diff --git a/packages/components/src/sender/components/layouts/MultiLineLayout.vue b/packages/components/src/sender/components/layouts/MultiLineLayout.vue new file mode 100644 index 000000000..33a754af1 --- /dev/null +++ b/packages/components/src/sender/components/layouts/MultiLineLayout.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/packages/components/src/sender/components/layouts/SingleLineLayout.vue b/packages/components/src/sender/components/layouts/SingleLineLayout.vue new file mode 100644 index 000000000..fb5f43bb2 --- /dev/null +++ b/packages/components/src/sender/components/layouts/SingleLineLayout.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/packages/components/src/sender/composables/index.ts b/packages/components/src/sender/composables/index.ts new file mode 100644 index 000000000..13bcbdaca --- /dev/null +++ b/packages/components/src/sender/composables/index.ts @@ -0,0 +1,9 @@ +/** + * Composables 统一导出 + */ + +export { useEditor } from './useEditor' +export { useAutoSize } from './useAutoSize' +export { useModeSwitch } from './useModeSwitch' + +export type { UseEditorReturn } from '../index.type' diff --git a/packages/components/src/sender/composables/useAutoSize.ts b/packages/components/src/sender/composables/useAutoSize.ts new file mode 100644 index 000000000..10d9da7e3 --- /dev/null +++ b/packages/components/src/sender/composables/useAutoSize.ts @@ -0,0 +1,104 @@ +/** + * 自动高度调整 + * + * 核心思路: + * 1. 只在用户传递了 autoSize 配置时才生效 + * 2. 监听真正的当前模式(currentMode) + * 3. 操作滚动容器的 min-height 和 max-height + * 4. 让浏览器自动处理滚动 + * + */ + +import { watch, nextTick, computed, type Ref } from 'vue' +import { useCssVar } from '@vueuse/core' +import type { InputMode, AutoSize } from '../index.type' + +export function useAutoSize(currentMode: Ref, editorRef: Ref, autoSize?: AutoSize) { + const lineHeightVar = useCssVar('--tr-sender-line-height', editorRef) + + const lineHeight = computed(() => { + const value = lineHeightVar.value + if (value) { + const parsed = parseFloat(value) + return isNaN(parsed) ? 26 : parsed + } + return 26 + }) + + const autoSizeConfig = computed(() => { + if (autoSize === false || autoSize === undefined) { + return null + } + + if (autoSize === true) { + return { + minRows: 1, + maxRows: 5, + } + } + + if (typeof autoSize === 'object') { + return { + minRows: autoSize.minRows, + maxRows: autoSize.maxRows, + } + } + + return null + }) + + /** + * 更新滚动容器高度 + */ + const updateHeight = () => { + if (!editorRef.value) return + + const scrollContainer = editorRef.value.querySelector('.tr-sender-editor-scroll') as HTMLElement + if (!scrollContainer) { + console.warn('⚠️ 找不到滚动容器 .tr-sender-editor-scroll') + return + } + + const config = autoSizeConfig.value + + // 多行模式且启用了 autoSize + if (currentMode.value === 'multiple' && config) { + const minHeight = lineHeight.value * config.minRows + const maxHeight = lineHeight.value * config.maxRows + + scrollContainer.style.minHeight = `${minHeight}px` + scrollContainer.style.maxHeight = `${maxHeight}px` + scrollContainer.style.overflowY = 'auto' + } + // 单行模式或未启用 autoSize + else { + scrollContainer.style.minHeight = '' + scrollContainer.style.maxHeight = '' + scrollContainer.style.overflowY = currentMode.value === 'single' ? 'hidden' : 'auto' + } + } + + watch( + currentMode, + () => { + nextTick(() => { + updateHeight() + }) + }, + { immediate: true }, + ) + + watch( + autoSizeConfig, + () => { + nextTick(() => { + updateHeight() + }) + }, + { immediate: true }, + ) + + return { + updateHeight, + } +} diff --git a/packages/components/src/sender/composables/useEditor.ts b/packages/components/src/sender/composables/useEditor.ts new file mode 100644 index 000000000..d84f85db8 --- /dev/null +++ b/packages/components/src/sender/composables/useEditor.ts @@ -0,0 +1,131 @@ +/** + * 编辑器初始化和管理 + */ + +import { ref, watch, onBeforeUnmount, nextTick, toRef } from 'vue' +import { useEditor as useTiptapEditor } from '@tiptap/vue-3' +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' +import History from '@tiptap/extension-history' +import Placeholder from '@tiptap/extension-placeholder' +import CharacterCount from '@tiptap/extension-character-count' +import type { AnyExtension } from '@tiptap/core' +import type { SenderProps, SenderEmits, UseEditorReturn } from '../index.type' + +/** + * 编辑器 Hook + * + * 职责: + * - 初始化编辑器 + * - 管理编辑器状态 + * - 处理编辑器事件 + * - 提供编辑器实例 + * + */ +export function useEditor(props: SenderProps, emit: SenderEmits): UseEditorReturn { + const editorRef = ref(null) + + // 将 placeholder 转换为响应式引用 + const placeholderRef = toRef(props, 'placeholder') + + /** + * 构建扩展列表 + * + * 基础扩展 + 用户传入的扩展 + */ + const buildExtensions = (): AnyExtension[] => { + const extensions: AnyExtension[] = [ + Document, + Paragraph, + Text, + History, // 提供 undo/redo 功能 + Placeholder.configure({ + placeholder: () => placeholderRef.value || '请输入内容...', + }), + CharacterCount.configure({ + mode: 'textSize', + }), + ] + + if (props.extensions?.length) { + extensions.push(...props.extensions) + } + + return extensions + } + + const editor = useTiptapEditor({ + content: props.modelValue ?? props.defaultValue ?? '', + extensions: buildExtensions(), + autofocus: props.autofocus ? 'end' : false, + editorProps: { + attributes: { + class: 'tr-sender-editor', + }, + // 处理粘贴事件 - 只粘贴纯文本 + handlePaste(view, event) { + const text = event.clipboardData?.getData('text/plain') + if (!text) return false + + // 处理文本:单行模式替换换行符,多行模式保留 + const processedText = props.mode === 'single' ? text.replace(/\r?\n/g, ' ') : text + + // 插入纯文本 + const { state } = view + const { tr } = state + tr.insertText(processedText) + view.dispatch(tr) + + nextTick(() => { + editor.value?.commands.scrollIntoView() + }) + + return true + }, + }, + onUpdate: (props) => { + const text = props.editor.getText() + emit('update:modelValue', text) + emit('input', text) + }, + onFocus: (props) => { + emit('focus', props.event as FocusEvent) + }, + onBlur: (props) => { + emit('blur', props.event as FocusEvent) + }, + }) + + // 监听外部 modelValue 变化 + watch( + () => props.modelValue, + (newValue) => { + if (editor.value && newValue !== editor.value.getText()) { + editor.value.commands.setContent(newValue ?? '', { emitUpdate: false }) + } + }, + ) + + // 监听 placeholder 变化,强制更新视图 + watch( + () => props.placeholder, + () => { + if (editor.value) { + const { state } = editor.value + const tr = state.tr + editor.value.view.dispatch(tr) + } + }, + ) + + // 清理 + onBeforeUnmount(() => { + editor.value?.destroy() + }) + + return { + editor, + editorRef, + } +} diff --git a/packages/components/src/sender/composables/useInputHandler.ts b/packages/components/src/sender/composables/useInputHandler.ts deleted file mode 100644 index 40a9eb4ef..000000000 --- a/packages/components/src/sender/composables/useInputHandler.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { ref, watch } from 'vue' -import type { SenderProps, SenderEmits } from '../index.type' - -/** - * 输入框值处理 Hook - * 对输入框的值进行集中处理 - * - * @param props 组件属性 - * @param emit 组件方法 - */ -export function useInputHandler(props: SenderProps, emit: SenderEmits) { - const inputValue = ref(props.modelValue || props.defaultValue || '') - const inputWrapper = ref(null) - - // 同步外部值变化 - watch( - () => props.modelValue, - (val) => { - if (val !== undefined && val !== inputValue.value) { - inputValue.value = val - } - }, - ) - - // 监听内部值变化,触发update:modelValue事件 - watch( - () => inputValue.value, - (val) => { - emit('update:modelValue', val) - }, - ) - - // 双向绑定处理 - const handleChange = (value: string) => { - inputValue.value = value - emit('update:modelValue', value) - } - - // 提交处理 - const handleSubmit = (event?: Event) => { - event?.preventDefault() - - const submitValue = inputValue.value - - if (!props.disabled && !props.loading && submitValue.trim()) { - emit('submit', submitValue) - } - } - - // 清空输入 - const handleClear = () => { - inputValue.value = '' - emit('update:modelValue', '') - emit('clear') - } - - // 输入法状态 - const isComposing = ref(false) - - // 清除输入 - const clearInput = () => { - handleClear() - } - - return { - inputValue, - inputWrapper, - isComposing, - handleChange, - handleSubmit, - handleClear, - clearInput, - } -} diff --git a/packages/components/src/sender/composables/useKeyboardHandler.ts b/packages/components/src/sender/composables/useKeyboardHandler.ts deleted file mode 100644 index 11deca6ee..000000000 --- a/packages/components/src/sender/composables/useKeyboardHandler.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { ComputedRef, Ref } from 'vue' -import type { SenderProps, SenderEmits, SpeechState, SubmitTrigger } from '../index.type' - -/** - * 键盘处理Hook - * 集中管理组件的键盘相关操作 - * - * @param props - 组件属性 - * @param emit - 组件方法 - * @param inputValue - 输入值 - * @param isComposing - 是否处于输入法组合状态(即编辑态) - * @param speechState - 语音识别状态 - * @param showSuggestions - 是否显示建议列表 - * @param activeSuggestion - 当前活动的建议项 - * @param acceptCurrentSuggestion - 接受当前建议的函数 - * @param closeSuggestionsPopup - 关闭建议弹窗的函数 - * @param navigateSuggestions - 导航建议列表的函数 - * @param toggleSpeech - 切换语音识别函数 - * @param canSubmit - 是否可以提交 - * @param currentMode - 当前输入模式 - * @param setMultipleMode - 设置为多行模式的回调函数 - * @param isTemplateMode - 是否处于模板编辑模式 - * @param exitTemplateMode - 退出模板编辑模式的回调函数 - */ -export function useKeyboardHandler( - props: SenderProps, - emit: SenderEmits, - inputValue: Ref, - isComposing: Ref, - speechState: SpeechState, - showSuggestions: Ref, - activeSuggestion: Ref, - acceptCurrentSuggestion: () => void, - closeSuggestionsPopup: (keepFocus?: boolean) => void, - navigateSuggestions: (direction: 'up' | 'down') => void, - toggleSpeech: () => void, - canSubmit: ComputedRef, - currentMode?: Ref<'single' | 'multiple'>, - setMultipleMode?: () => void, - isTemplateMode?: ComputedRef, - exitTemplateMode?: () => void, -) { - /** - * 触发提交 - */ - const triggerSubmit = () => { - if (!canSubmit.value) return - - if (isTemplateMode?.value) { - exitTemplateMode?.() - } - - emit('submit', inputValue.value.trim()) - } - - /** - * 检查是否为指定的提交快捷键 - * @param event 键盘事件 - * @param submitType 提交类型 - * @returns 是否触发提交 - * - * 提交行为说明: - * - 当 submitType 为 enter 时:按 Enter 键提交 - * - 当 submitType 为 ctrlEnter 时:按 Ctrl+Enter 提交,单独按 Enter 换行 - * - 当 submitType 为 shiftEnter 时:按 Shift+Enter 提交,单独按 Enter 换行 - */ - const checkSubmitShortcut = (event: KeyboardEvent, submitType: SubmitTrigger): boolean => { - const isEnter = event.key === 'Enter' - if (!isEnter) return false - - switch (submitType) { - case 'enter': - return !event.shiftKey && !event.ctrlKey && !event.metaKey - case 'ctrlEnter': - return (event.ctrlKey || event.metaKey) && !event.shiftKey - case 'shiftEnter': - return event.shiftKey && !event.ctrlKey && !event.metaKey - default: - return false - } - } - - /** - * 在光标位置插入换行符 - * @param target 输入框元素 - */ - const insertNewLine = (target: HTMLTextAreaElement) => { - const start = target.selectionStart ?? 0 - const end = target.selectionEnd ?? start - const currentValue = inputValue.value - - // 使用选区范围插入换行符,保持与原生 textarea 行为一致(替换选中文本) - inputValue.value = currentValue.substring(0, start) + '\n' + currentValue.substring(end) - - // 设置光标位置到换行符之后,并滚动到光标位置 - setTimeout(() => { - const newCursorPos = start + 1 - target.selectionStart = target.selectionEnd = newCursorPos - // 滚动到光标所在位置,确保光标可见 - target.scrollTop = target.scrollHeight - }, 0) - } - - /** - * 处理换行操作(仅在 submitType='enter' 时生效) - * @param event 键盘事件 - * @returns 是否已处理换行 - */ - const handleNewLine = (event: KeyboardEvent): boolean => { - // 只在 submitType='enter' 时支持 Ctrl+Enter 和 Shift+Enter 换行 - if (props.submitType !== 'enter' || event.key !== 'Enter') return false - - const isCtrlEnter = event.ctrlKey && !event.shiftKey - const isShiftEnter = event.shiftKey && !event.ctrlKey - - // Ctrl+Enter 或 Shift+Enter: 单行模式切换到多行,多行模式直接换行 - if (isCtrlEnter || isShiftEnter) { - event.preventDefault() - const target = event.target as HTMLTextAreaElement - - if (currentMode?.value === 'single' && setMultipleMode) { - setMultipleMode() - } - insertNewLine(target) - return true - } - - return false - } - - /** - * 处理键盘按下事件 - */ - const handleKeyPress = (event: KeyboardEvent) => { - if (isComposing.value) return // 阻止输入法状态下的提交 - - // 优先处理换行操作 - if (handleNewLine(event)) { - return - } - - if (showSuggestions.value) { - // 处理上下键 - 导航建议列表 - if (event.key === 'ArrowDown') { - event.preventDefault() - navigateSuggestions('down') - return - } - - if (event.key === 'ArrowUp') { - event.preventDefault() - navigateSuggestions('up') - return - } - - if (activeSuggestion.value) { - // 处理激活建议项的快捷键 - const activeSuggestionKeys = props.activeSuggestionKeys || ['Enter', 'Tab'] - - if (activeSuggestionKeys.includes(event.key)) { - event.preventDefault() - acceptCurrentSuggestion() - return - } - } - } - - // 处理Esc键 - 关闭建议列表或停止语音录制 - if (event.key === 'Escape') { - if (showSuggestions.value) { - closeSuggestionsPopup() - event.preventDefault() - } else if (speechState.isRecording) { - toggleSpeech() - event.preventDefault() - } - - emit('escape-press') - return - } - - // 检查是否匹配当前的提交快捷键 - if (checkSubmitShortcut(event, props.submitType as SubmitTrigger) && canSubmit.value) { - event.preventDefault() - triggerSubmit() - } - } - - return { - handleKeyPress, - triggerSubmit, - } -} diff --git a/packages/components/src/sender/composables/useKeyboardShortcuts.ts b/packages/components/src/sender/composables/useKeyboardShortcuts.ts new file mode 100644 index 000000000..ce5ed7888 --- /dev/null +++ b/packages/components/src/sender/composables/useKeyboardShortcuts.ts @@ -0,0 +1,78 @@ +/** + * 键盘快捷键管理 + * + * 职责: + * - 检查键盘事件是否匹配提交快捷键 + * - 处理模式切换逻辑 + */ + +import { isKey } from '../extensions/utils' +import type { UseKeyboardShortcutsParams, UseKeyboardShortcutsReturn } from '../index.type' + +/** + * 键盘快捷键 Hook + * + * 提供统一的键盘快捷键逻辑 + */ +export function useKeyboardShortcuts(params: UseKeyboardShortcutsParams): UseKeyboardShortcutsReturn { + const { submitType } = params + + /** + * 检查是否为指定的提交快捷键 + * + * @param event - 键盘事件 + * @returns 是否触发提交 + * + * 提交行为说明: + * - 当 submitType 为 'enter' 时:按 Enter 键提交(不带修饰键) + * - 当 submitType 为 'ctrlEnter' 时:按 Ctrl+Enter 或 Cmd+Enter 提交,单独按 Enter 换行 + * - 当 submitType 为 'shiftEnter' 时:按 Shift+Enter 提交,单独按 Enter 换行 + */ + const checkSubmitShortcut = (event: KeyboardEvent): boolean => { + if (!isKey(event, 'ENTER')) return false + + switch (submitType.value) { + case 'enter': + // Enter 提交:不能有任何修饰键 + return !event.shiftKey && !event.ctrlKey && !event.metaKey + case 'ctrlEnter': + // Ctrl+Enter 或 Cmd+Enter 提交 + return (event.ctrlKey || event.metaKey) && !event.shiftKey + case 'shiftEnter': + // Shift+Enter 提交 + return event.shiftKey && !event.ctrlKey && !event.metaKey + default: + return false + } + } + + /** + * 检查是否为换行键(非提交键) + * + * @param event - 键盘事件 + * @returns 是否为换行键 + * + * 换行键说明: + * - submitType 为 'enter' 时:Shift+Enter 或 Ctrl+Enter + * - submitType 为 'ctrlEnter' 时:Enter(不带修饰键) + * - submitType 为 'shiftEnter' 时:Enter(不带修饰键) + */ + const checkNewlineShortcut = (event: KeyboardEvent): boolean => { + if (!isKey(event, 'ENTER')) return false + + switch (submitType.value) { + case 'enter': + return event.shiftKey || event.ctrlKey || event.metaKey + case 'ctrlEnter': + case 'shiftEnter': + return !event.shiftKey && !event.ctrlKey && !event.metaKey + default: + return false + } + } + + return { + checkSubmitShortcut, + checkNewlineShortcut, + } +} diff --git a/packages/components/src/sender/composables/useModeSwitch.ts b/packages/components/src/sender/composables/useModeSwitch.ts new file mode 100644 index 000000000..7053f55bf --- /dev/null +++ b/packages/components/src/sender/composables/useModeSwitch.ts @@ -0,0 +1,106 @@ +/** + * 模式切换逻辑 + * 支持单行/多行模式的自动和手动切换 + */ + +import { ref, watch, nextTick, computed, type Ref } from 'vue' +import { useResizeObserver, useTimeoutFn } from '@vueuse/core' +import type { Editor } from '@tiptap/vue-3' +import type { SenderProps, UseModeSwitchReturn, InputMode } from '../index.type' + +export function useModeSwitch( + props: SenderProps, + editor: Ref, + editorRef: Ref, +): UseModeSwitchReturn { + const currentMode = ref(props.mode || 'single') + const isAutoSwitching = ref(false) + const initialMode = ref(props.mode || 'single') + + const { start: startAutoSwitchingTimeout, stop: stopAutoSwitchingTimeout } = useTimeoutFn( + () => { + isAutoSwitching.value = false + }, + 300, + { immediate: false }, + ) + + // 获取容器元素 + const containerRef = computed(() => { + return editorRef.value?.closest('.tr-sender-main') as HTMLElement | null + }) + + /** + * 检查内容是否溢出 + * 使用浏览器原生的 scrollWidth 检测 + */ + const checkOverflow = () => { + if (initialMode.value !== 'single') return + if (isAutoSwitching.value) return + if (!editor.value || !editorRef.value) 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 + + if (isOverflowing) { + setMode('multiple') + } + } else { + // 多行模式:清空文本后切换回单行 + if (!text.length) { + setMode('single') + } + } + } + + /** + * 设置模式 + * 注意:不再设置 white-space 样式,由容器的 overflow 来控制文本显示 + */ + const setMode = (mode: InputMode) => { + if (currentMode.value === mode) return + + isAutoSwitching.value = true + currentMode.value = mode + + nextTick(() => { + if (editor.value) { + editor.value.commands.focus('end') + } + + stopAutoSwitchingTimeout() + startAutoSwitchingTimeout() + }) + } + + useResizeObserver(containerRef, () => { + // 使用 requestAnimationFrame 避免频繁触发 + requestAnimationFrame(() => { + checkOverflow() + }) + }) + + watch( + () => props.mode, + (newMode) => { + if (newMode && newMode !== currentMode.value) { + initialMode.value = newMode + setMode(newMode) + } + }, + ) + + return { + currentMode, + isAutoSwitching, + setMode, + checkOverflow, + } +} diff --git a/packages/components/src/sender/composables/useSenderCore.ts b/packages/components/src/sender/composables/useSenderCore.ts new file mode 100644 index 000000000..11f87503d --- /dev/null +++ b/packages/components/src/sender/composables/useSenderCore.ts @@ -0,0 +1,312 @@ +/** + * Sender 核心逻辑聚合 + * + * 职责: + * - 统一管理所有 Hook 的初始化顺序 + * - 解决循环依赖问题 + * - 自动组装 Context 和 Expose + * - 作为逻辑层与视图层的桥梁 + */ + +import { EditorView } from '@tiptap/pm/view' +import { computed, provide, toRef, watch } from 'vue' +import type { SenderProps, SenderEmits, StructuredData } from '../index.type' +import { + MentionPluginKey, + SuggestionPluginKey, + TemplateSelectDropdownPluginKey, + getTemplateStructuredData, + getTextWithTemplates, + getMentionStructuredData, + getTextWithMentions, +} from '../extensions' +import { EXTENSION_NAMES } from '../extensions/constants' +import { SENDER_CONTEXT_KEY, type SenderContext } from '../types/context' +import { useEditor } from './useEditor' +import { useKeyboardShortcuts } from './useKeyboardShortcuts' +import { useModeSwitch } from './useModeSwitch' +import { useAutoSize } from './useAutoSize' + +/** + * useSenderCore 返回类型 + */ +export interface UseSenderCoreReturn { + /** + * Context 对象(用于 provide) + */ + context: SenderContext + + /** + * 需要暴露给父组件的方法(用于 defineExpose) + */ + expose: { + submit: () => void + clear: () => void + cancel: () => void + focus: () => void + blur: () => void + setContent: (content: string) => void + getContent: () => string + editor: SenderContext['editor'] + } +} + +/** + * Sender 核心逻辑 Hook + * + * 一键获取完整的 context 和 expose 对象 + */ +export function useSenderCore(props: SenderProps, emit: SenderEmits): UseSenderCoreReturn { + // ======================================== + // 1. 初始化编辑器(必须最先初始化,因为其他逻辑依赖它) + // ======================================== + + const { editor, editorRef } = useEditor(props, emit) + + // ======================================== + // 2. 基础状态计算(依赖 editor) + // ======================================== + + const hasContent = computed(() => { + if (!editor.value) return false + const text = getTextWithTemplates(editor.value) + return text.trim().length > 0 + }) + + const characterCount = computed(() => { + if (!editor.value) return 0 + const text = getTextWithTemplates(editor.value) + return text.length + }) + + const isOverLimit = computed(() => { + if (!props.maxLength) return false + return characterCount.value > props.maxLength + }) + + const canSubmit = computed(() => { + return ( + !props.disabled && + !props.loading && + hasContent.value && + !isOverLimit.value && + !props.defaultActions?.submit?.disabled + ) + }) + + // ======================================== + // 3. 定义核心方法(submit 需要在键盘处理器之前定义) + // ======================================== + + const submit = () => { + if (!canSubmit.value || !editor.value) return + + // 构建结构化数据(第二个参数,可选) + // 注意:Template 和 Mention 是互斥的使用场景 + let structuredData: StructuredData | undefined + let textContent = '' + + // Template(模板场景) + if (editor.value.extensionManager.extensions.some((ext) => ext.name === EXTENSION_NAMES.TEMPLATE)) { + const templateStructuredData = getTemplateStructuredData(editor.value) + if (templateStructuredData.length > 0) { + structuredData = templateStructuredData as StructuredData + } + textContent = getTextWithTemplates(editor.value) + } + // Mention(提及场景) + else if (editor.value.extensionManager.extensions.some((ext) => ext.name === EXTENSION_NAMES.MENTION)) { + const mentionStructuredData = getMentionStructuredData(editor.value) + if (mentionStructuredData.length > 0) { + structuredData = mentionStructuredData as StructuredData + } + textContent = getTextWithMentions(editor.value) + } + + // 如果没有扩展,使用默认的纯文本 + if (!textContent) { + textContent = editor.value.getText() + } + + // 触发 submit 事件 + emit('submit', textContent, structuredData) + } + + // ======================================== + // 4. 初始化模式切换 + // ======================================== + + const { currentMode, isAutoSwitching, setMode, checkOverflow } = useModeSwitch(props, editor, editorRef) + + // ======================================== + // 5. 初始化键盘快捷键处理器 + // ======================================== + + const keyboardHandlers = useKeyboardShortcuts({ + submitType: computed(() => props.submitType ?? 'enter'), + canSubmit, + mode: currentMode, + submit, + setMode, + }) + + // ======================================== + // 6. 动态注入键盘处理器(避免二次初始化) + // ======================================== + + watch( + editor, + (editorInstance) => { + if (editorInstance) { + // 使用 Tiptap 的 setOptions 动态注入键盘处理器 + editorInstance.setOptions({ + editorProps: { + ...editorInstance.options.editorProps, + handleKeyDown: (view: EditorView, event: KeyboardEvent) => { + // 0. 检查插件状态 - 如果建议面板激活或下拉菜单打开,不拦截键盘事件 + const mentionState = MentionPluginKey.getState(view.state) + const suggestionState = SuggestionPluginKey.getState(view.state) + const templateDropdownState = TemplateSelectDropdownPluginKey.getState(view.state) + + // 防御性检查:确保插件存在且状态激活 + if ( + (mentionState && mentionState.active) || + (suggestionState && suggestionState.active) || + (templateDropdownState && templateDropdownState.isOpen) + ) { + return false // 让插件/组件处理 + } + + // 1. 检查是否为提交快捷键(优先检查,避免误触发换行) + if (keyboardHandlers.checkSubmitShortcut(event)) { + event.preventDefault() + submit() + return true + } + + // 2. 处理换行键 + if (keyboardHandlers.checkNewlineShortcut(event)) { + event.preventDefault() + // 如果在单行模式,先切换到多行 + if (currentMode.value === 'single') { + setMode('multiple') + // 延迟执行换行,确保模式切换完成 + setTimeout(() => { + editorInstance.commands.splitBlock() + editorInstance.commands.focus() + }, 0) + } else { + editorInstance.commands.splitBlock() + } + return true + } + + return false + }, + }, + }) + } + }, + { immediate: true }, + ) + + // ======================================== + // 7. 初始化其他功能模块 + // ======================================== + + // 自动高度调整 + useAutoSize(currentMode, editorRef, props.autoSize) + + // 监听编辑器内容变化,检查是否需要切换模式 + watch( + () => editor.value?.state.doc.content, + () => { + setTimeout(() => { + checkOverflow() + }, 0) + }, + { deep: true }, + ) + + // ======================================== + // 8. 定义其他方法 + // ======================================== + + const focus = () => { + editor.value?.commands.focus() + } + + const clear = () => { + editor.value?.commands.clearContent() + editor.value?.commands.focus() + emit('clear') + } + + const cancel = () => { + emit('cancel') + } + + const blur = () => { + editor.value?.commands.blur() + } + + const setContent = (content: string) => { + editor.value?.commands.setContent(content) + } + + const getContent = () => { + return editor.value?.getText() || '' + } + + // ======================================== + // 9. 自动组装 Context + // ======================================== + + const context: SenderContext = { + editor, + editorRef, + mode: currentMode, + isAutoSwitching, + loading: computed(() => props.loading ?? false), + disabled: computed(() => props.disabled ?? false), + hasContent, + canSubmit, + isOverLimit, + characterCount, + maxLength: toRef(props, 'maxLength'), + size: computed(() => props.size ?? 'normal'), + showWordLimit: computed(() => props.showWordLimit ?? false), + clearable: computed(() => props.clearable ?? false), + defaultActions: toRef(props, 'defaultActions'), + submitType: computed(() => props.submitType ?? 'enter'), + stopText: toRef(props, 'stopText'), + submit, + clear, + cancel, + focus, + blur, + setContent, + getContent, + } + + // 提供 Context + provide(SENDER_CONTEXT_KEY, context) + + // ======================================== + // 10. 返回 Context 和 Expose + // ======================================== + + return { + context, + expose: { + submit, + clear, + cancel, + focus, + blur, + setContent, + getContent, + editor, + }, + } +} diff --git a/packages/components/src/sender/composables/useSlotScope.ts b/packages/components/src/sender/composables/useSlotScope.ts new file mode 100644 index 000000000..ac1dfa071 --- /dev/null +++ b/packages/components/src/sender/composables/useSlotScope.ts @@ -0,0 +1,52 @@ +/** + * Sender 插槽作用域 Composable + * + * 用于在布局组件中创建插槽作用域对象 + */ + +import { computed, type ComputedRef } from 'vue' +import { useSenderContext } from '../context' +import type { SenderSlotScope } from '../types/slots' + +/** + * 使用插槽作用域 + * + * 为增强按钮提供便捷的操作方法和常用状态 + * + * @returns SenderSlotScope computed 对象 + */ +export function useSlotScope(): ComputedRef { + const context = useSenderContext() + + return computed(() => ({ + // 编辑器实例 + editor: context.editor.value, + + // 基础操作 + focus: context.focus, + blur: context.blur, + + // 内容操作(为增强按钮设计) + insert: (content: string) => { + context.editor.value?.commands.insertContent(content + ' ') + context.focus() + }, + append: (content: string) => { + const editor = context.editor.value + if (editor) { + // 追加到文档末尾 + const endPos = editor.state.doc.content.size + editor.chain().focus().insertContentAt(endPos, content).run() + } + }, + replace: (content: string) => { + context.setContent(content) + context.focus() + }, + + // 常用状态 + disabled: context.disabled.value, + loading: context.loading.value, + hasContent: context.hasContent.value, + })) +} diff --git a/packages/components/src/sender/composables/useSuggestionHandler.ts b/packages/components/src/sender/composables/useSuggestionHandler.ts deleted file mode 100644 index e67b99c84..000000000 --- a/packages/components/src/sender/composables/useSuggestionHandler.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { ref, computed, watch, ComputedRef } from 'vue' -import type { ISuggestionItem } from '../index.type' - -/** - * 建议处理Hook - * 管理输入建议功能,提供建议项过滤、导航和选择功能 - * - * @param suggestions - 建议项列表 - * @param inputValue - 输入值 - * @param isComposing - 是否处于输入法组合状态 - * @param showTemplateEditor - 是否显示模板编辑器 - * @param onModelValueUpdate - 更新模型值的回调 - * @param onSuggestionSelect - 选择建议项的回调 - */ -export function useSuggestionHandler( - suggestions: ComputedRef, - inputValue: ReturnType>, - isComposing: ReturnType>, - showTemplateEditor: ComputedRef, - onModelValueUpdate: (value: string) => void, - onSuggestionSelect: (value: string) => void, -) { - const isPopupVisible = ref(false) - const activeKeyboardIndex = ref(-1) - const activeMouseIndex = ref(-1) - const interactionMode = ref<'keyboard' | 'mouse' | null>(null) - const autoCompleteText = ref('') - const showTabIndicator = ref(false) - - // 获取当前高亮的建议项 - const activeSuggestion = computed(() => { - if (!suggestions.value?.length) return '' - - const targetIndex = interactionMode.value === 'mouse' ? activeMouseIndex.value : activeKeyboardIndex.value - - return suggestions.value[targetIndex]?.content || '' - }) - - const setAutoComplete = (suffix: string) => { - autoCompleteText.value = suffix - showTabIndicator.value = true - } - - const clearAutoComplete = () => { - autoCompleteText.value = '' - showTabIndicator.value = false - } - - const syncAutoComplete = (suggestion?: string) => { - const targetText = suggestion || activeSuggestion.value - if (!targetText || !inputValue.value) { - clearAutoComplete() - return - } - - const suffix = targetText.substring(inputValue.value.length) - const isValidPrefix = targetText.toLowerCase().startsWith(inputValue.value.toLowerCase()) - - if (isValidPrefix && suffix) { - setAutoComplete(suffix) - } else { - clearAutoComplete() - } - } - - const clearSelection = () => { - activeKeyboardIndex.value = -1 - activeMouseIndex.value = -1 - interactionMode.value = null - } - - const openPopup = () => { - isPopupVisible.value = true - syncAutoComplete() - } - - const closePopup = () => { - isPopupVisible.value = false - clearSelection() - clearAutoComplete() - } - - const shouldShowPopup = computed(() => { - if (isComposing.value) return true - return Boolean(inputValue.value && suggestions.value?.length > 0 && !showTemplateEditor.value) - }) - - const applySuggestion = (suggestion: string) => { - closePopup() - inputValue.value = suggestion - onModelValueUpdate(suggestion) - onSuggestionSelect(suggestion) - } - - const confirmSelection = () => { - if (activeSuggestion.value) { - applySuggestion(activeSuggestion.value) - } - } - - // 键盘导航 - const navigateWithKeyboard = (direction: 'up' | 'down') => { - if (!isPopupVisible.value || !suggestions.value) return - - interactionMode.value = 'keyboard' - - if (activeKeyboardIndex.value === -1) { - activeKeyboardIndex.value = direction === 'down' ? 0 : suggestions.value.length - 1 - } else { - if (direction === 'down') { - activeKeyboardIndex.value = (activeKeyboardIndex.value + 1) % suggestions.value.length - } else { - activeKeyboardIndex.value = - (activeKeyboardIndex.value - 1 + suggestions.value.length) % suggestions.value.length - } - } - - syncAutoComplete() - } - - // 处理鼠标悬停 - const handleMouseEnter = (index: number) => { - if (!suggestions.value) return - - interactionMode.value = 'mouse' - activeMouseIndex.value = index - syncAutoComplete() - } - - const handleMouseLeave = () => { - if (!suggestions.value) return - - activeMouseIndex.value = -1 - - // 如果有键盘选中项,切换回键盘模式 - if (activeKeyboardIndex.value !== -1) { - interactionMode.value = 'keyboard' - } else { - interactionMode.value = null - } - - syncAutoComplete() - } - - // 监听条件变化,控制弹窗 - watch(shouldShowPopup, (shouldShow) => { - if (shouldShow) { - if (!isPopupVisible.value) { - openPopup() - } - } else { - if (isPopupVisible.value) { - closePopup() - } - } - }) - - return { - // 弹窗控制 - isPopupVisible, - openPopup, - closePopup, - - // 自动完成占位符 - autoCompleteText, - showTabIndicator, - syncAutoComplete, - - // 选中控制层 - activeSuggestion, - activeKeyboardIndex, - activeMouseIndex, - - // 交互处理 - navigateWithKeyboard, - handleMouseEnter, - handleMouseLeave, - - // 业务操作 - applySuggestion, - confirmSelection, - } -} diff --git a/packages/components/src/sender/composables/useUndoRedo.ts b/packages/components/src/sender/composables/useUndoRedo.ts deleted file mode 100644 index 5f8d1f8d7..000000000 --- a/packages/components/src/sender/composables/useUndoRedo.ts +++ /dev/null @@ -1,55 +0,0 @@ -export interface UseUndoRedoOptions { - onRemoveHistory?: (list: T[]) => void -} - -export function useUndoRedo(initial: T, options: UseUndoRedoOptions = {}) { - let undoStack: T[] = [] - let redoStack: T[] = [] - let currentValue: T = initial - - const commit = (newValue: T) => { - undoStack.push(currentValue) - currentValue = newValue - if (redoStack.length) { - options.onRemoveHistory?.(redoStack) - } - redoStack = [] - } - - const undo = () => { - if (undoStack.length) { - redoStack.push(currentValue) - currentValue = undoStack.pop() as T - - return currentValue - } - - return null - } - - const redo = () => { - if (redoStack.length) { - undoStack.push(currentValue) - currentValue = redoStack.pop() as T - - return currentValue - } - - return null - } - - const clear = () => { - if (undoStack.length) { - options.onRemoveHistory?.(undoStack) - } - if (redoStack.length) { - options.onRemoveHistory?.(redoStack) - } - undoStack = [] - redoStack = [] - } - - const get = () => currentValue - - return { commit, undo, redo, clear, get } -} diff --git a/packages/components/src/sender/context/index.ts b/packages/components/src/sender/context/index.ts new file mode 100644 index 000000000..6fd055b09 --- /dev/null +++ b/packages/components/src/sender/context/index.ts @@ -0,0 +1,22 @@ +/** + * Sender Context 实现 + */ + +import { inject } from 'vue' +import { SENDER_CONTEXT_KEY } from '../types/context' +import { SenderContext } from './types' + +/** + * 获取 Sender Context + */ +export function useSenderContext(): SenderContext { + const context = inject(SENDER_CONTEXT_KEY) + + if (!context) { + throw new Error('useSenderContext must be used within Sender component') + } + + return context +} + +export * from './types' diff --git a/packages/components/src/sender/context/types.ts b/packages/components/src/sender/context/types.ts new file mode 100644 index 000000000..27538561a --- /dev/null +++ b/packages/components/src/sender/context/types.ts @@ -0,0 +1,8 @@ +/** + * Context 相关类型定义 + * + * 注意:SenderContext 类型定义在 index.type.ts 中 + * 这里只做类型导出,避免重复定义 + */ + +export type { SenderContext } from '../index.type' diff --git a/packages/components/src/sender/extensions/constants.ts b/packages/components/src/sender/extensions/constants.ts new file mode 100644 index 000000000..12ff43a15 --- /dev/null +++ b/packages/components/src/sender/extensions/constants.ts @@ -0,0 +1,98 @@ +/** + * 扩展名称常量 + * + * 用于 TipTap 扩展定义中的 name 属性 + * 这些是注册到编辑器的扩展名称 + */ +export const EXTENSION_NAMES = { + /** Template 扩展(包含 TemplateBlock 和 TemplateSelect 子扩展) */ + TEMPLATE: 'template', + /** Mention 扩展 */ + MENTION: 'mention', + /** Suggestion 扩展 */ + SUGGESTION: 'suggestion', +} as const + +/** + * ProseMirror 节点类型名称常量 + * + * 用于 node.type.name 检查和节点创建 + * 这些是 ProseMirror Schema 中注册的节点类型名称 + */ +export const NODE_TYPE_NAMES = { + /** TemplateBlock 节点类型(可编辑块) */ + TEMPLATE_BLOCK: 'templateBlock', + /** TemplateSelect 节点类型(下拉选择) */ + TEMPLATE_SELECT: 'templateSelect', + /** Mention 节点类型 */ + MENTION: 'mention', + /** Paragraph 节点类型(ProseMirror 内置) */ + PARAGRAPH: 'paragraph', + /** Text 节点类型(ProseMirror 内置) */ + TEXT: 'text', +} as const + +/** + * ProseMirror 插件 Key 名称常量 + */ +export const PLUGIN_KEY_NAMES = { + /** Mention 插件 */ + MENTION: 'mention', + /** Suggestion 插件 */ + SUGGESTION: 'suggestion', + /** Template Select 下拉菜单插件 */ + TEMPLATE_SELECT_DROPDOWN: 'templateSelectDropdown', + /** Template Select 零宽字符插件 */ + TEMPLATE_SELECT_ZERO_WIDTH: 'templateSelectZeroWidth', + /** Template Select 键盘导航插件 */ + TEMPLATE_SELECT_KEYBOARD: 'templateSelectKeyboard', + /** Template Block 零宽字符插件 */ + TEMPLATE_BLOCK_ZERO_WIDTH: 'templateBlockZeroWidth', + /** Template Block 键盘导航插件 */ + TEMPLATE_BLOCK_KEYBOARD: 'templateBlockKeyboard', + /** Template Block 粘贴处理插件 */ + TEMPLATE_BLOCK_PASTE: 'templateBlockPaste', +} as const + +/** + * 用户 API 类型常量 + * + * 用于 TemplateItem 等用户 API 中的 type 字段 + * 这些是暴露给用户的类型名称,与内部节点类型可能不同 + */ +export const USER_API_TYPES = { + /** 文本类型 */ + TEXT: 'text', + /** 模板块类型(对应内部的 TemplateBlock 节点) */ + BLOCK: 'block', + /** 选择器类型(对应内部的 TemplateSelect 节点) */ + SELECT: 'select', + /** Mention 类型 */ + MENTION: 'mention', +} as const + +/** + * 键盘按键常量 + */ +export const KEYBOARD_KEYS = { + /** Enter 键 */ + ENTER: 'Enter', + /** Escape 键 */ + ESCAPE: 'Escape', + /** Tab 键 */ + TAB: 'Tab', + /** Backspace 键 */ + BACKSPACE: 'Backspace', + /** Delete 键 */ + DELETE: 'Delete', + /** 上箭头键 */ + ARROW_UP: 'ArrowUp', + /** 下箭头键 */ + ARROW_DOWN: 'ArrowDown', + /** 左箭头键 */ + ARROW_LEFT: 'ArrowLeft', + /** 右箭头键 */ + ARROW_RIGHT: 'ArrowRight', + /** 空格键 */ + SPACE: ' ', +} as const diff --git a/packages/components/src/sender/extensions/index.ts b/packages/components/src/sender/extensions/index.ts new file mode 100644 index 000000000..5ccbf7163 --- /dev/null +++ b/packages/components/src/sender/extensions/index.ts @@ -0,0 +1,24 @@ +/** + * Tiptap 扩展统一导出 + */ + +// ===== Mention ===== +export { Mention, mention, MentionPluginKey } from './mention' +export { getMentions, getTextWithMentions, getMentionStructuredData } from './mention' +export type { MentionAttrs, MentionOptions, MentionItem, MentionStructuredItem } from './mention' + +// ===== Suggestion ===== +export { Suggestion, suggestion, SuggestionPluginKey } from './suggestion' +export { syncAutoComplete, processHighlights, highlightSuggestionText } from './suggestion' +export type { + SenderSuggestionItem, + SuggestionOptions, + SuggestionState, + SuggestionTextPart, + HighlightFunction, +} from './suggestion' + +// ===== Template ===== +export { Template, template } from './template' +export { getTemplateStructuredData, getTextWithTemplates, TemplateSelectDropdownPluginKey } from './template' +export type { TemplateAttrs, TemplateOptions } from './template' diff --git a/packages/components/src/sender/extensions/mention/commands.ts b/packages/components/src/sender/extensions/mention/commands.ts new file mode 100644 index 000000000..3fd4d204f --- /dev/null +++ b/packages/components/src/sender/extensions/mention/commands.ts @@ -0,0 +1,46 @@ +/** + * Mention 扩展命令实现 + */ + +import type { Editor } from '@tiptap/core' +import { generateId } from '../utils' +import type { MentionAttrs } from './types' +import { NODE_TYPE_NAMES } from '../constants' + +/** + * Mention 命令集合 + */ +export const mentionCommands = { + /** + * 插入 mention 节点 + */ + insertMention: + (attrs: Partial) => + ({ commands }: { commands: Editor['commands'] }) => { + return commands.insertContent({ + type: NODE_TYPE_NAMES.MENTION, + attrs: { + id: attrs.id || generateId('mention'), + label: attrs.label || '', + value: attrs.value, + }, + }) + }, + + /** + * 删除 mention 节点 + */ + deleteMention: + (id: string) => + ({ tr, state }: { tr: Editor['state']['tr']; state: Editor['state'] }) => { + let deleted = false + state.doc.descendants((node, pos) => { + if (node.type.name === NODE_TYPE_NAMES.MENTION && node.attrs.id === id) { + tr.delete(pos, pos + node.nodeSize) + deleted = true + return false + } + }) + return deleted + }, +} diff --git a/packages/components/src/sender/extensions/mention/components/mention-list.vue b/packages/components/src/sender/extensions/mention/components/mention-list.vue new file mode 100644 index 000000000..c69b2b4b4 --- /dev/null +++ b/packages/components/src/sender/extensions/mention/components/mention-list.vue @@ -0,0 +1,194 @@ + + + + + diff --git a/packages/components/src/sender/extensions/mention/components/mention-view.vue b/packages/components/src/sender/extensions/mention/components/mention-view.vue new file mode 100644 index 000000000..76d124822 --- /dev/null +++ b/packages/components/src/sender/extensions/mention/components/mention-view.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/packages/components/src/sender/extensions/mention/extension.ts b/packages/components/src/sender/extensions/mention/extension.ts new file mode 100644 index 000000000..b4da627af --- /dev/null +++ b/packages/components/src/sender/extensions/mention/extension.ts @@ -0,0 +1,159 @@ +/** + * Mention 扩展定义 + * + * 定义 mention 节点的结构、属性、渲染方式和插件 + */ + +import { Node, mergeAttributes } from '@tiptap/core' +import { VueNodeViewRenderer } from '@tiptap/vue-3' +import { watch, isRef, type WatchStopHandle } from 'vue' +import MentionView from './components/mention-view.vue' +import { createSuggestionPlugin, MentionPluginKey } from './plugin' +import { EXTENSION_NAMES } from '../constants' +import type { MentionOptions } from './types' +import { mentionCommands } from './commands' +import './index.less' + +/** + * Mention 扩展定义 + */ +export const Mention = Node.create({ + name: EXTENSION_NAMES.MENTION, + + // 节点配置 + group: 'inline', + inline: true, + atom: true, // 不可编辑,作为整体 + selectable: true, + draggable: false, + + // 节点属性 + addAttributes() { + return { + id: { + default: null, + parseHTML: (element) => element.getAttribute('data-id'), + renderHTML: (attributes) => { + if (!attributes.id) { + return {} + } + return { + 'data-id': attributes.id, + } + }, + }, + label: { + default: null, + parseHTML: (element) => element.getAttribute('data-label'), + renderHTML: (attributes) => { + if (!attributes.label) { + return {} + } + return { + 'data-label': attributes.label, + } + }, + }, + value: { + default: null, + parseHTML: (element) => element.getAttribute('data-value'), + renderHTML: (attributes) => { + if (!attributes.value) { + return {} + } + return { + 'data-value': attributes.value, + } + }, + }, + } + }, + + // HTML 解析 + parseHTML() { + return [ + { + tag: 'span[data-mention]', + }, + ] + }, + + // HTML 渲染 + renderHTML({ node, HTMLAttributes }) { + return [ + 'span', + mergeAttributes(this.options.HTMLAttributes || {}, HTMLAttributes, { + 'data-mention': '', + 'data-id': node.attrs.id as string, + 'data-label': node.attrs.label as string, + 'data-value': node.attrs.value as string, + }), + `${this.options.char}${node.attrs.label as string}`, + ] + }, + + // 使用 Vue 组件渲染 + addNodeView() { + // @ts-expect-error - Vue SFC type compatibility + return VueNodeViewRenderer(MentionView) + }, + + // 添加 storage 用于存储实例状态 + addStorage() { + return { + watchStopHandle: null as WatchStopHandle | null, + } + }, + + onCreate() { + const { items } = this.options + + // 如果是响应式数据,监听变化 + if (isRef(items)) { + // 保存到实例 storage + this.storage.watchStopHandle = watch( + items, + () => { + // 触发一次事务,使插件重新计算状态 + // 使用 MentionPluginKey 而非字符串,确保插件能正确接收更新 + const tr = this.editor.state.tr + tr.setMeta(MentionPluginKey, { type: 'mention-update' }) + this.editor.view.dispatch(tr) + }, + { deep: true }, + ) + } + }, + + onDestroy() { + if (this.storage.watchStopHandle) { + this.storage.watchStopHandle() + this.storage.watchStopHandle = null + } + }, + + // 添加 Suggestion 插件 + addProseMirrorPlugins() { + return [ + createSuggestionPlugin({ + editor: this.editor, + char: this.options.char, + items: this.options.items, + allowSpaces: this.options.allowSpaces || false, + }), + ] + }, + + // 配置选项 + addOptions() { + return { + items: [], + char: '@', + } + }, + + // 自定义命令 + addCommands() { + return mentionCommands + }, +}) diff --git a/packages/components/src/sender/extensions/mention/index.less b/packages/components/src/sender/extensions/mention/index.less new file mode 100644 index 000000000..ff31991b8 --- /dev/null +++ b/packages/components/src/sender/extensions/mention/index.less @@ -0,0 +1,12 @@ +/** + * SkillMention 样式 + * CSS 变量统一在 src/styles/variables.css 中定义 + */ + +/* ==================== 全局样式 ==================== */ + +/* Mention 触发区域高亮 */ +.ProseMirror .mention-trigger { + background: var(--tr-sender-mention-trigger-bg); + border-radius: 2px; +} diff --git a/packages/components/src/sender/extensions/mention/index.ts b/packages/components/src/sender/extensions/mention/index.ts new file mode 100644 index 000000000..8a2f49bd0 --- /dev/null +++ b/packages/components/src/sender/extensions/mention/index.ts @@ -0,0 +1,44 @@ +/** + * Mention 扩展 + * + * 提及功能,用于提及某项的场景(如 @用户、#标签 等) + */ + +import type { Ref } from 'vue' +import { Mention } from './extension' +import type { MentionItem, MentionOptions } from './types' + +// ===== 导出扩展类和工具 ===== +export { Mention } from './extension' +export { MentionPluginKey } from './plugin' +export { mentionCommands } from './commands' +export * from './types' +export * from './utils' + +// ===== 便捷函数 ===== + +/** + * 创建 Mention 扩展的便捷函数 + * + * @param items - 提及项列表 + * @param char - 触发字符,默认 '@' + * @param options - 其他配置项 + * + * @example + * ```typescript + * const extensions = [mention(items)] + * const extensions = [mention(items, '#')] + * const extensions = [mention(items, '@', { allowSpaces: true })] + * ``` + */ +export function mention( + items: MentionItem[] | Ref, + char: string = '@', + options?: Partial>, +) { + return Mention.configure({ + items, + char, + ...options, + }) +} diff --git a/packages/components/src/sender/extensions/mention/plugin.ts b/packages/components/src/sender/extensions/mention/plugin.ts new file mode 100644 index 000000000..fca9242c8 --- /dev/null +++ b/packages/components/src/sender/extensions/mention/plugin.ts @@ -0,0 +1,353 @@ +/** + * Mention Suggestion 插件 + * + * 基于 ProseMirror 插件实现 + * - 监听触发字符输入(可配置,默认为 @) + * - 过滤匹配的提及项列表 + * - 使用 @floating-ui/dom 定位弹窗 + * - 处理键盘导航和选择 + */ + +import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state' +import type { EditorState, Transaction } from '@tiptap/pm/state' +import type { EditorView } from '@tiptap/pm/view' +import { Decoration, DecorationSet } from '@tiptap/pm/view' +import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom' +import { VueRenderer } from '@tiptap/vue-3' +import type { Editor } from '@tiptap/core' +import { isRef } from 'vue' +import type { Ref } from 'vue' +import MentionList from './components/mention-list.vue' +import { findTextRange, generateId, isKey, isAnyKey } from '../utils' +import type { MentionItem, MentionSuggestionState } from './types' +import { PLUGIN_KEY_NAMES, NODE_TYPE_NAMES } from '../constants' + +export const MentionPluginKey = new PluginKey(PLUGIN_KEY_NAMES.MENTION) + +interface PluginOptions { + editor: Editor + char: string + items: MentionItem[] | Ref + allowSpaces: boolean +} + +/** + * 过滤提及项列表 + */ +function filterItems(items: MentionItem[], query: string): MentionItem[] { + if (!query) { + return items + } + + const lowerQuery = query.toLowerCase() + + return items.filter((item) => { + // 匹配标签 + if (item.label.toLowerCase().includes(lowerQuery)) { + return true + } + + // 匹配关联值 + if (item.value?.toLowerCase().includes(lowerQuery)) { + return true + } + + return false + }) +} + +/** + * 创建 Suggestion 插件 + */ +export function createSuggestionPlugin(options: PluginOptions): Plugin { + const { editor, char, items, allowSpaces } = options + + let component: VueRenderer | null = null + let popup: HTMLElement | null = null + let cleanup: (() => void) | null = null + + return new Plugin({ + key: MentionPluginKey, + + state: { + init(): MentionSuggestionState { + return { + active: false, + range: null, + query: '', + filteredItems: [], + } + }, + + apply(tr: Transaction, state: MentionSuggestionState): MentionSuggestionState { + // 检查是否有 meta 更新 + const meta = tr.getMeta(MentionPluginKey) + + if (meta) { + // 关闭弹窗 + if (meta.type === 'close') { + return { + active: false, + range: null, + query: '', + filteredItems: [], + } + } + } + + // 如果文档没有变化,保持状态 + if (!tr.docChanged && !tr.selectionSet) { + return state + } + + // 查找触发 + const suggestion = findTextRange(tr.selection, char, allowSpaces) + + if (!suggestion) { + return { + active: false, + range: null, + query: '', + filteredItems: [], + } + } + + // 过滤提及项 + const currentItems = isRef(items) ? items.value : items + const filteredItems = filterItems(currentItems, suggestion.query) + + return { + active: filteredItems.length > 0, + range: suggestion.range, + query: suggestion.query, + filteredItems, + } + }, + }, + + props: { + // 装饰器:高亮触发区域 + decorations(state: EditorState): DecorationSet { + const pluginState = this.getState(state) + + if (!pluginState?.active || !pluginState.range) { + return DecorationSet.empty + } + + const decoration = Decoration.inline(pluginState.range.from, pluginState.range.to, { + class: 'mention-trigger', + }) + + return DecorationSet.create(state.doc, [decoration]) + }, + + // 键盘处理 + handleKeyDown(view: EditorView, event: KeyboardEvent): boolean { + const pluginState = MentionPluginKey.getState(view.state) + + // 处理 Backspace:检测是否在 mention 节点右侧 + if (isKey(event, 'BACKSPACE')) { + const { selection } = view.state + const { $from } = selection + + // 检查光标前面是否是 mention 节点 + if ($from.nodeBefore && $from.nodeBefore.type.name === NODE_TYPE_NAMES.MENTION) { + event.preventDefault() + + const { tr } = view.state + const nodePos = $from.pos - $from.nodeBefore.nodeSize + + // 删除 mention 节点 + tr.delete(nodePos, $from.pos) + + // 插入触发字符(统一行为:第一次删除转换为 @,第二次删除才删除 @) + tr.insertText(char, nodePos) + + // 设置光标位置到触发字符后面 + tr.setSelection(TextSelection.create(tr.doc, nodePos + 1)) + + view.dispatch(tr) + + // 聚焦编辑器 + view.focus() + + return true + } + } + + // 如果建议面板未激活,不处理其他键盘事件 + if (!pluginState?.active) { + return false + } + + // Esc 关闭 + if (isKey(event, 'ESCAPE')) { + event.preventDefault() + + const tr = view.state.tr + tr.setMeta(MentionPluginKey, { + type: 'close', + }) + view.dispatch(tr) + + // 销毁组件 + if (component) { + cleanup?.() + cleanup = null + component.destroy() + component = null + } + if (popup) { + popup.remove() + popup = null + } + + return true + } + + // Enter 或 Tab:选择当前高亮的提及项 + if (isAnyKey(event, ['ENTER', 'TAB'])) { + event.preventDefault() + + // 尝试通过组件方法选择 + const handled = component?.ref?.onKeyDown?.({ event }) + if (handled) { + return true + } + + // 如果组件方法不可用,直接选择第一个提及项(fallback) + if (pluginState.filteredItems.length > 0 && pluginState.range) { + const firstItem = pluginState.filteredItems[0] + insertMention(view, pluginState.range, firstItem) + return true + } + + return true + } + + // ArrowUp 和 ArrowDown:交给组件处理 + if (isAnyKey(event, ['ARROW_UP', 'ARROW_DOWN'])) { + const handled = component?.ref?.onKeyDown?.({ event }) + return handled || false + } + + // 其他键不处理 + return false + }, + }, + + view() { + return { + update(view: EditorView) { + const state = MentionPluginKey.getState(view.state) + + if (state?.active && state.filteredItems.length > 0) { + // 创建或更新弹窗 + if (!component) { + component = new VueRenderer(MentionList, { + props: { + items: state.filteredItems, + command: (props: { id: string; label: string; value?: string }) => { + const item: MentionItem = { + id: props.id, + label: props.label, + value: props.value || '', + } + if (state.range) { + insertMention(view, state.range, item) + } + }, + }, + editor, + }) + + popup = component.element as HTMLElement + popup.style.position = 'absolute' + popup.style.zIndex = '1000' + document.body.appendChild(popup) + } else { + // 更新 props + component.updateProps({ + items: state.filteredItems, + }) + } + + // 使用 floating-ui 定位 + const referenceElement = view.dom.querySelector('.mention-trigger') + if (referenceElement && popup) { + // 清理旧的自动更新 + cleanup?.() + + // 设置自动更新 + cleanup = autoUpdate(referenceElement, popup, () => { + computePosition(referenceElement, popup!, { + placement: 'bottom-start', + middleware: [offset(8), flip(), shift({ padding: 8 })], + }).then((result: { x: number; y: number }) => { + if (popup) { + Object.assign(popup.style, { + left: `${result.x}px`, + top: `${result.y}px`, + }) + } + }) + }) + } + } else { + // 销毁弹窗 + if (component) { + cleanup?.() + cleanup = null + component.destroy() + component = null + } + if (popup) { + popup.remove() + popup = null + } + } + }, + + destroy() { + cleanup?.() + component?.destroy() + popup?.remove() + }, + } + }, + }) +} + +/** + * 插入 mention + */ +function insertMention(view: EditorView, range: { from: number; to: number }, item: MentionItem) { + view.focus() + + const { state, dispatch } = view + const { tr } = state + + const mentionNode = state.schema.nodes.mention.create({ + id: item.id || generateId('mention'), + label: item.label, + value: item.value || '', + }) + + // 创建空格文本节点 + const spaceNode = state.schema.text(' ') + + // 删除触发文本(包括触发字符) + tr.delete(range.from, range.to) + + // 插入 mention 节点和空格 + tr.insert(range.from, [mentionNode, spaceNode]) + + // 设置光标到空格之后(mention 节点 + 空格 = +2) + const cursorPos = range.from + 2 + tr.setSelection(TextSelection.create(tr.doc, cursorPos)) + + // 滚动到视图 + tr.scrollIntoView() + + dispatch(tr) +} diff --git a/packages/components/src/sender/extensions/mention/types.ts b/packages/components/src/sender/extensions/mention/types.ts new file mode 100644 index 000000000..f4126cbef --- /dev/null +++ b/packages/components/src/sender/extensions/mention/types.ts @@ -0,0 +1,152 @@ +/** + * Mention 扩展类型定义 + */ + +import type { Ref } from 'vue' +import '@tiptap/core' + +// ===== 类型定义 ===== + +/** + * 提及项数据结构(用户侧) + * + * 用户传入的数据格式,id 可选,插件会自动生成 + */ +export interface MentionItem { + /** + * 唯一标识(可选) + * + * 如果不提供,插件会自动生成 + */ + id?: string + + /** + * 显示名称,如 "小小画家"(必传) + */ + label: string + + /** + * 关联值(必传) + * + * 可以是任意字符串值,如 AI 提示词、用户 ID、标签内容等 + */ + value: string + + /** + * 图标(可选) + */ + icon?: string +} + +/** + * 结构化数据项(提交时返回) + * + * 用于表示文本和 mention 的混合结构 + */ +export type MentionStructuredItem = + | { + type: 'text' + content: string + } + | { + type: 'mention' + content: string // 显示名称 + value: string // 关联值 + } + +/** + * Mention 节点属性(内部使用) + * + * ProseMirror 节点的属性,id 必填(由插件保证) + */ +export interface MentionAttrs { + /** + * 唯一标识(必填) + * + * 由插件自动生成或使用用户提供的值 + */ + id: string + + /** + * 显示名称 + */ + label: string + + /** + * 关联值(可选) + */ + value?: string +} + +/** + * Mention 配置选项 + */ +export interface MentionOptions { + /** + * 提及项列表 + */ + items: MentionItem[] | Ref + + /** + * 触发字符,默认 '@' + */ + char: string + + /** + * 是否允许空格,默认 false + */ + allowSpaces?: boolean + + /** + * HTML 属性 + */ + HTMLAttributes?: Record +} + +/** + * Mention Suggestion 插件状态 + */ +export interface MentionSuggestionState { + /** + * 是否激活 + */ + active: boolean + + /** + * 触发范围 + */ + range: { from: number; to: number } | null + + /** + * 查询文本 + */ + query: string + + /** + * 过滤后的提及项列表 + */ + filteredItems: MentionItem[] +} + +// ===== 模块扩展声明 ===== + +/** + * 扩展 Tiptap Commands 接口 + * + * 使 TypeScript 能够识别自定义命令 + */ +declare module '@tiptap/core' { + interface Commands { + mention: { + /** + * 插入 mention 节点 + */ + insertMention: (attrs: Partial) => ReturnType + + /** + * 删除 mention 节点 + */ + deleteMention: (id: string) => ReturnType + } + } +} diff --git a/packages/components/src/sender/extensions/mention/utils.ts b/packages/components/src/sender/extensions/mention/utils.ts new file mode 100644 index 000000000..a64eb9ad7 --- /dev/null +++ b/packages/components/src/sender/extensions/mention/utils.ts @@ -0,0 +1,111 @@ +/** + * Mention 扩展工具函数 + */ + +import type { Editor } from '@tiptap/core' +import type { MentionItem, MentionStructuredItem } from './types' +import { EXTENSION_NAMES, NODE_TYPE_NAMES, USER_API_TYPES } from '../constants' + +/** + * 获取所有 mention 节点(辅助函数) + * + * 返回文档中所有的 mention 节点数据 + */ +export function getMentions(editor: Editor): MentionItem[] { + const mentions: MentionItem[] = [] + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + editor.state.doc.descendants((node: any) => { + if (node.type.name === NODE_TYPE_NAMES.MENTION) { + mentions.push({ + id: node.attrs.id as string, + label: node.attrs.label as string, + value: (node.attrs.value as string) || '', + }) + } + }) + + return mentions +} + +/** + * 获取包含 mention 标签的完整文本 + * + * 自动从编辑器中获取 mention 扩展的 char 配置 + * + * @param editor - 编辑器实例 + * + * @example + * getTextWithMentions(editor) // @代码分析 hello world @文本分析 12315 + * // 如果配置了 char: '#',则返回:#代码分析 hello world #文本分析 12315 + */ +export function getTextWithMentions(editor: Editor): string { + // 获取 mention 扩展的 char 配置 + const mentionExt = editor.extensionManager.extensions.find((ext) => ext.name === EXTENSION_NAMES.MENTION) + const char = mentionExt?.options?.char || '@' + + let text = '' + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + editor.state.doc.descendants((node: any) => { + if (node.type.name === NODE_TYPE_NAMES.MENTION) { + // Mention 节点 (atom: true):手动添加 char + label + // 因为 atom 节点在 getText() 中会被跳过 + text += `${char}${node.attrs.label as string}` + } else if (node.type.name === NODE_TYPE_NAMES.TEXT) { + // 文本节点:直接添加文本 + text += node.text || '' + } + }) + + return text.trim() +} + +/** + * 获取结构化数据(包含文本和 mention 的混合结构) + * + * 返回按顺序排列的文本和 mention 节点,用于确认内容和顺序 + * + * @example + * 输入:帮我分析 @张三 的周报(或 #标签 等,取决于 char 配置) + * 返回:[ + * { type: 'text', content: '帮我分析 ' }, + * { type: 'mention', content: '张三', value: '...' }, + * { type: 'text', content: ' 的周报' } + * ] + */ +export function getMentionStructuredData(editor: Editor): MentionStructuredItem[] { + const items: MentionStructuredItem[] = [] + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + editor.state.doc.descendants((node: any, _pos: number, parent: any) => { + // 只处理段落的直接子节点,避免重复收集 + if (parent && parent.type.name === NODE_TYPE_NAMES.PARAGRAPH) { + if (node.type.name === NODE_TYPE_NAMES.MENTION) { + // Mention 节点 + items.push({ + type: USER_API_TYPES.MENTION, + content: node.attrs.label as string, + value: (node.attrs.value as string) || '', + }) + } else if (node.type.name === NODE_TYPE_NAMES.TEXT) { + // 文本节点 + const text = node.text || '' + if (text) { + // 合并连续的文本节点 + const lastItem = items[items.length - 1] + if (lastItem && lastItem.type === USER_API_TYPES.TEXT) { + lastItem.content = (lastItem.content || '') + text + } else { + items.push({ + type: USER_API_TYPES.TEXT, + content: text, + }) + } + } + } + } + }) + + return items +} diff --git a/packages/components/src/sender/extensions/suggestion/extension.ts b/packages/components/src/sender/extensions/suggestion/extension.ts new file mode 100644 index 000000000..beec15e73 --- /dev/null +++ b/packages/components/src/sender/extensions/suggestion/extension.ts @@ -0,0 +1,68 @@ +/** + * Suggestion 扩展定义 + */ + +import { Extension } from '@tiptap/core' +import { watch, isRef, type WatchStopHandle } from 'vue' +import { createSuggestionPlugin, SuggestionPluginKey } from './plugin' +import type { SuggestionOptions } from './types' +import { EXTENSION_NAMES } from '../constants' +import './index.less' + +/** + * Suggestion 扩展定义 + * + * 支持全局匹配模式的智能联想功能 + */ +export const Suggestion = Extension.create({ + name: EXTENSION_NAMES.SUGGESTION, + + addOptions() { + return { + items: [], + activeSuggestionKeys: ['Enter'], + popupWidth: 400, + showAutoComplete: true, + } + }, + + // 添加 storage 用于存储实例状态 + addStorage() { + return { + watchStopHandle: null as WatchStopHandle | null, + } + }, + + onCreate() { + if (isRef(this.options.items)) { + // 保存到实例 storage + this.storage.watchStopHandle = watch( + this.options.items, + () => { + // 触发更新 + const tr = this.editor.state.tr + // 使用 SuggestionPluginKey 确保插件能正确接收更新 + tr.setMeta(SuggestionPluginKey, { type: 'update' }) + this.editor.view.dispatch(tr) + }, + { deep: true }, + ) + } + }, + + onDestroy() { + if (this.storage.watchStopHandle) { + this.storage.watchStopHandle() + this.storage.watchStopHandle = null + } + }, + + addProseMirrorPlugins() { + return [ + createSuggestionPlugin({ + editor: this.editor, + ...this.options, + }), + ] + }, +}) diff --git a/packages/components/src/sender/extensions/suggestion/index.less b/packages/components/src/sender/extensions/suggestion/index.less new file mode 100644 index 000000000..f6ace23bf --- /dev/null +++ b/packages/components/src/sender/extensions/suggestion/index.less @@ -0,0 +1,46 @@ +/** + * Suggestion 插件样式 + * + * 包含建议列表和自动补全提示的样式 + * CSS 变量统一在 src/styles/variables.css 中定义 + */ + +/** + * 自动补全提示 + * + * 通过 Decoration.widget 插入到文档中 + */ +.suggestion-autocomplete { + display: inline-block; + height: 16px; + pointer-events: none; + user-select: none; + color: var(--tr-suggestion-autocomplete-color); + white-space: nowrap; + + /** + * 补全文本 + * + * 灰色显示,表示待输入内容 + */ + .autocomplete-text { + color: inherit; + } + + /** + * Tab 按键提示 + * + * 虚线边框,提示用户可按 Tab 应用补全 + */ + .tab-hint { + display: inline; + margin-left: 8px; + padding: 4px 6px; + border: 1px dashed var(--tr-suggestion-tab-hint-border); + border-radius: 4px; + font-size: 12px; + color: var(--tr-suggestion-tab-hint-color); + background: var(--tr-suggestion-tab-hint-bg); + vertical-align: middle; + } +} diff --git a/packages/components/src/sender/extensions/suggestion/index.ts b/packages/components/src/sender/extensions/suggestion/index.ts new file mode 100644 index 000000000..18b3d32bf --- /dev/null +++ b/packages/components/src/sender/extensions/suggestion/index.ts @@ -0,0 +1,40 @@ +/** + * Suggestion 扩展 + * + * 为 Sender 提供智能联想功能 + */ + +import type { Ref } from 'vue' +import { Suggestion } from './extension' +import type { SenderSuggestionItem, SuggestionOptions } from './types' + +// ===== 导出扩展类和工具 ===== +export { Suggestion } from './extension' +export { SuggestionPluginKey } from './plugin' +export * from './types' +export { syncAutoComplete } from './utils/filter' +export { processHighlights, highlightSuggestionText, convertHighlightsArrayToTextParts } from './utils/highlight' + +// ===== 便捷函数 ===== + +/** + * 创建 Suggestion 扩展的便捷函数 + * + * @param items - 建议项列表 + * @param options - 其他配置项 + * + * @example + * ```typescript + * const extensions = [suggestion(suggestions)] + * const extensions = [suggestion(suggestions, { popupWidth: 500 })] + * ``` + */ +export function suggestion( + items: SenderSuggestionItem[] | Ref, + options?: Partial>, +) { + return Suggestion.configure({ + items, + ...options, + }) +} diff --git a/packages/components/src/sender/extensions/suggestion/plugin.ts b/packages/components/src/sender/extensions/suggestion/plugin.ts new file mode 100644 index 000000000..acfec717c --- /dev/null +++ b/packages/components/src/sender/extensions/suggestion/plugin.ts @@ -0,0 +1,507 @@ +/** + * Suggestion ProseMirror 插件 + * + * 实现建议列表的显示、过滤、选中等核心逻辑 + */ + +import { Plugin, PluginKey } from '@tiptap/pm/state' +import type { Transaction } from '@tiptap/pm/state' +import type { EditorView } from '@tiptap/pm/view' +import { Decoration, DecorationSet } from '@tiptap/pm/view' +import { VueRenderer } from '@tiptap/vue-3' +import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom' +import type { Editor } from '@tiptap/core' +import SuggestionList from './suggestion-list.vue' +import { syncAutoComplete } from './utils/filter' +import type { SuggestionOptions, SuggestionState, SenderSuggestionItem } from './types' +import { PLUGIN_KEY_NAMES, EXTENSION_NAMES } from '../constants' +import { isKey, isAnyKey } from '../utils' + +/** + * 插件 Key,用于访问插件状态 + */ +export const SuggestionPluginKey = new PluginKey(PLUGIN_KEY_NAMES.SUGGESTION) + +/** + * 插件配置接口 + */ +interface PluginOptions extends SuggestionOptions { + editor: Editor +} + +/** + * 创建 Suggestion 插件 + * + * @param options - 插件配置 + * @returns ProseMirror 插件 + */ +export function createSuggestionPlugin(options: PluginOptions): Plugin { + const { + editor, + activeSuggestionKeys = ['Enter'], + popupWidth = 400, + showAutoComplete = true, + filterFn, + onSelect, + } = options + + let component: VueRenderer | null = null + let popup: HTMLElement | null = null + let cleanup: (() => void) | null = null + let justClosed = false + + /** + * 获取当前的 suggestions(动态从 editor 的 extensionManager 中获取) + */ + function getCurrentSuggestions(): SenderSuggestionItem[] { + const suggestionExtension = editor.extensionManager.extensions.find( + (ext) => ext.name === EXTENSION_NAMES.SUGGESTION, + ) + const options = suggestionExtension?.options + const items = options?.items || options?.suggestions || [] + + // 处理 Ref (简单的 value 检查,避免引入 vue 依赖导致类型问题) + if (items && typeof items === 'object' && 'value' in items) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (items as any).value + } + + return items as SenderSuggestionItem[] + } + + /** + * 过滤建议项 + */ + function doFilterSuggestions(query: string): SenderSuggestionItem[] { + const suggestions = getCurrentSuggestions() + + // 如果提供了 filterFn,使用自定义过滤 + // 否则不过滤,直接返回所有项 + return filterFn ? filterFn(suggestions, query) : suggestions + } + + /** + * 获取当前查询文本 + */ + function getCurrentQuery(docQuery: string): string { + return docQuery + } + + /** + * 计算补全文本 + */ + function getAutoComplete( + selectedIndex: number, + query: string, + filteredSuggestions: SenderSuggestionItem[], + ): { text: string; show: boolean; showTab: boolean } { + if (selectedIndex === -1 || !filteredSuggestions[selectedIndex]) { + return { text: '', show: false, showTab: false } + } + + const selectedItem = filteredSuggestions[selectedIndex] + return syncAutoComplete(selectedItem.content, query) + } + + /** + * 插入建议内容 + */ + function insertSuggestion(_view: EditorView, range: { from: number; to: number } | null, item: SenderSuggestionItem) { + if (!range) return + + // 触发回调,返回 false 可阻止默认回填 + const shouldInsert = onSelect?.(item) !== false + + if (shouldInsert) { + editor.commands.setContent(item.content) + } + + editor.commands.focus() + } + + /** + * 选中并关闭建议列表 + */ + function selectAndClose(view: EditorView, state: SuggestionState) { + const selectedItem = state.filteredSuggestions[state.selectedIndex] + if (selectedItem) { + insertSuggestion(view, state.range, selectedItem) + } + + // 关闭建议列表 + const tr = view.state.tr + tr.setMeta(SuggestionPluginKey, { type: 'close' }) + view.dispatch(tr) + } + + /** + * 定位弹窗 + */ + function positionPopup(view: EditorView, popup: HTMLElement) { + // 清理旧的自动更新 + cleanup?.() + + // 查找编辑器包装容器(tr-sender-editor-wrapper) + const editorWrapper = view.dom.closest('.tr-sender') + const referenceElement = (editorWrapper as HTMLElement) || view.dom + + // 计算弹窗宽度(基于输入框宽度) + const calculatePopupWidth = (): string => { + if (typeof popupWidth === 'number') { + return `${popupWidth}px` + } + + // 如果是百分比或 '100%',基于 referenceElement 的宽度计算 + if (typeof popupWidth === 'string') { + if (popupWidth.endsWith('%')) { + const percentage = parseFloat(popupWidth) / 100 + const referenceWidth = referenceElement.offsetWidth + return `${referenceWidth * percentage}px` + } + return popupWidth + } + + return '400px' // 默认值 + } + + // 设置自动更新 + cleanup = autoUpdate(referenceElement, popup, () => { + computePosition(referenceElement, popup, { + placement: 'top-start', + middleware: [ + offset(8), + flip({ + fallbackPlacements: ['bottom-start', 'top-start'], + }), + shift({ padding: 8 }), + ], + }).then(({ x, y }) => { + // 设置定位和宽度样式 + popup.style.position = 'absolute' + popup.style.left = `${x}px` + popup.style.top = `${y}px` + popup.style.zIndex = '2000' + popup.style.width = calculatePopupWidth() // 基于输入框宽度计算 + }) + }) + } + + /** + * 创建自动补全装饰器 + */ + function createAutoCompleteDecorations(state: SuggestionState): DecorationSet { + if (!showAutoComplete || !state.active || !state.autoCompleteText || !state.range) { + return DecorationSet.empty + } + + const doc = editor.state.doc + const { selection } = editor.state + const cursorPos = selection.$head.pos + + // 在全局模式下,只有当光标在文档末尾时才显示补全提示 + // 这样可以避免用户移动光标时出现补全文本插入到中间的问题 + const isAtEnd = cursorPos >= doc.content.size - 1 + if (!isAtEnd) { + return DecorationSet.empty + } + + // 创建补全提示元素 + const widget = Decoration.widget( + cursorPos, + () => { + const container = document.createElement('span') + container.className = 'suggestion-autocomplete' + container.contentEditable = 'false' + + // 补全文本 + const complete = document.createElement('span') + complete.className = 'autocomplete-text' + complete.textContent = state.autoCompleteText + container.appendChild(complete) + + // Tab 提示 + if (state.showTabIndicator) { + const tabHint = document.createElement('span') + tabHint.className = 'tab-hint' + tabHint.textContent = 'TAB' + container.appendChild(tabHint) + } + + return container + }, + { + side: 1, // 显示在光标右侧 + }, + ) + + return DecorationSet.create(doc, [widget]) + } + + return new Plugin({ + key: SuggestionPluginKey, + + state: { + init(): SuggestionState { + return { + active: false, + range: null, + query: '', + filteredSuggestions: [], + selectedIndex: -1, + autoCompleteText: '', + showTabIndicator: false, + } + }, + + apply(tr: Transaction, state: SuggestionState): SuggestionState { + // 检查是否有 meta 更新 + const meta = tr.getMeta(SuggestionPluginKey) + + if (meta) { + // 关闭建议列表 + if (meta.type === 'close') { + justClosed = true + setTimeout(() => { + justClosed = false + }, 0) + return { + active: false, + range: null, + query: '', + filteredSuggestions: [], + selectedIndex: -1, + autoCompleteText: '', + showTabIndicator: false, + } + } + + // 更新选中索引 + if (meta.type === 'updateIndex') { + const newState = { ...state, selectedIndex: meta.index } + const autoComplete = getAutoComplete(meta.index, state.query, state.filteredSuggestions) + return { + ...newState, + autoCompleteText: autoComplete.text, + showTabIndicator: autoComplete.showTab, + } + } + } + + // 保持关闭状态,防止立即重新打开 + if (justClosed) { + return state + } + + // 如果文档没有变化,保持状态 + if (!tr.docChanged && !tr.selectionSet) { + return state + } + + // 全局模式:提取完整文本 + const docQuery = tr.doc.textContent.trim() + + // 获取当前查询文本 + const query = getCurrentQuery(docQuery) + + // ✅ 如果输入框为空,关闭建议列表(所有模式都适用) + if (!docQuery) { + return { + active: false, + range: null, + query: '', + filteredSuggestions: [], + selectedIndex: -1, + autoCompleteText: '', + showTabIndicator: false, + } + } + + // 过滤建议项 + const filteredSuggestions = doFilterSuggestions(query) + + // 如果没有匹配项,关闭建议列表 + if (filteredSuggestions.length === 0) { + return { + active: false, + range: null, + query: '', + filteredSuggestions: [], + selectedIndex: -1, + autoCompleteText: '', + showTabIndicator: false, + } + } + + // 计算补全文本 + const autoComplete = getAutoComplete(0, query, filteredSuggestions) + + return { + active: true, + range: { from: 0, to: tr.doc.content.size }, + query, + filteredSuggestions, + selectedIndex: 0, + autoCompleteText: autoComplete.text, + showTabIndicator: autoComplete.showTab, + } + }, + }, + + props: { + decorations(state) { + const pluginState = this.getState(state) + return createAutoCompleteDecorations( + pluginState || { + active: false, + range: null, + query: '', + filteredSuggestions: [], + selectedIndex: -1, + autoCompleteText: '', + showTabIndicator: false, + }, + ) + }, + + handleKeyDown(view: EditorView, event: KeyboardEvent): boolean { + const state = SuggestionPluginKey.getState(view.state) + + if (!state?.active) { + return false + } + + // Tab 键:应用自动补全 + if (isKey(event, 'TAB') && state.autoCompleteText) { + event.preventDefault() + selectAndClose(view, state) + return true + } + + // ↑↓ 键:导航 + if (isAnyKey(event, ['ARROW_UP', 'ARROW_DOWN'])) { + event.preventDefault() + + const direction = isKey(event, 'ARROW_DOWN') ? 1 : -1 + const length = state.filteredSuggestions.length + + // 计算新索引(循环) + let newIndex = state.selectedIndex + direction + if (newIndex < 0) { + newIndex = length - 1 + } else if (newIndex >= length) { + newIndex = 0 + } + + // 更新状态 + const tr = view.state.tr + tr.setMeta(SuggestionPluginKey, { + type: 'updateIndex', + index: newIndex, + }) + view.dispatch(tr) + + return true + } + + // 快捷键选中建议项 + if (activeSuggestionKeys.includes(event.key)) { + event.preventDefault() + selectAndClose(view, state) + return true + } + + // Esc 键:关闭 + if (isKey(event, 'ESCAPE')) { + event.preventDefault() + + const tr = view.state.tr + tr.setMeta(SuggestionPluginKey, { type: 'close' }) + view.dispatch(tr) + + return true + } + + return false + }, + }, + + view() { + return { + update(view: EditorView) { + const state = SuggestionPluginKey.getState(view.state) + + if (state?.active && state.filteredSuggestions.length > 0) { + // 创建或更新弹窗 + if (!component) { + component = new VueRenderer(SuggestionList, { + props: { + show: state.active && state.filteredSuggestions.length > 0, + suggestions: state.filteredSuggestions, + popupStyle: { + // 宽度在 computePosition 回调中动态设置,这里只设置 maxWidth + maxWidth: '100%', + }, + activeKeyboardIndex: state.selectedIndex, + activeMouseIndex: -1, + inputValue: state.query, + onSelect: (item: SenderSuggestionItem) => { + insertSuggestion(view, state.range, item) + + // 关闭建议列表 + const tr = view.state.tr + tr.setMeta(SuggestionPluginKey, { type: 'close' }) + view.dispatch(tr) + }, + onMouseEnter: (index: number) => { + const tr = view.state.tr + tr.setMeta(SuggestionPluginKey, { type: 'updateIndex', index }) + view.dispatch(tr) + }, + }, + editor, + }) + + popup = component.element as HTMLElement + document.body.appendChild(popup) + } else { + // 更新 props + component.updateProps({ + show: state.active && state.filteredSuggestions.length > 0, + suggestions: state.filteredSuggestions, + popupStyle: { + // 宽度在 computePosition 回调中动态设置,这里只设置 maxWidth + maxWidth: '100%', + }, + activeKeyboardIndex: state.selectedIndex, + inputValue: state.query, + }) + } + + // 定位弹窗 + if (popup) { + positionPopup(view, popup) + } + } else { + // 销毁弹窗 + if (component) { + cleanup?.() + cleanup = null + component.destroy() + component = null + } + if (popup) { + popup.remove() + popup = null + } + } + }, + + destroy() { + cleanup?.() + component?.destroy() + popup?.remove() + }, + } + }, + }) +} diff --git a/packages/components/src/sender/components/SuggestionList.vue b/packages/components/src/sender/extensions/suggestion/suggestion-list.vue similarity index 57% rename from packages/components/src/sender/components/SuggestionList.vue rename to packages/components/src/sender/extensions/suggestion/suggestion-list.vue index 13d968f34..f46836f7c 100644 --- a/packages/components/src/sender/components/SuggestionList.vue +++ b/packages/components/src/sender/extensions/suggestion/suggestion-list.vue @@ -1,46 +1,97 @@ - - diff --git a/packages/components/src/sender/extensions/suggestion/types.ts b/packages/components/src/sender/extensions/suggestion/types.ts new file mode 100644 index 000000000..c26019063 --- /dev/null +++ b/packages/components/src/sender/extensions/suggestion/types.ts @@ -0,0 +1,273 @@ +/** + * Suggestion 插件类型定义 + * + * 包含建议项、高亮、插件配置和状态等类型定义 + */ + +/** + * 高亮文本片段 + */ +export interface SuggestionTextPart { + text: string + isMatch: boolean +} + +/** + * 高亮函数类型 + * + * @param suggestionText - 建议项文本 + * @param inputText - 用户输入文本 + * @returns 包含文本片段和匹配状态的数组 + */ +export type HighlightFunction = (suggestionText: string, inputText: string) => SuggestionTextPart[] + +/** + * 建议项类型 + * + * @example + * ```typescript + * // 自动匹配 + * { content: 'ECS-云服务器' } + * + * // 精确指定高亮 + * { + * content: 'ECS-云服务器', + * highlights: ['ECS', '云服务器'] + * } + * + * // 自定义高亮函数 + * { + * content: 'ECS-云服务器', + * highlights: (text, query) => [ + * { text: 'ECS', isMatch: true }, + * { text: '-云服务器', isMatch: false } + * ] + * } + * ``` + */ +export interface SenderSuggestionItem { + /** + * 建议项内容(必填) + */ + content: string + + /** + * 显示标签(可选) + * + * 默认使用 content + */ + label?: string + + /** + * 高亮方式(可选) + * + * - undefined: 自动匹配(默认) + * - string[]: 精确指定高亮片段 + * - function: 自定义高亮逻辑 + */ + highlights?: string[] | HighlightFunction + + /** + * 自定义数据(可选) + * + * 用于扩展功能 + */ + data?: Record +} + +/** + * 插件状态 + * + * 管理建议列表的显示、过滤、选中等状态 + */ +export interface SuggestionState { + /** + * 是否激活(有匹配的建议项) + */ + active: boolean + + /** + * 匹配范围 + * + * 全局模式:整个文档范围 + * 字符模式:触发字符到光标的范围 + */ + range: { from: number; to: number } | null + + /** + * 查询文本 + * + * 全局模式:整个输入内容 + * 字符模式:触发字符后的文本 + */ + query: string + + /** + * 过滤后的建议项 + */ + filteredSuggestions: SenderSuggestionItem[] + + /** + * 当前选中的建议项索引 + * + * -1 表示未选中 + */ + selectedIndex: number + + /** + * 自动补全文本 + * + * 选中项的剩余部分 + */ + autoCompleteText: string + + /** + * 是否显示 Tab 提示 + */ + showTabIndicator: boolean +} + +import type { Ref } from 'vue' + +/** + * 插件配置选项 + */ +export interface SuggestionOptions { + /** + * 建议项列表(可选) + * + * 不传或传空数组时,建议功能不会显示 + * + * @default [] + * + * @example + * ```typescript + * const items = ref([ + * { content: 'ECS-云服务器' }, + * { content: 'RDS-数据库' } + * ]) + * ``` + */ + items?: SenderSuggestionItem[] | Ref + + /** + * 自定义过滤函数(可选) + * + * - 不传:不过滤,直接显示所有项 + * - 传入:使用自定义过滤逻辑 + * + * @default undefined(不过滤) + * + * @example 模糊匹配过滤 + * ```typescript + * filterFn: (items, query) => { + * return items.filter(item => + * item.content.toLowerCase().includes(query.toLowerCase()) + * ) + * } + * ``` + * + * @example 前缀匹配过滤 + * ```typescript + * filterFn: (items, query) => { + * return items.filter(item => + * item.content.toLowerCase().startsWith(query.toLowerCase()) + * ) + * } + * ``` + */ + filterFn?: (suggestions: SenderSuggestionItem[], query: string) => SenderSuggestionItem[] + + /** + * 选中建议项的按键 + * + * 注意:Tab 键用于自动补全,不受此配置控制 + * + * @default ['Enter'] + * + * @example 只允许 Enter 选中 + * ```typescript + * activeSuggestionKeys: ['Enter'] + * ``` + * + * @example 允许 Enter 和 Space 选中 + * ```typescript + * activeSuggestionKeys: ['Enter', ' '] // 注意:空格键是 ' ' + * ``` + * + * @example 禁用所有选中按键(只能点击) + * ```typescript + * activeSuggestionKeys: [] + * ``` + */ + activeSuggestionKeys?: string[] + + /** + * 弹窗宽度 + * + * @default 400 + */ + popupWidth?: number | string + + /** + * 是否显示自动补全提示 + * + * @default true + */ + showAutoComplete?: boolean + + /** + * 选中建议项的回调 + * + * @param item - 选中的建议项(包含完整的 SenderSuggestionItem 信息) + * @returns 返回 false 可阻止默认回填行为 + * + * @example 默认行为(自动回填) + * ```typescript + * onSelect: (item) => { + * console.log('Selected:', item) + * // 不返回 false,内容会自动回填 + * } + * ``` + * + * @example 阻止默认行为并自定义回填 + * ```typescript + * onSelect: (item) => { + * editor.commands.setContent(`前缀-${item.content}-后缀`) + * return false // 阻止默认回填 + * } + * ``` + * + * @example 条件性阻止 + * ```typescript + * onSelect: (item) => { + * if (item.data?.needsValidation) { + * validateAndFill(item) + * return false + * } + * // 否则使用默认回填 + * } + * ``` + */ + onSelect?: (item: SenderSuggestionItem) => void | false +} + +/** + * 插件 Key 类型 + * + * 用于访问插件状态 + */ +export interface SuggestionPluginKeyType { + getState: (state: EditorState) => SuggestionState | undefined +} + +/** + * EditorState 类型(来自 @tiptap/pm/state) + */ +export interface EditorState { + doc: unknown + selection: unknown + storedMarks: unknown + schema: unknown + [key: string]: unknown +} diff --git a/packages/components/src/sender/extensions/suggestion/utils/filter.ts b/packages/components/src/sender/extensions/suggestion/utils/filter.ts new file mode 100644 index 000000000..623c5c008 --- /dev/null +++ b/packages/components/src/sender/extensions/suggestion/utils/filter.ts @@ -0,0 +1,57 @@ +/** + * 计算自动补全文本 + * + * 检查选中项是否以输入内容开头,如果是则返回剩余部分 + * + * @param selectedSuggestion - 选中的建议项内容 + * @param inputText - 用户输入的文本 + * @returns 补全信息 + * + * @example + * ```typescript + * // 有补全文本 + * syncAutoComplete('ECS-云服务器', 'ECS') + * // { text: '-云服务器', show: true, showTab: true } + * + * // 无补全文本(输入完整) + * syncAutoComplete('ECS', 'ECS') + * // { text: '', show: false, showTab: false } + * + * // 不匹配 + * syncAutoComplete('ECS-云服务器', 'CDN') + * // { text: '', show: false, showTab: false } + * ``` + */ +export const syncAutoComplete = ( + selectedSuggestion: string, + inputText: string, +): { + text: string + show: boolean + showTab: boolean +} => { + // 基础检查 + if (!selectedSuggestion || !inputText) { + return { text: '', show: false, showTab: false } + } + + // 检查前缀匹配(忽略大小写) + const lowerSuggestion = selectedSuggestion.toLowerCase() + const lowerInput = inputText.toLowerCase() + + if (!lowerSuggestion.startsWith(lowerInput)) { + return { text: '', show: false, showTab: false } + } + + // 提取剩余部分 + const suffix = selectedSuggestion.substring(inputText.length) + + // 判断是否显示 + const shouldShow = suffix.length > 0 + + return { + text: suffix, + show: shouldShow, + showTab: shouldShow, + } +} diff --git a/packages/components/src/sender/utils/suggestionHighlight.ts b/packages/components/src/sender/extensions/suggestion/utils/highlight.ts similarity index 64% rename from packages/components/src/sender/utils/suggestionHighlight.ts rename to packages/components/src/sender/extensions/suggestion/utils/highlight.ts index 1e617f252..f243a832d 100644 --- a/packages/components/src/sender/utils/suggestionHighlight.ts +++ b/packages/components/src/sender/extensions/suggestion/utils/highlight.ts @@ -1,10 +1,30 @@ -import type { ISuggestionItem, SuggestionTextPart } from '../index.type' +/** + * 高亮处理工具 + * + * 复用自 Sender 组件,提供三种高亮模式: + * 1. 自动匹配:根据输入内容自动高亮 + * 2. 精确指定:通过数组指定要高亮的文本片段 + * 3. 自定义函数:完全自定义高亮逻辑 + */ + +import type { SenderSuggestionItem, SuggestionTextPart } from '../types' /** * 将预定义的高亮字符串数组转换为文本片段 + * * @param content - 完整的建议文本 * @param highlights - 需要高亮的文本片段数组 * @returns 包含文本片段和匹配状态的数组 + * + * @example + * ```typescript + * const result = convertHighlightsArrayToTextParts('ECS-云服务器', ['ECS', '云服务器']) + * // [ + * // { text: 'ECS', isMatch: true }, + * // { text: '-', isMatch: false }, + * // { text: '云服务器', isMatch: true } + * // ] + * ``` */ export const convertHighlightsArrayToTextParts = (content: string, highlights: string[]): SuggestionTextPart[] => { if (!highlights.length) { @@ -91,10 +111,20 @@ export const convertHighlightsArrayToTextParts = (content: string, highlights: s } /** - * 处理建议项文本高亮 + * 处理建议项文本高亮(自动匹配模式) + * * @param suggestionText - 建议文本 * @param inputText - 输入文本 * @returns 包含文本片段和匹配状态的数组 + * + * @example + * ```typescript + * const result = highlightSuggestionText('ECS-云服务器', 'ECS') + * // [ + * // { text: 'ECS', isMatch: true }, + * // { text: '-云服务器', isMatch: false } + * // ] + * ``` */ export const highlightSuggestionText = (suggestionText: string, inputText: string): SuggestionTextPart[] => { if (!inputText || !suggestionText) { @@ -106,11 +136,41 @@ export const highlightSuggestionText = (suggestionText: string, inputText: strin /** * 处理建议项的高亮 + * + * 支持三种高亮模式: + * 1. 自动匹配:根据输入内容自动高亮 + * 2. 精确指定:通过数组指定要高亮的文本片段 + * 3. 自定义函数:完全自定义高亮逻辑 + * * @param item - 建议项 * @param inputText - 用户输入文本 * @returns 包含文本片段和匹配状态的数组 + * + * @example + * ```typescript + * // 自动匹配 + * processHighlights({ content: 'ECS-云服务器' }, 'ECS') + * + * // 精确指定 + * processHighlights( + * { content: 'ECS-云服务器', highlights: ['ECS', '云服务器'] }, + * 'ECS' + * ) + * + * // 自定义函数 + * processHighlights( + * { + * content: 'ECS-云服务器', + * highlights: (text, query) => [ + * { text: 'ECS', isMatch: true }, + * { text: '-云服务器', isMatch: false } + * ] + * }, + * 'ECS' + * ) + * ``` */ -export const processHighlights = (item: ISuggestionItem, inputText: string): SuggestionTextPart[] => { +export const processHighlights = (item: SenderSuggestionItem, inputText: string): SuggestionTextPart[] => { const { content, highlights } = item // 情况1:使用自定义高亮函数 @@ -123,6 +183,6 @@ export const processHighlights = (item: ISuggestionItem, inputText: string): Sug return convertHighlightsArrayToTextParts(content, highlights) } - // 情况3:使用默认高亮函数 + // 情况3:使用默认高亮函数(自动匹配) return highlightSuggestionText(content, inputText) } diff --git a/packages/components/src/sender/extensions/template/block/extension.ts b/packages/components/src/sender/extensions/template/block/extension.ts new file mode 100644 index 000000000..01aa4232d --- /dev/null +++ b/packages/components/src/sender/extensions/template/block/extension.ts @@ -0,0 +1,105 @@ +/** + * TemplateBlock 扩展定义(可编辑块) + */ + +import { Node, mergeAttributes } from '@tiptap/core' +import { VueNodeViewRenderer } from '@tiptap/vue-3' +import { watch, isRef } from 'vue' +import type { TemplateOptions } from '../types' +import TemplateBlockView from './template-block-view.vue' +import { ensureZeroWidthChars, keyboardNavigationPlugin, pasteHandlerPlugin } from './plugins' +import { NODE_TYPE_NAMES } from '../../constants' + +/** + * TemplateBlock 节点定义(可编辑块) + */ +export const TemplateBlock = Node.create({ + name: NODE_TYPE_NAMES.TEMPLATE_BLOCK, + + // 节点配置 + group: 'inline', + inline: true, + content: 'text*', // 允许内部有文本内容 + atom: false, // 不是 atom 节点,允许光标进入 + selectable: true, + draggable: false, + + onCreate() { + const { items } = this.options + + if (items && isRef(items)) { + watch( + items, + () => { + const currentItems = isRef(items) ? items.value : items + if (currentItems !== null && currentItems !== undefined) { + this.editor.commands.setTemplateData(currentItems) + this.editor.commands.focusFirstTemplate() + } + }, + { deep: true, immediate: true }, + ) + } + }, + + // 节点属性 + addAttributes() { + return { + id: { + default: null, + parseHTML: (element) => element.getAttribute('data-id'), + renderHTML: (attributes) => { + if (!attributes.id) { + return {} + } + return { + 'data-id': attributes.id, + } + }, + }, + content: { + default: '', + parseHTML: (element) => element.getAttribute('data-content') || element.textContent, + renderHTML: (attributes) => { + return { + 'data-content': attributes.content, + } + }, + }, + } + }, + + // HTML 解析 + parseHTML() { + return [ + { + tag: 'span[data-template]', + }, + ] + }, + + // HTML 渲染 + renderHTML({ node, HTMLAttributes }) { + const content = node.textContent || '' + return [ + 'span', + mergeAttributes(this.options.HTMLAttributes || {}, HTMLAttributes, { + 'data-template': '', + 'data-id': node.attrs.id as string, + 'data-content': content, + }), + content, + ] + }, + + // 使用 Vue 组件渲染 + addNodeView() { + // @ts-expect-error - Vue SFC type compatibility + return VueNodeViewRenderer(TemplateBlockView) + }, + + // 添加插件 + addProseMirrorPlugins() { + return [ensureZeroWidthChars(), keyboardNavigationPlugin(), pasteHandlerPlugin()] + }, +}) diff --git a/packages/components/src/sender/extensions/template/block/plugins.ts b/packages/components/src/sender/extensions/template/block/plugins.ts new file mode 100644 index 000000000..c43c8a7a4 --- /dev/null +++ b/packages/components/src/sender/extensions/template/block/plugins.ts @@ -0,0 +1,643 @@ +/** + * TemplateBlock 插件 + * 管理零宽字符和光标行为 + */ + +import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state' +import type { EditorState, Transaction } from '@tiptap/pm/state' +import type { EditorView } from '@tiptap/pm/view' +import type { Node as PMNode } from '@tiptap/pm/model' +import { ZERO_WIDTH_CHAR } from '../utils' +import { NODE_TYPE_NAMES, PLUGIN_KEY_NAMES } from '../../constants' +import { isKey, isAnyKey } from '../../utils' + +/** + * 处理零宽字符逻辑 + * 确保模板块前后有零宽字符,保证光标可以正确定位 + */ +function handleZeroWidthCharLogic(newState: EditorState): Transaction | null { + const todoPositions: Array = [] + let { tr } = newState + + newState.doc.descendants((node: PMNode, pos: number, parent: PMNode | null) => { + if (node.type.name === NODE_TYPE_NAMES.PARAGRAPH && node.childCount > 0) { + const { lastChild, firstChild } = node + + // 如果第一个 child 是模板块,在其前添加零宽字符 + if (firstChild && firstChild.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK) { + todoPositions.push(pos + 1) + } + + // 如果最后一个 child 是模板块,在其后添加零宽字符 + if (lastChild && lastChild.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK) { + const paragraphEndPos = pos + node.nodeSize - 1 + const prevChar = tr.doc.textBetween(paragraphEndPos - 1, paragraphEndPos, '', '') + if (prevChar !== ZERO_WIDTH_CHAR) { + todoPositions.push(paragraphEndPos) + } + } + + // 如果段落只有一个零宽字符,删除它 + if (lastChild === firstChild && lastChild && lastChild.isText && lastChild.text === ZERO_WIDTH_CHAR) { + todoPositions.push(['remove', pos + 1]) + } + } + + // 如果模板块内容为空,插入零宽字符占位 + if (node.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK && node.content.size === 0) { + todoPositions.push(pos + 1) + } + + // 如果模板块后面有其他节点,在中间插入零宽字符 + if (node.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK && parent) { + let nodeIndex = -1 + parent.forEach((child: PMNode, _offset: number, i: number) => { + if (child === node) { + nodeIndex = i + } + }) + + if (nodeIndex > -1 && nodeIndex < parent.childCount - 1) { + const nextSibling = parent.child(nodeIndex + 1) + // 只在连续两个模板块之间插入零宽字符 + // 不在模板块和文本之间插入,避免零宽字符被合并到文本节点 + if (nextSibling.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK) { + const nextPos = pos + node.nodeSize + // 检查是否已经有零宽字符 + const existingChar = tr.doc.textBetween(nextPos, nextPos + 1, '', '') + if (existingChar !== ZERO_WIDTH_CHAR) { + todoPositions.push(nextPos) + } + } + } + } + }) + + if (todoPositions.length > 0) { + // 从后往前处理,避免位置偏移 + todoPositions + .sort((a, b) => { + const aOrder = Array.isArray(a) ? a[1] : a + const bOrder = Array.isArray(b) ? b[1] : b + return bOrder - aOrder + }) + .forEach((insertPos) => { + if (Array.isArray(insertPos) && insertPos[0] === 'remove') { + tr = tr.delete(insertPos[1], insertPos[1] + 1) + } else if (typeof insertPos === 'number') { + tr = tr.insertText(ZERO_WIDTH_CHAR, insertPos, insertPos) + } + }) + return tr + } + + return null +} + +/** + * 零宽字符管理插件 + */ +export function ensureZeroWidthChars() { + return new Plugin({ + key: new PluginKey(PLUGIN_KEY_NAMES.TEMPLATE_BLOCK_ZERO_WIDTH), + appendTransaction(transactions: readonly Transaction[], _oldState: EditorState, newState: EditorState) { + // 只在内容发生变化时修正 + const docChanged = transactions.some((tr) => tr.docChanged) + if (!docChanged) return null + + return handleZeroWidthCharLogic(newState) + }, + }) +} + +/** + * 键盘导航插件 + */ +export function keyboardNavigationPlugin() { + return new Plugin({ + key: new PluginKey(PLUGIN_KEY_NAMES.TEMPLATE_BLOCK_KEYBOARD), + props: { + handleKeyDown(view: EditorView, event: KeyboardEvent) { + const { state, dispatch } = view + const { selection } = state + const { $from } = selection + + // 处理左箭头 + if (isKey(event, 'ARROW_LEFT')) { + if ($from.nodeBefore && $from.nodeBefore.isText && $from.nodeBefore.text) { + if ($from.nodeBefore.text === ZERO_WIDTH_CHAR) { + const parent = $from.parent + const index = $from.index() + + if (index >= 2) { + const secondBeforeCursorNode = parent.child(index - 2) + if (secondBeforeCursorNode.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK) { + // 进入模板块末尾(跳过零宽字符,进入节点内部) + const nextCursorPos = $from.pos - 2 + dispatch(state.tr.setSelection(TextSelection.create(state.doc, nextCursorPos))) + event.preventDefault() + return true + } + } else if (index === 1 && $from.pos !== 0) { + // 跳到上一个段落 + const nextCursorPos = $from.before() - 1 + // 防止位置越界 + if (nextCursorPos >= 0) { + dispatch(state.tr.setSelection(TextSelection.create(state.doc, nextCursorPos))) + event.preventDefault() + return true + } + } + } + } + } + + // 处理右箭头 + if (isKey(event, 'ARROW_RIGHT')) { + if ($from.nodeAfter && $from.nodeAfter.isText) { + if ($from.nodeAfter.text === ZERO_WIDTH_CHAR) { + const parent = $from.parent + const index = $from.index() + + if (index < parent.childCount - 1) { + const secondAfterCursorNode = parent.child(index + 1) + if (secondAfterCursorNode.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK) { + // 进入模板块开头(跳过零宽字符,进入节点内部) + const newPos = $from.pos + 2 + dispatch(state.tr.setSelection(TextSelection.create(state.doc, newPos))) + event.preventDefault() + return true + } + } else if (index === parent.childCount - 1 && state.doc.lastChild !== $from.node()) { + // 跳到下一个段落 + const nextCursorPos = $from.after() + 1 + dispatch(state.tr.setSelection(TextSelection.create(state.doc, nextCursorPos))) + event.preventDefault() + return true + } + } + } + } + + // 处理光标在模板块内部时的方向键导航 + const currentNode = $from.node() + if (currentNode.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK) { + const content = currentNode.textContent || '' + + // 场景1: 模板块为空或只有零宽字符时,按左右箭头键直接跳出节点 + if (content === '' || content === ZERO_WIDTH_CHAR) { + if (isAnyKey(event, ['ARROW_LEFT', 'ARROW_RIGHT'])) { + const pos = isKey(event, 'ARROW_LEFT') ? $from.before() : $from.after() + // 检查是否需要跳转(避免重复跳转) + if (selection.from !== pos) { + dispatch(state.tr.setSelection(TextSelection.create(state.doc, pos))) + event.preventDefault() + return true + } + } + } + // 场景2: 模板块有内容时,处理边界的箭头键导航 + else { + // 光标在模板块最左侧,按左箭头,跳出到模板块前 + if (isKey(event, 'ARROW_LEFT') && $from.pos === $from.start()) { + const pos = $from.before() + dispatch(state.tr.setSelection(TextSelection.create(state.doc, pos))) + event.preventDefault() + return true + } + // 光标在模板块最右侧,按右箭头,跳出到模板块后 + if (isKey(event, 'ARROW_RIGHT') && $from.pos === $from.end()) { + const pos = $from.after() + dispatch(state.tr.setSelection(TextSelection.create(state.doc, pos))) + event.preventDefault() + return true + } + } + } + + // 处理 Backspace + if (isKey(event, 'BACKSPACE') && selection.empty) { + const currentNode = $from.node() + const beforeNode = $from.nodeBefore + + // 如果光标在模板块内部 + if (currentNode.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK) { + const content = currentNode.textContent || '' + + // 删除最后一个字符时,插入零宽字符(保留模板块) + if ($from.pos === $from.end() && content.length === 1 && content !== ZERO_WIDTH_CHAR) { + const pos = $from.pos - 1 + dispatch(state.tr.insertText(ZERO_WIDTH_CHAR, pos, pos + 1)) + event.preventDefault() + return true + } + + // 如果内容只剩零宽字符,再次删除时跳出到模板块前(保留模板块) + if (content === ZERO_WIDTH_CHAR) { + const nodePos = $from.before() + const tr = state.tr.setSelection(TextSelection.create(state.doc, nodePos)) + dispatch(tr) + event.preventDefault() + return true + } + + // 如果模板块为空,首次按 Backspace 时跳出到模板块前 + // 注意:此时零宽字符可能还未插入,需要单独处理 + if (content === '') { + const nodePos = $from.before() + const tr = state.tr.setSelection(TextSelection.create(state.doc, nodePos)) + dispatch(tr) + event.preventDefault() + return true + } + + // 如果光标在模板块开头,且有内容,跳出到模板块前 + // 防止 ProseMirror 默认行为导致前面的文本被吸入模板块 + if ($from.pos === $from.start() && content.length > 0 && content !== ZERO_WIDTH_CHAR) { + const nodePos = $from.before() + dispatch(state.tr.setSelection(TextSelection.create(state.doc, nodePos))) + event.preventDefault() + return true + } + + // 其他情况:让 ProseMirror 默认处理(光标在模板块中间,正常删除字符) + return false + } + + // 删除模板块前的单个字符时,保留零宽字符 + if ( + beforeNode && + beforeNode.isText && + beforeNode.text?.length === 1 && + beforeNode.text !== ZERO_WIDTH_CHAR && + $from.nodeAfter && + $from.nodeAfter.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK + ) { + const begin = $from.pos - beforeNode.nodeSize + const end = $from.pos + let tr = state.tr.delete(begin, end) + tr = tr.insertText(ZERO_WIDTH_CHAR, begin, begin) + dispatch(tr) + event.preventDefault() + return true + } + + // 从右侧删除模板块 + if (beforeNode && beforeNode.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK) { + const content = beforeNode.textContent || '' + // 判断是否为空:排除零宽字符 + const isEmpty = content.length === 0 || content === ZERO_WIDTH_CHAR + + // 如果模板块无内容,删除整个模板块 + if (isEmpty) { + const parent = $from.parent + const index = $from.index() + const afterNode = $from.nodeAfter + let deleteStart = $from.pos - beforeNode.nodeSize + let deleteEnd = $from.pos + + // 只删除模板块前的零宽字符,不删除其他文本节点 + // 检查前面是否有零宽字符(必须是零宽字符,不能是其他文本) + if (index > 1) { + const prevPrevNode = parent.child(index - 2) + if (prevPrevNode && prevPrevNode.isText && prevPrevNode.text === ZERO_WIDTH_CHAR) { + deleteStart = deleteStart - 1 + } + } + + // 检查后面是否有零宽字符 + if (afterNode && afterNode.isText && afterNode.text?.startsWith(ZERO_WIDTH_CHAR)) { + deleteEnd = deleteEnd + 1 + } + + dispatch(state.tr.delete(deleteStart, deleteEnd)) + event.preventDefault() + return true + } + // 如果有内容,将光标移动到模板块末尾(进入模板块) + else { + const targetPos = $from.pos - 1 // 模板块末尾位置 + dispatch(state.tr.setSelection(TextSelection.create(state.doc, targetPos))) + event.preventDefault() + return true + } + } + + // 删除零宽字符前的模板块 + if (beforeNode && beforeNode.isText) { + const parent = $from.parent + const index = $from.index() + + // 只处理纯零宽字符的情况,不处理包含实际文本的节点 + if (beforeNode.text === ZERO_WIDTH_CHAR) { + // 检查前面是否有模板块(隔着零宽字符) + if (index > 1) { + const prevPrevNode = parent.child(index - 2) + + if (prevPrevNode.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK) { + const content = prevPrevNode.textContent || '' + const isEmpty = content.length === 0 || content === ZERO_WIDTH_CHAR + + // 如果模板块无内容,删除整个模板块和零宽字符 + if (isEmpty) { + const deleteStart = $from.pos - beforeNode.nodeSize - prevPrevNode.nodeSize + const afterNode = $from.nodeAfter + let deleteEnd = $from.pos + + // 检查后面是否有零宽字符 + if (afterNode && afterNode.isText && afterNode.text === ZERO_WIDTH_CHAR) { + deleteEnd = deleteEnd + 1 + } + + dispatch(state.tr.delete(deleteStart, deleteEnd)) + event.preventDefault() + return true + } + // 如果有内容,跳过零宽字符进入模板块 + else { + const nextCursorPos = $from.pos - 2 + // 防止位置越界 + if (nextCursorPos >= 0) { + dispatch(state.tr.setSelection(TextSelection.create(state.doc, nextCursorPos))) + event.preventDefault() + return true + } + } + } + } + } else if (index === 1 && $from.pos !== 1 && beforeNode.text === ZERO_WIDTH_CHAR) { + // 删除换行和零宽字符 + const startPos = selection.from - 1 - 2 + // 防止位置越界 + if (startPos >= 0) { + dispatch(state.tr.delete(startPos, selection.to)) + event.preventDefault() + return true + } + } + } + } + + // 处理选区删除 + if (isKey(event, 'BACKSPACE') && !selection.empty) { + let startPos = selection.from + let endPos = selection.to + const nodeBefore = $from.nodeBefore + const nodeAfter = $from.nodeAfter + + // 扩展选区以包含零宽字符 + if (nodeBefore && nodeBefore.isText && nodeBefore.text?.endsWith(ZERO_WIDTH_CHAR)) { + startPos -= 1 + } + if (nodeAfter && nodeAfter.isText && nodeAfter.text?.startsWith(ZERO_WIDTH_CHAR)) { + endPos += 1 + } + + if (startPos !== selection.from || endPos !== selection.to) { + const tr = state.tr.delete(startPos, endPos) + dispatch(tr) + event.preventDefault() + return true + } + } + + // 处理 Delete 键 + if (isKey(event, 'DELETE') && selection.empty) { + const currentNode = $from.node() + const afterNode = $from.nodeAfter + + // 如果光标在模板块内部 + if (currentNode.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK) { + const content = currentNode.textContent || '' + + // 删除第一个字符时,插入零宽字符(保留模板块) + if ($from.pos === $from.start() && content.length === 1 && content !== ZERO_WIDTH_CHAR) { + const pos = $from.pos + dispatch(state.tr.insertText(ZERO_WIDTH_CHAR, pos, pos + 1)) + event.preventDefault() + return true + } + + // 如果内容只剩零宽字符,再次删除时跳出到模板块后(保留模板块) + if (content === ZERO_WIDTH_CHAR) { + const nodePos = $from.after() + const tr = state.tr.setSelection(TextSelection.create(state.doc, nodePos)) + dispatch(tr) + event.preventDefault() + return true + } + + // 如果模板块为空,首次按 Delete 时跳出到模板块后 + // 注意:此时零宽字符可能还未插入,需要单独处理 + if (content === '') { + const nodePos = $from.after() + const tr = state.tr.setSelection(TextSelection.create(state.doc, nodePos)) + dispatch(tr) + event.preventDefault() + return true + } + + // 如果光标在模板块末尾,且有内容,跳出到模板块后 + // 防止 ProseMirror 默认行为导致后面的文本被吸入模板块 + if ($from.pos === $from.end() && content.length > 0 && content !== ZERO_WIDTH_CHAR) { + const nodePos = $from.after() + dispatch(state.tr.setSelection(TextSelection.create(state.doc, nodePos))) + event.preventDefault() + return true + } + + // 其他情况:让 ProseMirror 默认处理(光标在模板块中间,正常删除字符) + return false + } + + // 删除模板块后的单个字符时,保留零宽字符 + if ( + afterNode && + afterNode.isText && + afterNode.text?.length === 1 && + afterNode.text !== ZERO_WIDTH_CHAR && + $from.nodeBefore && + $from.nodeBefore.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK + ) { + const begin = $from.pos + const end = $from.pos + afterNode.nodeSize + let tr = state.tr.delete(begin, end) + tr = tr.insertText(ZERO_WIDTH_CHAR, begin, begin) + dispatch(tr) + event.preventDefault() + return true + } + + // 从左侧删除模板块 + if (afterNode && afterNode.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK) { + const content = afterNode.textContent || '' + // 判断是否为空:排除零宽字符 + const isEmpty = content.length === 0 || content === ZERO_WIDTH_CHAR + + // 如果模板块无内容,删除整个模板块 + if (isEmpty) { + const parent = $from.parent + const index = $from.index() + const beforeNode = $from.nodeBefore + let deleteStart = $from.pos + let deleteEnd = $from.pos + afterNode.nodeSize + + // 只删除模板块后的零宽字符,不删除其他文本节点 + // 检查前面是否有零宽字符(必须是零宽字符,不能是其他文本) + if (beforeNode && beforeNode.isText && beforeNode.text === ZERO_WIDTH_CHAR) { + deleteStart = deleteStart - 1 + } + + // 检查后面是否有零宽字符 + if (index < parent.childCount - 1) { + const nextNextNode = parent.child(index + 1) + if (nextNextNode && nextNextNode.isText && nextNextNode.text === ZERO_WIDTH_CHAR) { + deleteEnd = deleteEnd + 1 + } + } + + dispatch(state.tr.delete(deleteStart, deleteEnd)) + event.preventDefault() + return true + } + // 如果有内容,将光标移动到模板块开头(进入模板块) + else { + const targetPos = $from.pos + 1 // 模板块开头位置 + dispatch(state.tr.setSelection(TextSelection.create(state.doc, targetPos))) + event.preventDefault() + return true + } + } + + // 删除零宽字符后的模板块 + if (afterNode && afterNode.isText) { + const parent = $from.parent + const index = $from.index() + + // 检查后面是否有模板块(可能隔着零宽字符) + if (index < parent.childCount - 1) { + const nextNextNode = parent.child(index + 1) + if (nextNextNode.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK) { + const content = nextNextNode.textContent || '' + const isEmpty = content.length === 0 || content === ZERO_WIDTH_CHAR + + // 如果模板块无内容,删除整个模板块和中间的文本节点 + if (isEmpty) { + let deleteStart = $from.pos + const deleteEnd = $from.pos + afterNode.nodeSize + nextNextNode.nodeSize + const beforeNode = $from.nodeBefore + + // 检查前面是否有零宽字符 + if (beforeNode && beforeNode.isText && beforeNode.text?.endsWith(ZERO_WIDTH_CHAR)) { + deleteStart = deleteStart - 1 + } + + dispatch(state.tr.delete(deleteStart, deleteEnd)) + event.preventDefault() + return true + } + // 如果有内容且后面是零宽字符,跳过零宽字符进入模板块 + if (afterNode.text === ZERO_WIDTH_CHAR || afterNode.text?.startsWith(ZERO_WIDTH_CHAR)) { + const nextCursorPos = $from.pos + 2 + dispatch(state.tr.setSelection(TextSelection.create(state.doc, nextCursorPos))) + event.preventDefault() + return true + } + } + } + } + } + + // 处理选区删除(Delete 键) + if (isKey(event, 'DELETE') && !selection.empty) { + let startPos = selection.from + let endPos = selection.to + const nodeBefore = $from.nodeBefore + const nodeAfter = $from.nodeAfter + + // 扩展选区以包含零宽字符 + if (nodeBefore && nodeBefore.isText && nodeBefore.text?.endsWith(ZERO_WIDTH_CHAR)) { + startPos -= 1 + } + if (nodeAfter && nodeAfter.isText && nodeAfter.text?.startsWith(ZERO_WIDTH_CHAR)) { + endPos += 1 + } + + if (startPos !== selection.from || endPos !== selection.to) { + const tr = state.tr.delete(startPos, endPos) + dispatch(tr) + event.preventDefault() + return true + } + } + + return false + }, + }, + }) +} + +/** + * 粘贴处理插件 + */ +export function pasteHandlerPlugin() { + return new Plugin({ + key: new PluginKey(PLUGIN_KEY_NAMES.TEMPLATE_BLOCK_PASTE), + props: { + handlePaste(view: EditorView, event: ClipboardEvent) { + const types = event.clipboardData?.types || [] + const html = event.clipboardData?.getData('text/html') + + // 如果包含模板块的 HTML,让 Tiptap 默认处理 + if ( + (types.includes('text/html') && html?.includes('data-template')) || + types.includes('application/x-prosemirror-slice') + ) { + return false + } + + const text = event.clipboardData?.getData('text/plain') + if (text) { + const { state, dispatch } = view + const $from = state.selection.$from + let tr = state.tr + + // 移除光标周围的零宽字符 + if ($from.nodeBefore && $from.nodeBefore.isText && $from.nodeBefore.text === ZERO_WIDTH_CHAR) { + tr = tr.delete($from.pos - $from.nodeBefore.nodeSize, $from.pos) + } + if ($from.nodeAfter && $from.nodeAfter.isText && $from.nodeAfter.text === ZERO_WIDTH_CHAR) { + tr = tr.delete($from.pos, $from.pos + $from.nodeAfter.nodeSize) + } + + // 处理多行粘贴 + const lines = text.split('\n') + let finalCursorPos: number + + if (lines.length === 1) { + tr = tr.insertText(lines[0], tr.selection.from, tr.selection.to) + finalCursorPos = tr.selection.$to.pos + } else { + tr = tr.insertText(lines[0], tr.selection.from, tr.selection.to) + let pos = tr.selection.$to.pos + + for (let i = 1; i < lines.length; i++) { + const paragraph = state.schema.nodes.paragraph.create({}, lines[i] ? state.schema.text(lines[i]) : null) + tr = tr.insert(pos, paragraph) + pos += paragraph.nodeSize + } + finalCursorPos = pos + } + + tr = tr.setSelection(TextSelection.create(tr.doc, finalCursorPos)) + tr = tr.scrollIntoView() + dispatch(tr) + event.preventDefault() + return true + } + + return false + }, + }, + }) +} diff --git a/packages/components/src/sender/extensions/template/block/template-block-view.vue b/packages/components/src/sender/extensions/template/block/template-block-view.vue new file mode 100644 index 000000000..01d37dc31 --- /dev/null +++ b/packages/components/src/sender/extensions/template/block/template-block-view.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/packages/components/src/sender/extensions/template/commands.ts b/packages/components/src/sender/extensions/template/commands.ts new file mode 100644 index 000000000..7e07e5b1d --- /dev/null +++ b/packages/components/src/sender/extensions/template/commands.ts @@ -0,0 +1,175 @@ +/** + * Template 扩展命令实现 + */ + +import type { Editor } from '@tiptap/core' +import { TextSelection } from '@tiptap/pm/state' +import { generateId } from '../utils' +import type { TemplateItem, TemplateAttrs, TemplateSelectAttrs } from './types' +import { NODE_TYPE_NAMES, USER_API_TYPES } from '../constants' + +// ProseMirror Node 类型 +type PMNode = ReturnType & { nodeSize: number } + +/** + * 获取所有模板块节点 + */ +function getAllTemplates(editor: Editor): Array<{ node: PMNode; pos: number }> { + const blocks: Array<{ node: PMNode; pos: number }> = [] + + editor.state.doc.descendants((node, pos) => { + if (node.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK) { + blocks.push({ node, pos }) + } + }) + + return blocks +} + +/** + * Template 命令集合 + */ +export const templateCommands = { + /** + * 设置模板数据(批量) + */ + setTemplateData: + (items: TemplateItem[]) => + ({ commands }: { commands: Editor['commands'] }) => { + // 清空编辑器 + commands.clearContent() + + // 构建内容 + const content: Array> = [] + + items.forEach((item) => { + if (item.type === USER_API_TYPES.TEXT) { + // 添加文本节点 + if (item.content) { + content.push({ + type: NODE_TYPE_NAMES.TEXT, + text: item.content, + }) + } + } else if (item.type === USER_API_TYPES.BLOCK) { + // 添加模板块节点(内部包含文本) + content.push({ + type: NODE_TYPE_NAMES.TEMPLATE_BLOCK, + attrs: { + id: item.id || generateId('template'), + content: item.content, + }, + content: item.content + ? [ + { + type: NODE_TYPE_NAMES.TEXT, + text: item.content, + }, + ] + : [], + }) + } else if (item.type === USER_API_TYPES.SELECT) { + // 添加选择器节点 + content.push({ + type: NODE_TYPE_NAMES.TEMPLATE_SELECT, + attrs: { + id: item.id || generateId('select'), + placeholder: item.placeholder, + options: item.options, + value: item.value || null, + }, + }) + } + }) + + // 如果有内容,插入到段落中 + if (content.length > 0) { + commands.insertContent({ + type: NODE_TYPE_NAMES.PARAGRAPH, + content, + }) + } + + return true + }, + + /** + * 插入模板块 + */ + insertTemplate: + (attrs: Partial) => + ({ commands }: { commands: Editor['commands'] }) => { + const content = attrs.content || '' + return commands.insertContent({ + type: NODE_TYPE_NAMES.TEMPLATE_BLOCK, + attrs: { + id: attrs.id || generateId('template'), + content, + }, + content: content + ? [ + { + type: NODE_TYPE_NAMES.TEXT, + text: content, + }, + ] + : [], + }) + }, + + /** + * 聚焦到第一个模板块 + */ + focusFirstTemplate: + () => + ({ editor }: { editor: Editor }) => { + const blocks = getAllTemplates(editor) + + // 使用 setTimeout 确保在文档更新后执行 + setTimeout(() => { + const { state, view } = editor + const tr = state.tr + + try { + let targetPos: number + + if (blocks.length === 0) { + // 没有模板块时,聚焦到文档末尾 + targetPos = state.doc.content.size - 1 + } else { + // 有模板块时,聚焦到第一个模板块的末尾 + const { node, pos } = blocks[0] + const contentLength = node.textContent?.length || 0 + targetPos = pos + 1 + contentLength + } + + // 使用 ProseMirror 的 TextSelection 精确设置光标位置 + const selection = TextSelection.create(state.doc, targetPos) + tr.setSelection(selection) + view.dispatch(tr) + view.focus() + } catch (error) { + console.error('[focusFirstTemplate] 设置光标失败', error) + } + }, 0) + + return true + }, + + /** + * 插入选择器 + */ + insertTemplateSelect: + (attrs: Partial) => + ({ commands }: { commands: Editor['commands'] }) => { + return commands.insertContent({ + type: NODE_TYPE_NAMES.TEMPLATE_SELECT, + attrs: { + id: attrs.id || generateId('select'), + placeholder: attrs.placeholder || 'Please select', + options: attrs.options || [], + value: attrs.value || null, + }, + }) + }, +} diff --git a/packages/components/src/sender/extensions/template/extension.ts b/packages/components/src/sender/extensions/template/extension.ts new file mode 100644 index 000000000..7f219337c --- /dev/null +++ b/packages/components/src/sender/extensions/template/extension.ts @@ -0,0 +1,26 @@ +/** + * Template 扩展定义(统一入口) + */ + +import { Extension } from '@tiptap/core' +import type { TemplateOptions } from './types' +import { templateCommands } from './commands' +import { TemplateBlock } from './block/extension' +import { TemplateSelect } from './select/extension' +import { EXTENSION_NAMES } from '../constants' + +/** + * Template 扩展(统一入口,包含 TemplateBlock 和 TemplateSelect) + */ +export const Template = Extension.create({ + name: EXTENSION_NAMES.TEMPLATE, + + addExtensions() { + return [TemplateBlock.configure(this.options), TemplateSelect] + }, + + // 添加命令(统一命令入口) + addCommands() { + return templateCommands + }, +}) diff --git a/packages/components/src/sender/extensions/template/index.ts b/packages/components/src/sender/extensions/template/index.ts new file mode 100644 index 000000000..8c330bce3 --- /dev/null +++ b/packages/components/src/sender/extensions/template/index.ts @@ -0,0 +1,41 @@ +/** + * Template 扩展 + * + * 模板块节点,用于模板填充功能 + */ + +import type { Ref } from 'vue' +import { Template } from './extension' +import type { TemplateItem, TemplateOptions } from './types' + +// ===== 导出扩展类和工具 ===== +export { Template } from './extension' +export { TemplateSelect } from './select/extension' +export { templateCommands } from './commands' +export * from './types' +export { getTextWithTemplates, getTemplateStructuredData } from './utils' +export { TemplateSelectDropdownPluginKey } from './select/plugins' + +// ===== 便捷函数 ===== + +/** + * 创建 Template 扩展的便捷函数 + * + * @param items - 模板项列表 + * @param options - 其他配置项 + * + * @example + * ```typescript + * const extensions = [template(templates)] + * const extensions = [template(templates, { HTMLAttributes: { class: 'custom' } })] + * ``` + */ +export function template( + items: TemplateItem[] | Ref, + options?: Partial>, +) { + return Template.configure({ + items, + ...options, + }) +} diff --git a/packages/components/src/sender/extensions/template/select/dropdown-manager.ts b/packages/components/src/sender/extensions/template/select/dropdown-manager.ts new file mode 100644 index 000000000..a35a25f0f --- /dev/null +++ b/packages/components/src/sender/extensions/template/select/dropdown-manager.ts @@ -0,0 +1,68 @@ +/** + * 下拉菜单管理器(单例模式) + */ + +// 全局状态:当前打开的下拉菜单 ID +let currentOpenDropdown: string | null = null + +/** + * 打开下拉菜单 + */ +export function openDropdown(selectId: string): void { + // 关闭之前打开的下拉菜单 + if (currentOpenDropdown && currentOpenDropdown !== selectId) { + closeDropdown(currentOpenDropdown) + } + currentOpenDropdown = selectId +} + +/** + * 关闭下拉菜单 + */ +export function closeDropdown(selectId: string): void { + if (currentOpenDropdown === selectId) { + currentOpenDropdown = null + } +} + +/** + * 获取当前打开的下拉菜单 ID + */ +export function getCurrentOpenDropdown(): string | null { + return currentOpenDropdown +} + +/** + * 关闭所有下拉菜单 + */ +export function closeAllDropdowns(): void { + currentOpenDropdown = null +} + +/** + * 设置点击外部关闭监听 + */ +export function setupClickOutside( + selectElement: HTMLElement, + dropdownElement: HTMLElement, + onClose: () => void, +): () => void { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node + + if (!selectElement.contains(target) && !dropdownElement.contains(target)) { + onClose() + document.removeEventListener('click', handleClickOutside) + } + } + + // 延迟添加监听器,避免立即触发 + setTimeout(() => { + document.addEventListener('click', handleClickOutside) + }, 0) + + // 返回清理函数 + return () => { + document.removeEventListener('click', handleClickOutside) + } +} diff --git a/packages/components/src/sender/extensions/template/select/extension.ts b/packages/components/src/sender/extensions/template/select/extension.ts new file mode 100644 index 000000000..fb7ab3281 --- /dev/null +++ b/packages/components/src/sender/extensions/template/select/extension.ts @@ -0,0 +1,122 @@ +/** + * TemplateSelect 扩展定义 + */ + +import { Node, mergeAttributes } from '@tiptap/core' +import { VueNodeViewRenderer } from '@tiptap/vue-3' +import type { 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>({ + name: NODE_TYPE_NAMES.TEMPLATE_SELECT, + + // 节点配置 + group: 'inline', + inline: true, + atom: true, // 原子节点,光标不能进入 + selectable: false, + draggable: false, + + // 节点属性 + addAttributes() { + return { + id: { + default: null, + parseHTML: (element) => element.getAttribute('data-id'), + renderHTML: (attributes) => { + if (!attributes.id) return {} + return { 'data-id': attributes.id } + }, + }, + placeholder: { + default: 'Please select', + parseHTML: (element) => element.getAttribute('data-placeholder'), + renderHTML: (attributes) => { + return { 'data-placeholder': attributes.placeholder } + }, + }, + options: { + default: [], + parseHTML: (element) => { + const optionsStr = element.getAttribute('data-options') + if (!optionsStr) return [] + + try { + return JSON.parse(optionsStr) + } catch (error) { + console.warn('Failed to parse template select options:', error) + return [] + } + }, + renderHTML: (attributes) => { + try { + return { 'data-options': JSON.stringify(attributes.options) } + } catch (error) { + console.error('Failed to stringify template select options:', error) + return { 'data-options': '[]' } + } + }, + }, + value: { + default: null, + parseHTML: (element) => element.getAttribute('data-value') || null, + renderHTML: (attributes) => { + if (!attributes.value) return {} + return { 'data-value': attributes.value } + }, + }, + } + }, + + // HTML 解析 + parseHTML() { + return [ + { + tag: 'span[data-template-select]', + }, + ] + }, + + // HTML 渲染 + renderHTML({ node, HTMLAttributes }) { + const selectedOption = (node.attrs.options as TemplateSelectAttrs['options']).find( + (opt) => opt.value === node.attrs.value, + ) + const displayText = selectedOption?.label || node.attrs.placeholder + + let optionsStr = '[]' + try { + optionsStr = JSON.stringify(node.attrs.options) + } catch (error) { + console.error('Failed to stringify template select options in renderHTML:', error) + } + + return [ + 'span', + mergeAttributes(HTMLAttributes, { + 'data-template-select': '', + 'data-id': node.attrs.id as string, + 'data-placeholder': node.attrs.placeholder as string, + 'data-options': optionsStr, + 'data-value': (node.attrs.value as string) || '', + }), + displayText, + ] + }, + + // 使用 Vue 组件渲染 + addNodeView() { + // @ts-expect-error - Vue SFC type compatibility + return VueNodeViewRenderer(TemplateSelectView) + }, + + // 添加插件 + addProseMirrorPlugins() { + return [selectDropdownStatePlugin(), selectZeroWidthPlugin(), selectKeyboardPlugin()] + }, +}) diff --git a/packages/components/src/sender/extensions/template/select/plugins.ts b/packages/components/src/sender/extensions/template/select/plugins.ts new file mode 100644 index 000000000..d32f7c383 --- /dev/null +++ b/packages/components/src/sender/extensions/template/select/plugins.ts @@ -0,0 +1,252 @@ +/** + * TemplateSelect 插件 + */ + +import { Plugin, PluginKey } from '@tiptap/pm/state' +import type { EditorState, Transaction } from '@tiptap/pm/state' +import type { EditorView } from '@tiptap/pm/view' +import type { Node as PMNode } from '@tiptap/pm/model' +import { ZERO_WIDTH_CHAR } from '../utils' +import { NODE_TYPE_NAMES, PLUGIN_KEY_NAMES } from '../../constants' +import { isAnyKey, isKey } from '../../utils' + +/** + * Template Select 下拉菜单状态 + */ +interface TemplateSelectDropdownState { + /** + * 是否有下拉菜单打开 + */ + isOpen: boolean + /** + * 当前打开的下拉菜单 ID + */ + selectId: string | null +} + +/** + * Template Select 下拉菜单状态插件 Key + */ +export const TemplateSelectDropdownPluginKey = new PluginKey( + PLUGIN_KEY_NAMES.TEMPLATE_SELECT_DROPDOWN, +) + +/** + * 下拉菜单状态管理插件 + * 用于在 ProseMirror 插件状态中跟踪下拉菜单的打开/关闭状态 + */ +export function selectDropdownStatePlugin() { + return new Plugin({ + key: TemplateSelectDropdownPluginKey, + + state: { + init(): TemplateSelectDropdownState { + return { + isOpen: false, + selectId: null, + } + }, + + apply(tr: Transaction, state: TemplateSelectDropdownState): TemplateSelectDropdownState { + const meta = tr.getMeta(TemplateSelectDropdownPluginKey) + + if (meta) { + if (meta.type === 'open') { + return { + isOpen: true, + selectId: meta.selectId, + } + } + + if (meta.type === 'close') { + return { + isOpen: false, + selectId: null, + } + } + } + + return state + }, + }, + }) +} + +/** + * 零宽字符管理插件 + * 注意:零宽字符现在由 Vue 组件直接渲染,不需要插件动态插入 + * 这个插件保留用于清理孤立的零宽字符 + */ +export function selectZeroWidthPlugin() { + return new Plugin({ + key: new PluginKey(PLUGIN_KEY_NAMES.TEMPLATE_SELECT_ZERO_WIDTH), + + appendTransaction(transactions: readonly Transaction[], _oldState: EditorState, newState: EditorState) { + // 只在内容发生变化时修正 + const docChanged = transactions.some((tr) => tr.docChanged) + if (!docChanged) return null + + // 清理孤立的零宽字符(段落中只有一个零宽字符的情况) + const todoPositions: Array<['remove', number]> = [] + let { tr } = newState + + newState.doc.descendants((node: PMNode, pos: number) => { + if (node.type.name === NODE_TYPE_NAMES.PARAGRAPH && node.childCount > 0) { + const { lastChild, firstChild } = node + // 如果段落只有一个零宽字符,删除它 + if (lastChild === firstChild && lastChild && lastChild.isText && lastChild.text === ZERO_WIDTH_CHAR) { + todoPositions.push(['remove', pos + 1]) + } + } + }) + + if (todoPositions.length > 0) { + todoPositions.forEach(([, pos]) => { + tr = tr.delete(pos, pos + 1) + }) + return tr + } + + return null + }, + }) +} + +/** + * 键盘导航插件 + */ +export function selectKeyboardPlugin() { + return new Plugin({ + key: new PluginKey(PLUGIN_KEY_NAMES.TEMPLATE_SELECT_KEYBOARD), + + props: { + handleKeyDown(view: EditorView, event: KeyboardEvent) { + const { state, dispatch } = view + const { selection } = state + const { $from } = selection + + // 如果有下拉菜单打开,拦截键盘事件让 Vue 组件处理 + const dropdownState = TemplateSelectDropdownPluginKey.getState(view.state) + if (dropdownState?.isOpen) { + if (isAnyKey(event, ['ENTER', 'ARROW_UP', 'ARROW_DOWN', 'ESCAPE'])) { + // 返回 true 表示"已处理",阻止事件继续传播到 useSenderCore + return true + } + } + + // 处理 Backspace 删除选择器 + // 注意:零宽字符现在由 Vue 组件渲染,总是存在于 templateSelect 前后 + if (isKey(event, 'BACKSPACE') && selection.empty) { + const beforeNode = $from.nodeBefore + const afterNode = $from.nodeAfter + + // 场景1:光标前面直接是 templateSelect 节点 + // 删除整个选择器(包括内置的零宽字符) + if (beforeNode?.type.name === NODE_TYPE_NAMES.TEMPLATE_SELECT) { + dispatch(state.tr.delete($from.pos - beforeNode.nodeSize, $from.pos)) + event.preventDefault() + return true + } + + // 场景2:光标前面是零宽字符(templateSelect 的 suffix) + // 需要找到并删除前面的 templateSelect 节点 + if (beforeNode?.isText && beforeNode.text === ZERO_WIDTH_CHAR) { + // 查找零宽字符前面的节点 + const posBeforeZeroWidth = $from.pos - 1 + const $posBeforeZeroWidth = state.doc.resolve(posBeforeZeroWidth) + const nodeBeforeZeroWidth = $posBeforeZeroWidth.nodeBefore + + // 如果零宽字符前面是 templateSelect,删除 templateSelect + 零宽字符 + if (nodeBeforeZeroWidth?.type.name === NODE_TYPE_NAMES.TEMPLATE_SELECT) { + const deleteFrom = posBeforeZeroWidth - nodeBeforeZeroWidth.nodeSize + const deleteTo = $from.pos // 包括零宽字符 + dispatch(state.tr.delete(deleteFrom, deleteTo)) + event.preventDefault() + return true + } + } + + // 场景3:光标后面是 templateSelect,前面是普通文本 + // 删除文本的最后一个字符 + if (afterNode?.type.name === NODE_TYPE_NAMES.TEMPLATE_SELECT) { + // 如果前面是普通文本(非零宽字符) + if (beforeNode?.isText && beforeNode.text !== ZERO_WIDTH_CHAR) { + dispatch(state.tr.delete($from.pos - 1, $from.pos)) + event.preventDefault() + return true + } + // 如果前面是 template 节点,不处理,让 TemplateBlock 插件处理 + if (beforeNode?.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK) { + return false + } + } + + // 场景4:光标在段落末尾(afterNode 为 null),前面是普通文本 + // 这种情况通常发生在删除了段落末尾的 templateSelect 之后 + if (!afterNode && beforeNode?.isText && beforeNode.text !== ZERO_WIDTH_CHAR) { + dispatch(state.tr.delete($from.pos - 1, $from.pos)) + event.preventDefault() + return true + } + } + + // 处理 Delete 删除选择器 + // 注意:零宽字符现在由 Vue 组件渲染,总是存在于 templateSelect 前后 + if (isKey(event, 'DELETE') && selection.empty) { + const afterNode = $from.nodeAfter + const beforeNode = $from.nodeBefore + + // 场景1:光标后面直接是 templateSelect 节点 + // 删除整个选择器(包括内置的零宽字符) + if (afterNode?.type.name === NODE_TYPE_NAMES.TEMPLATE_SELECT) { + dispatch(state.tr.delete($from.pos, $from.pos + afterNode.nodeSize)) + event.preventDefault() + return true + } + + // 场景2:光标后面是零宽字符(templateSelect 的 prefix) + // 需要找到并删除后面的 templateSelect 节点 + if (afterNode?.isText && afterNode.text === ZERO_WIDTH_CHAR) { + // 查找零宽字符后面的节点 + const posAfterZeroWidth = $from.pos + 1 + const $posAfterZeroWidth = state.doc.resolve(posAfterZeroWidth) + const nodeAfterZeroWidth = $posAfterZeroWidth.nodeAfter + + // 如果零宽字符后面是 templateSelect,删除零宽字符 + templateSelect + if (nodeAfterZeroWidth?.type.name === NODE_TYPE_NAMES.TEMPLATE_SELECT) { + const deleteTo = posAfterZeroWidth + nodeAfterZeroWidth.nodeSize + dispatch(state.tr.delete($from.pos, deleteTo)) + event.preventDefault() + return true + } + } + + // 场景3:光标前面是 templateSelect,后面是普通文本 + // 删除文本的第一个字符 + if (beforeNode?.type.name === NODE_TYPE_NAMES.TEMPLATE_SELECT) { + // 如果后面是普通文本(非零宽字符) + if (afterNode?.isText && afterNode.text !== ZERO_WIDTH_CHAR) { + dispatch(state.tr.delete($from.pos, $from.pos + 1)) + event.preventDefault() + return true + } + // 如果后面是 template 节点,不处理,让 TemplateBlock 插件处理 + if (afterNode?.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK) { + return false + } + } + + // 场景4:光标在段落开头(beforeNode 为 null),后面是普通文本 + // 这种情况通常发生在删除了段落开头的 templateSelect 之后 + if (!beforeNode && afterNode?.isText && afterNode.text !== ZERO_WIDTH_CHAR) { + dispatch(state.tr.delete($from.pos, $from.pos + 1)) + event.preventDefault() + return true + } + } + + return false + }, + }, + }) +} diff --git a/packages/components/src/sender/extensions/template/select/template-select-view.vue b/packages/components/src/sender/extensions/template/select/template-select-view.vue new file mode 100644 index 000000000..83cd1faac --- /dev/null +++ b/packages/components/src/sender/extensions/template/select/template-select-view.vue @@ -0,0 +1,390 @@ + + + + + + + + diff --git a/packages/components/src/sender/extensions/template/types.ts b/packages/components/src/sender/extensions/template/types.ts new file mode 100644 index 000000000..5ed87be78 --- /dev/null +++ b/packages/components/src/sender/extensions/template/types.ts @@ -0,0 +1,121 @@ +/** + * Template 扩展类型定义 + */ + +import type { Ref } from 'vue' +import type { TemplateItem, SelectOption } from '../../index.type' +import '@tiptap/core' + +// 重新导出 TemplateItem 和 SelectOption 以便外部使用 +export type { TemplateItem, SelectOption } + +/** + * TemplateSelect 节点属性 + */ +export interface TemplateSelectAttrs { + /** + * 唯一标识 + */ + id: string + + /** + * 占位文字(未选择时显示) + */ + placeholder: string + + /** + * 选项列表 + */ + options: SelectOption[] + + /** + * 当前选中的值(可选) + */ + value?: string +} + +/** + * Template 节点属性 + */ +export interface TemplateAttrs { + /** + * 模板块 ID + */ + id: string + + /** + * 模板块内容 + */ + content: string +} + +/** + * Template 配置选项 + */ +export interface TemplateOptions { + /** + * 模板数据列表(推荐使用 ref 实现响应式) + * + * 支持两种配置方式: + * 1. 传入 ref(推荐):自动双向绑定,解决时序问题 + * 2. 传入数组:仅用于静态初始化 + * + * @example 响应式配置(推荐) + * ```typescript + * const items = ref([ + * { type: 'text', content: '帮我分析' }, + * { type: 'template', content: '' } + * ]) + * Template.configure({ items }) // 传入 ref,自动双向绑定 + * ``` + * + * @example 静态配置 + * ```typescript + * Template.configure({ + * items: [ + * { type: 'text', content: '帮我分析' }, + * { type: 'template', content: '' } + * ] + * }) + * ``` + */ + items?: TemplateItem[] | Ref + + /** + * HTML 属性 + */ + HTMLAttributes?: Record +} + +// ===== 模块扩展声明 ===== + +/** + * 扩展 Tiptap Commands 接口 + * + * 使 TypeScript 能够识别自定义命令 + */ +declare module '@tiptap/core' { + interface Commands { + template: { + /** + * 设置模板数据(批量) + */ + setTemplateData: (items: TemplateItem[]) => ReturnType + + /** + * 插入模板块 + */ + insertTemplate: (attrs: Partial) => ReturnType + + /** + * 聚焦到第一个模板块 + */ + focusFirstTemplate: () => ReturnType + + /** + * 插入选择器 + */ + insertTemplateSelect: (attrs: Partial) => ReturnType + } + } +} diff --git a/packages/components/src/sender/extensions/template/utils.ts b/packages/components/src/sender/extensions/template/utils.ts new file mode 100644 index 000000000..95c270ec6 --- /dev/null +++ b/packages/components/src/sender/extensions/template/utils.ts @@ -0,0 +1,72 @@ +/** + * Template 扩展工具函数 + */ + +import type { Editor } from '@tiptap/core' +import type { TemplateItem } from '../../index.type' +import { NODE_TYPE_NAMES, USER_API_TYPES } from '../constants' + +/** + * 零宽字符常量 + * Unicode: U+200B (Zero Width Space) + * HTML Entity: ​ + */ +export const ZERO_WIDTH_CHAR = '\u200B' + +/** + * 获取包含 template 的完整文本 + * + * 例如:请帮我分析 [模板内容1] 和 [模板内容2] + */ +export function getTextWithTemplates(editor: Editor): string { + const items = getTemplateStructuredData(editor) + return items.map((item) => item.content).join('') +} + +/** + * 获取结构化数据(辅助函数) + * + * 返回包含文本和模板块的结构化数组 + */ +export function getTemplateStructuredData(editor: Editor): TemplateItem[] { + const items: TemplateItem[] = [] + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + editor.state.doc.descendants((node: any, _pos: number, parent: any) => { + // 只处理段落的直接子节点,避免重复收集模板块内部的文本 + if (parent && parent.type.name === NODE_TYPE_NAMES.PARAGRAPH) { + if (node.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK) { + const content = (node.textContent || '').replace(new RegExp(ZERO_WIDTH_CHAR, 'g'), '') + items.push({ + type: USER_API_TYPES.BLOCK, + content, + }) + } else if (node.type.name === NODE_TYPE_NAMES.TEMPLATE_SELECT) { + // 获取选中的值 + const selectedOption = node.attrs.options.find((opt: { value: string }) => opt.value === node.attrs.value) + const content = selectedOption?.value || '' + + items.push({ + type: USER_API_TYPES.SELECT, + content, + }) + } else if (node.type.name === NODE_TYPE_NAMES.TEXT) { + const text = (node.text || '').replace(new RegExp(ZERO_WIDTH_CHAR, 'g'), '') + if (text) { + // 合并连续的文本节点 + const lastItem = items[items.length - 1] + if (lastItem && lastItem.type === USER_API_TYPES.TEXT) { + lastItem.content += text + } else { + items.push({ + type: USER_API_TYPES.TEXT, + content: text, + }) + } + } + } + } + }) + + return items +} diff --git a/packages/components/src/sender/extensions/utils/id-generator.ts b/packages/components/src/sender/extensions/utils/id-generator.ts new file mode 100644 index 000000000..cebc9d1f4 --- /dev/null +++ b/packages/components/src/sender/extensions/utils/id-generator.ts @@ -0,0 +1,19 @@ +/** + * 生成唯一 ID + * + * 用于所有扩展生成唯一标识符 + */ + +/** + * 生成唯一 ID + * + * @param prefix - ID 前缀 + * @returns 唯一 ID 字符串 + * + * @example + * generateId('mention') // mention_1701234567890_abc123def + * generateId('template') // template_1701234567890_xyz789uvw + */ +export function generateId(prefix: string): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}` +} diff --git a/packages/components/src/sender/extensions/utils/index.ts b/packages/components/src/sender/extensions/utils/index.ts new file mode 100644 index 000000000..f1ad8deb1 --- /dev/null +++ b/packages/components/src/sender/extensions/utils/index.ts @@ -0,0 +1,8 @@ +/** + * 共享工具函数统一导出 + */ + +export { generateId } from './id-generator' +export { findTextRange } from './position' +export { isKey, isAnyKey, isArrowKey, isDeleteKey } from './keyboard' +export type { KeyboardKey } from './keyboard' diff --git a/packages/components/src/sender/extensions/utils/keyboard.ts b/packages/components/src/sender/extensions/utils/keyboard.ts new file mode 100644 index 000000000..7c704014a --- /dev/null +++ b/packages/components/src/sender/extensions/utils/keyboard.ts @@ -0,0 +1,68 @@ +/** + * 键盘事件工具函数 + * + * 提供跨平台的键盘事件处理,支持 Windows/Linux/Mac + */ + +import { KEYBOARD_KEYS } from '../constants' + +/** + * 键盘按键类型 + */ +export type KeyboardKey = keyof typeof KEYBOARD_KEYS + +/** + * 检查是否按下指定按键 + * + * @param event - 键盘事件 + * @param key - 按键名称 + * @returns 是否按下指定按键 + * + * @example + * if (isKey(event, 'ENTER')) { ... } + * if (isKey(event, 'ARROW_UP')) { ... } + */ +export const isKey = (event: KeyboardEvent, key: KeyboardKey): boolean => { + return event.key === KEYBOARD_KEYS[key] +} + +/** + * 检查是否按下多个按键中的任意一个 + * + * @param event - 键盘事件 + * @param keys - 按键名称数组 + * @returns 是否按下指定按键之一 + * + * @example + * if (isAnyKey(event, ['ARROW_UP', 'ARROW_DOWN'])) { ... } + * if (isAnyKey(event, ['ENTER', 'TAB'])) { ... } + */ +export const isAnyKey = (event: KeyboardEvent, keys: KeyboardKey[]): boolean => { + return keys.some((key) => event.key === KEYBOARD_KEYS[key]) +} + +/** + * 检查是否按下方向键 + * + * @param event - 键盘事件 + * @returns 是否按下方向键 + * + * @example + * if (isArrowKey(event)) { ... } + */ +export const isArrowKey = (event: KeyboardEvent): boolean => { + return isAnyKey(event, ['ARROW_UP', 'ARROW_DOWN', 'ARROW_LEFT', 'ARROW_RIGHT']) +} + +/** + * 检查是否按下删除键(Backspace 或 Delete) + * + * @param event - 键盘事件 + * @returns 是否按下删除键 + * + * @example + * if (isDeleteKey(event)) { ... } + */ +export const isDeleteKey = (event: KeyboardEvent): boolean => { + return isAnyKey(event, ['BACKSPACE', 'DELETE']) +} diff --git a/packages/components/src/sender/extensions/utils/position.ts b/packages/components/src/sender/extensions/utils/position.ts new file mode 100644 index 000000000..bda02806e --- /dev/null +++ b/packages/components/src/sender/extensions/utils/position.ts @@ -0,0 +1,65 @@ +/** + * 位置查找工具函数 + * + * 用于 mention 和 suggestion 扩展查找触发字符位置 + */ + +import type { Selection } from '@tiptap/pm/state' + +/** + * 查找触发字符的位置和查询文本 + * + * 用于 mention 和 suggestion 扩展 + * + * @param selection - 当前光标位置 + * @param char - 触发字符 + * @param allowSpaces - 是否允许空格 + * @returns 触发范围和查询文本,未找到返回 null + * + * @example + * const result = findTextRange(selection, '@', false) + * if (result) { + * console.log(result.range) // { from: 10, to: 20 } + * console.log(result.query) // 'user' + * } + */ +export function findTextRange( + selection: Selection, + char: string, + allowSpaces: boolean = false, +): { range: { from: number; to: number }; query: string } | null { + const { $from } = selection + + // 光标不在文本节点或选区不为空时,不触发 + if (!selection.empty || !$from.parent.isTextblock) { + return null + } + + // 获取光标前的文本内容(从当前文本块开始到光标位置) + const textBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, '\ufffc') + + // 查找最后一个触发字符的位置 + const lastCharIndex = textBefore.lastIndexOf(char) + + // 未找到触发字符 + if (lastCharIndex === -1) { + return null + } + + // 提取查询文本(触发字符之后的内容) + const query = textBefore.slice(lastCharIndex + char.length) + + // 如果不允许空格且查询包含空格,则不触发 + if (!allowSpaces && query.includes(' ')) { + return null + } + + // 计算绝对位置范围 + const from = $from.start() + lastCharIndex + const to = $from.pos + + return { + range: { from, to }, + query, + } +} diff --git a/packages/components/src/sender/index.less b/packages/components/src/sender/index.less index 91f53f515..e53f4a98d 100644 --- a/packages/components/src/sender/index.less +++ b/packages/components/src/sender/index.less @@ -1,502 +1,20 @@ -// 主要组件样式 -.tiny-sender { - position: relative; - color: var(--tr-sender-text-color); - - // 模式切换的主过渡效果 - transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1); - - // 单行到多行模式的切换过渡 - &.mode-single, - &.mode-multiple { - transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1); - } - - // 自动切换模式时的过渡 - &.is-auto-switching { - - .tiny-input__inner, - .tiny-textarea__inner { - transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1); - } - - .tiny-sender__footer-slot, - .tiny-sender__actions-slot, - .tiny-sender__toolbar { - transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1); - } - } - - // TinyUI 输入框调整 - :deep(.tiny-sender__input-field-wrapper .tiny-input__prefix) { - left: 0; - display: flex; - align-items: center; - } - - :deep(.tiny-sender__input-field-wrapper .tiny-input__inner) { - border: none; - padding-left: 0; - padding-right: 16px; - font-size: var(--tr-sender-input-font-size); - background-color: var(--tr-sender-bg-color); - color: var(--tr-sender-text-color); - height: var(--tr-sender-input-height); // 固定高度以减少跳动 - line-height: var(--tr-sender-input-line-height); // 行高保持一致 - - &::placeholder { - color: var(--tr-sender-placeholder-color); - } - } - - :deep(.tiny-sender__input-field-wrapper .is-disabled .tiny-input__inner) { - border: none; - background: var(--tr-sender-bg-color); - color: var(--tr-sender-placeholder-color); - } - - :deep(.tiny-sender__input-field-wrapper .tiny-textarea.is-disabled) { - background-color: var(--tr-sender-bg-color); - - & .tiny-textarea__inner { - background: var(--tr-sender-bg-color); - border: none; - color: var(--tr-sender-placeholder-color); - } - } - - :deep(.tiny-sender__input-field-wrapper .tiny-input__suffix) { - right: 0; - display: flex; - align-items: center; - } - - :deep(.tiny-sender__input-field-wrapper .tiny-textarea__inner) { - font-size: var(--tr-sender-input-font-size); - border: none; - min-height: var(--tr-sender-textarea-min-height) !important; // 最小高度与单行保持一致 - padding: 0; - background-color: var(--tr-sender-bg-color); - color: var(--tr-sender-text-color); - line-height: var(--tr-sender-input-line-height); // 行高与单行保持一致 - - &::placeholder { - color: var(--tr-sender-placeholder-color); - } - - &::-webkit-scrollbar { - width: 12px !important; - } - } - - :deep(.tiny-sender__input-field-wrapper .tiny-textarea:before, - .tiny-sender__input-field-wrapper .tiny-textarea:after) { - display: none; - } - - // 内容容器 - 包含输入框、工具栏等 - &__container { - display: flex; - flex-direction: column; - width: 100%; - transition: all 0.3s ease-out; - min-height: var(--tr-sender-container-min-height); // 设置最小高度避免内容区域跳变 - } - - // 输入行 - 横向布局,包含prefix、content、actions - &__input-row { - display: flex; - align-items: stretch; - width: 100%; - transition: height 0.3s ease-out; - min-height: var(--tr-sender-container-min-height); // 与容器一致的最小高度 - } - - // 头部插槽 - &__header-slot { - width: 100%; - min-height: var(--tr-sender-header-min-height); - overflow-y: auto; - border-radius: var(--tr-sender-border-radius) var(--tr-sender-border-radius) 0 0; - background: var(--tr-sender-header-bg-color); - z-index: 1; - } - - // 底部插槽 - &__footer-slot { - border-radius: 0 0 var(--tr-sender-border-radius) var(--tr-sender-border-radius); - background: var(--tr-sender-footer-bg); - z-index: 1; - min-height: var(--tr-sender-footer-min-height); - - &.tiny-sender__bottom-row { - padding: var(--tr-sender-bottom-row-padding); - display: flex; - justify-content: space-between; - align-items: center; - box-sizing: content-box; - } - - .suggestion-item { - padding: var(15px 10px 10px 24px); - cursor: pointer; - transition: background-color var(--tr-sender-transition-duration); - - &:hover { - background: var(--tr-sender-footer-hover); - } - } - } - - // 底部左侧区域 - &__footer-left { - display: flex; - align-items: center; - gap: var(--tr-sender-gap); - } - - // 底部右侧区域 - &__footer-right { - display: flex; - align-items: center; - gap: var(--tr-sender-gap); - } - - // 输入区域 - &__input-wrapper { - position: relative; - width: 100%; - border: none; - padding: 0; - border-radius: var(--tr-sender-border-radius); - box-shadow: var(--tr-sender-box-shadow); - background-color: var(--tr-sender-bg-color); - display: flex; - flex-direction: column; - - .tiny-input__count { - display: flex; - } - - .tiny-input__count-inner { - height: 22px; - right: 0; - } - } - - // 内容区域 - &__content-area { - flex: var(--tr-sender-content-flex-grow) 1 var(--tr-sender-content-min-width); - min-width: var(--tr-sender-content-min-width); - position: relative; - overflow: hidden; - min-height: var(--tr-sender-input-height); - - // 当前置插槽存在时,调整内边距 - .has-prefix & { - padding: var(--tr-sender-content-padding-with-prefix) !important; - } - - .has-header & { - padding-top: 0; - } - - // 单行模式下的内边距 - .mode-single & { - padding: var(--tr-sender-content-padding-single); - } - - // 多行模式下的内边距 - .mode-multiple & { - padding: var(--tr-sender-content-padding-multiple); - } - - .tiny-textarea, - .tiny-input { - width: 100%; - transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1); - } - } - - // 前缀区域 - &__prefix-slot { - flex: 0 0 var(--tr-sender-prefix-min-width); - display: flex; - align-items: start; - justify-content: center; - background: var(--tr-sender-bg-color); - border-radius: var(--tr-sender-border-radius) 0 0 var(--tr-sender-border-radius); - transition: background-color var(--tr-sender-transition-duration); - padding: var(--tr-sender-prefix-padding); - - &:hover { - background-color: var(--tr-sender-prefix-hover-bg); - } - } - - // 动作区域 - &__actions-slot { - display: flex; - align-items: center; - margin-left: auto; - flex: 0 0 auto; - min-width: var(--tr-sender-actions-min-width); - padding-right: var(--tr-sender-actions-padding-right); - transition: - opacity 0.3s cubic-bezier(0.25, 1, 0.5, 1), - transform 0.3s cubic-bezier(0.25, 1, 0.5, 1), - visibility 0.3s step-end; - position: absolute; - right: 0; - top: 50%; - transform: translateY(-50%); - - .button-icon { - font-size: var(--tr-sender-actions-icon-size); - transition: transform var(--tr-sender-transition-duration); - - &:hover { - transform: scale(1.1); - } - } - } - - &__toolbar { - transition: - opacity 0.3s cubic-bezier(0.25, 1, 0.5, 1), - transform 0.3s cubic-bezier(0.25, 1, 0.5, 1), - visibility 0.3s step-end; - will-change: transform, opacity; - } - - &__bottom-row { - padding: var(--tr-sender-bottom-row-padding); - } - - // 字数限制 - &__word-limit { - font-size: var(--tr-sender-word-limit-font-size); - font-weight: 400; - line-height: 22px; - text-align: left; - letter-spacing: 0; - color: var(--tr-sender-word-limit-color); - - // 超出限制的红色警示样式 - &.is-over-limit { - &>.real-word-length { - color: var(--tr-sender-word-limit-error-color); - font-weight: 500; - } - } - } - - // 输入字段包装器 - &__input-field-wrapper { - position: relative; - width: 100%; - } - - // 自动完成占位符 - &__completion-placeholder { - position: absolute; - top: 0; - left: 0; - height: var(--tr-sender-input-height); - font-size: var(--tr-sender-completion-placeholder-font-size); - color: var(--tr-sender-completion-placeholder-color); - pointer-events: none; - white-space: pre; - line-height: var(--tr-sender-completion-placeholder-line-height); - display: flex; - align-items: center; - text-overflow: ellipsis; - overflow: hidden; - - .user-input-mirror { - visibility: hidden; - } - } - - // Tab提示 - &__tab-hint { - border: 1px dashed var(--tr-sender-tab-hint-border-color); - font-size: var(--tr-sender-tab-hint-font-size); - color: var(--tr-sender-tab-hint-color); - border-radius: 4px; - pointer-events: none; - display: inline-block; - flex-shrink: 0; - } - - // 禁用状态 - &.is-disabled { - cursor: not-allowed; - } - - // 加载状态 - &.is-loading { - - .tiny-input__inner { - background-color: var(--tr-sender-bg-color); - } - - .tiny-textarea__inner { - background-color: var(--tr-sender-bg-color); - } - } - - // 响应式设计 - @media (max-width: 768px) { - &__prefix-slot { - width: var(--tr-sender-prefix-min-width); - min-width: var(--tr-sender-prefix-min-width); - } - - &__actions-slot { - padding: 0 calc(var(--tr-sender-padding-right) / 2); - min-width: auto; - } - - &__header-slot, - &__footer-slot { - padding: calc(var(--tr-sender-padding-top) / 2); - } - - &__content-area { - min-width: var(--tr-sender-content-min-width); - } - } - - // 模式切换的过渡动画 - // 单行模式 - &.mode-single .tiny-sender__actions-slot { - opacity: 1; - visibility: visible; - transform: translateY(-50%); - transition: - opacity 0.3s cubic-bezier(0.25, 1, 0.5, 1), - transform 0.3s cubic-bezier(0.25, 1, 0.5, 1), - visibility 0s; - } - - // 多行模式 - actions 消失 - &.mode-multiple .tiny-sender__actions-slot { - opacity: 0; - visibility: hidden; - transform: translateY(-50%) translateX(10px); - transition: - opacity 0.3s cubic-bezier(0.25, 1, 0.5, 1), - transform 0.3s cubic-bezier(0.25, 1, 0.5, 1), - visibility 0s 0.3s; - z-index: -1; - } - - // 多行模式 - toolbar 出现 - &.mode-multiple .tiny-sender__toolbar { - opacity: 1; - visibility: visible; - transform: translateY(0); - transition: - opacity 0.3s cubic-bezier(0.25, 1, 0.5, 1), - transform 0.3s cubic-bezier(0.25, 1, 0.5, 1), - visibility 0s; - z-index: 2; - position: relative; - } - - // 单行模式 - toolbar 消失 - &.mode-single .tiny-sender__toolbar { - opacity: 0; - visibility: hidden; - transform: translateY(10px); - transition: - opacity 0.3s cubic-bezier(0.25, 1, 0.5, 1), - transform 0.3s cubic-bezier(0.25, 1, 0.5, 1), - visibility 0s 0.3s; - z-index: -1; - height: 0; - overflow: hidden; - } - - // 固定文本样式 - &__decorative-content { - position: absolute; - top: 0; - left: 0; - border-radius: var(--tr-sender-border-radius); - padding: var(--tr-sender-content-padding-single); - background-color: var(--tr-sender-decorative-content-bg-color); - line-height: var(--tr-sender-decorative-content-line-height); - color: var(--tr-sender-decorative-content-color); - z-index: 1; - display: flex; - align-items: center; - width: 100%; - - a { - color: var(--tr-sender-decorative-content-link-color); - text-decoration: none; - margin-left: 4px; - - &:hover { - text-decoration: underline; - color: var(--tr-sender-decorative-content-link-color); - } - } - } -} - -.shortcut-hint { - position: absolute; - bottom: -16px; - font-size: var(--tr-sender-shortcut-hint-font-size); - color: var(--tr-sender-shortcut-hint-color); - white-space: nowrap; - user-select: none; -} - -/* 模式切换过渡动画 */ -.tiny-sender-slide-up { - - &-enter-active, - &-leave-active { - transition: - opacity var(--tr-sender-suggestion-transition-duration) var(--tr-sender-suggestion-transition-style), - transform var(--tr-sender-suggestion-transition-duration) var(--tr-sender-suggestion-transition-style); - } - - &-enter-from, - &-leave-to { - opacity: 0; - transform: translateY(10px); - } - - &-enter-to, - &-leave-from { - opacity: 1; - transform: translateY(0); - } -} - -.tiny-sender-slide-down { - - &-enter-active, - &-leave-active { - transition: - opacity var(--tr-sender-suggestion-transition-duration) var(--tr-sender-suggestion-transition-style), - transform var(--tr-sender-suggestion-transition-duration) var(--tr-sender-suggestion-transition-style); - } - - &-enter-from, - &-leave-to { - opacity: 0; - transform: translateY(-10px); - } - - &-enter-to, - &-leave-from { - opacity: 1; - transform: translateY(0); - } +/** + * Sender 组件样式 + * CSS 变量统一在 src/styles/components/sender.less 中定义 + */ + +// 紧凑尺寸 - size="small" +.tr-sender--small { + --tr-sender-font-size: var(--tr-sender-font-size-small); + --tr-sender-line-height: var(--tr-sender-line-height-small); + --tr-sender-border-radius: var(--tr-sender-border-radius-small); + --tr-sender-padding: var(--tr-sender-padding-small); + --tr-sender-footer-gap: var(--tr-sender-footer-gap-small); + --tr-sender-header-padding: var(--tr-sender-header-padding-small); + --tr-sender-multi-main-padding: var(--tr-sender-multi-main-padding-small); + --tr-sender-footer-padding: var(--tr-sender-footer-padding-small); + --tr-sender-button-size: var(--tr-sender-button-size-small); + --tr-sender-button-size-submit: var(--tr-sender-button-size-submit-small); + --tr-sender-prefix-padding-right: var(--tr-sender-prefix-padding-right-small); + --tr-sender-actions-padding-right: var(--tr-sender-actions-padding-right-small); } diff --git a/packages/components/src/sender/index.ts b/packages/components/src/sender/index.ts index 04697642d..c5d584b95 100644 --- a/packages/components/src/sender/index.ts +++ b/packages/components/src/sender/index.ts @@ -1,12 +1,62 @@ -import { App } from 'vue' -import Sender from './index.vue' +/** + * Sender 组件入口 + * + * 提供两种扩展使用方式: + * 1. 静态属性:Sender.Mention.configure() - 用于扩展继承 + * 2. 便捷函数:Sender.mention() - 用于简单场景 + */ -Sender.name = 'TrSender' +import type { App } from 'vue' +import SenderComponent from './index.vue' +import { Mention, Suggestion, Template, mention, suggestion, template } from './extensions' +import './index.less' +// 设置组件名称 +SenderComponent.name = 'TrSender' + +// Vue 插件安装函数 const install = function (app: App) { - app.component(Sender.name!, Sender) + app.component(SenderComponent.name!, SenderComponent) } -Sender.install = install +// 扩展组件,添加静态属性和便捷函数 +const Sender = Object.assign(SenderComponent, { + install, + // 扩展类(用于继承) + Mention, + Suggestion, + Template, + // 便捷函数(用于简单场景) + mention, + suggestion, + template, +}) + +export default Sender + +export type { + SenderProps, + SenderEmits, + SenderSlots, + SenderContext, + UseEditorReturn, + UseModeSwitchReturn, + UseSuggestionReturn, + UseKeyboardShortcutsReturn, + TemplateItem, + MentionItem, + DefaultActions, +} from './index.type' + +export { useSenderContext } from './context' -export default Sender as typeof Sender & { install: typeof install } +// ========== 扩展类型导出 ========== +export type { TemplateAttrs, TemplateOptions } from './extensions/template' +export type { MentionAttrs, MentionOptions } from './extensions/mention' +export type { + SenderSuggestionItem, + SuggestionOptions, + SuggestionState, + SuggestionTextPart, + HighlightFunction, +} from './extensions/suggestion' diff --git a/packages/components/src/sender/index.type.ts b/packages/components/src/sender/index.type.ts index 772e30eb1..67c22d1d9 100644 --- a/packages/components/src/sender/index.type.ts +++ b/packages/components/src/sender/index.type.ts @@ -1,206 +1,362 @@ -import type { Ref, VNode, Component } from 'vue' -import type { TemplateItem, TextItem } from './types/editor.type' +import type { Extension } from '@tiptap/core' +import type { InputMode, SubmitTrigger, DefaultActions, AutoSize, StructuredData } from './types/base' + +// 导出所有子模块类型 +export * from './types/base' +export * from './types/composables' +export * from './types/components' +export * from './types/context' +export * from './types/slots' + +// 导入插槽作用域类型 +import type { SenderSlotScope } from './types/slots' + +// 导出扩展类型(供用户使用) +export type { MentionItem } from './extensions/mention' +export type { + SenderSuggestionItem, + SuggestionOptions, + SuggestionState, + SuggestionTextPart, + HighlightFunction, +} from './extensions/suggestion' + +// ============================================ +// 主组件 Props +// ============================================ /** - * 组件核心类型定义 + * Sender 组件 Props */ +export interface SenderProps { + // ===== 核心数据 ===== -// 主题类型 -export type ThemeType = 'light' | 'dark' + /** + * 输入内容(双向绑定) + * + * 支持 v-model + */ + modelValue?: string -// 输入模式类型 -export type InputMode = 'single' | 'multiple' + /** + * 默认值 + * + * 仅在初始化时使用 + */ + defaultValue?: string -// 提交触发方式 -export type SubmitTrigger = 'enter' | 'ctrlEnter' | 'shiftEnter' + // ===== 基础配置 ===== -// 语音回调函数集合 -export interface SpeechCallbacks { - onStart: () => void - onInterim: (transcript: string) => void - onFinal: (transcript: string) => void - onEnd: (transcript?: string) => void - onError: (error: Error) => void -} + /** + * 占位符文本 + * + * @default '请输入内容...' + */ + placeholder?: string -// 语音处理器接口(统一接口,支持内置和自定义实现) -// 职责说明: -// - start: 启动语音识别,接收 callbacks 用于通知识别过程中的各种事件 -// - stop: 清理资源 -// - isSupported: 检查当前环境是否支持该语音识别方式 -export interface SpeechHandler { - start: (callbacks: SpeechCallbacks) => Promise | void - stop: () => Promise | void - isSupported: () => boolean -} + /** + * 是否禁用 + * + * @default false + */ + disabled?: boolean -// 语音识别配置 -export interface SpeechConfig { - customHandler?: SpeechHandler // 自定义语音处理器(传入则使用自定义,否则使用内置) - lang?: string // 识别语言,默认浏览器语言 - continuous?: boolean // 是否持续识别 - interimResults?: boolean // 是否返回中间结果 - autoReplace?: boolean // 是否自动替换当前输入内容 - onVoiceButtonClick?: (isRecording: boolean, preventDefault: () => void) => void | Promise // 录音按钮点击拦截器 -} + /** + * 是否加载中 + * + * 加载状态下显示停止按钮 + * + * @default false + */ + loading?: boolean -export type AutoSize = boolean | { minRows: number; maxRows: number } - -export type TooltipRender = () => VNode | string - -export interface ControlState { - tooltips?: string | TooltipRender // 工具提示 - disabled?: boolean // 是否禁用 - tooltipPlacement?: - | 'top' - | 'top-start' - | 'top-end' - | 'bottom' - | 'bottom-start' - | 'bottom-end' - | 'left' - | 'left-start' - | 'left-end' - | 'right' - | 'right-start' - | 'right-end' // tooltip 弹窗位置 -} + /** + * 是否自动聚焦 + * + * @default false + */ + autofocus?: boolean -interface fileUploadConfig { - accept?: string // 接受的文件类型 - multiple?: boolean // 是否支持多选文件 - reset?: boolean // 选择文件后是否重置输入,默认为 true -} + // ===== 模式控制 ===== -interface VoiceButtonConfig { - icon?: VNode | Component // 自定义语音图标(未录音状态) -} + /** + * 输入模式 + * + * - single: 单行模式 + * - multiple: 多行模式 + * + * @default 'single' + */ + mode?: InputMode -export interface ButtonGroupConfig { - file?: ControlState & fileUploadConfig // 文件上传按钮 - submit?: ControlState // 提交按钮 - voice?: VoiceButtonConfig // 语音按钮 -} -// 高亮片段类型 -export interface SuggestionTextPart { - text: string - isMatch: boolean -} + /** + * 自动调整高度 + * + * - false: 不自动调整 + * - true: 自动调整(默认 1-3 行) + * - { minRows, maxRows }: 自定义行数范围 + * + * 仅在 mode === 'multiple' 时有效 + * + * @default { minRows: 1, maxRows: 3 } + */ + autoSize?: AutoSize -// 高亮函数类型 -type HighlightFunction = (suggestionText: string, inputText: string) => SuggestionTextPart[] - -// 建议项类型 -export interface ISuggestionItem { - content: string - // 三种可能的高亮方式: - // 1. 未定义:使用默认高亮函数 - // 2. 字符串数组:指定要高亮的文本片段 - // 3. 函数:自定义高亮逻辑 - highlights?: string[] | HighlightFunction -} + // ===== 内容控制 ===== -// Sender组件属性 -export interface SenderProps { - autofocus?: boolean // 自动聚焦 - autoSize?: AutoSize // 自适应内容高度 - allowSpeech?: boolean // 是否允许语音识别 - allowFiles?: boolean // 是否允许上传附件 - clearable?: boolean // 是否显示清除按钮 - disabled?: boolean // 禁用状态 - defaultValue?: string | null // 默认值 - loading?: boolean // 加载状态 - modelValue?: string // 双向绑定值 - mode?: InputMode // 输入框模式:单行/多行 - maxLength?: number // 最大输入长度 - buttonGroup?: ButtonGroupConfig // 按钮组配置 - submitType?: SubmitTrigger // 提交触发方式 - speech?: boolean | SpeechConfig // 语音识别配置 - placeholder?: string // 占位文本 - showWordLimit?: boolean // 显示字数统计 - suggestions?: ISuggestionItem[] // 输入建议 - suggestionPopupWidth?: string | number // 联想建议弹窗宽度,如 '300px' 或 300 - activeSuggestionKeys?: string[] // 激活建议项的按键,默认 ['Enter', 'Tab'] - theme?: ThemeType // 主题 - templateData?: UserItem[] // 模板数据 - stopText?: string // 停止按钮文字,不传则只显示图标 -} + /** + * 最大字符数 + * + * @default Infinity + */ + maxLength?: number + + /** + * 是否显示字数限制 + * + * 仅在 maxLength 有值时有效 + * + * @default false + */ + showWordLimit?: boolean + + /** + * 是否显示清空按钮 + * + * @default false + */ + clearable?: boolean + + // ===== 扩展配置 ===== + + /** + * Tiptap 扩展配置 + * + * 用于添加增强输入能力,如 Template、Mention、Suggestion 等 + * + * @example 基础使用 + * ```typescript + * import { Template } from '@tiny-robot/components/sender/extensions' + * + * + * ``` + * + * @example 带配置的扩展(响应式推荐) + * ```typescript + * import { Mention, Suggestion } from '@tiny-robot/components/sender/extensions' + * + * const mentions = ref([...]) + * const suggestions = ref([...]) + * + * const extensions = [ + * Mention.configure({ items: mentions }), + * Suggestion.configure({ items: suggestions }) + * ] + * + * + * ``` + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + extensions?: Extension[] | any[] + + // ===== 样式定制 ===== + + /** + * 组件尺寸 + * + * - normal: 正常尺寸(默认) + * - small: 紧凑模式,更小的字体、间距和图标 + * + * @default 'normal' + */ + size?: 'normal' | 'small' -export interface ActionButtonsProps { - loading?: boolean // 加载状态 - disabled?: boolean // 是否禁用 - showClear?: boolean // 是否可以清除 - hasContent?: boolean // 是否有文本内容 - buttonGroup?: ButtonGroupConfig - allowSpeech?: boolean // 是否允许语音识别 - speechStatus?: { - isRecording: boolean // 是否正在录制 - isSupported: boolean // 是否支持语音识别 - } - allowFiles?: boolean // 是否允许上传附件 - submitType?: SubmitTrigger // 提交触发方式 - showShortcuts?: boolean // 是否显示快捷键提示 - isOverLimit?: boolean // 是否超出字数限制 - stopText?: string // 停止按钮文字,不传则只显示图标 + /** + * 停止按钮文字 + * + * @default '停止响应' + */ + stopText?: string + + // ===== 默认按钮配置 ===== + + /** + * 默认操作按钮配置 + * + * 用于统一配置默认按钮(Clear、Submit)的状态和提示 + * + * @example 基础使用 + * ```vue + * + * ``` + * + * @example 动态配置 + * ```vue + * + * + * + * ``` + * + * @default undefined + */ + defaultActions?: DefaultActions + + // ===== 提交配置 ===== + + /** + * 提交触发方式 + * + * @default 'enter' + */ + submitType?: SubmitTrigger } -// 组件事件定义 -export type SenderEmits = { +// ============================================ +// 主组件 Emits +// ============================================ + +/** + * Sender 组件 Emits + */ +export interface SenderEmits { + /** + * 更新输入内容 + * + * @param e - 事件名 + * @param value - 新内容 + */ (e: 'update:modelValue', value: string): void - (e: 'update:templateData', value: UserItem[]): void - (e: 'submit', value: string): void - (e: 'clear'): void - (e: 'speech-start'): void - (e: 'speech-end', transcript?: string): void - (e: 'speech-interim', transcript: string): void - (e: 'speech-error', error: Error): void - (e: 'suggestion-select', value: string): void + + /** + * 提交内容(增强版) + * + * @param e - 事件名 + * @param textContent - 提交的内容(纯文本,如 "帮我分析 @张三 的周报") + * @param structuredData - 结构化数据(可选) + * + * @example + * ```typescript + * function handleSubmit(text: string, data?: StructuredData) { + * console.log('纯文本:', text) + * + * if (data?.template) { + * // Template 场景 + * console.log('模板数据:', data.template) + * } + * + * if (data?.mentions) { + * // Mention 场景 + * console.log('提及的人:', data.mentions) + * } + * } + * ``` + */ + (e: 'submit', textContent: string, structuredData?: StructuredData): void + + /** + * 聚焦事件 + * + * @param e - 事件名 + * @param event - 原生事件 + */ (e: 'focus', event: FocusEvent): void + + /** + * 失焦事件 + * + * @param e - 事件名 + * @param event - 原生事件 + */ (e: 'blur', event: FocusEvent): void - (e: 'escape-press'): void // 按下Esc键时触发 - (e: 'cancel'): void // 取消发送状态时触发 - (e: 'reset-template'): void // 重置模板状态,退出模板编辑模式 - (e: 'files-selected', files: File[]): void // 文件选择事件 -} -// 语音识别状态 -export interface SpeechState { - isRecording: boolean // 是否正在录音 - isSupported: boolean // 是否支持语音识别 - error?: Error // 错误信息 -} + /** + * 清空事件 + * + * @param e - 事件名 + */ + (e: 'clear'): void -// 语音识别Hook配置 -export interface SpeechHookOptions extends SpeechConfig { - onStart?: () => void - onEnd?: (transcript?: string) => void - onInterim?: (transcript: string) => void - onFinal?: (transcript: string) => void - onError?: (error: Error) => void -} + /** + * 取消事件 + * + * 在 loading 状态下点击停止按钮时触发 + * 用于取消正在进行的操作(如 AI 响应) + * + * @param e - 事件名 + */ + (e: 'cancel'): void -// 输入处理器返回类型 -export interface InputHandler { - inputValue: Ref - isComposing: Ref - clearInput: () => void + /** + * 输入事件 + * + * @param e - 事件名 + * @param value - 当前内容 + */ + (e: 'input', value: string): void } -// 键盘处理器返回类型 -export interface KeyboardHandler { - handleKeyPress: (e: KeyboardEvent) => void - triggerSubmit: () => void -} +// ============================================ +// 主组件 Slots +// ============================================ -// 语音识别Hook返回类型 -export interface SpeechHandlerResult { - speechState: SpeechState - start: () => void - stop: () => void -} +/** + * Sender 组件 Slots + */ +export interface SenderSlots { + /** + * 头部插槽 + */ + header?: () => unknown + + /** + * 前缀插槽 + */ + prefix?: () => unknown -export type UserTextItem = Omit & { id?: TextItem['id'] } + /** + * 内容插槽 + * + * @param props - 插槽属性 + * @param props.editor - 编辑器实例 + */ + content?: (props: { editor: unknown }) => unknown -export type UserTemplateItem = Omit, 'id'> & { id?: TemplateItem['id'] } + /** + * 单行模式内联操作按钮插槽 + * + * @example + * ```vue + * + * + * + * ``` + */ + 'actions-inline'?: (scope: SenderSlotScope) => unknown -export type UserItem = UserTextItem | UserTemplateItem + /** + * 底部插槽(多行模式) + */ + footer?: (scope: SenderSlotScope) => unknown + + /** + * 底部右侧插槽(多行模式) + */ + 'footer-right'?: (scope: SenderSlotScope) => unknown +} diff --git a/packages/components/src/sender/index.vue b/packages/components/src/sender/index.vue index 93dd3f6f4..b2def639f 100644 --- a/packages/components/src/sender/index.vue +++ b/packages/components/src/sender/index.vue @@ -1,798 +1,138 @@ - // 判断是否需要切换到多行模式 - if (textWidth > availableWidth && availableWidth > minThreshold && currentMode.value === 'single') { - isAutoSwitching.value = true - currentMode.value = 'multiple' + - // 在切换模式时保留原始文本格式 - nextTick(() => { - if (inputRef.value) { - setTimeout(() => { - // 使用组件作用域查找textarea - const textareaElement = senderRef.value?.querySelector('.tiny-textarea__inner') as HTMLInputElement - if (textareaElement) { - // 确保textarea的white-space属性正确设置 - textareaElement.style.whiteSpace = 'pre-wrap' - const pos = inputValue.value.length - textareaElement.focus() - textareaElement.setSelectionRange(pos, pos) - } - isAutoSwitching.value = false - }, 300) - } else { - isAutoSwitching.value = false + - - - - diff --git a/packages/components/src/sender/types/base.ts b/packages/components/src/sender/types/base.ts new file mode 100644 index 000000000..8e08313a7 --- /dev/null +++ b/packages/components/src/sender/types/base.ts @@ -0,0 +1,271 @@ +import type { TooltipContent, TooltipPlacement } from '../../sender-actions/types/common' +import type { MentionItem, MentionStructuredItem } from '../extensions/mention/types' + +// ============================================ +// 基础类型 +// ============================================ + +/** + * 输入模式 + * - single: 单行模式,适用于简短输入 + * - multiple: 多行模式,适用于长文本输入 + */ +export type InputMode = 'single' | 'multiple' + +/** + * 提交触发方式 + * - enter: Enter 键提交 + * - ctrlEnter: Ctrl+Enter 提交 + * - shiftEnter: Shift+Enter 提交 + */ +export type SubmitTrigger = 'enter' | 'ctrlEnter' | 'shiftEnter' + +// ============================================ +// 模板相关类型 +// ============================================ + +/** + * 选择器选项 + */ +export interface SelectOption { + /** + * 显示文本 + */ + label: string + + /** + * 选择后的值 + */ + value: string + + /** + * 自定义数据(可选) + */ + data?: string +} + +/** + * 模板项(用户侧) + * + * 用户传入的模板数据格式 + * 组件内部会转换为 Tiptap 节点格式 + */ +export type TemplateItem = + | { + /** + * 模板 ID,可选 + * 如果不提供,组件会自动生成 + */ + id?: string + + /** + * 类型:普通文本 + */ + type: 'text' + + /** + * 内容 + */ + content: string + } + | { + /** + * 模板 ID,可选 + * 如果不提供,组件会自动生成 + */ + id?: string + + /** + * 类型:模板块(可编辑) + */ + type: 'block' + + /** + * 内容 + */ + content: string + } + | { + /** + * 模板 ID,可选 + * 如果不提供,组件会自动生成 + */ + id?: string + + /** + * 类型:选择器 + */ + type: 'select' + + /** + * 内容(选中的值) + */ + content: string + + /** + * 占位文字(未选择时显示) + */ + placeholder?: string + + /** + * 选项列表 + */ + options?: SelectOption[] + + /** + * 当前选中的值 + */ + value?: string + } + +// ============================================ +// Mention 相关类型(从扩展导入) +// ============================================ + +/** + * 提及项 + * + * 用于 @ 提及功能的数据 + * + * @deprecated 请从 extensions/mention 导入 + * @see packages/components/src/sender/extensions/mention/types.ts + */ +export type { MentionItem } + +// ============================================ +// Submit 事件相关类型 +// ============================================ + +/** + * 结构化数据(联合类型) + * + * Submit 事件的第二个参数,根据使用的扩展直接返回对应的数据数组 + * + * **设计理念**: + * - 第一个参数 `text`:纯文本内容,适用于简单场景 + * - 第二个参数 `data`:结构化数据数组,直接使用无需解包 + * + * **类型说明**: + * - `TemplateItem[]`: 使用 Template 扩展时返回(混合结构) + * - `MentionStructuredItem[]`: 使用 Mention 扩展时返回(混合结构) + * + * @example Template 场景 + * ```typescript + * function handleSubmit(text: string, data?: StructuredData) { + * // text: "帮我分析 的周报" + * // data: [ + * // { type: 'text', content: '帮我分析 ' }, + * // { type: 'block', content: '张三' }, + * // { type: 'text', content: ' 的周报' } + * // ] + * + * // 提取模板块(可编辑部分) + * if (data && data.some(item => item.type === 'block')) { + * const blocks = data.filter(item => item.type === 'block') + * console.log('模板块:', blocks) + * } + * + * // 提取选择器 + * if (data && data.some(item => item.type === 'select')) { + * const selects = data.filter(item => item.type === 'select') + * console.log('选择器:', selects) + * } + * } + * ``` + * + * @example Mention 场景 + * ```typescript + * function handleSubmit(text: string, data?: StructuredData) { + * // text: "帮我分析 @张三 的周报" + * // data: [ + * // { type: 'text', content: '帮我分析 ' }, + * // { type: 'mention', content: '张三', value: '...' }, + * // { type: 'text', content: ' 的周报' } + * // ] + * + * // 统一使用 content 属性 + * const allContent = data?.map(item => item.content).join('') + * console.log('完整内容:', allContent) + * + * // 提取 mention(正确的类型守卫) + * if (data && data.some(item => item.type === 'mention')) { + * const mentions = data.filter(item => item.type === 'mention') + * console.log('提及的人:', mentions.map(m => m.content)) + * console.log('关联值:', mentions.map(m => m.value)) + * } + * } + * ``` + */ +export type StructuredData = TemplateItem[] | MentionStructuredItem[] + +// ============================================ +// 按钮配置相关类型 +// ============================================ + +/** + * 默认操作按钮配置 + * + * 用于统一配置 Sender 的默认按钮(Clear、Submit) + * + * @example + * ```typescript + * const defaultActions = { + * submit: { disabled: !isValid, tooltip: '请完善表单' }, + * clear: { tooltip: '清空内容' } + * } + * ``` + */ +export interface DefaultActions { + /** + * 提交按钮配置 + */ + submit?: { + /** + * 是否禁用 + */ + disabled?: boolean + + /** + * 工具提示 + * - string: 简单文本 + * - () => string | VNode: 渲染函数,支持复杂内容 + */ + tooltip?: TooltipContent + + /** + * Tooltip 位置 + */ + tooltipPlacement?: TooltipPlacement + } + + /** + * 清空按钮配置 + */ + clear?: { + /** + * 是否禁用 + */ + disabled?: boolean + + /** + * 工具提示 + * - string: 简单文本 + * - () => string | VNode: 渲染函数,支持复杂内容 + */ + tooltip?: TooltipContent + + /** + * Tooltip 位置 + */ + tooltipPlacement?: TooltipPlacement + } +} + +// ============================================ +// 工具类型 +// ============================================ + +/** + * 自动高度配置 + */ +export type AutoSize = boolean | { minRows: number; maxRows: number } diff --git a/packages/components/src/sender/types/components.ts b/packages/components/src/sender/types/components.ts new file mode 100644 index 000000000..233864eb2 --- /dev/null +++ b/packages/components/src/sender/types/components.ts @@ -0,0 +1,84 @@ +import type { SenderSuggestionItem } from '../extensions/suggestion/types' + +// ============================================ +// 组件 Props 类型 +// ============================================ + +/** + * WordCounter Props + */ +export interface WordCounterProps { + /** + * 当前字符数 + */ + current: number + + /** + * 最大字符数 + */ + max: number + + /** + * 是否超出限制 + */ + isOverLimit: boolean +} + +/** + * SuggestionList Props + */ +export interface SuggestionListProps { + /** + * 是否显示 + */ + show: boolean + + /** + * 建议列表 + */ + suggestions: SenderSuggestionItem[] + + /** + * 键盘激活索引 + */ + activeKeyboardIndex: number + + /** + * 鼠标激活索引 + */ + activeMouseIndex: number + + /** + * 输入值 + */ + inputValue: string + + /** + * 弹窗样式 + */ + popupStyle?: Record +} + +/** + * SuggestionList Emits + */ +export interface SuggestionListEmits { + /** + * 选择建议 + * + * @param suggestion - 建议内容 + */ + (e: 'select', suggestion: string): void + + /** + * 鼠标进入 + * + * @param index - 索引 + */ + (e: 'mouse-enter', index: number): void + + /** + * 鼠标离开 + */ + (e: 'mouse-leave'): void +} diff --git a/packages/components/src/sender/types/composables.ts b/packages/components/src/sender/types/composables.ts new file mode 100644 index 000000000..a82f9efc2 --- /dev/null +++ b/packages/components/src/sender/types/composables.ts @@ -0,0 +1,146 @@ +import type { Ref } from 'vue' +import type { Editor } from '@tiptap/vue-3' +import type { InputMode, SubmitTrigger } from './base' + +// ============================================ +// Composables 相关类型 +// ============================================ + +/** + * 键盘处理器接口 + */ +export interface KeyboardHandlers { + checkSubmitShortcut: (event: KeyboardEvent) => boolean + checkNewlineShortcut: (event: KeyboardEvent) => boolean + submit: () => void +} + +/** + * useKeyboardShortcuts Hook 参数 + */ +export interface UseKeyboardShortcutsParams { + submitType: Ref + canSubmit: Ref + mode: Ref + submit: () => void + setMode: (mode: InputMode) => void +} + +/** + * useKeyboardShortcuts Hook 返回值 + */ +export interface UseKeyboardShortcutsReturn { + checkSubmitShortcut: (event: KeyboardEvent) => boolean + checkNewlineShortcut: (event: KeyboardEvent) => boolean +} + +/** + * useEditor 返回类型 + */ +export interface UseEditorReturn { + /** + * 编辑器实例 + * 注意:Tiptap 的 useEditor 返回 ShallowRef + */ + editor: Ref + + /** + * 编辑器 DOM 引用 + */ + editorRef: Ref +} + +/** + * useModeSwitch 返回类型 + */ +export interface UseModeSwitchReturn { + /** + * 当前模式 + */ + currentMode: Ref + + /** + * 是否正在自动切换 + */ + isAutoSwitching: Ref + + /** + * 设置模式 + * + * @param mode - 输入模式 + */ + setMode: (mode: InputMode) => void + + /** + * 检查内容溢出 + * + * 用于自动切换模式 + */ + checkOverflow: () => void +} + +/** + * useSuggestion 返回类型 + */ +export interface UseSuggestionReturn { + /** + * 弹窗是否可见 + */ + isPopupVisible: Ref + + /** + * 当前激活的建议 + */ + activeSuggestion: Ref + + /** + * 键盘导航的激活索引 + */ + activeKeyboardIndex: Ref + + /** + * 鼠标悬停的激活索引 + */ + activeMouseIndex: Ref + + /** + * 自动补全文本 + */ + autoCompleteText: Ref + + /** + * 是否显示 Tab 提示器 + */ + showTabIndicator: Ref + + /** + * 应用建议 + * + * @param suggestion - 建议内容 + */ + applySuggestion: (suggestion: string) => void + + /** + * 键盘导航 + * + * @param direction - 方向(上/下) + */ + navigateWithKeyboard: (direction: 'up' | 'down') => void + + /** + * 鼠标进入 + * + * @param index - 建议项索引 + */ + handleMouseEnter: (index: number) => void + + /** + * 鼠标离开 + */ + handleMouseLeave: () => void + + /** + * 关闭弹窗 + */ + closePopup: () => void +} diff --git a/packages/components/src/sender/types/context.ts b/packages/components/src/sender/types/context.ts new file mode 100644 index 000000000..d4ef67d38 --- /dev/null +++ b/packages/components/src/sender/types/context.ts @@ -0,0 +1,170 @@ +import type { Ref } from 'vue' +import type { Editor } from '@tiptap/vue-3' +import type { InputMode, DefaultActions, SubmitTrigger } from './base' + +/** + * Sender Context + * + * 通过 provide/inject 在组件树中共享的状态和方法 + * 所有子组件都可以通过 inject 获取 + */ +export interface SenderContext { + // ===== 编辑器相关 ===== + + /** + * Tiptap 编辑器实例 + * 注意:Tiptap 的 useEditor 返回 ShallowRef + */ + editor: Ref + + /** + * 编辑器 DOM 引用 + */ + editorRef: Ref + + // ===== 状态相关 ===== + + /** + * 当前输入模式 + */ + mode: Ref + + /** + * 是否正在自动切换模式 + * 用于控制切换时的过渡动画 + */ + isAutoSwitching: Ref + + /** + * 是否加载中 + */ + loading: Ref + + /** + * 是否禁用 + */ + disabled: Ref + + /** + * 是否有内容 + */ + hasContent: Ref + + /** + * 是否可以提交 + * + * 综合判断: + * - !disabled + * - !loading + * - hasContent + * - !isOverLimit + * - !defaultActions.submit?.disabled + */ + canSubmit: Ref + + /** + * 是否超出字数限制 + */ + isOverLimit: Ref + + // ===== 字数统计 ===== + + /** + * 当前字符数 + */ + characterCount: Ref + + /** + * 最大字符数限制 + */ + maxLength: Ref + + // ===== 样式相关 ===== + + /** + * 组件的尺寸 + */ + size: Ref<'small' | 'normal'> + + // ===== 配置相关 ===== + + /** + * 是否显示字数限制 + */ + showWordLimit: Ref + + /** + * 是否显示清空按钮 + */ + clearable: Ref + + /** + * 默认操作按钮配置 + */ + defaultActions: Ref + + /** + * 提交触发方式 + */ + submitType: Ref + + /** + * 停止按钮文字 + */ + stopText: Ref + + // ===== 方法相关 ===== + + /** + * 提交内容 + */ + submit: () => void + + /** + * 清空内容 + */ + clear: () => void + + /** + * 取消操作 + * + * 在 loading 状态下触发,用于取消正在进行的操作 + */ + cancel: () => void + + /** + * 聚焦编辑器 + */ + focus: () => void + + /** + * 失焦编辑器 + */ + blur: () => void + + /** + * 设置编辑器内容 + * + * @param content - 内容(HTML 或 JSON) + */ + setContent: (content: string) => void + + /** + * 获取编辑器内容 + * + * @returns 内容(HTML) + */ + getContent: () => string +} + +/** + * Context Key + * + * 用于 provide/inject 的 key + */ +export const SENDER_CONTEXT_KEY = Symbol('sender-context') + +/** + * useSenderContext 返回类型 + */ +export type UseSenderContextReturn = SenderContext diff --git a/packages/components/src/sender/types/editor.type.ts b/packages/components/src/sender/types/editor.type.ts deleted file mode 100644 index 24f38db7d..000000000 --- a/packages/components/src/sender/types/editor.type.ts +++ /dev/null @@ -1,58 +0,0 @@ -interface BaseTextItem { - id: string - type: string - content: string -} - -export interface TextItem extends BaseTextItem { - type: 'text' -} - -export interface TemplateItem extends BaseTextItem { - type: 'template' - prefix: string - suffix: string -} - -export interface ExtendedTextItem extends BaseTextItem { - type: 'text' | 'template' | 'prefix' | 'suffix' -} - -export interface StructuredDataItem { - id: string - type: 'block' | 'text' | 'template' | 'prefix' | 'suffix' - content: string | StructuredDataItem[] - asChild?: boolean - readonly?: boolean -} - -export interface EditorRange extends StaticRange { - readonly endEl?: HTMLElement | null - readonly endId?: string - readonly endType?: string - readonly startEl?: HTMLElement | null - readonly startId?: string - readonly startType?: string -} - -export interface SelectedItem { - id: string - type: ExtendedTextItem['type'] - startOffset: number - endOffset: number -} - -export interface CreateItem { - tag: 'new' - afterId?: string - type: 'text' - content: string -} - -export interface DataItem { - id: string - type: 'block' | 'text' | 'template' | 'prefix' | 'suffix' - content: string | DataItem[] - readonly?: boolean - asChild?: boolean -} diff --git a/packages/components/src/sender/types/slots.ts b/packages/components/src/sender/types/slots.ts new file mode 100644 index 000000000..0b0a35ed7 --- /dev/null +++ b/packages/components/src/sender/types/slots.ts @@ -0,0 +1,86 @@ +/** + * Sender 插槽作用域类型定义 + * + * 通过插槽作用域暴露给外部组件的状态和方法 + */ + +import type { Editor } from '@tiptap/core' + +/** + * Sender 插槽作用域 + * + * 通过插槽作用域暴露给外部组件的状态和方法 + * 主要为增强按钮(Upload、Voice 等)提供便捷的操作方法 + */ +export interface SenderSlotScope { + // ===== 编辑器实例 ===== + /** + * Tiptap 编辑器实例 + * 用于高级操作 + */ + editor: Editor | undefined + + // ===== 基础操作 ===== + /** + * 聚焦编辑器 + */ + focus: () => void + + /** + * 失焦编辑器 + */ + blur: () => void + + // ===== 内容操作(为增强按钮设计)===== + /** + * 插入内容到当前光标位置 + * + * 适用场景:语音输入、快捷短语插入 + * + * @param content - 要插入的内容 + * @example + * ```vue + * + * ``` + */ + insert: (content: string) => void + + /** + * 追加内容到编辑器末尾 + * + * 适用场景:连续语音输入、批量添加内容 + * + * @param content - 要追加的内容 + */ + append: (content: string) => void + + /** + * 替换编辑器全部内容 + * + * 适用场景:模板填充、内容重置 + * + * @param content - 新内容 + */ + replace: (content: string) => void + + // ===== 常用状态(便捷访问)===== + /** + * 是否禁用 + * 用于控制自定义按钮状态 + */ + disabled: boolean + + /** + * 是否加载中 + * 用于控制按钮加载状态和禁用 + */ + loading: boolean + + /** + * 是否有内容 + * 用于控制按钮显示/隐藏 + */ + hasContent: boolean +} diff --git a/packages/components/src/styles/components/index.css b/packages/components/src/styles/components/index.css index 53e181f1c..fd52d291d 100644 --- a/packages/components/src/styles/components/index.css +++ b/packages/components/src/styles/components/index.css @@ -2,6 +2,7 @@ @import './bubble.less'; @import './bubble-list.less'; @import './bubble-tool.less'; +@import './sender.less'; @import './history.less'; @import './prompt.less'; @import './prompts.less'; diff --git a/packages/components/src/styles/components/sender.less b/packages/components/src/styles/components/sender.less new file mode 100644 index 000000000..22618156b --- /dev/null +++ b/packages/components/src/styles/components/sender.less @@ -0,0 +1,69 @@ +.tr-sender-vars() { + @prefix: tr-sender; + + @vars: { + /* 基础颜色(引用全局变量) */ + bg-color: var(--tr-container-bg-default); + text-color: var(--tr-text-primary); + placeholder-color: var(--tr-text-tertiary); + button-hover-bg: var(--tr-container-bg-hover); + + /* 尺寸(默认 normal) */ + font-size: 16px; + line-height: 26px; + border-radius: 26px; + + /* 间距(默认 normal) */ + padding: 15px 20px; + gap: 8px; + footer-gap: 12px; + + /* Header 区域 */ + header-padding: 12px 20px; + header-divider-inset: 20px; + multi-main-padding: 16px 20px 12px; + + /* Footer 区域 */ + footer-padding: 0 10px 10px; + + /* 前缀和操作区 */ + prefix-padding-right: 4px; + actions-padding-right: 10px; + + /* 按钮(默认 normal) */ + button-size: 32px; + button-size-submit: 36px; + + /* 动画 */ + transition-duration: 0.2s; + }; + + @small-vars: { + /* 紧凑尺寸 - size="small" */ + font-size: 14px; + line-height: 24px; + border-radius: 24px; + padding: 12px 16px; + footer-gap: 8px; + header-padding: 12px 16px; + multi-main-padding: 14px 16px 10px; + footer-padding: 0 10px 10px; + button-size: 28px; + button-size-submit: 32px; + prefix-padding-right: 4px; + actions-padding-right: 8px; + }; + + :root, + [data-tr-theme] { + each(@vars, { + --@{prefix}-@{key}: @value; + }); + + each(@small-vars, { + --@{prefix}-@{key}-small: @value; + }); + } +} + +.tr-sender-vars(); diff --git a/packages/components/src/styles/variables.css b/packages/components/src/styles/variables.css index 425883fd1..5e1b83b15 100644 --- a/packages/components/src/styles/variables.css +++ b/packages/components/src/styles/variables.css @@ -124,6 +124,68 @@ /* ===== 阴影系统 ===== */ --tr-shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.04); + + /* ===== Sender 输入框 ===== */ + --tr-sender-bg-color-disabled: #f0f0f0; + --tr-sender-text-color-disabled: #a0a0a0; + --tr-sender-placeholder-color-disabled: #c0c0c0; + --tr-sender-box-shadow: 0 4px 16px 0px rgba(0, 0, 0, 0.08); + --tr-sender-header-border-bottom: 1px solid #e0e0e0; + --tr-sender-button-active-bg: rgba(0, 0, 0, 0.12); + --tr-sender-word-limit-color: #808080; + --tr-sender-word-limit-error-color: #f23030; + + /* ===== Suggestion 联想列表 ===== */ + --tr-suggestion-bg-color: #ffffff; + --tr-suggestion-box-shadow-color: rgba(0, 0, 0, 0.08); + --tr-suggestion-text-color: #191919; + --tr-suggestion-hover-bg-color: #f5f5f5; + --tr-suggestion-scrollbar-thumb-color: rgba(0, 0, 0, 0.2); + --tr-suggestion-scrollbar-thumb-hover-color: rgba(0, 0, 0, 0.3); + --tr-suggestion-item-font-size: 14px; + --tr-suggestion-item-icon-size: 16px; + --tr-suggestion-autocomplete-color: #999999; + --tr-suggestion-tab-hint-border: #999999; + --tr-suggestion-tab-hint-color: #666666; + --tr-suggestion-tab-hint-bg: rgba(255, 255, 255, 0.8); + + /* ===== Mention 提及列表 ===== */ + --tr-sender-mention-color: #1476ff; + --tr-sender-mention-bg: rgba(20, 118, 255, 0.1); + --tr-sender-mention-hover-bg: rgba(20, 118, 255, 0.15); + --tr-sender-mention-list-bg: #ffffff; + --tr-sender-mention-list-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + --tr-sender-mention-text-primary: #191919; + --tr-sender-mention-text-secondary: #666666; + --tr-sender-mention-text-tertiary: #808080; + --tr-sender-mention-item-hover-bg: #f5f5f5; + --tr-sender-mention-item-selected-bg: #f5f5f5; + --tr-sender-mention-scrollbar-thumb: rgba(0, 0, 0, 0.2); + --tr-sender-mention-scrollbar-thumb-hover: rgba(0, 0, 0, 0.3); + --tr-sender-mention-trigger-bg: rgba(20, 118, 255, 0.05); + + /* ===== Template Block 模板块 ===== */ + --tr-sender-template-color: #1476ff; + --tr-sender-template-bg: rgba(20, 118, 255, 0.1); + --tr-sender-template-border-radius: 6px; + --tr-sender-template-padding: 2px 4px; + --tr-sender-template-margin: 0 4px; + --tr-sender-template-min-width: 32px; + + /* ===== Template Select 模板选择器 ===== */ + --tr-sender-template-select-color: #1476ff; + --tr-sender-template-select-placeholder-color: rgba(82, 145, 255, 0.5); + --tr-sender-template-select-bg: rgba(20, 118, 255, 0.1); + --tr-sender-template-select-bg-hover: rgba(20, 118, 255, 0.15); + --tr-sender-template-select-bg-active: rgba(20, 118, 255, 0.2); + --tr-sender-template-select-dropdown-bg: #ffffff; + --tr-sender-template-select-dropdown-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + --tr-sender-template-select-text-primary: #191919; + --tr-sender-template-select-text-secondary: #666666; + --tr-sender-template-select-option-hover-bg: #f5f5f5; + --tr-sender-template-select-option-selected-bg: #f5f5f5; + --tr-sender-template-select-scrollbar-thumb: rgba(0, 0, 0, 0.2); + --tr-sender-template-select-scrollbar-thumb-hover: rgba(0, 0, 0, 0.3); } /* ===== 深色主题 ===== */ @@ -188,4 +250,62 @@ --tr-mcp-server-picker-divider-color: rgba(255, 255, 255, 0.1); --tr-mcp-server-picker-shadow: 0px 4px 16px 0px rgba(255, 255, 255, 0.08); --tr-mcp-server-picker-border-color-default: rgba(255, 255, 255, 0.15); + + /* ===== Sender 输入框 ===== */ + --tr-sender-bg-color-disabled: rgba(255, 255, 255, 0.06); + --tr-sender-text-color-disabled: #808080; + --tr-sender-placeholder-color-disabled: #666666; + --tr-sender-box-shadow: 0 4px 16px 0px rgba(0, 0, 0, 0.48); + --tr-sender-header-border-bottom: 1px solid #4a4a4a; + --tr-sender-button-active-bg: rgba(255, 255, 255, 0.15); + --tr-sender-word-limit-color: #808080; + --tr-sender-word-limit-error-color: #d94838; + + /* ===== Suggestion 联想列表 ===== */ + --tr-suggestion-bg-color: #333333; + --tr-suggestion-box-shadow-color: rgba(0, 0, 0, 0.48); + --tr-suggestion-text-color: #e6e6e6; + --tr-suggestion-hover-bg-color: #262626; + --tr-suggestion-scrollbar-thumb-color: rgba(255, 255, 255, 0.2); + --tr-suggestion-scrollbar-thumb-hover-color: rgba(255, 255, 255, 0.3); + --tr-suggestion-item-font-size: 14px; + --tr-suggestion-item-icon-size: 16px; + --tr-suggestion-autocomplete-color: #808080; + --tr-suggestion-tab-hint-border: #808080; + --tr-suggestion-tab-hint-color: #b3b3b3; + --tr-suggestion-tab-hint-bg: rgba(0, 0, 0, 0.3); + + /* ===== Mention 提及列表 ===== */ + --tr-sender-mention-color: #5291ff; + --tr-sender-mention-bg: rgba(82, 145, 255, 0.15); + --tr-sender-mention-hover-bg: rgba(82, 145, 255, 0.2); + --tr-sender-mention-list-bg: #333333; + --tr-sender-mention-list-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.48); + --tr-sender-mention-text-primary: #e6e6e6; + --tr-sender-mention-text-secondary: #b3b3b3; + --tr-sender-mention-text-tertiary: #808080; + --tr-sender-mention-item-hover-bg: #262626; + --tr-sender-mention-item-selected-bg: #262626; + --tr-sender-mention-scrollbar-thumb: rgba(255, 255, 255, 0.2); + --tr-sender-mention-scrollbar-thumb-hover: rgba(255, 255, 255, 0.3); + --tr-sender-mention-trigger-bg: rgba(82, 145, 255, 0.1); + + /* ===== Template Block 模板块 ===== */ + --tr-sender-template-color: #5291ff; + --tr-sender-template-bg: rgba(82, 145, 255, 0.15); + + /* ===== Template Select 模板选择器 ===== */ + --tr-sender-template-select-color: #5291ff; + --tr-sender-template-select-placeholder-color: rgba(82, 145, 255, 0.5); + --tr-sender-template-select-bg: rgba(82, 145, 255, 0.15); + --tr-sender-template-select-bg-hover: rgba(82, 145, 255, 0.2); + --tr-sender-template-select-bg-active: rgba(82, 145, 255, 0.25); + --tr-sender-template-select-dropdown-bg: #333333; + --tr-sender-template-select-dropdown-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.48); + --tr-sender-template-select-text-primary: #e6e6e6; + --tr-sender-template-select-text-secondary: #b3b3b3; + --tr-sender-template-select-option-hover-bg: #262626; + --tr-sender-template-select-option-selected-bg: #262626; + --tr-sender-template-select-scrollbar-thumb: rgba(255, 255, 255, 0.2); + --tr-sender-template-select-scrollbar-thumb-hover: rgba(255, 255, 255, 0.3); } diff --git a/packages/components/vite.config.ts b/packages/components/vite.config.ts index d05fff92c..25485051f 100644 --- a/packages/components/vite.config.ts +++ b/packages/components/vite.config.ts @@ -43,7 +43,15 @@ export default defineConfig({ }, minify: true, rollupOptions: { - external: ['vue', 'vue-router', '@opentiny/vue', '@opentiny/tiny-robot-svgs', 'markdown-it', 'dompurify'], + external: [ + 'vue', + 'vue-router', + '@opentiny/vue', + '@opentiny/tiny-robot-svgs', + 'markdown-it', + 'dompurify', + /^@tiptap.*/, + ], input: entries, output: { format: 'es',