fix(app): handle multiline web paste in prompt composer (#17509)
This commit is contained in:
@@ -2,7 +2,6 @@ import { useFilteredList } from "@opencode-ai/ui/hooks"
|
|||||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||||
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||||
import {
|
import {
|
||||||
@@ -411,7 +410,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFocused = createFocusSignal(() => editorRef)
|
|
||||||
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
|
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
|
||||||
|
|
||||||
const pick = () => fileInputRef?.click()
|
const pick = () => fileInputRef?.click()
|
||||||
@@ -1014,7 +1012,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
|
const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
|
||||||
editor: () => editorRef,
|
editor: () => editorRef,
|
||||||
isFocused,
|
|
||||||
isDialogActive: () => !!dialog.active,
|
isDialogActive: () => !!dialog.active,
|
||||||
setDraggingType: (type) => setStore("draggingType", type),
|
setDraggingType: (type) => setStore("draggingType", type),
|
||||||
focusEditor: () => {
|
focusEditor: () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { attachmentMime } from "./files"
|
import { attachmentMime } from "./files"
|
||||||
|
import { pasteMode } from "./paste"
|
||||||
|
|
||||||
describe("attachmentMime", () => {
|
describe("attachmentMime", () => {
|
||||||
test("keeps PDFs when the browser reports the mime", async () => {
|
test("keeps PDFs when the browser reports the mime", async () => {
|
||||||
@@ -22,3 +23,22 @@ describe("attachmentMime", () => {
|
|||||||
expect(await attachmentMime(file)).toBeUndefined()
|
expect(await attachmentMime(file)).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("pasteMode", () => {
|
||||||
|
test("uses native paste for short single-line text", () => {
|
||||||
|
expect(pasteMode("hello world")).toBe("native")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses manual paste for multiline text", () => {
|
||||||
|
expect(
|
||||||
|
pasteMode(`{
|
||||||
|
"ok": true
|
||||||
|
}`),
|
||||||
|
).toBe("manual")
|
||||||
|
expect(pasteMode("a\r\nb")).toBe("manual")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses manual paste for large text", () => {
|
||||||
|
expect(pasteMode("x".repeat(8000))).toBe("manual")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { uuid } from "@/utils/uuid"
|
import { uuid } from "@/utils/uuid"
|
||||||
import { getCursorPosition } from "./editor-dom"
|
import { getCursorPosition } from "./editor-dom"
|
||||||
import { attachmentMime } from "./files"
|
import { attachmentMime } from "./files"
|
||||||
const LARGE_PASTE_CHARS = 8000
|
import { normalizePaste, pasteMode } from "./paste"
|
||||||
const LARGE_PASTE_BREAKS = 120
|
|
||||||
|
|
||||||
function dataUrl(file: File, mime: string) {
|
function dataUrl(file: File, mime: string) {
|
||||||
return new Promise<string>((resolve) => {
|
return new Promise<string>((resolve) => {
|
||||||
@@ -25,20 +24,8 @@ function dataUrl(file: File, mime: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function largePaste(text: string) {
|
|
||||||
if (text.length >= LARGE_PASTE_CHARS) return true
|
|
||||||
let breaks = 0
|
|
||||||
for (const char of text) {
|
|
||||||
if (char !== "\n") continue
|
|
||||||
breaks += 1
|
|
||||||
if (breaks >= LARGE_PASTE_BREAKS) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
type PromptAttachmentsInput = {
|
type PromptAttachmentsInput = {
|
||||||
editor: () => HTMLDivElement | undefined
|
editor: () => HTMLDivElement | undefined
|
||||||
isFocused: () => boolean
|
|
||||||
isDialogActive: () => boolean
|
isDialogActive: () => boolean
|
||||||
setDraggingType: (type: "image" | "@mention" | null) => void
|
setDraggingType: (type: "image" | "@mention" | null) => void
|
||||||
focusEditor: () => void
|
focusEditor: () => void
|
||||||
@@ -91,7 +78,6 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePaste = async (event: ClipboardEvent) => {
|
const handlePaste = async (event: ClipboardEvent) => {
|
||||||
if (!input.isFocused()) return
|
|
||||||
const clipboardData = event.clipboardData
|
const clipboardData = event.clipboardData
|
||||||
if (!clipboardData) return
|
if (!clipboardData) return
|
||||||
|
|
||||||
@@ -126,16 +112,23 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
|
|
||||||
if (!plainText) return
|
if (!plainText) return
|
||||||
|
|
||||||
if (largePaste(plainText)) {
|
const text = normalizePaste(plainText)
|
||||||
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
|
|
||||||
|
const put = () => {
|
||||||
|
if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
|
||||||
input.focusEditor()
|
input.focusEditor()
|
||||||
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
|
return input.addPart({ type: "text", content: text, start: 0, end: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
|
if (pasteMode(text) === "manual") {
|
||||||
|
put()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text)
|
||||||
if (inserted) return
|
if (inserted) return
|
||||||
|
|
||||||
input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
put()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGlobalDragOver = (event: DragEvent) => {
|
const handleGlobalDragOver = (event: DragEvent) => {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const LARGE_PASTE_CHARS = 8000
|
||||||
|
const LARGE_PASTE_BREAKS = 120
|
||||||
|
|
||||||
|
function largePaste(text: string) {
|
||||||
|
if (text.length >= LARGE_PASTE_CHARS) return true
|
||||||
|
let breaks = 0
|
||||||
|
for (const char of text) {
|
||||||
|
if (char !== "\n") continue
|
||||||
|
breaks += 1
|
||||||
|
if (breaks >= LARGE_PASTE_BREAKS) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePaste(text: string) {
|
||||||
|
if (!text.includes("\r")) return text
|
||||||
|
return text.replace(/\r\n?/g, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pasteMode(text: string) {
|
||||||
|
if (largePaste(text)) return "manual"
|
||||||
|
if (text.includes("\n") || text.includes("\r")) return "manual"
|
||||||
|
return "native"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user