impl paste in rich text area
This commit is contained in:
@@ -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",
|
||||
|
||||
+352
-2
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>
|
||||
|
||||
Reference in New Issue
Block a user