fix(app): large text pasted into prompt-input causes main thread lock
This commit is contained in:
@@ -89,6 +89,8 @@ const EXAMPLES = [
|
|||||||
"prompt.example.25",
|
"prompt.example.25",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
const NON_EMPTY_TEXT = /[^\s\u200B]/
|
||||||
|
|
||||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
@@ -636,7 +638,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
let buffer = ""
|
let buffer = ""
|
||||||
|
|
||||||
const flushText = () => {
|
const flushText = () => {
|
||||||
const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "")
|
let content = buffer
|
||||||
|
if (content.includes("\r")) content = content.replace(/\r\n?/g, "\n")
|
||||||
|
if (content.includes("\u200B")) content = content.replace(/\u200B/g, "")
|
||||||
buffer = ""
|
buffer = ""
|
||||||
if (!content) return
|
if (!content) return
|
||||||
parts.push({ type: "text", content, start: position, end: position + content.length })
|
parts.push({ type: "text", content, start: position, end: position + content.length })
|
||||||
@@ -714,10 +718,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const rawParts = parseFromDOM()
|
const rawParts = parseFromDOM()
|
||||||
const images = imageAttachments()
|
const images = imageAttachments()
|
||||||
const cursorPosition = getCursorPosition(editorRef)
|
const cursorPosition = getCursorPosition(editorRef)
|
||||||
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
|
const rawText =
|
||||||
const trimmed = rawText.replace(/\u200B/g, "").trim()
|
rawParts.length === 1 && rawParts[0]?.type === "text"
|
||||||
|
? rawParts[0].content
|
||||||
|
: rawParts.map((p) => ("content" in p ? p.content : "")).join("")
|
||||||
const hasNonText = rawParts.some((part) => part.type !== "text")
|
const hasNonText = rawParts.some((part) => part.type !== "text")
|
||||||
const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
|
const shouldReset = !NON_EMPTY_TEXT.test(rawText) && !hasNonText && images.length === 0
|
||||||
|
|
||||||
if (shouldReset) {
|
if (shouldReset) {
|
||||||
closePopover()
|
closePopover()
|
||||||
@@ -757,19 +763,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addPart = (part: ContentPart) => {
|
const addPart = (part: ContentPart) => {
|
||||||
const selection = window.getSelection()
|
if (part.type === "image") return false
|
||||||
if (!selection || selection.rangeCount === 0) return
|
|
||||||
|
|
||||||
const cursorPosition = getCursorPosition(editorRef)
|
const selection = window.getSelection()
|
||||||
const currentPrompt = prompt.current()
|
if (!selection) return false
|
||||||
const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("")
|
|
||||||
const textBeforeCursor = rawText.substring(0, cursorPosition)
|
if (selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) {
|
||||||
const atMatch = textBeforeCursor.match(/@(\S*)$/)
|
editorRef.focus()
|
||||||
|
const cursor = prompt.cursor() ?? promptLength(prompt.current())
|
||||||
|
setCursorPosition(editorRef, cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection.rangeCount === 0) return false
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
if (!editorRef.contains(range.startContainer)) return false
|
||||||
|
|
||||||
if (part.type === "file" || part.type === "agent") {
|
if (part.type === "file" || part.type === "agent") {
|
||||||
|
const cursorPosition = getCursorPosition(editorRef)
|
||||||
|
const rawText = prompt
|
||||||
|
.current()
|
||||||
|
.map((p) => ("content" in p ? p.content : ""))
|
||||||
|
.join("")
|
||||||
|
const textBeforeCursor = rawText.substring(0, cursorPosition)
|
||||||
|
const atMatch = textBeforeCursor.match(/@(\S*)$/)
|
||||||
const pill = createPill(part)
|
const pill = createPill(part)
|
||||||
const gap = document.createTextNode(" ")
|
const gap = document.createTextNode(" ")
|
||||||
const range = selection.getRangeAt(0)
|
|
||||||
|
|
||||||
if (atMatch) {
|
if (atMatch) {
|
||||||
const start = atMatch.index ?? cursorPosition - atMatch[0].length
|
const start = atMatch.index ?? cursorPosition - atMatch[0].length
|
||||||
@@ -784,8 +802,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
range.collapse(true)
|
range.collapse(true)
|
||||||
selection.removeAllRanges()
|
selection.removeAllRanges()
|
||||||
selection.addRange(range)
|
selection.addRange(range)
|
||||||
} else if (part.type === "text") {
|
}
|
||||||
const range = selection.getRangeAt(0)
|
|
||||||
|
if (part.type === "text") {
|
||||||
const fragment = createTextFragment(part.content)
|
const fragment = createTextFragment(part.content)
|
||||||
const last = fragment.lastChild
|
const last = fragment.lastChild
|
||||||
range.deleteContents()
|
range.deleteContents()
|
||||||
@@ -821,6 +840,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
handleInput()
|
handleInput()
|
||||||
closePopover()
|
closePopover()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||||
|
|||||||
@@ -7,6 +7,19 @@ import { getCursorPosition } from "./editor-dom"
|
|||||||
|
|
||||||
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||||
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
type PromptAttachmentsInput = {
|
type PromptAttachmentsInput = {
|
||||||
editor: () => HTMLDivElement | undefined
|
editor: () => HTMLDivElement | undefined
|
||||||
@@ -14,7 +27,7 @@ type PromptAttachmentsInput = {
|
|||||||
isDialogActive: () => boolean
|
isDialogActive: () => boolean
|
||||||
setDraggingType: (type: "image" | "@mention" | null) => void
|
setDraggingType: (type: "image" | "@mention" | null) => void
|
||||||
focusEditor: () => void
|
focusEditor: () => void
|
||||||
addPart: (part: ContentPart) => void
|
addPart: (part: ContentPart) => boolean
|
||||||
readClipboardImage?: () => Promise<File | null>
|
readClipboardImage?: () => Promise<File | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +102,13 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!plainText) return
|
if (!plainText) return
|
||||||
|
|
||||||
|
if (largePaste(plainText)) {
|
||||||
|
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
|
||||||
|
input.focusEditor()
|
||||||
|
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
|
||||||
|
}
|
||||||
|
|
||||||
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
|
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
|
||||||
if (inserted) return
|
if (inserted) return
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,28 @@ describe("prompt-input editor dom", () => {
|
|||||||
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("createTextFragment avoids break-node explosion for large multiline content", () => {
|
||||||
|
const content = Array.from({ length: 220 }, () => "line").join("\n")
|
||||||
|
const fragment = createTextFragment(content)
|
||||||
|
const container = document.createElement("div")
|
||||||
|
container.appendChild(fragment)
|
||||||
|
|
||||||
|
expect(container.childNodes.length).toBe(1)
|
||||||
|
expect(container.childNodes[0]?.nodeType).toBe(Node.TEXT_NODE)
|
||||||
|
expect(container.textContent).toBe(content)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("createTextFragment keeps terminal break in large multiline fallback", () => {
|
||||||
|
const content = `${Array.from({ length: 220 }, () => "line").join("\n")}\n`
|
||||||
|
const fragment = createTextFragment(content)
|
||||||
|
const container = document.createElement("div")
|
||||||
|
container.appendChild(fragment)
|
||||||
|
|
||||||
|
expect(container.childNodes.length).toBe(2)
|
||||||
|
expect(container.childNodes[0]?.textContent).toBe(content.slice(0, -1))
|
||||||
|
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
||||||
|
})
|
||||||
|
|
||||||
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
|
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
|
||||||
const container = document.createElement("div")
|
const container = document.createElement("div")
|
||||||
container.appendChild(document.createTextNode("ab\u200B"))
|
container.appendChild(document.createTextNode("ab\u200B"))
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
|
const MAX_BREAKS = 200
|
||||||
|
|
||||||
export function createTextFragment(content: string): DocumentFragment {
|
export function createTextFragment(content: string): DocumentFragment {
|
||||||
const fragment = document.createDocumentFragment()
|
const fragment = document.createDocumentFragment()
|
||||||
|
let breaks = 0
|
||||||
|
for (const char of content) {
|
||||||
|
if (char !== "\n") continue
|
||||||
|
breaks += 1
|
||||||
|
if (breaks > MAX_BREAKS) {
|
||||||
|
const tail = content.endsWith("\n")
|
||||||
|
const text = tail ? content.slice(0, -1) : content
|
||||||
|
if (text) fragment.appendChild(document.createTextNode(text))
|
||||||
|
if (tail) fragment.appendChild(document.createElement("br"))
|
||||||
|
return fragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const segments = content.split("\n")
|
const segments = content.split("\n")
|
||||||
segments.forEach((segment, index) => {
|
segments.forEach((segment, index) => {
|
||||||
if (segment) {
|
if (segment) {
|
||||||
|
|||||||
Reference in New Issue
Block a user