From ec23b7bd921e42c7d6041cacecc81c34a6ab6237 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Sun, 24 Sep 2023 15:16:23 +0800 Subject: [PATCH] feat: add clipboard paste handler now plugin can register paste handler with `regChatInputPasteHandler` --- .../ChatBox/ChatInputBox/clipboard-helper.ts | 62 +++++++++++++++- .../components/ChatBox/ChatInputBox/index.tsx | 42 ++++++++--- .../ChatBox/ChatInputBox/usePasteHandler.tsx | 72 +++++++++++++++++++ client/web/src/plugin/common/reg.ts | 6 ++ client/web/src/utils/hot-key.ts | 11 +++ 5 files changed, 181 insertions(+), 12 deletions(-) create mode 100644 client/web/src/components/ChatBox/ChatInputBox/usePasteHandler.tsx diff --git a/client/web/src/components/ChatBox/ChatInputBox/clipboard-helper.ts b/client/web/src/components/ChatBox/ChatInputBox/clipboard-helper.ts index d57e2e53..b053f4e1 100644 --- a/client/web/src/components/ChatBox/ChatInputBox/clipboard-helper.ts +++ b/client/web/src/components/ChatBox/ChatInputBox/clipboard-helper.ts @@ -1,8 +1,55 @@ +import { + getMessageTextDecorators, + pluginChatInputPasteHandler, +} from '@/plugin/common'; +import { t } from 'tailchat-shared'; + +export interface ChatInputPasteHandlerData { + files: FileList; + text: string; +} + +export interface ChatInputPasteHandlerContext { + sendMessage: (text: string) => Promise; + applyMessage: (text: string) => void; +} + +export interface ChatInputPasteHandler { + name: string; + label?: string; + match: ( + event: React.ClipboardEvent + ) => boolean; + handler: ( + data: ChatInputPasteHandlerData, + ctx: ChatInputPasteHandlerContext + ) => void; +} + export class ClipboardHelper { - data: DataTransfer; + constructor( + private event: React.ClipboardEvent + ) {} - constructor(e: { clipboardData: DataTransfer }) { - this.data = e.clipboardData; + get data() { + return this.event.clipboardData; + } + + get builtinHandlers(): ChatInputPasteHandler[] { + return [ + { + name: 'pasteUrl', + label: t('转为Url富文本'), + match: (e) => e.clipboardData.getData('text/plain').startsWith('http'), + handler: (data, { applyMessage }) => { + applyMessage(getMessageTextDecorators().url(data.text)); + }, + }, + ]; + } + + get pasteHandlers(): ChatInputPasteHandler[] { + return [...this.builtinHandlers, ...pluginChatInputPasteHandler]; } hasImage(): File | false { @@ -19,6 +66,15 @@ export class ClipboardHelper { return file; } + /** + * 匹配是否有粘贴事件处理器 + */ + matchPasteHandler(): ChatInputPasteHandler[] { + const handlers = this.pasteHandlers.filter((h) => h.match(this.event)); + + return handlers; + } + private isPasteImage(items: DataTransferItemList): DataTransferItem | false { let i = 0; let item: DataTransferItem; diff --git a/client/web/src/components/ChatBox/ChatInputBox/index.tsx b/client/web/src/components/ChatBox/ChatInputBox/index.tsx index 8e8d35e8..5f94b609 100644 --- a/client/web/src/components/ChatBox/ChatInputBox/index.tsx +++ b/client/web/src/components/ChatBox/ChatInputBox/index.tsx @@ -20,6 +20,7 @@ import { ChatInputEmotion } from './Emotion'; import _uniq from 'lodash/uniq'; import { ChatDropArea } from './ChatDropArea'; import { Icon } from 'tailchat-design'; +import { usePasteHandler } from './usePasteHandler'; interface ChatInputBoxProps { onSendMsg: (msg: string, meta?: SendMessagePayloadMeta) => Promise; @@ -32,15 +33,23 @@ export const ChatInputBox: React.FC = React.memo((props) => { const [message, setMessage] = useState(''); const [mentions, setMentions] = useState([]); const { disabled } = useChatInputMentionsContext(); - const handleSendMsg = useEvent(async () => { - await props.onSendMsg(message, { + const { runPasteHandlers, pasteHandlerContainer } = usePasteHandler(); + + const sendMessage = useEvent( + async (msg: string, meta?: SendMessagePayloadMeta) => { + await props.onSendMsg(msg, meta); + setMessage(''); + inputRef.current?.focus(); + } + ); + + const handleSendMsg = useEvent(() => { + sendMessage(message, { mentions: _uniq(mentions), // 发送前去重 }); - setMessage(''); - inputRef.current?.focus(); }); - const handleAppendMsg = useEvent((append: string) => { + const appendMsg = useEvent((append: string) => { setMessage(message + append); inputRef.current?.focus(); @@ -56,7 +65,8 @@ export const ChatInputBox: React.FC = React.memo((props) => { ); const handlePaste = useEvent( - (e: React.ClipboardEvent) => { + (e: React.ClipboardEvent) => { + const el: HTMLTextAreaElement | HTMLInputElement = e.currentTarget; const helper = new ClipboardHelper(e); const image = helper.hasImage(); if (image) { @@ -68,6 +78,18 @@ export const ChatInputBox: React.FC = React.memo((props) => { ); }); } + + if (!el.value) { + // 当没有任何输入内容时才会执行handler + const handlers = helper.matchPasteHandler(); + if (handlers.length > 0) { + // 弹出选择框 + runPasteHandlers(handlers, e, { + sendMessage, + applyMessage: setMessage, + }); + } + } } ); @@ -92,11 +114,11 @@ export const ChatInputBox: React.FC = React.memo((props) => { message, setMessage, sendMsg: props.onSendMsg, - appendMsg: handleAppendMsg, + appendMsg, }} >
-
+
{/* This w-0 is magic to ensure show mention and long text */}
= React.memo((props) => { />
+ {pasteHandlerContainer} + {!disabled && ( <>
@@ -126,7 +150,7 @@ export const ChatInputBox: React.FC = React.memo((props) => { handleSendMsg()} + onClick={handleSendMsg} /> ) : ( diff --git a/client/web/src/components/ChatBox/ChatInputBox/usePasteHandler.tsx b/client/web/src/components/ChatBox/ChatInputBox/usePasteHandler.tsx new file mode 100644 index 00000000..d32b2016 --- /dev/null +++ b/client/web/src/components/ChatBox/ChatInputBox/usePasteHandler.tsx @@ -0,0 +1,72 @@ +import { useGlobalKeyDown } from '@/hooks/useGlobalKeyDown'; +import { isAlphabetHotkey, isSpaceHotkey } from '@/utils/hot-key'; +import React, { useState } from 'react'; +import { t, useEvent } from 'tailchat-shared'; +import type { + ChatInputPasteHandler, + ChatInputPasteHandlerContext, + ChatInputPasteHandlerData, +} from './clipboard-helper'; + +export function usePasteHandler() { + const [inner, setInner] = useState(null); + + useGlobalKeyDown((e) => { + if (inner === null) { + return; + } + + if (isAlphabetHotkey(e) || isSpaceHotkey(e)) { + setInner(null); + } + }); + + const runPasteHandlers = useEvent( + ( + handlers: ChatInputPasteHandler[], + event: React.ClipboardEvent, + context: ChatInputPasteHandlerContext + ) => { + const clipboardData = event.clipboardData; + const data: ChatInputPasteHandlerData = { + files: clipboardData.files, + text: clipboardData.getData('text/plain'), + }; // for get data later, because event is sync + + if (handlers.length === 1) { + console.log(`Running paste handler: ${handlers[0].name}`); + event.stopPropagation(); + event.preventDefault(); + handlers[0].handler(data, context); + } else if (handlers.length >= 2) { + // 弹出popup + setInner( +
+
+ {t( + '看起来有多个剪切板处理工具被同时匹配,请选择其中一项或者忽略' + )} +
+ {handlers.map((h) => ( +
{ + console.log(`Running paste handler: ${h.name}`); + h.handler(data, context); + setInner(null); + }} + > + {h.label} +
+ ))} +
+ ); + } + } + ); + + const pasteHandlerContainer =
{inner}
; + + return { runPasteHandlers, pasteHandlerContainer }; +} diff --git a/client/web/src/plugin/common/reg.ts b/client/web/src/plugin/common/reg.ts index f1e7fb45..547cdc24 100644 --- a/client/web/src/plugin/common/reg.ts +++ b/client/web/src/plugin/common/reg.ts @@ -14,7 +14,10 @@ import type { MetaFormFieldMeta } from 'tailchat-design'; import type { FullModalFactoryConfig } from '@/components/FullModal/Factory'; import type { ReactElement } from 'react'; import type { BaseCardPayload } from '@/components/Card'; +import type { ChatInputPasteHandler } from '@/components/ChatBox/ChatInputBox/clipboard-helper'; + export type { BaseCardPayload }; + /** * 注册自定义面板 */ @@ -343,3 +346,6 @@ export const [pluginLoginAction, regLoginAction] = buildRegList<{ name: string; component: React.ComponentType; }>(); + +export const [pluginChatInputPasteHandler, regChatInputPasteHandler] = + buildRegList(); diff --git a/client/web/src/utils/hot-key.ts b/client/web/src/utils/hot-key.ts index d214b44e..a2b22890 100644 --- a/client/web/src/utils/hot-key.ts +++ b/client/web/src/utils/hot-key.ts @@ -2,6 +2,8 @@ import { isHotkey } from 'is-hotkey'; export const isEnterHotkey = isHotkey('enter'); +export const isSpaceHotkey = isHotkey('space'); + export const isEscHotkey = isHotkey('esc'); export const isQuickSwitcher = isHotkey('mod+k'); @@ -9,3 +11,12 @@ export const isQuickSwitcher = isHotkey('mod+k'); export const isArrowUp = isHotkey('up'); export const isArrowDown = isHotkey('down'); + +/** + * 判断输入是否是字母 + */ +export function isAlphabetHotkey(e: KeyboardEvent) { + const key = e.key; + + return /[a-zA-Z]/.test(key); +}