|
|
@@ -1,63 +1,74 @@
|
|
|
-import { createEffect, on, Component, createMemo, Show } from "solid-js"
|
|
|
+import { useLocal } from "@/context"
|
|
|
+import { Button, Icon, IconButton, Select, SelectDialog, Tooltip } from "@opencode-ai/ui"
|
|
|
+import { useFilteredList } from "@opencode-ai/ui/hooks"
|
|
|
+import { createEffect, on, Component, createMemo, Show, Switch, Match, For } from "solid-js"
|
|
|
import { createStore } from "solid-js/store"
|
|
|
+import { FileIcon } from "@/ui"
|
|
|
+import { getDirectory, getFilename } from "@/utils"
|
|
|
+import { createFocusSignal } from "@solid-primitives/active-element"
|
|
|
+import { TextSelection } from "@/context/local"
|
|
|
+import { DateTime } from "luxon"
|
|
|
|
|
|
-interface TextPart {
|
|
|
- type: "text"
|
|
|
+interface PartBase {
|
|
|
content: string
|
|
|
}
|
|
|
|
|
|
-interface AttachmentPart {
|
|
|
- type: "attachment"
|
|
|
- fileId: string
|
|
|
- name: string
|
|
|
+interface TextPart extends PartBase {
|
|
|
+ type: "text"
|
|
|
}
|
|
|
|
|
|
-export type ContentPart = TextPart | AttachmentPart
|
|
|
-
|
|
|
-export interface AttachmentToAdd {
|
|
|
- id: string
|
|
|
- name: string
|
|
|
+interface FileAttachmentPart extends PartBase {
|
|
|
+ type: "file"
|
|
|
+ path: string
|
|
|
+ selection?: TextSelection
|
|
|
}
|
|
|
|
|
|
-type AddAttachmentCallback = (attachment: AttachmentToAdd) => void
|
|
|
-
|
|
|
-export interface PopoverState {
|
|
|
- isOpen: boolean
|
|
|
- searchQuery: string
|
|
|
- addAttachment: AddAttachmentCallback
|
|
|
-}
|
|
|
+export type ContentPart = TextPart | FileAttachmentPart
|
|
|
|
|
|
interface PromptInputProps {
|
|
|
onSubmit: (parts: ContentPart[]) => void
|
|
|
- onShowAttachments?: (state: PopoverState | null) => void
|
|
|
class?: string
|
|
|
+ ref?: (el: HTMLDivElement) => void
|
|
|
}
|
|
|
|
|
|
export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
- let editorRef: HTMLDivElement | undefined
|
|
|
+ const local = useLocal()
|
|
|
+ let editorRef!: HTMLDivElement
|
|
|
|
|
|
const defaultParts = [{ type: "text", content: "" } as const]
|
|
|
const [store, setStore] = createStore<{
|
|
|
contentParts: ContentPart[]
|
|
|
- popover: {
|
|
|
- isOpen: boolean
|
|
|
- searchQuery: string
|
|
|
- }
|
|
|
+ popoverIsOpen: boolean
|
|
|
}>({
|
|
|
contentParts: defaultParts,
|
|
|
- popover: {
|
|
|
- isOpen: false,
|
|
|
- searchQuery: "",
|
|
|
- },
|
|
|
+ popoverIsOpen: false,
|
|
|
})
|
|
|
|
|
|
const isEmpty = createMemo(() => isEqual(store.contentParts, defaultParts))
|
|
|
+ const isFocused = createFocusSignal(() => editorRef)
|
|
|
+
|
|
|
+ createEffect(() => {
|
|
|
+ if (isFocused()) {
|
|
|
+ handleInput()
|
|
|
+ } else {
|
|
|
+ setStore("popoverIsOpen", false)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ const { flat, active, onInput, onKeyDown } = useFilteredList<string>({
|
|
|
+ items: local.file.search,
|
|
|
+ key: (x) => x,
|
|
|
+ onSelect: (path) => {
|
|
|
+ if (!path) return
|
|
|
+ addPart({ type: "file", path, content: "@" + getFilename(path) })
|
|
|
+ setStore("popoverIsOpen", false)
|
|
|
+ },
|
|
|
+ })
|
|
|
|
|
|
createEffect(
|
|
|
on(
|
|
|
() => store.contentParts,
|
|
|
(currentParts) => {
|
|
|
- if (!editorRef) return
|
|
|
const domParts = parseFromDOM()
|
|
|
if (isEqual(currentParts, domParts)) return
|
|
|
|
|
|
@@ -70,14 +81,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
editorRef.innerHTML = ""
|
|
|
currentParts.forEach((part) => {
|
|
|
if (part.type === "text") {
|
|
|
- editorRef!.appendChild(document.createTextNode(part.content))
|
|
|
- } else if (part.type === "attachment") {
|
|
|
+ editorRef.appendChild(document.createTextNode(part.content))
|
|
|
+ } else if (part.type === "file") {
|
|
|
const pill = document.createElement("span")
|
|
|
- pill.textContent = `@${part.name}`
|
|
|
- pill.className = "attachment-pill"
|
|
|
- pill.setAttribute("data-file-id", part.fileId)
|
|
|
+ pill.textContent = part.content
|
|
|
+ pill.setAttribute("data-type", "file")
|
|
|
+ pill.setAttribute("data-path", part.path)
|
|
|
pill.setAttribute("contenteditable", "false")
|
|
|
- editorRef!.appendChild(pill)
|
|
|
+ pill.style.userSelect = "text"
|
|
|
+ pill.style.cursor = "default"
|
|
|
+ editorRef.appendChild(pill)
|
|
|
}
|
|
|
})
|
|
|
|
|
|
@@ -88,30 +101,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
),
|
|
|
)
|
|
|
|
|
|
- createEffect(() => {
|
|
|
- if (store.popover.isOpen) {
|
|
|
- props.onShowAttachments?.({
|
|
|
- isOpen: true,
|
|
|
- searchQuery: store.popover.searchQuery,
|
|
|
- addAttachment: addAttachment,
|
|
|
- })
|
|
|
- } else {
|
|
|
- props.onShowAttachments?.(null)
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
const parseFromDOM = (): ContentPart[] => {
|
|
|
- if (!editorRef) return []
|
|
|
const newParts: ContentPart[] = []
|
|
|
editorRef.childNodes.forEach((node) => {
|
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
|
if (node.textContent) newParts.push({ type: "text", content: node.textContent })
|
|
|
- } else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.fileId) {
|
|
|
- newParts.push({
|
|
|
- type: "attachment",
|
|
|
- fileId: (node as HTMLElement).dataset.fileId!,
|
|
|
- name: node.textContent!.substring(1),
|
|
|
- })
|
|
|
+ } else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type) {
|
|
|
+ switch ((node as HTMLElement).dataset.type) {
|
|
|
+ case "file":
|
|
|
+ newParts.push({
|
|
|
+ type: "file",
|
|
|
+ path: (node as HTMLElement).dataset.path!,
|
|
|
+ content: node.textContent!,
|
|
|
+ })
|
|
|
+ break
|
|
|
+ default:
|
|
|
+ break
|
|
|
+ }
|
|
|
}
|
|
|
})
|
|
|
if (newParts.length === 0) newParts.push(...defaultParts)
|
|
|
@@ -120,96 +126,234 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
|
|
|
const handleInput = () => {
|
|
|
const rawParts = parseFromDOM()
|
|
|
- const cursorPosition = getCursorPosition(editorRef!)
|
|
|
- const rawText = rawParts.map((p) => (p.type === "text" ? p.content : `@${p.name}`)).join("")
|
|
|
+ const cursorPosition = getCursorPosition(editorRef)
|
|
|
+ const rawText = rawParts.map((p) => p.content).join("")
|
|
|
|
|
|
const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
|
|
|
if (atMatch) {
|
|
|
- setStore("popover", { isOpen: true, searchQuery: atMatch[1] })
|
|
|
- } else if (store.popover.isOpen) {
|
|
|
- setStore("popover", "isOpen", false)
|
|
|
+ onInput(atMatch[1])
|
|
|
+ setStore("popoverIsOpen", true)
|
|
|
+ } else if (store.popoverIsOpen) {
|
|
|
+ setStore("popoverIsOpen", false)
|
|
|
}
|
|
|
|
|
|
setStore("contentParts", rawParts)
|
|
|
}
|
|
|
|
|
|
- const addAttachment: AddAttachmentCallback = (attachment) => {
|
|
|
- const rawText = store.contentParts.map((p) => (p.type === "text" ? p.content : `@${p.name}`)).join("")
|
|
|
- const cursorPosition = getCursorPosition(editorRef!)
|
|
|
-
|
|
|
+ const addPart = (part: ContentPart) => {
|
|
|
+ const cursorPosition = getCursorPosition(editorRef)
|
|
|
+ const rawText = store.contentParts.map((p) => p.content).join("")
|
|
|
const textBeforeCursor = rawText.substring(0, cursorPosition)
|
|
|
const atMatch = textBeforeCursor.match(/@(\S*)$/)
|
|
|
-
|
|
|
if (!atMatch) return
|
|
|
|
|
|
const startIndex = atMatch.index!
|
|
|
+ const endIndex = cursorPosition
|
|
|
+
|
|
|
+ const {
|
|
|
+ parts: nextParts,
|
|
|
+ cursorIndex,
|
|
|
+ cursorOffset,
|
|
|
+ inserted,
|
|
|
+ } = store.contentParts.reduce(
|
|
|
+ (acc, item) => {
|
|
|
+ if (acc.inserted) {
|
|
|
+ acc.parts.push(item)
|
|
|
+ acc.runningIndex += item.content.length
|
|
|
+ return acc
|
|
|
+ }
|
|
|
|
|
|
- // Create new structured content
|
|
|
- const newParts: ContentPart[] = []
|
|
|
- const textBeforeTrigger = rawText.substring(0, startIndex)
|
|
|
- if (textBeforeTrigger) newParts.push({ type: "text", content: textBeforeTrigger })
|
|
|
+ const nextIndex = acc.runningIndex + item.content.length
|
|
|
+ if (nextIndex <= startIndex) {
|
|
|
+ acc.parts.push(item)
|
|
|
+ acc.runningIndex = nextIndex
|
|
|
+ return acc
|
|
|
+ }
|
|
|
+
|
|
|
+ if (item.type !== "text") {
|
|
|
+ acc.parts.push(item)
|
|
|
+ acc.runningIndex = nextIndex
|
|
|
+ return acc
|
|
|
+ }
|
|
|
+
|
|
|
+ const headLength = Math.max(0, startIndex - acc.runningIndex)
|
|
|
+ const tailLength = Math.max(0, endIndex - acc.runningIndex)
|
|
|
+ const head = item.content.slice(0, headLength)
|
|
|
+ const tail = item.content.slice(tailLength)
|
|
|
|
|
|
- newParts.push({ type: "attachment", fileId: attachment.id, name: attachment.name })
|
|
|
+ if (head) acc.parts.push({ type: "text", content: head })
|
|
|
|
|
|
- // Add a space after the pill for better UX
|
|
|
- newParts.push({ type: "text", content: " " })
|
|
|
+ acc.parts.push(part)
|
|
|
+
|
|
|
+ const rest = /^\s/.test(tail) ? tail : ` ${tail}`
|
|
|
+ if (rest) {
|
|
|
+ acc.cursorIndex = acc.parts.length
|
|
|
+ acc.cursorOffset = Math.min(1, rest.length)
|
|
|
+ acc.parts.push({ type: "text", content: rest })
|
|
|
+ }
|
|
|
+
|
|
|
+ acc.inserted = true
|
|
|
+ acc.runningIndex = nextIndex
|
|
|
+ return acc
|
|
|
+ },
|
|
|
+ {
|
|
|
+ parts: [] as ContentPart[],
|
|
|
+ runningIndex: 0,
|
|
|
+ inserted: false,
|
|
|
+ cursorIndex: null as number | null,
|
|
|
+ cursorOffset: 0,
|
|
|
+ },
|
|
|
+ )
|
|
|
|
|
|
- const textAfterCursor = rawText.substring(cursorPosition)
|
|
|
- if (textAfterCursor) newParts.push({ type: "text", content: textAfterCursor })
|
|
|
+ if (!inserted || cursorIndex === null) return
|
|
|
|
|
|
- setStore("contentParts", newParts)
|
|
|
- setStore("popover", "isOpen", false)
|
|
|
+ setStore("contentParts", nextParts)
|
|
|
+ setStore("popoverIsOpen", false)
|
|
|
|
|
|
- // Set cursor position after the newly added pill + space
|
|
|
- // We need to wait for the DOM to update
|
|
|
queueMicrotask(() => {
|
|
|
- setCursorPosition(editorRef!, textBeforeTrigger.length + 1 + attachment.name.length + 1)
|
|
|
+ const node = editorRef.childNodes[cursorIndex]
|
|
|
+ if (node && node.nodeType === Node.TEXT_NODE) {
|
|
|
+ const range = document.createRange()
|
|
|
+ const selection = window.getSelection()
|
|
|
+ const length = node.textContent ? node.textContent.length : 0
|
|
|
+ const offset = cursorOffset > length ? length : cursorOffset
|
|
|
+ range.setStart(node, offset)
|
|
|
+ range.collapse(true)
|
|
|
+ selection?.removeAllRanges()
|
|
|
+ selection?.addRange(range)
|
|
|
+ }
|
|
|
})
|
|
|
}
|
|
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
|
- if (store.popover.isOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
|
|
- // In a real implementation, you'd prevent default and delegate this to the popover
|
|
|
- console.log("Key press delegated to popover:", event.key)
|
|
|
+ if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
|
|
+ onKeyDown(event)
|
|
|
event.preventDefault()
|
|
|
return
|
|
|
}
|
|
|
-
|
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
|
- event.preventDefault()
|
|
|
- if (store.contentParts.length > 0) {
|
|
|
- props.onSubmit([...store.contentParts])
|
|
|
- setStore("contentParts", defaultParts)
|
|
|
- }
|
|
|
+ handleSubmit(event)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleSubmit = (event: Event) => {
|
|
|
+ event.preventDefault()
|
|
|
+ if (store.contentParts.length > 0) {
|
|
|
+ props.onSubmit([...store.contentParts])
|
|
|
+ setStore("contentParts", defaultParts)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
- <div
|
|
|
- classList={{
|
|
|
- "size-full max-w-xl bg-surface-raised-stronger-non-alpha border border-border-strong-base": true,
|
|
|
- "rounded-2xl overflow-clip focus-within:shadow-xs-border-selected": true,
|
|
|
- [props.class ?? ""]: !!props.class,
|
|
|
- }}
|
|
|
- >
|
|
|
- <div class="p-3" />
|
|
|
- <div class="relative">
|
|
|
- <div
|
|
|
- ref={editorRef}
|
|
|
- contenteditable="true"
|
|
|
- onInput={handleInput}
|
|
|
- onKeyDown={handleKeyDown}
|
|
|
- classList={{
|
|
|
- "w-full p-3 text-sm focus:outline-none": true,
|
|
|
- }}
|
|
|
- />
|
|
|
- <Show when={isEmpty()}>
|
|
|
- <div class="absolute bottom-0 left-0 p-3 text-sm text-text-weak pointer-events-none">
|
|
|
- Plan and build anything
|
|
|
+ <div class="relative size-full max-w-[640px] _max-h-[320px] flex flex-col gap-3">
|
|
|
+ <Show when={store.popoverIsOpen}>
|
|
|
+ <div class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10 overflow-y-auto flex flex-col p-2 pb-0 rounded-2xl border border-border-base bg-surface-raised-stronger-non-alpha shadow-md">
|
|
|
+ <For each={flat()}>
|
|
|
+ {(i) => (
|
|
|
+ <div
|
|
|
+ classList={{
|
|
|
+ "w-full flex items-center justify-between rounded-md": true,
|
|
|
+ "bg-surface-raised-base-hover": active() === i,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div class="flex items-center gap-x-2 grow min-w-0">
|
|
|
+ <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
|
|
+ <div class="flex items-center text-14-regular">
|
|
|
+ <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
|
|
+ {getDirectory(i)}/
|
|
|
+ </span>
|
|
|
+ <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </For>
|
|
|
+ </div>
|
|
|
+ </Show>
|
|
|
+ <form
|
|
|
+ onSubmit={handleSubmit}
|
|
|
+ classList={{
|
|
|
+ "bg-surface-raised-stronger-non-alpha border border-border-strong-base": true,
|
|
|
+ "rounded-2xl overflow-clip focus-within:shadow-xs-border-selected": true,
|
|
|
+ [props.class ?? ""]: !!props.class,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div class="relative max-h-[240px] overflow-y-auto">
|
|
|
+ <div
|
|
|
+ ref={(el) => {
|
|
|
+ editorRef = el
|
|
|
+ props.ref?.(el)
|
|
|
+ }}
|
|
|
+ contenteditable="true"
|
|
|
+ onInput={handleInput}
|
|
|
+ onKeyDown={handleKeyDown}
|
|
|
+ classList={{
|
|
|
+ "w-full p-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
|
|
+ "[&>[data-type=file]]:text-icon-info-active": true,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ <Show when={isEmpty()}>
|
|
|
+ <div class="absolute top-0 left-0 p-3 text-14-regular text-text-weak pointer-events-none">
|
|
|
+ Plan and build anything
|
|
|
+ </div>
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+ <div class="p-3 flex items-center justify-between">
|
|
|
+ <div class="flex items-center justify-start gap-1">
|
|
|
+ <Select
|
|
|
+ options={local.agent.list().map((agent) => agent.name)}
|
|
|
+ current={local.agent.current().name}
|
|
|
+ onSelect={local.agent.set}
|
|
|
+ class="capitalize"
|
|
|
+ />
|
|
|
+ <SelectDialog
|
|
|
+ title="Select model"
|
|
|
+ placeholder="Search models"
|
|
|
+ emptyMessage="No model results"
|
|
|
+ key={(x) => `${x.provider.id}:${x.id}`}
|
|
|
+ items={local.model.list()}
|
|
|
+ current={local.model.current()}
|
|
|
+ filterKeys={["provider.name", "name", "id"]}
|
|
|
+ groupBy={(x) => x.provider.name}
|
|
|
+ sortGroupsBy={(a, b) => {
|
|
|
+ const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
|
|
|
+ const aProvider = a.items[0].provider.id
|
|
|
+ const bProvider = b.items[0].provider.id
|
|
|
+ if (order.includes(aProvider) && !order.includes(bProvider)) return -1
|
|
|
+ if (!order.includes(aProvider) && order.includes(bProvider)) return 1
|
|
|
+ return order.indexOf(aProvider) - order.indexOf(bProvider)
|
|
|
+ }}
|
|
|
+ onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)}
|
|
|
+ trigger={
|
|
|
+ <Button as="div" variant="ghost">
|
|
|
+ {local.model.current()?.name ?? "Select model"}
|
|
|
+ <span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
|
|
|
+ <Icon name="chevron-down" size="small" />
|
|
|
+ </Button>
|
|
|
+ }
|
|
|
+ >
|
|
|
+ {(i) => (
|
|
|
+ <div class="w-full flex items-center justify-between gap-x-3">
|
|
|
+ <div class="flex items-center gap-x-2.5 text-text-muted grow min-w-0">
|
|
|
+ <img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0 " />
|
|
|
+ <div class="flex gap-x-3 items-baseline flex-[1_0_0]">
|
|
|
+ <span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span>
|
|
|
+ <span class="text-12-medium text-text-weak overflow-hidden text-ellipsis truncate min-w-0">
|
|
|
+ {DateTime.fromFormat(i.release_date, "yyyy-MM-dd").toFormat("LLL yyyy")}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <Show when={!i.cost || i.cost?.input === 0}>
|
|
|
+ <div class="overflow-hidden text-12-medium text-text-strong">Free</div>
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </SelectDialog>
|
|
|
</div>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- <div class="p-3" />
|
|
|
+ <IconButton type="submit" disabled={isEmpty()} icon="arrow-up" variant="primary" />
|
|
|
+ </div>
|
|
|
+ </form>
|
|
|
</div>
|
|
|
)
|
|
|
}
|
|
|
@@ -223,7 +367,7 @@ function isEqual(arrA: ContentPart[], arrB: ContentPart[]): boolean {
|
|
|
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
|
|
|
return false
|
|
|
}
|
|
|
- if (partA.type === "attachment" && partA.fileId !== (partB as AttachmentPart).fileId) {
|
|
|
+ if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
|
|
|
return false
|
|
|
}
|
|
|
}
|
|
|
@@ -241,24 +385,48 @@ function getCursorPosition(parent: HTMLElement): number {
|
|
|
}
|
|
|
|
|
|
function setCursorPosition(parent: HTMLElement, position: number) {
|
|
|
- let child = parent.firstChild
|
|
|
- let offset = position
|
|
|
- while (child) {
|
|
|
- if (offset > child.textContent!.length) {
|
|
|
- offset -= child.textContent!.length
|
|
|
- child = child.nextSibling
|
|
|
- } else {
|
|
|
- try {
|
|
|
- const range = document.createRange()
|
|
|
- const sel = window.getSelection()
|
|
|
- range.setStart(child, offset)
|
|
|
- range.collapse(true)
|
|
|
- sel?.removeAllRanges()
|
|
|
- sel?.addRange(range)
|
|
|
- } catch (e) {
|
|
|
- console.error("Failed to set cursor position.", e)
|
|
|
- }
|
|
|
+ let remaining = position
|
|
|
+ let node = parent.firstChild
|
|
|
+ while (node) {
|
|
|
+ const length = node.textContent ? node.textContent.length : 0
|
|
|
+ const isText = node.nodeType === Node.TEXT_NODE
|
|
|
+ const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
|
|
|
+
|
|
|
+ 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 (isFile && remaining <= length) {
|
|
|
+ const range = document.createRange()
|
|
|
+ const selection = window.getSelection()
|
|
|
+ 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)
|
|
|
}
|