feat: add clipboard paste handler

now plugin can register paste handler with `regChatInputPasteHandler`
pull/146/merge
moonrailgun 1 year ago
parent f2462095bf
commit ec23b7bd92

@ -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<void>;
applyMessage: (text: string) => void;
}
export interface ChatInputPasteHandler {
name: string;
label?: string;
match: (
event: React.ClipboardEvent<HTMLTextAreaElement | HTMLInputElement>
) => boolean;
handler: (
data: ChatInputPasteHandlerData,
ctx: ChatInputPasteHandlerContext
) => void;
}
export class ClipboardHelper {
data: DataTransfer;
constructor(
private event: React.ClipboardEvent<HTMLTextAreaElement | HTMLInputElement>
) {}
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;

@ -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<void>;
@ -32,15 +33,23 @@ export const ChatInputBox: React.FC<ChatInputBoxProps> = React.memo((props) => {
const [message, setMessage] = useState('');
const [mentions, setMentions] = useState<string[]>([]);
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<ChatInputBoxProps> = React.memo((props) => {
);
const handlePaste = useEvent(
(e: React.ClipboardEvent<HTMLDivElement | HTMLTextAreaElement>) => {
(e: React.ClipboardEvent<HTMLTextAreaElement | HTMLInputElement>) => {
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<ChatInputBoxProps> = 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<ChatInputBoxProps> = React.memo((props) => {
message,
setMessage,
sendMsg: props.onSendMsg,
appendMsg: handleAppendMsg,
appendMsg,
}}
>
<div className="px-4 py-2">
<div className="bg-white dark:bg-gray-600 flex rounded-md items-center">
<div className="bg-white dark:bg-gray-600 flex rounded-md items-center relative">
{/* This w-0 is magic to ensure show mention and long text */}
<div className="flex-1 w-0">
<ChatInputBoxInput
@ -111,6 +133,8 @@ export const ChatInputBox: React.FC<ChatInputBoxProps> = React.memo((props) => {
/>
</div>
{pasteHandlerContainer}
{!disabled && (
<>
<div className="px-2 flex space-x-1">
@ -126,7 +150,7 @@ export const ChatInputBox: React.FC<ChatInputBoxProps> = React.memo((props) => {
<Icon
icon="mdi:send-circle-outline"
className="text-2xl cursor-pointer"
onClick={() => handleSendMsg()}
onClick={handleSendMsg}
/>
) : (
<ChatInputAddon />

@ -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<React.ReactNode>(null);
useGlobalKeyDown((e) => {
if (inner === null) {
return;
}
if (isAlphabetHotkey(e) || isSpaceHotkey(e)) {
setInner(null);
}
});
const runPasteHandlers = useEvent(
(
handlers: ChatInputPasteHandler[],
event: React.ClipboardEvent<HTMLTextAreaElement | HTMLInputElement>,
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(
<div className="absolute bottom-2 bg-content-light bg-opacity-90 dark:bg-content-dark dark:bg-opacity-90 border dark:border-gray-900 shadow rounded px-2 py-1 space-y-1 w-72">
<div>
{t(
'看起来有多个剪切板处理工具被同时匹配,请选择其中一项或者忽略'
)}
</div>
{handlers.map((h) => (
<div
key={h.name}
className="bg-black bg-opacity-40 hover:bg-opacity-80 rounded px-2 py-1 cursor-pointer"
onClick={() => {
console.log(`Running paste handler: ${h.name}`);
h.handler(data, context);
setInner(null);
}}
>
{h.label}
</div>
))}
</div>
);
}
}
);
const pasteHandlerContainer = <div className="absolute top-0">{inner}</div>;
return { runPasteHandlers, pasteHandlerContainer };
}

@ -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<ChatInputPasteHandler>();

@ -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);
}

Loading…
Cancel
Save