impl paste in rich text area

This commit is contained in:
2026-07-15 19:49:44 +08:00
parent a3ebb6670f
commit 776d13baaa
6 changed files with 488 additions and 22 deletions
+1
View File
@@ -16,6 +16,7 @@
"dependencies": {
"@lexical/react": "^0.47.0",
"@lexical/utils": "^0.47.0",
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@vitejs/plugin-react": "^5.0.4",
"lexical": "^0.47.0",
"lucide-react": "^0.546.0",
File diff suppressed because it is too large Load Diff
@@ -19,3 +19,4 @@ tauri-plugin-dialog = "2.7.1"
tauri-plugin-opener = "2.5.4"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
tauri-plugin-clipboard-manager = "2.3.2"
@@ -0,0 +1,13 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件。",
"windows": ["client"],
"permissions": [
"clipboard-manager:allow-read-image",
"clipboard-manager:allow-read-text",
"core:image:allow-rgba",
"core:image:allow-size",
"core:resources:allow-close"
]
}
@@ -922,6 +922,7 @@ fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_clipboard_manager::init())
.manage(PreviewRegistry::default())
.setup(|app| {
configure_game_creator_runtime_config_dir(app.handle())?;
@@ -4,6 +4,7 @@ import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { readImage, readText } from '@tauri-apps/plugin-clipboard-manager';
import {
$createParagraphNode,
$createTextNode,
@@ -13,17 +14,16 @@ import {
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_EDITOR,
COMMAND_PRIORITY_HIGH,
createCommand,
type LexicalCommand,
type LexicalNode,
PASTE_COMMAND,
} from 'lexical';
import { Upload } from 'lucide-react';
import { useEffect, useMemo, useRef } from 'react';
import type {
HomeAttachmentDraft,
RichText,
} from '../../useHomeDraftStore';
import type { HomeAttachmentDraft, RichText } from '../../useHomeDraftStore';
import AttachmentContext from './attachmentContext';
import {
$createAttachmentNode,
@@ -41,11 +41,79 @@ type RichInputAreaProps = {
};
const INSERT_ATTACHMENTS_COMMAND: LexicalCommand<HomeAttachmentDraft[]> =
createCommand('INSERT_HOME_ATTACHMENTS_COMMAND');
const INSERT_CLIPBOARD_TEXT_COMMAND: LexicalCommand<string> = createCommand(
'INSERT_HOME_CLIPBOARD_TEXT_COMMAND',
);
function createAttachmentDrafts(files: readonly File[]) {
const now = Date.now();
return files.map((file, index) => ({
id: `${file.name}:${file.size}:${file.lastModified}:${index}:${now}`,
file,
}));
}
async function readNativeClipboardImageFile() {
// this used Tauri native api, unavailable in browser env.
let image;
try {
image = await readImage();
const [{ width, height }, rgba] = await Promise.all([
image.size(),
image.rgba(),
]);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
return null;
}
context.putImageData(
new ImageData(new Uint8ClampedArray(rgba), width, height),
0,
0,
);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/png'),
);
if (!blob) {
return null;
}
return new File([blob], `pasted-image-${Date.now()}.png`, {
type: 'image/png',
});
} catch {
return null;
} finally {
await image?.close();
}
}
async function readNativeClipboardText() {
try {
return await readText();
} catch {
return '';
}
}
function selectEditableEndWhenNeeded() {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
return;
}
const root = $getRoot();
if (root.getChildrenSize() === 0) {
root.append($createParagraphNode());
}
root.selectEnd();
}
function EditorPlugins({
// attachments,
onChange,
}: Pick<RichInputAreaProps, 'attachments' | 'onChange'>) {
onAttachmentsAdd,
}: Pick<RichInputAreaProps, 'onChange' | 'onAttachmentsAdd'>) {
const [editor] = useLexicalComposerContext();
useEffect(
@@ -53,13 +121,7 @@ function EditorPlugins({
editor.registerCommand(
INSERT_ATTACHMENTS_COMMAND,
(nextAttachments) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
const root = $getRoot();
if (root.getChildrenSize() === 0)
root.append($createParagraphNode());
root.selectEnd();
}
selectEditableEndWhenNeeded();
const currentSelection = $getSelection();
if ($isRangeSelection(currentSelection)) {
currentSelection.insertNodes(
@@ -75,6 +137,49 @@ function EditorPlugins({
[editor],
);
useEffect(
() =>
editor.registerCommand(
INSERT_CLIPBOARD_TEXT_COMMAND,
(text) => {
selectEditableEndWhenNeeded();
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertText(text);
}
return true;
},
COMMAND_PRIORITY_EDITOR,
),
[editor],
);
useEffect(
() =>
editor.registerCommand(
PASTE_COMMAND,
(event) => {
event.preventDefault();
void readNativeClipboardImageFile().then(async (file) => {
if (!file) {
const text = await readNativeClipboardText();
if (text.length === 0) {
return;
}
editor.dispatchCommand(INSERT_CLIPBOARD_TEXT_COMMAND, text);
return;
}
const nextAttachments = createAttachmentDrafts([file]);
editor.dispatchCommand(INSERT_ATTACHMENTS_COMMAND, nextAttachments);
onAttachmentsAdd(nextAttachments);
});
return true;
},
COMMAND_PRIORITY_HIGH,
),
[editor, onAttachmentsAdd],
);
return (
<OnChangePlugin
onChange={(editorState) => {
@@ -118,11 +223,7 @@ function UploadButton({
const files = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = '';
if (files.length === 0) return;
const now = Date.now();
const nextAttachments = files.map((file, index) => ({
id: `${file.name}:${file.size}:${file.lastModified}:${index}:${now}`,
file,
}));
const nextAttachments = createAttachmentDrafts(files);
editor.dispatchCommand(INSERT_ATTACHMENTS_COMMAND, nextAttachments);
onAttachmentsAdd(nextAttachments);
}}
@@ -139,7 +240,6 @@ function UploadButton({
);
}
export default function RichInputArea(props: RichInputAreaProps) {
const attachmentMap = useMemo(
() =>
@@ -195,8 +295,8 @@ export default function RichInputArea(props: RichInputAreaProps) {
</div>
</div>
<EditorPlugins
attachments={props.attachments}
onChange={props.onChange}
onAttachmentsAdd={props.onAttachmentsAdd}
/>
</LexicalComposer>
</AttachmentContext.Provider>