diff options
Diffstat (limited to 'packages/app/src/components/prompt-input.tsx')
| -rw-r--r-- | packages/app/src/components/prompt-input.tsx | 910 |
1 files changed, 53 insertions, 857 deletions
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 9186dcfa3..3f0ba314e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1,21 +1,9 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" -import { - createEffect, - on, - Component, - Show, - For, - onMount, - onCleanup, - Switch, - Match, - createMemo, - createSignal, -} from "solid-js" -import { createStore, produce } from "solid-js/store" +import { createEffect, on, Component, Show, For, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js" +import { createStore } from "solid-js/store" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" -import { useFile, type FileSelection } from "@/context/file" +import { useFile } from "@/context/file" import { ContentPart, DEFAULT_PROMPT, @@ -28,7 +16,7 @@ import { } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" -import { useNavigate, useParams } from "@solidjs/router" +import { useParams } from "@solidjs/router" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" import { FileIcon } from "@opencode-ai/ui/file-icon" @@ -47,27 +35,13 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid import { useProviders } from "@/hooks/use-providers" import { useCommand } from "@/context/command" import { Persist, persisted } from "@/utils/persist" -import { Identifier } from "@/utils/id" -import { Worktree as WorktreeState } from "@/utils/worktree" import { SessionContextUsage } from "@/components/session-context-usage" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" -import { useGlobalSync } from "@/context/global-sync" -import { usePlatform } from "@/context/platform" -import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client" -import { Binary } from "@opencode-ai/util/binary" -import { showToast } from "@opencode-ai/ui/toast" -import { base64Encode } from "@opencode-ai/util/encode" - -const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] -const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] - -type PendingPrompt = { - abort: AbortController - cleanup: VoidFunction -} - -const pending = new Map<string, PendingPrompt>() +import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" +import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" +import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history" +import { createPromptSubmit } from "./prompt-input/submit" interface PromptInputProps { class?: string @@ -116,11 +90,8 @@ interface SlashCommand { } export const PromptInput: Component<PromptInputProps> = (props) => { - const navigate = useNavigate() const sdk = useSDK() const sync = useSync() - const globalSync = useGlobalSync() - const platform = usePlatform() const local = useLocal() const files = useFile() const prompt = usePrompt() @@ -272,20 +243,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => { }), ) - const clonePromptParts = (prompt: Prompt): Prompt => - prompt.map((part) => { - if (part.type === "text") return { ...part } - if (part.type === "image") return { ...part } - if (part.type === "agent") return { ...part } - return { - ...part, - selection: part.selection ? { ...part.selection } : undefined, - } - }) - - const promptLength = (prompt: Prompt) => - prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0) - const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const length = position === "start" ? 0 : promptLength(p) setStore("applyingHistory", true) @@ -329,110 +286,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const [composing, setComposing] = createSignal(false) const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 - const addImageAttachment = async (file: File) => { - if (!ACCEPTED_FILE_TYPES.includes(file.type)) return - - const reader = new FileReader() - reader.onload = () => { - const dataUrl = reader.result as string - const attachment: ImageAttachmentPart = { - type: "image", - id: crypto.randomUUID(), - filename: file.name, - mime: file.type, - dataUrl, - } - const cursorPosition = prompt.cursor() ?? getCursorPosition(editorRef) - prompt.set([...prompt.current(), attachment], cursorPosition) - } - reader.readAsDataURL(file) - } - - const removeImageAttachment = (id: string) => { - const current = prompt.current() - const next = current.filter((part) => part.type !== "image" || part.id !== id) - prompt.set(next, prompt.cursor()) - } - - const handlePaste = async (event: ClipboardEvent) => { - if (!isFocused()) return - const clipboardData = event.clipboardData - if (!clipboardData) return - - event.preventDefault() - event.stopPropagation() - - const items = Array.from(clipboardData.items) - const fileItems = items.filter((item) => item.kind === "file") - const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) - - if (imageItems.length > 0) { - for (const item of imageItems) { - const file = item.getAsFile() - if (file) await addImageAttachment(file) - } - return - } - - if (fileItems.length > 0) { - showToast({ - title: language.t("prompt.toast.pasteUnsupported.title"), - description: language.t("prompt.toast.pasteUnsupported.description"), - }) - return - } - - const plainText = clipboardData.getData("text/plain") ?? "" - if (!plainText) return - addPart({ type: "text", content: plainText, start: 0, end: 0 }) - } - - const handleGlobalDragOver = (event: DragEvent) => { - if (dialog.active) return - - event.preventDefault() - const hasFiles = event.dataTransfer?.types.includes("Files") - if (hasFiles) { - setStore("dragging", true) - } - } - - const handleGlobalDragLeave = (event: DragEvent) => { - if (dialog.active) return - - // relatedTarget is null when leaving the document window - if (!event.relatedTarget) { - setStore("dragging", false) - } - } - - const handleGlobalDrop = async (event: DragEvent) => { - if (dialog.active) return - - event.preventDefault() - setStore("dragging", false) - - const dropped = event.dataTransfer?.files - if (!dropped) return - - for (const file of Array.from(dropped)) { - if (ACCEPTED_FILE_TYPES.includes(file.type)) { - await addImageAttachment(file) - } - } - } - - onMount(() => { - document.addEventListener("dragover", handleGlobalDragOver) - document.addEventListener("dragleave", handleGlobalDragLeave) - document.addEventListener("drop", handleGlobalDrop) - }) - onCleanup(() => { - document.removeEventListener("dragover", handleGlobalDragOver) - document.removeEventListener("dragleave", handleGlobalDragLeave) - document.removeEventListener("drop", handleGlobalDrop) - }) - createEffect(() => { if (!isFocused()) setStore("popover", null) }) @@ -826,36 +679,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => { queueScroll() } - const setRangeEdge = (range: Range, edge: "start" | "end", offset: number) => { - let remaining = offset - const nodes = Array.from(editorRef.childNodes) - - for (const node of nodes) { - const length = getNodeLength(node) - const isText = node.nodeType === Node.TEXT_NODE - const isPill = - node.nodeType === Node.ELEMENT_NODE && - ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") - const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" - - if (isText && remaining <= length) { - if (edge === "start") range.setStart(node, remaining) - if (edge === "end") range.setEnd(node, remaining) - return - } - - if ((isPill || isBreak) && remaining <= length) { - if (edge === "start" && remaining === 0) range.setStartBefore(node) - if (edge === "start" && remaining > 0) range.setStartAfter(node) - if (edge === "end" && remaining === 0) range.setEndBefore(node) - if (edge === "end" && remaining > 0) range.setEndAfter(node) - return - } - - remaining -= length - } - } - const addPart = (part: ContentPart) => { const selection = window.getSelection() if (!selection || selection.rangeCount === 0) return @@ -873,8 +696,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => { if (atMatch) { const start = atMatch.index ?? cursorPosition - atMatch[0].length - setRangeEdge(range, "start", start) - setRangeEdge(range, "end", cursorPosition) + setRangeEdge(editorRef, range, "start", start) + setRangeEdge(editorRef, range, "end", cursorPosition) } range.deleteContents() @@ -913,81 +736,57 @@ export const PromptInput: Component<PromptInputProps> = (props) => { setStore("popover", null) } - const abort = async () => { - const sessionID = params.id - if (!sessionID) return Promise.resolve() - const queued = pending.get(sessionID) - if (queued) { - queued.abort.abort() - queued.cleanup() - pending.delete(sessionID) - return Promise.resolve() - } - return sdk.client.session - .abort({ - sessionID, - }) - .catch(() => {}) - } - const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { - const text = prompt - .map((p) => ("content" in p ? p.content : "")) - .join("") - .trim() - const hasImages = prompt.some((part) => part.type === "image") - if (!text && !hasImages) return - - const entry = clonePromptParts(prompt) const currentHistory = mode === "shell" ? shellHistory : history const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory - const lastEntry = currentHistory.entries[0] - if (lastEntry && isPromptEqual(lastEntry, entry)) return - - setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY)) + const next = prependHistoryEntry(currentHistory.entries, prompt) + if (next === currentHistory.entries) return + setCurrentHistory("entries", next) } const navigateHistory = (direction: "up" | "down") => { - const entries = store.mode === "shell" ? shellHistory.entries : history.entries - const current = store.historyIndex - - if (direction === "up") { - if (entries.length === 0) return false - if (current === -1) { - setStore("savedPrompt", clonePromptParts(prompt.current())) - setStore("historyIndex", 0) - applyHistoryPrompt(entries[0], "start") - return true - } - if (current < entries.length - 1) { - const next = current + 1 - setStore("historyIndex", next) - applyHistoryPrompt(entries[next], "start") - return true - } - return false - } + const result = navigatePromptHistory({ + direction, + entries: store.mode === "shell" ? shellHistory.entries : history.entries, + historyIndex: store.historyIndex, + currentPrompt: prompt.current(), + savedPrompt: store.savedPrompt, + }) + if (!result.handled) return false + setStore("historyIndex", result.historyIndex) + setStore("savedPrompt", result.savedPrompt) + applyHistoryPrompt(result.prompt, result.cursor) + return true + } - if (current > 0) { - const next = current - 1 - setStore("historyIndex", next) - applyHistoryPrompt(entries[next], "end") - return true - } - if (current === 0) { - setStore("historyIndex", -1) - const saved = store.savedPrompt - if (saved) { - applyHistoryPrompt(saved, "end") - setStore("savedPrompt", null) - return true - } - applyHistoryPrompt(DEFAULT_PROMPT, "end") - return true - } + const { addImageAttachment, removeImageAttachment, handlePaste } = createPromptAttachments({ + editor: () => editorRef, + isFocused, + isDialogActive: () => !!dialog.active, + setDragging: (value) => setStore("dragging", value), + addPart, + }) - return false - } + const { abort, handleSubmit } = createPromptSubmit({ + info, + imageAttachments, + commentCount, + mode: () => store.mode, + working, + editor: () => editorRef, + queueScroll, + promptLength, + addToHistory, + resetHistoryNavigation: () => { + setStore("historyIndex", -1) + setStore("savedPrompt", null) + }, + setMode: (mode) => setStore("mode", mode), + setPopover: (popover) => setStore("popover", popover), + newSessionWorktree: props.newSessionWorktree, + onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, + onSubmit: props.onSubmit, + }) const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Backspace") { @@ -1127,503 +926,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } } - const handleSubmit = async (event: Event) => { - event.preventDefault() - - const currentPrompt = prompt.current() - const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") - const images = imageAttachments().slice() - const mode = store.mode - - if (text.trim().length === 0 && images.length === 0 && commentCount() === 0) { - if (working()) abort() - return - } - - const currentModel = local.model.current() - const currentAgent = local.agent.current() - if (!currentModel || !currentAgent) { - showToast({ - title: language.t("prompt.toast.modelAgentRequired.title"), - description: language.t("prompt.toast.modelAgentRequired.description"), - }) - return - } - - const errorMessage = (err: unknown) => { - if (err && typeof err === "object" && "data" in err) { - const data = (err as { data?: { message?: string } }).data - if (data?.message) return data.message - } - if (err instanceof Error) return err.message - return language.t("common.requestFailed") - } - - addToHistory(currentPrompt, mode) - setStore("historyIndex", -1) - setStore("savedPrompt", null) - - const projectDirectory = sdk.directory - const isNewSession = !params.id - const worktreeSelection = props.newSessionWorktree ?? "main" - - let sessionDirectory = projectDirectory - let client = sdk.client - - if (isNewSession) { - if (worktreeSelection === "create") { - const createdWorktree = await client.worktree - .create({ directory: projectDirectory }) - .then((x) => x.data) - .catch((err) => { - showToast({ - title: language.t("prompt.toast.worktreeCreateFailed.title"), - description: errorMessage(err), - }) - return undefined - }) - - if (!createdWorktree?.directory) { - showToast({ - title: language.t("prompt.toast.worktreeCreateFailed.title"), - description: language.t("common.requestFailed"), - }) - return - } - WorktreeState.pending(createdWorktree.directory) - sessionDirectory = createdWorktree.directory - } - - if (worktreeSelection !== "main" && worktreeSelection !== "create") { - sessionDirectory = worktreeSelection - } - - if (sessionDirectory !== projectDirectory) { - client = createOpencodeClient({ - baseUrl: sdk.url, - fetch: platform.fetch, - directory: sessionDirectory, - throwOnError: true, - }) - globalSync.child(sessionDirectory) - } - - props.onNewSessionWorktreeReset?.() - } - - let session = info() - if (!session && isNewSession) { - session = await client.session - .create() - .then((x) => x.data ?? undefined) - .catch((err) => { - showToast({ - title: language.t("prompt.toast.sessionCreateFailed.title"), - description: errorMessage(err), - }) - return undefined - }) - if (session) { - layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) - navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) - } - } - if (!session) return - - props.onSubmit?.() - - const model = { - modelID: currentModel.id, - providerID: currentModel.provider.id, - } - const agent = currentAgent.name - const variant = local.model.variant.current() - - const clearInput = () => { - prompt.reset() - setStore("mode", "normal") - setStore("popover", null) - } - - const restoreInput = () => { - prompt.set(currentPrompt, promptLength(currentPrompt)) - setStore("mode", mode) - setStore("popover", null) - requestAnimationFrame(() => { - editorRef.focus() - setCursorPosition(editorRef, promptLength(currentPrompt)) - queueScroll() - }) - } - - if (mode === "shell") { - clearInput() - client.session - .shell({ - sessionID: session.id, - agent, - model, - command: text, - }) - .catch((err) => { - showToast({ - title: language.t("prompt.toast.shellSendFailed.title"), - description: errorMessage(err), - }) - restoreInput() - }) - return - } - - if (text.startsWith("/")) { - const [cmdName, ...args] = text.split(" ") - const commandName = cmdName.slice(1) - const customCommand = sync.data.command.find((c) => c.name === commandName) - if (customCommand) { - clearInput() - client.session - .command({ - sessionID: session.id, - command: commandName, - arguments: args.join(" "), - agent, - model: `${model.providerID}/${model.modelID}`, - variant, - parts: images.map((attachment) => ({ - id: Identifier.ascending("part"), - type: "file" as const, - mime: attachment.mime, - url: attachment.dataUrl, - filename: attachment.filename, - })), - }) - .catch((err) => { - showToast({ - title: language.t("prompt.toast.commandSendFailed.title"), - description: errorMessage(err), - }) - restoreInput() - }) - return - } - } - - const toAbsolutePath = (path: string) => - path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/") - - const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[] - const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[] - - const fileAttachmentParts = fileAttachments.map((attachment) => { - const absolute = toAbsolutePath(attachment.path) - const query = attachment.selection - ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` - : "" - return { - id: Identifier.ascending("part"), - type: "file" as const, - mime: "text/plain", - url: `file://${absolute}${query}`, - filename: getFilename(attachment.path), - source: { - type: "file" as const, - text: { - value: attachment.content, - start: attachment.start, - end: attachment.end, - }, - path: absolute, - }, - } - }) - - const agentAttachmentParts = agentAttachments.map((attachment) => ({ - id: Identifier.ascending("part"), - type: "agent" as const, - name: attachment.name, - source: { - value: attachment.content, - start: attachment.start, - end: attachment.end, - }, - })) - - const usedUrls = new Set(fileAttachmentParts.map((part) => part.url)) - - const context = prompt.context.items().slice() - - const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) - - const contextParts: Array< - | { - id: string - type: "text" - text: string - synthetic?: boolean - } - | { - id: string - type: "file" - mime: string - url: string - filename?: string - } - > = [] - - const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => { - const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined - const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined - const range = - start === undefined || end === undefined - ? "this file" - : start === end - ? `line ${start}` - : `lines ${start} through ${end}` - - return `The user made the following comment regarding ${range} of ${path}: ${comment}` - } - - const addContextFile = (input: { path: string; selection?: FileSelection; comment?: string }) => { - const absolute = toAbsolutePath(input.path) - const query = input.selection ? `?start=${input.selection.startLine}&end=${input.selection.endLine}` : "" - const url = `file://${absolute}${query}` - - const comment = input.comment?.trim() - if (!comment && usedUrls.has(url)) return - usedUrls.add(url) - - if (comment) { - contextParts.push({ - id: Identifier.ascending("part"), - type: "text", - text: commentNote(input.path, input.selection, comment), - synthetic: true, - }) - } - - contextParts.push({ - id: Identifier.ascending("part"), - type: "file", - mime: "text/plain", - url, - filename: getFilename(input.path), - }) - } - - for (const item of context) { - if (item.type !== "file") continue - addContextFile({ path: item.path, selection: item.selection, comment: item.comment }) - } - - const imageAttachmentParts = images.map((attachment) => ({ - id: Identifier.ascending("part"), - type: "file" as const, - mime: attachment.mime, - url: attachment.dataUrl, - filename: attachment.filename, - })) - - const messageID = Identifier.ascending("message") - const textPart = { - id: Identifier.ascending("part"), - type: "text" as const, - text, - } - const requestParts = [ - textPart, - ...fileAttachmentParts, - ...contextParts, - ...agentAttachmentParts, - ...imageAttachmentParts, - ] - - const optimisticParts = requestParts.map((part) => ({ - ...part, - sessionID: session.id, - messageID, - })) as unknown as Part[] - - const optimisticMessage: Message = { - id: messageID, - sessionID: session.id, - role: "user", - time: { created: Date.now() }, - agent, - model, - } - - const addOptimisticMessage = () => { - if (sessionDirectory === projectDirectory) { - sync.set( - produce((draft) => { - const messages = draft.message[session.id] - if (!messages) { - draft.message[session.id] = [optimisticMessage] - } else { - const result = Binary.search(messages, messageID, (m) => m.id) - messages.splice(result.index, 0, optimisticMessage) - } - draft.part[messageID] = optimisticParts - .filter((p) => !!p?.id) - .slice() - .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - }), - ) - return - } - - globalSync.child(sessionDirectory)[1]( - produce((draft) => { - const messages = draft.message[session.id] - if (!messages) { - draft.message[session.id] = [optimisticMessage] - } else { - const result = Binary.search(messages, messageID, (m) => m.id) - messages.splice(result.index, 0, optimisticMessage) - } - draft.part[messageID] = optimisticParts - .filter((p) => !!p?.id) - .slice() - .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - }), - ) - } - - const removeOptimisticMessage = () => { - if (sessionDirectory === projectDirectory) { - sync.set( - produce((draft) => { - const messages = draft.message[session.id] - if (messages) { - const result = Binary.search(messages, messageID, (m) => m.id) - if (result.found) messages.splice(result.index, 1) - } - delete draft.part[messageID] - }), - ) - return - } - - globalSync.child(sessionDirectory)[1]( - produce((draft) => { - const messages = draft.message[session.id] - if (messages) { - const result = Binary.search(messages, messageID, (m) => m.id) - if (result.found) messages.splice(result.index, 1) - } - delete draft.part[messageID] - }), - ) - } - - for (const item of commentItems) { - prompt.context.remove(item.key) - } - - clearInput() - addOptimisticMessage() - - const waitForWorktree = async () => { - const worktree = WorktreeState.get(sessionDirectory) - if (!worktree || worktree.status !== "pending") return true - - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "busy" }) - } - - const controller = new AbortController() - - const cleanup = () => { - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "idle" }) - } - removeOptimisticMessage() - for (const item of commentItems) { - prompt.context.add({ - type: "file", - path: item.path, - selection: item.selection, - comment: item.comment, - commentID: item.commentID, - commentOrigin: item.commentOrigin, - preview: item.preview, - }) - } - restoreInput() - } - - pending.set(session.id, { abort: controller, cleanup }) - - const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => { - if (controller.signal.aborted) { - resolve({ status: "failed", message: "aborted" }) - return - } - controller.signal.addEventListener( - "abort", - () => { - resolve({ status: "failed", message: "aborted" }) - }, - { once: true }, - ) - }) - - const timeoutMs = 5 * 60 * 1000 - const timer = { id: undefined as number | undefined } - const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => { - timer.id = window.setTimeout(() => { - resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") }) - }, timeoutMs) - }) - - const result = await Promise.race([WorktreeState.wait(sessionDirectory), abort, timeout]).finally(() => { - if (timer.id === undefined) return - clearTimeout(timer.id) - }) - pending.delete(session.id) - if (controller.signal.aborted) return false - if (result.status === "failed") throw new Error(result.message) - return true - } - - const send = async () => { - const ok = await waitForWorktree() - if (!ok) return - await client.session.prompt({ - sessionID: session.id, - agent, - model, - messageID, - parts: requestParts, - variant, - }) - } - - void send().catch((err) => { - pending.delete(session.id) - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "idle" }) - } - showToast({ - title: language.t("prompt.toast.promptSendFailed.title"), - description: errorMessage(err), - }) - removeOptimisticMessage() - for (const item of commentItems) { - prompt.context.add({ - type: "file", - path: item.path, - selection: item.selection, - comment: item.comment, - commentID: item.commentID, - commentOrigin: item.commentOrigin, - preview: item.preview, - }) - } - restoreInput() - }) - } - return ( <div class="relative size-full _max-h-[320px] flex flex-col gap-3"> <Show when={store.popover}> @@ -2087,109 +1389,3 @@ export const PromptInput: Component<PromptInputProps> = (props) => { </div> ) } - -function createTextFragment(content: string): DocumentFragment { - const fragment = document.createDocumentFragment() - const segments = content.split("\n") - segments.forEach((segment, index) => { - if (segment) { - fragment.appendChild(document.createTextNode(segment)) - } else if (segments.length > 1) { - fragment.appendChild(document.createTextNode("\u200B")) - } - if (index < segments.length - 1) { - fragment.appendChild(document.createElement("br")) - } - }) - return fragment -} - -function getNodeLength(node: Node): number { - if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 - return (node.textContent ?? "").replace(/\u200B/g, "").length -} - -function getTextLength(node: Node): number { - if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length - if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 - let length = 0 - for (const child of Array.from(node.childNodes)) { - length += getTextLength(child) - } - return length -} - -function getCursorPosition(parent: HTMLElement): number { - const selection = window.getSelection() - if (!selection || selection.rangeCount === 0) return 0 - const range = selection.getRangeAt(0) - if (!parent.contains(range.startContainer)) return 0 - const preCaretRange = range.cloneRange() - preCaretRange.selectNodeContents(parent) - preCaretRange.setEnd(range.startContainer, range.startOffset) - return getTextLength(preCaretRange.cloneContents()) -} - -function setCursorPosition(parent: HTMLElement, position: number) { - let remaining = position - let node = parent.firstChild - while (node) { - const length = getNodeLength(node) - const isText = node.nodeType === Node.TEXT_NODE - const isPill = - node.nodeType === Node.ELEMENT_NODE && - ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") - const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" - - if (isText && remaining <= length) { - const range = document.createRange() - const selection = window.getSelection() - range.setStart(node, remaining) - range.collapse(true) - selection?.removeAllRanges() - selection?.addRange(range) - return - } - - if ((isPill || isBreak) && remaining <= length) { - const range = document.createRange() - const selection = window.getSelection() - if (remaining === 0) { - range.setStartBefore(node) - } - if (remaining > 0 && isPill) { - range.setStartAfter(node) - } - if (remaining > 0 && isBreak) { - const next = node.nextSibling - if (next && next.nodeType === Node.TEXT_NODE) { - range.setStart(next, 0) - } - if (!next || next.nodeType !== Node.TEXT_NODE) { - range.setStartAfter(node) - } - } - range.collapse(true) - selection?.removeAllRanges() - selection?.addRange(range) - return - } - - remaining -= length - node = node.nextSibling - } - - const fallbackRange = document.createRange() - const fallbackSelection = window.getSelection() - const last = parent.lastChild - if (last && last.nodeType === Node.TEXT_NODE) { - const len = last.textContent ? last.textContent.length : 0 - fallbackRange.setStart(last, len) - } - if (!last || last.nodeType !== Node.TEXT_NODE) { - fallbackRange.selectNodeContents(parent) - } - fallbackRange.collapse(false) - fallbackSelection?.removeAllRanges() - fallbackSelection?.addRange(fallbackRange) -} |
