feat(tui): support pasting clipboard images (#5563)
* 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
This commit is contained in:
+157
-2
@@ -1,5 +1,8 @@
|
||||
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
|
||||
@@ -7,9 +10,32 @@ export interface PasteInsertion {
|
||||
description: string
|
||||
}
|
||||
|
||||
/** Keeps large pasted text out of the editor without changing what is sent. */
|
||||
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")
|
||||
@@ -26,19 +52,148 @@ export class ComposerDraft {
|
||||
return { text: `${label} `, compacted: true, description }
|
||||
}
|
||||
|
||||
expand(visible: string): string {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user