mirror of
https://github.com/wavetermdev/waveterm
synced 2026-04-21 14:37:16 +00:00
Big Paste Handler Cleanup (#2535)
Handle both types of paste data. Write utility functions to normalize
paste events to {text; image}[]. Fix duplication issue (call
preventDefault() early). Handle multiple image pasting (by adding a
slight delay). Convert Ctrl:Shift:v to use a *native paste* which allows
capturing of images!
This commit is contained in:
parent
4b6a3ed330
commit
a9db209218
17 changed files with 221 additions and 232 deletions
|
|
@ -7,7 +7,6 @@ Wave Terminal uses a modular ViewModel system to define interactive blocks. Each
|
|||
### Key Concepts
|
||||
|
||||
1. **ViewModel Structure**
|
||||
|
||||
- Implements the `ViewModel` interface.
|
||||
- Defines:
|
||||
- `viewType`: Unique block type identifier.
|
||||
|
|
@ -19,14 +18,12 @@ Wave Terminal uses a modular ViewModel system to define interactive blocks. Each
|
|||
- Lifecycle methods like `dispose()`, `giveFocus()`, `keyDownHandler()`.
|
||||
|
||||
2. **ViewComponent Structure**
|
||||
|
||||
- A **React function component** implementing `ViewComponentProps<T extends ViewModel>`.
|
||||
- Uses `blockId`, `blockRef`, `contentRef`, and `model` as props.
|
||||
- Retrieves ViewModel state using Jotai atoms.
|
||||
- Returns JSX for rendering.
|
||||
|
||||
3. **Header Elements (`HeaderElem[]`)**
|
||||
|
||||
- Can include:
|
||||
- **Icons (`IconButtonDecl`)**: Clickable buttons.
|
||||
- **Text (`HeaderText`)**: Metadata or status.
|
||||
|
|
@ -34,13 +31,11 @@ Wave Terminal uses a modular ViewModel system to define interactive blocks. Each
|
|||
- **Menu Buttons (`MenuButton`)**: Dropdowns.
|
||||
|
||||
4. **Jotai Atoms for State Management**
|
||||
|
||||
- Use `atom<T>`, `PrimitiveAtom<T>`, `WritableAtom<T>` for dynamic properties.
|
||||
- `splitAtom` for managing lists of atoms.
|
||||
- Read settings from `globalStore` and override with block metadata.
|
||||
|
||||
5. **Metadata vs. Global Config**
|
||||
|
||||
- **Block Metadata (`SetMetaCommand`)**: Each block persists its **own configuration** in its metadata (`blockAtom.meta`).
|
||||
- **Global Config (`SetConfigCommand`)**: Provides **default settings** for all blocks, stored in config files.
|
||||
- **Cascading Behavior**:
|
||||
|
|
@ -50,7 +45,6 @@ Wave Terminal uses a modular ViewModel system to define interactive blocks. Each
|
|||
- Updating a global setting is done via `SetConfigCommand` (applies globally unless overridden).
|
||||
|
||||
6. **Useful Helper Functions**
|
||||
|
||||
- To avoid repetitive boilerplate, use these global utilities from `global.ts`:
|
||||
- `useBlockMetaKeyAtom(blockId, key)`: Retrieves and updates block-specific metadata.
|
||||
- `useOverrideConfigAtom(blockId, key)`: Reads from global config but allows per-block overrides.
|
||||
|
|
@ -139,7 +133,7 @@ type HeaderTextButton = {
|
|||
type HeaderText = {
|
||||
elemtype: "text";
|
||||
text: string;
|
||||
ref?: React.MutableRefObject<HTMLDivElement>;
|
||||
ref?: React.RefObject<HTMLDivElement>;
|
||||
className?: string;
|
||||
noGrow?: boolean;
|
||||
onClick?: (e: React.MouseEvent<any>) => void;
|
||||
|
|
@ -150,7 +144,7 @@ type HeaderInput = {
|
|||
value: string;
|
||||
className?: string;
|
||||
isDisabled?: boolean;
|
||||
ref?: React.MutableRefObject<HTMLInputElement>;
|
||||
ref?: React.RefObject<HTMLInputElement>;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
|
|
|
|||
|
|
@ -400,6 +400,10 @@ export function initIpcHandlers() {
|
|||
incrementTermCommandsRun();
|
||||
});
|
||||
|
||||
electron.ipcMain.on("native-paste", (event) => {
|
||||
event.sender.paste();
|
||||
});
|
||||
|
||||
electron.ipcMain.on("open-builder", (event, appId?: string) => {
|
||||
fireAndForget(() => createBuilderWindow(appId || ""));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ contextBridge.exposeInMainWorld("api", {
|
|||
setWaveAIOpen: (isOpen: boolean) => ipcRenderer.send("set-waveai-open", isOpen),
|
||||
closeBuilderWindow: () => ipcRenderer.send("close-builder-window"),
|
||||
incrementTermCommands: () => ipcRenderer.send("increment-term-commands"),
|
||||
nativePaste: () => ipcRenderer.send("native-paste"),
|
||||
});
|
||||
|
||||
// Custom event for "new-window"
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ type SubMenuProps = {
|
|||
};
|
||||
visibleSubMenus: { [key: string]: any };
|
||||
hoveredItems: string[];
|
||||
subMenuRefs: React.MutableRefObject<{ [key: string]: React.RefObject<HTMLDivElement> }>;
|
||||
subMenuRefs: React.RefObject<{ [key: string]: React.RefObject<HTMLDivElement> }>;
|
||||
handleMouseEnterItem: (
|
||||
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
parentKey: string | null,
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ interface InputProps {
|
|||
autoSelect?: boolean;
|
||||
disabled?: boolean;
|
||||
isNumber?: boolean;
|
||||
inputRef?: React.MutableRefObject<any>;
|
||||
inputRef?: React.RefObject<any>;
|
||||
manageFocus?: (isFocused: boolean) => void;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ interface TypeAheadModalProps {
|
|||
onSelect?: (_: string) => void;
|
||||
onClickBackdrop?: () => void;
|
||||
onKeyDown?: (_) => void;
|
||||
giveFocusRef?: React.MutableRefObject<() => boolean>;
|
||||
giveFocusRef?: React.RefObject<() => boolean>;
|
||||
autoFocus?: boolean;
|
||||
selectIndex?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -612,6 +612,11 @@ class RpcApiType {
|
|||
return client.wshRpcCall("writeappfile", data, opts);
|
||||
}
|
||||
|
||||
// command "writetempfile" [call]
|
||||
WriteTempFileCommand(client: WshClient, data: CommandWriteTempFileData, opts?: RpcOpts): Promise<string> {
|
||||
return client.wshRpcCall("writetempfile", data, opts);
|
||||
}
|
||||
|
||||
// command "wshactivity" [call]
|
||||
WshActivityCommand(client: WshClient, data: {[key: string]: number}, opts?: RpcOpts): Promise<void> {
|
||||
return client.wshRpcCall("wshactivity", data, opts);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ type CSVRow = {
|
|||
};
|
||||
|
||||
interface CSVViewProps {
|
||||
parentRef: React.MutableRefObject<HTMLDivElement>;
|
||||
parentRef: React.RefObject<HTMLDivElement>;
|
||||
content: string;
|
||||
filename: string;
|
||||
readonly: boolean;
|
||||
|
|
|
|||
|
|
@ -152,11 +152,11 @@ export class PreviewModel implements ViewModel {
|
|||
openFileModal: PrimitiveAtom<boolean>;
|
||||
openFileModalDelay: PrimitiveAtom<boolean>;
|
||||
openFileError: PrimitiveAtom<string>;
|
||||
openFileModalGiveFocusRef: React.MutableRefObject<() => boolean>;
|
||||
openFileModalGiveFocusRef: React.RefObject<() => boolean>;
|
||||
|
||||
markdownShowToc: PrimitiveAtom<boolean>;
|
||||
|
||||
monacoRef: React.MutableRefObject<MonacoTypes.editor.IStandaloneCodeEditor>;
|
||||
monacoRef: React.RefObject<MonacoTypes.editor.IStandaloneCodeEditor>;
|
||||
|
||||
showHiddenFiles: PrimitiveAtom<boolean>;
|
||||
refreshVersion: PrimitiveAtom<number>;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// Copyright 2025, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
|
||||
import { BlockNodeModel } from "@/app/block/blocktypes";
|
||||
import { appHandleKeyDown } from "@/app/store/keymodel";
|
||||
import { waveEventSubscribe } from "@/app/store/wps";
|
||||
|
|
@ -15,6 +14,7 @@ import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
|
|||
import {
|
||||
atoms,
|
||||
getAllBlockComponentModels,
|
||||
getApi,
|
||||
getBlockComponentModel,
|
||||
getBlockMetaKeyAtom,
|
||||
getConnStatusAtom,
|
||||
|
|
@ -29,22 +29,15 @@ import * as keyutil from "@/util/keyutil";
|
|||
import { boundNumber, stringToBase64 } from "@/util/util";
|
||||
import * as jotai from "jotai";
|
||||
import * as React from "react";
|
||||
import {
|
||||
computeTheme,
|
||||
createTempFileFromBlob,
|
||||
DefaultTermTheme,
|
||||
handleImagePasteBlob as handleImagePasteBlobUtil,
|
||||
supportsImageInput as supportsImageInputUtil,
|
||||
} from "./termutil";
|
||||
import { TermWrap } from "./termwrap";
|
||||
import { getBlockingCommand } from "./shellblocking";
|
||||
import { computeTheme, DefaultTermTheme } from "./termutil";
|
||||
import { TermWrap } from "./termwrap";
|
||||
|
||||
export class TermViewModel implements ViewModel {
|
||||
|
||||
viewType: string;
|
||||
nodeModel: BlockNodeModel;
|
||||
connected: boolean;
|
||||
termRef: React.MutableRefObject<TermWrap> = { current: null };
|
||||
termRef: React.RefObject<TermWrap> = { current: null };
|
||||
blockAtom: jotai.Atom<Block>;
|
||||
termMode: jotai.Atom<string>;
|
||||
blockId: string;
|
||||
|
|
@ -398,51 +391,6 @@ export class TermViewModel implements ViewModel {
|
|||
RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data });
|
||||
}
|
||||
|
||||
async handlePaste() {
|
||||
try {
|
||||
const clipboardItems = await navigator.clipboard.read();
|
||||
|
||||
for (const item of clipboardItems) {
|
||||
// Check for images first
|
||||
const imageTypes = item.types.filter((type) => type.startsWith("image/"));
|
||||
if (imageTypes.length > 0 && this.supportsImageInput()) {
|
||||
const blob = await item.getType(imageTypes[0]);
|
||||
await this.handleImagePasteBlob(blob);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle text
|
||||
if (item.types.includes("text/plain")) {
|
||||
const blob = await item.getType("text/plain");
|
||||
const text = await blob.text();
|
||||
this.termRef.current?.terminal.paste(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Paste error:", err);
|
||||
// Fallback to text-only paste
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) {
|
||||
this.termRef.current?.terminal.paste(text);
|
||||
}
|
||||
} catch (fallbackErr) {
|
||||
console.error("Fallback paste error:", fallbackErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
supportsImageInput(): boolean {
|
||||
return supportsImageInputUtil();
|
||||
}
|
||||
|
||||
async handleImagePasteBlob(blob: Blob): Promise<void> {
|
||||
await handleImagePasteBlobUtil(blob, TabRpcClient, (text) => {
|
||||
this.termRef.current?.terminal.paste(text);
|
||||
});
|
||||
}
|
||||
|
||||
setTermMode(mode: "term" | "vdom") {
|
||||
if (mode == "term") {
|
||||
mode = null;
|
||||
|
|
@ -559,22 +507,26 @@ export class TermViewModel implements ViewModel {
|
|||
const shiftEnterNewlineAtom = getOverrideConfigAtom(this.blockId, "term:shiftenternewline");
|
||||
const shiftEnterNewlineEnabled = globalStore.get(shiftEnterNewlineAtom) ?? true;
|
||||
if (shiftEnterNewlineEnabled) {
|
||||
this.sendDataToController("\u001b\n");
|
||||
this.sendDataToController("\n");
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:v")) {
|
||||
this.handlePaste();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
getApi().nativePaste();
|
||||
// this.termRef.current?.pasteHandler();
|
||||
return false;
|
||||
} else if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:c")) {
|
||||
const sel = this.termRef.current?.terminal.getSelection();
|
||||
navigator.clipboard.writeText(sel);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const sel = this.termRef.current?.terminal.getSelection();
|
||||
if (!sel) {
|
||||
return false;
|
||||
}
|
||||
navigator.clipboard.writeText(sel);
|
||||
return false;
|
||||
} else if (keyutil.checkKeyPressed(waveEvent, "Cmd:k")) {
|
||||
event.preventDefault();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export const DefaultTermTheme = "default-dark";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import base64 from "base64-js";
|
||||
import { colord } from "colord";
|
||||
|
||||
function applyTransparencyToColor(hexColor: string, transparency: number): string {
|
||||
|
|
@ -10,7 +13,7 @@ function applyTransparencyToColor(hexColor: string, transparency: number): strin
|
|||
}
|
||||
|
||||
// returns (theme, bgcolor, transparency (0 - 1.0))
|
||||
function computeTheme(
|
||||
export function computeTheme(
|
||||
fullConfig: FullConfigType,
|
||||
themeName: string,
|
||||
termTransparency: number
|
||||
|
|
@ -33,11 +36,6 @@ function computeTheme(
|
|||
return [themeCopy, bgcolor];
|
||||
}
|
||||
|
||||
export { computeTheme };
|
||||
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { WshClient } from "@/app/store/wshclient";
|
||||
|
||||
export const MIME_TO_EXT: Record<string, string> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
|
|
@ -47,6 +45,11 @@ export const MIME_TO_EXT: Record<string, string> = {
|
|||
"image/bmp": "bmp",
|
||||
"image/svg+xml": "svg",
|
||||
"image/tiff": "tiff",
|
||||
"image/heic": "heic",
|
||||
"image/heif": "heif",
|
||||
"image/avif": "avif",
|
||||
"image/x-icon": "ico",
|
||||
"image/vnd.microsoft.icon": "ico",
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -55,45 +58,38 @@ export const MIME_TO_EXT: Record<string, string> = {
|
|||
* and returns the file path.
|
||||
*
|
||||
* @param blob - The Blob to save
|
||||
* @param client - The WshClient for RPC calls
|
||||
* @returns The path to the created temporary file
|
||||
* @throws Error if blob is too large (>5MB) or data URL is invalid
|
||||
*/
|
||||
export async function createTempFileFromBlob(blob: Blob, client: WshClient): Promise<string> {
|
||||
export async function createTempFileFromBlob(blob: Blob): Promise<string> {
|
||||
// Check size limit (5MB)
|
||||
if (blob.size > 5 * 1024 * 1024) {
|
||||
throw new Error("Image too large (>5MB)");
|
||||
}
|
||||
|
||||
// Get file extension from MIME type
|
||||
const ext = MIME_TO_EXT[blob.type] || "png";
|
||||
if (!blob.type.startsWith("image/") || !MIME_TO_EXT[blob.type]) {
|
||||
throw new Error(`Unsupported or invalid image type: ${blob.type}`);
|
||||
}
|
||||
const ext = MIME_TO_EXT[blob.type];
|
||||
|
||||
// Generate unique filename with timestamp and random component
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
const filename = `waveterm_paste_${timestamp}_${random}.${ext}`;
|
||||
|
||||
// Get platform-appropriate temp file path from backend
|
||||
const tempPath = await RpcApi.GetTempDirCommand(client, { filename });
|
||||
|
||||
// Convert blob to base64 using FileReader
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const arrayBuffer = await new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onload = () => resolve(reader.result as ArrayBuffer);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
reader.readAsArrayBuffer(blob);
|
||||
});
|
||||
|
||||
// Extract base64 data from data URL (remove "data:image/png;base64," prefix)
|
||||
const parts = dataUrl.split(",");
|
||||
if (parts.length < 2) {
|
||||
throw new Error("Invalid data URL format");
|
||||
}
|
||||
const base64Data = parts[1];
|
||||
const base64Data = base64.fromByteArray(new Uint8Array(arrayBuffer));
|
||||
|
||||
// Write image to temp file
|
||||
await RpcApi.FileWriteCommand(client, {
|
||||
info: { path: tempPath },
|
||||
// Write image to temp file and get path
|
||||
const tempPath = await RpcApi.WriteTempFileCommand(TabRpcClient, {
|
||||
filename,
|
||||
data64: base64Data,
|
||||
});
|
||||
|
||||
|
|
@ -101,34 +97,100 @@ export async function createTempFileFromBlob(blob: Blob, client: WshClient): Pro
|
|||
}
|
||||
|
||||
/**
|
||||
* Checks if image input is supported.
|
||||
* Images will be saved as temp files and the path will be pasted.
|
||||
* Claude Code and other AI tools can then read the file.
|
||||
* Extracts text or image data from a clipboard item.
|
||||
* Prioritizes images over text - if an image is found, only the image is returned.
|
||||
*
|
||||
* @returns true if image input is supported
|
||||
* @param item - Either a DataTransferItem or ClipboardItem
|
||||
* @returns Object with either text or image, or null if neither could be extracted
|
||||
*/
|
||||
export function supportsImageInput(): boolean {
|
||||
return true;
|
||||
export async function extractClipboardData(
|
||||
item: DataTransferItem | ClipboardItem
|
||||
): Promise<{ text?: string; image?: Blob } | null> {
|
||||
// Check if it's a DataTransferItem (has 'kind' property)
|
||||
if ("kind" in item) {
|
||||
const dataTransferItem = item as DataTransferItem;
|
||||
|
||||
// Check for image first
|
||||
if (dataTransferItem.type.startsWith("image/")) {
|
||||
const blob = dataTransferItem.getAsFile();
|
||||
if (blob) {
|
||||
return { image: blob };
|
||||
}
|
||||
}
|
||||
|
||||
// If not an image, try text
|
||||
if (dataTransferItem.kind === "string") {
|
||||
return new Promise((resolve) => {
|
||||
dataTransferItem.getAsString((text) => {
|
||||
resolve(text ? { text } : null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// It's a ClipboardItem
|
||||
const clipboardItem = item as ClipboardItem;
|
||||
|
||||
// Check for image first
|
||||
const imageTypes = clipboardItem.types.filter((type) => type.startsWith("image/"));
|
||||
if (imageTypes.length > 0) {
|
||||
const blob = await clipboardItem.getType(imageTypes[0]);
|
||||
return { image: blob };
|
||||
}
|
||||
|
||||
// If not an image, try text
|
||||
const textType = clipboardItem.types.find((t) => ["text/plain", "text/html", "text/rtf"].includes(t));
|
||||
if (textType) {
|
||||
const blob = await clipboardItem.getType(textType);
|
||||
const text = await blob.text();
|
||||
return text ? { text } : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles pasting an image blob by creating a temp file and pasting its path.
|
||||
* Extracts all clipboard data from a ClipboardEvent using multiple fallback methods.
|
||||
* Tries ClipboardEvent.clipboardData.items first, then Clipboard API, then simple getData().
|
||||
*
|
||||
* @param blob - The image blob to paste
|
||||
* @param client - The WshClient for RPC calls
|
||||
* @param pasteFn - Function to paste the file path into the terminal
|
||||
* @param e - The ClipboardEvent (optional)
|
||||
* @returns Array of objects containing text and/or image data
|
||||
*/
|
||||
export async function handleImagePasteBlob(
|
||||
blob: Blob,
|
||||
client: WshClient,
|
||||
pasteFn: (text: string) => void
|
||||
): Promise<void> {
|
||||
export async function extractAllClipboardData(e?: ClipboardEvent): Promise<Array<{ text?: string; image?: Blob }>> {
|
||||
const results: Array<{ text?: string; image?: Blob }> = [];
|
||||
|
||||
try {
|
||||
const tempPath = await createTempFileFromBlob(blob, client);
|
||||
// Paste the file path (like iTerm2 does when you copy a file)
|
||||
// Claude Code will read the file and display it as [Image #N]
|
||||
pasteFn(tempPath + " ");
|
||||
// First try using ClipboardEvent.clipboardData.items
|
||||
if (e?.clipboardData?.items) {
|
||||
for (let i = 0; i < e.clipboardData.items.length; i++) {
|
||||
const data = await extractClipboardData(e.clipboardData.items[i]);
|
||||
if (data) {
|
||||
results.push(data);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// Fallback: Try Clipboard API
|
||||
const clipboardItems = await navigator.clipboard.read();
|
||||
for (const item of clipboardItems) {
|
||||
const data = await extractClipboardData(item);
|
||||
if (data) {
|
||||
results.push(data);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
} catch (err) {
|
||||
console.error("Error pasting image:", err);
|
||||
console.error("Clipboard read error:", err);
|
||||
// Final fallback: simple text paste
|
||||
if (e?.clipboardData) {
|
||||
const text = e.clipboardData.getData("text/plain");
|
||||
if (text) {
|
||||
results.push({ text });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,18 +18,15 @@ import { Terminal } from "@xterm/xterm";
|
|||
import debug from "debug";
|
||||
import * as jotai from "jotai";
|
||||
import { debounce } from "throttle-debounce";
|
||||
import {
|
||||
createTempFileFromBlob,
|
||||
handleImagePasteBlob as handleImagePasteBlobUtil,
|
||||
supportsImageInput as supportsImageInputUtil,
|
||||
} from "./termutil";
|
||||
import { FitAddon } from "./fitaddon";
|
||||
import { createTempFileFromBlob, extractAllClipboardData } from "./termutil";
|
||||
|
||||
const dlog = debug("wave:termwrap");
|
||||
|
||||
const TermFileName = "term";
|
||||
const TermCacheFileName = "cache:term:full";
|
||||
const MinDataProcessedForCache = 100 * 1024;
|
||||
export const SupportsImageInput = true;
|
||||
|
||||
// detect webgl support
|
||||
function detectWebGLSupport(): boolean {
|
||||
|
|
@ -441,74 +438,11 @@ export class TermWrap {
|
|||
this.handleResize_debounced = debounce(50, this.handleResize.bind(this));
|
||||
this.terminal.open(this.connectElem);
|
||||
this.handleResize();
|
||||
let pasteEventHandler = async (e: ClipboardEvent) => {
|
||||
this.pasteActive = true;
|
||||
|
||||
try {
|
||||
// First try using ClipboardEvent.clipboardData (works in Electron)
|
||||
if (e.clipboardData && e.clipboardData.items) {
|
||||
const items = e.clipboardData.items;
|
||||
|
||||
// Check for images first
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
if (item.type.startsWith("image/")) {
|
||||
if (this.supportsImageInput()) {
|
||||
e.preventDefault();
|
||||
const blob = item.getAsFile();
|
||||
if (blob) {
|
||||
await this.handleImagePasteBlob(blob);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle text
|
||||
const text = e.clipboardData.getData("text/plain");
|
||||
if (text) {
|
||||
this.terminal.paste(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try Clipboard API for newer browsers
|
||||
const clipboardItems = await navigator.clipboard.read();
|
||||
for (const item of clipboardItems) {
|
||||
const imageTypes = item.types.filter((type) => type.startsWith("image/"));
|
||||
if (imageTypes.length > 0 && this.supportsImageInput()) {
|
||||
await this.handleImagePaste(item, imageTypes[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.types.includes("text/plain")) {
|
||||
const blob = await item.getType("text/plain");
|
||||
const text = await blob.text();
|
||||
this.terminal.paste(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Paste error:", err);
|
||||
// Final fallback to simple text paste
|
||||
if (e.clipboardData) {
|
||||
const text = e.clipboardData.getData("text/plain");
|
||||
if (text) {
|
||||
this.terminal.paste(text);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
this.pasteActive = false;
|
||||
}, 30);
|
||||
}
|
||||
};
|
||||
pasteEventHandler = pasteEventHandler.bind(this);
|
||||
this.connectElem.addEventListener("paste", pasteEventHandler, true);
|
||||
const pasteHandler = this.pasteHandler.bind(this);
|
||||
this.connectElem.addEventListener("paste", pasteHandler, true);
|
||||
this.toDispose.push({
|
||||
dispose: () => {
|
||||
this.connectElem.removeEventListener("paste", pasteEventHandler, true);
|
||||
this.connectElem.removeEventListener("paste", pasteHandler, true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -642,36 +576,19 @@ export class TermWrap {
|
|||
return;
|
||||
}
|
||||
|
||||
// Paste Deduplication
|
||||
// xterm.js paste() method triggers onData event, causing handleTermData to be called twice:
|
||||
// 1. From our paste handler (pasteActive=true)
|
||||
// 2. From xterm.js onData (pasteActive=false)
|
||||
// We allow the first call and block the second duplicate
|
||||
const DEDUP_WINDOW_MS = 50;
|
||||
const now = Date.now();
|
||||
const timeSinceLastPaste = now - this.lastPasteTime;
|
||||
|
||||
if (this.pasteActive) {
|
||||
// First paste event - record it and allow through
|
||||
this.pasteActive = false;
|
||||
this.lastPasteData = data;
|
||||
this.lastPasteTime = now;
|
||||
if (this.multiInputCallback) {
|
||||
this.multiInputCallback(data);
|
||||
}
|
||||
} else if (timeSinceLastPaste < DEDUP_WINDOW_MS && data === this.lastPasteData && this.lastPasteData) {
|
||||
// Second paste event with same data within time window - this is a duplicate, block it
|
||||
dlog("Blocked duplicate paste data:", data);
|
||||
this.lastPasteData = ""; // Clear to allow same data to be pasted later
|
||||
return;
|
||||
}
|
||||
|
||||
// IME Deduplication (for Capslock input method switching)
|
||||
// When switching input methods with Capslock during composition, some systems send the
|
||||
// composed text twice. We allow the first send and block subsequent duplicates.
|
||||
const IMEDedupWindowMs = 50;
|
||||
const now = Date.now();
|
||||
const timeSinceCompositionEnd = now - this.lastCompositionEnd;
|
||||
|
||||
if (timeSinceCompositionEnd < DEDUP_WINDOW_MS && data === this.lastComposedText && this.lastComposedText) {
|
||||
if (timeSinceCompositionEnd < IMEDedupWindowMs && data === this.lastComposedText && this.lastComposedText) {
|
||||
if (!this.firstDataAfterCompositionSent) {
|
||||
// First send after composition - allow it but mark as sent
|
||||
this.firstDataAfterCompositionSent = true;
|
||||
|
|
@ -781,26 +698,6 @@ export class TermWrap {
|
|||
}
|
||||
}
|
||||
|
||||
supportsImageInput(): boolean {
|
||||
return supportsImageInputUtil();
|
||||
}
|
||||
|
||||
async handleImagePasteBlob(blob: Blob): Promise<void> {
|
||||
await handleImagePasteBlobUtil(blob, TabRpcClient, (text) => {
|
||||
this.terminal.paste(text);
|
||||
});
|
||||
}
|
||||
|
||||
async handleImagePaste(item: ClipboardItem, mimeType: string): Promise<void> {
|
||||
try {
|
||||
const blob = await item.getType(mimeType);
|
||||
// Reuse the existing handleImagePasteBlob logic
|
||||
await this.handleImagePasteBlob(blob);
|
||||
} catch (err) {
|
||||
console.error("Error processing image:", err);
|
||||
}
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
const oldRows = this.terminal.rows;
|
||||
const oldCols = this.terminal.cols;
|
||||
|
|
@ -842,4 +739,34 @@ export class TermWrap {
|
|||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async pasteHandler(e?: ClipboardEvent): Promise<void> {
|
||||
this.pasteActive = true;
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
|
||||
try {
|
||||
const clipboardData = await extractAllClipboardData(e);
|
||||
let firstImage = true;
|
||||
for (const data of clipboardData) {
|
||||
if (data.image && SupportsImageInput) {
|
||||
if (!firstImage) {
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
const tempPath = await createTempFileFromBlob(data.image);
|
||||
this.terminal.paste(tempPath + " ");
|
||||
firstImage = false;
|
||||
}
|
||||
if (data.text) {
|
||||
this.terminal.paste(data.text);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Paste error:", err);
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
this.pasteActive = false;
|
||||
}, 30);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5
frontend/types/custom.d.ts
vendored
5
frontend/types/custom.d.ts
vendored
|
|
@ -123,6 +123,7 @@ declare global {
|
|||
setWaveAIOpen: (isOpen: boolean) => void; // set-waveai-open
|
||||
closeBuilderWindow: () => void; // close-builder-window
|
||||
incrementTermCommands: () => void; // increment-term-commands
|
||||
nativePaste: () => void; // native-paste
|
||||
};
|
||||
|
||||
type ElectronContextMenuItem = {
|
||||
|
|
@ -206,7 +207,7 @@ declare global {
|
|||
type HeaderText = {
|
||||
elemtype: "text";
|
||||
text: string;
|
||||
ref?: React.MutableRefObject<HTMLDivElement>;
|
||||
ref?: React.RefObject<HTMLDivElement>;
|
||||
className?: string;
|
||||
noGrow?: boolean;
|
||||
onClick?: (e: React.MouseEvent<any>) => void;
|
||||
|
|
@ -217,7 +218,7 @@ declare global {
|
|||
value: string;
|
||||
className?: string;
|
||||
isDisabled?: boolean;
|
||||
ref?: React.MutableRefObject<HTMLInputElement>;
|
||||
ref?: React.RefObject<HTMLInputElement>;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
|
|
|
|||
6
frontend/types/gotypes.d.ts
vendored
6
frontend/types/gotypes.d.ts
vendored
|
|
@ -463,6 +463,12 @@ declare global {
|
|||
data64: string;
|
||||
};
|
||||
|
||||
// wshrpc.CommandWriteTempFileData
|
||||
type CommandWriteTempFileData = {
|
||||
filename: string;
|
||||
data64: string;
|
||||
};
|
||||
|
||||
// wconfig.ConfigError
|
||||
type ConfigError = {
|
||||
file: string;
|
||||
|
|
|
|||
|
|
@ -731,6 +731,12 @@ func WriteAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAppFileData,
|
|||
return err
|
||||
}
|
||||
|
||||
// command "writetempfile", wshserver.WriteTempFileCommand
|
||||
func WriteTempFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteTempFileData, opts *wshrpc.RpcOpts) (string, error) {
|
||||
resp, err := sendRpcRequestCallHelper[string](w, "writetempfile", data, opts)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// command "wshactivity", wshserver.WshActivityCommand
|
||||
func WshActivityCommand(w *wshutil.WshRpc, data map[string]int, opts *wshrpc.RpcOpts) error {
|
||||
_, err := sendRpcRequestCallHelper[any](w, "wshactivity", data, opts)
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ const (
|
|||
Command_FileShareCapability = "filesharecapability"
|
||||
Command_FileRestoreBackup = "filerestorebackup"
|
||||
Command_GetTempDir = "gettempdir"
|
||||
Command_WriteTempFile = "writetempfile"
|
||||
|
||||
Command_EventPublish = "eventpublish"
|
||||
Command_EventRecv = "eventrecv"
|
||||
|
|
@ -224,6 +225,7 @@ type WshRpcInterface interface {
|
|||
FileShareCapabilityCommand(ctx context.Context, path string) (FileShareCapability, error)
|
||||
FileRestoreBackupCommand(ctx context.Context, data CommandFileRestoreBackupData) error
|
||||
GetTempDirCommand(ctx context.Context, data CommandGetTempDirData) (string, error)
|
||||
WriteTempFileCommand(ctx context.Context, data CommandWriteTempFileData) (string, error)
|
||||
EventPublishCommand(ctx context.Context, data wps.WaveEvent) error
|
||||
EventSubCommand(ctx context.Context, data wps.SubscriptionRequest) error
|
||||
EventUnsubCommand(ctx context.Context, data string) error
|
||||
|
|
@ -628,6 +630,11 @@ type CommandGetTempDirData struct {
|
|||
FileName string `json:"filename,omitempty"`
|
||||
}
|
||||
|
||||
type CommandWriteTempFileData struct {
|
||||
FileName string `json:"filename"`
|
||||
Data64 string `json:"data64"`
|
||||
}
|
||||
|
||||
type CommandRemoteStreamTarData struct {
|
||||
Path string `json:"path"`
|
||||
Opts *FileCopyOpts `json:"opts,omitempty"`
|
||||
|
|
|
|||
|
|
@ -468,6 +468,30 @@ func (ws *WshServer) GetTempDirCommand(ctx context.Context, data wshrpc.CommandG
|
|||
return tempDir, nil
|
||||
}
|
||||
|
||||
func (ws *WshServer) WriteTempFileCommand(ctx context.Context, data wshrpc.CommandWriteTempFileData) (string, error) {
|
||||
if data.FileName == "" {
|
||||
return "", fmt.Errorf("filename is required")
|
||||
}
|
||||
name := filepath.Base(data.FileName)
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return "", fmt.Errorf("invalid filename")
|
||||
}
|
||||
tempDir, err := os.MkdirTemp("", "waveterm-")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error creating temp directory: %w", err)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(data.Data64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error decoding base64 data: %w", err)
|
||||
}
|
||||
tempPath := filepath.Join(tempDir, name)
|
||||
err = os.WriteFile(tempPath, decoded, 0600)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error writing temp file: %w", err)
|
||||
}
|
||||
return tempPath, nil
|
||||
}
|
||||
|
||||
func (ws *WshServer) DeleteSubBlockCommand(ctx context.Context, data wshrpc.CommandDeleteBlockData) error {
|
||||
err := wcore.DeleteBlock(ctx, data.BlockId, false)
|
||||
if err != nil {
|
||||
|
|
|
|||
Loading…
Reference in a new issue