mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
* feat(tui): support pasting clipboard images * fix(tui): keep image placeholders atomic * fix(tui): reconcile duplicate image placeholders * fix(tui): retain highlighted image placeholders * fix(tui): preserve image placeholder layout * fix(tui): keep image display state local * fix(tui): reject images in commands
200 lines
6.3 KiB
TypeScript
200 lines
6.3 KiB
TypeScript
import type { OutboundMedia } from "./protocol"
|
|
|
|
const LARGE_PASTE_CHARS = 1_000
|
|
const LARGE_PASTE_LINES = 10
|
|
export const MAX_DRAFT_IMAGES = 4
|
|
|
|
export interface PasteInsertion {
|
|
text: string
|
|
compacted: boolean
|
|
description: string
|
|
}
|
|
|
|
export interface DraftEditReconciliation {
|
|
value: string
|
|
cursor: number
|
|
removedImages: string[]
|
|
}
|
|
|
|
const IMAGE_EXTENSIONS = {
|
|
"image/png": "png",
|
|
"image/jpeg": "jpg",
|
|
"image/webp": "webp",
|
|
"image/gif": "gif",
|
|
} as const
|
|
|
|
interface DraftImage {
|
|
dataUrl: string
|
|
mimeType: keyof typeof IMAGE_EXTENSIONS
|
|
}
|
|
|
|
/** Keeps large pasted text and image payloads out of the editable composer surface. */
|
|
export class ComposerDraft {
|
|
private readonly pastes = new Map<string, string>()
|
|
private readonly images = new Map<string, OutboundMedia>()
|
|
|
|
get imageCount(): number {
|
|
return this.images.size
|
|
}
|
|
|
|
paste(value: string): PasteInsertion {
|
|
const text = value.replace(/\r\n/gu, "\n").replace(/\r/gu, "\n")
|
|
const lines = text.split("\n").length
|
|
if (text.length < LARGE_PASTE_CHARS && lines < LARGE_PASTE_LINES) {
|
|
return { text, compacted: false, description: "" }
|
|
}
|
|
|
|
const description = lines > 1 ? `${lines} lines` : `${text.length} characters`
|
|
const base = `[Pasted ${description}]`
|
|
let label = base
|
|
for (let index = 2; this.pastes.has(label); index += 1) label = `${base.slice(0, -1)} #${index}]`
|
|
this.pastes.set(label, text)
|
|
return { text: `${label} `, compacted: true, description }
|
|
}
|
|
|
|
private imageLabelInUse(label: string, visible: string): boolean {
|
|
if (this.images.has(label) || visible.includes(label)) return true
|
|
for (const content of this.pastes.values()) {
|
|
if (content.includes(label)) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
private nextImageIndex(visible: string): number {
|
|
let index = 1
|
|
while (this.imageLabelInUse(`[Image #${index}]`, visible)) index += 1
|
|
return index
|
|
}
|
|
|
|
image(image: DraftImage, visible = ""): PasteInsertion | null {
|
|
if (this.images.size >= MAX_DRAFT_IMAGES) return null
|
|
const index = this.nextImageIndex(visible)
|
|
const label = `[Image #${index}]`
|
|
this.images.set(label, {
|
|
data_url: image.dataUrl,
|
|
name: `clipboard-image-${index}.${IMAGE_EXTENSIONS[image.mimeType]}`,
|
|
})
|
|
return { text: `${label} `, compacted: true, description: label.slice(1, -1) }
|
|
}
|
|
|
|
private expandPastes(visible: string): string {
|
|
let expanded = visible
|
|
for (const [label, content] of this.pastes) expanded = expanded.split(label).join(content)
|
|
return expanded
|
|
}
|
|
|
|
private labelOccurrences(content: string, label: string): number {
|
|
return content.split(label).length - 1
|
|
}
|
|
|
|
imagePlaceholderRanges(visible: string): Array<{ start: number; end: number }> {
|
|
const ranges: Array<{ start: number; end: number }> = []
|
|
for (const label of this.images.keys()) {
|
|
let start = visible.indexOf(label)
|
|
while (start >= 0) {
|
|
ranges.push({ start, end: start + label.length })
|
|
start = visible.indexOf(label, start + label.length)
|
|
}
|
|
}
|
|
return ranges.sort((left, right) => left.start - right.start)
|
|
}
|
|
|
|
snapImageCursor(visible: string, cursor: number, previousCursor: number): number {
|
|
const range = this.imagePlaceholderRanges(visible)
|
|
.find(({ start, end }) => cursor > start && cursor < end)
|
|
if (!range) return cursor
|
|
if (previousCursor <= range.start) return range.end
|
|
if (previousCursor >= range.end) return range.start
|
|
return cursor - range.start < range.end - cursor ? range.start : range.end
|
|
}
|
|
|
|
moveImageCursor(visible: string, cursor: number, direction: -1 | 1): number | null {
|
|
const range = this.imagePlaceholderRanges(visible).find(({ start, end }) => (
|
|
direction < 0
|
|
? cursor > start && cursor <= end
|
|
: cursor >= start && cursor < end
|
|
))
|
|
if (!range) return null
|
|
return direction < 0 ? range.start : range.end
|
|
}
|
|
|
|
reconcileImageEdit(
|
|
previous: string,
|
|
value: string,
|
|
cursor: number,
|
|
): DraftEditReconciliation {
|
|
let oldStart = 0
|
|
const sharedLength = Math.min(previous.length, value.length)
|
|
while (oldStart < sharedLength && previous[oldStart] === value[oldStart]) oldStart += 1
|
|
|
|
let oldEnd = previous.length
|
|
let newEnd = value.length
|
|
while (
|
|
oldEnd > oldStart
|
|
&& newEnd > oldStart
|
|
&& previous[oldEnd - 1] === value[newEnd - 1]
|
|
) {
|
|
oldEnd -= 1
|
|
newEnd -= 1
|
|
}
|
|
|
|
const ranges = this.imagePlaceholderRanges(previous).filter(({ start, end }) => (
|
|
oldStart === oldEnd
|
|
? oldStart > start && oldStart < end
|
|
: oldStart < end && oldEnd > start
|
|
))
|
|
if (!ranges.length) return { value, cursor, removedImages: [] }
|
|
|
|
const replaceStart = Math.min(oldStart, ...ranges.map((range) => range.start))
|
|
const replaceEnd = Math.max(oldEnd, ...ranges.map((range) => range.end))
|
|
const inserted = value.slice(oldStart, newEnd)
|
|
const reconciled = previous.slice(0, replaceStart) + inserted + previous.slice(replaceEnd)
|
|
const missing = [...this.images.keys()].filter((label) => !reconciled.includes(label))
|
|
for (const label of missing) this.images.delete(label)
|
|
return {
|
|
value: reconciled,
|
|
cursor: replaceStart + inserted.length,
|
|
removedImages: missing.map((label) => label.slice(1, -1)),
|
|
}
|
|
}
|
|
|
|
hasImageLabelConflict(visible: string): boolean {
|
|
const expanded = this.expandPastes(visible)
|
|
return [...this.images.keys()]
|
|
.some((label) => this.labelOccurrences(expanded, label) !== 1)
|
|
}
|
|
|
|
expand(visible: string): string {
|
|
let expanded = this.expandPastes(visible)
|
|
for (const label of this.images.keys()) {
|
|
if (this.labelOccurrences(expanded, label) === 1) expanded = expanded.replace(label, "")
|
|
}
|
|
return expanded
|
|
}
|
|
|
|
display(visible: string): string {
|
|
return this.expandPastes(visible)
|
|
}
|
|
|
|
media(visible: string): OutboundMedia[] {
|
|
return [...this.images]
|
|
.filter(([label]) => visible.includes(label))
|
|
.sort(([left], [right]) => visible.indexOf(left) - visible.indexOf(right))
|
|
.map(([, media]) => media)
|
|
}
|
|
|
|
prune(visible: string): void {
|
|
for (const label of this.pastes.keys()) {
|
|
if (!visible.includes(label)) this.pastes.delete(label)
|
|
}
|
|
for (const label of this.images.keys()) {
|
|
if (!visible.includes(label)) this.images.delete(label)
|
|
}
|
|
}
|
|
|
|
clear(): void {
|
|
this.pastes.clear()
|
|
this.images.clear()
|
|
}
|
|
}
|