export const IMAGE_PLACEHOLDER_SOURCE = String.raw`\[Image #(\d+)\]`; const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); const wordSegmenter = new Intl.Segmenter(undefined, { granularity: "word" }); export type SupportedImageMimeType = | "image/png" | "image/jpeg" | "image/gif" | "image/webp"; export interface ImageAttachment { id: number; placeholder: string; bytes: Uint8Array; mimeType: SupportedImageMimeType; } export type ImagePlaceholderSegment = | { type: "text"; text: string } | { type: "image"; placeholder: string }; function placeholderRegex(): RegExp { return new RegExp(IMAGE_PLACEHOLDER_SOURCE, "g"); } export function createImagePlaceholder(id: number): string { return `[Image #${String(id).padStart(2, "0")}]`; } export class AttachmentStore { private readonly attachments = new Map(); private nextId = 1; add(bytes: Uint8Array, mimeType: SupportedImageMimeType): ImageAttachment { const id = this.nextId++; const attachment = { id, placeholder: createImagePlaceholder(id), bytes, mimeType, }; this.attachments.set(attachment.placeholder, attachment); return attachment; } has(placeholder: string): boolean { return this.attachments.has(placeholder); } matching(text: string): ImageAttachment[] { const result: ImageAttachment[] = []; const seen = new Set(); for (const match of text.matchAll(placeholderRegex())) { const placeholder = match[0]; if (seen.has(placeholder)) continue; const attachment = this.attachments.get(placeholder); if (!attachment) continue; seen.add(placeholder); result.push(attachment); } return result; } clear(): void { this.attachments.clear(); this.nextId = 1; } } export function splitImagePlaceholders(text: string): ImagePlaceholderSegment[] { const segments: ImagePlaceholderSegment[] = []; let cursor = 0; for (const match of text.matchAll(placeholderRegex())) { if (match.index > cursor) { segments.push({ type: "text", text: text.slice(cursor, match.index) }); } segments.push({ type: "image", placeholder: match[0] }); cursor = match.index + match[0].length; } if (cursor < text.length) { segments.push({ type: "text", text: text.slice(cursor) }); } return segments; } export function detectImageMimeType(bytes: Uint8Array): SupportedImageMimeType | undefined { if ( bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a ) { return "image/png"; } if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { return "image/jpeg"; } if (bytes.length >= 6) { const header = String.fromCharCode(...bytes.slice(0, 6)); if (header === "GIF87a" || header === "GIF89a") return "image/gif"; } if ( bytes.length >= 12 && String.fromCharCode(...bytes.slice(0, 4)) === "RIFF" && String.fromCharCode(...bytes.slice(8, 12)) === "WEBP" ) { return "image/webp"; } return undefined; } interface AtomicSpan { start: number; end: number; } function atomicSpans(text: string, isKnown: (placeholder: string) => boolean): AtomicSpan[] { const spans: AtomicSpan[] = []; for (const match of text.matchAll(placeholderRegex())) { if (!isKnown(match[0])) continue; spans.push({ start: match.index, end: match.index + match[0].length }); } return spans; } export function segmentWithAtomicImages( text: string, mode: "word" | "grapheme", isKnown: (placeholder: string) => boolean, ): Intl.SegmentData[] { const spans = atomicSpans(text, isKnown); const segmenter = mode === "word" ? wordSegmenter : graphemeSegmenter; if (spans.length === 0) return [...segmenter.segment(text)]; const result: Intl.SegmentData[] = []; let spanIndex = 0; for (const segment of segmenter.segment(text)) { while (spanIndex < spans.length && spans[spanIndex]!.end <= segment.index) spanIndex++; const span = spans[spanIndex]; if (span && segment.index >= span.start && segment.index < span.end) { if (segment.index === span.start) { result.push({ segment: text.slice(span.start, span.end), index: span.start, input: text }); } continue; } result.push(segment); } return result; } export function findAtomicDeleteRange( line: string, cursorColumn: number, direction: "backward" | "forward", isKnown: (placeholder: string) => boolean, ): AtomicSpan | undefined { for (const match of line.matchAll(placeholderRegex())) { const placeholder = match[0]; if (!isKnown(placeholder)) continue; const start = match.index; const end = start + placeholder.length; if (direction === "backward" && cursorColumn > start && cursorColumn <= end) { return { start, end }; } if (direction === "forward" && cursorColumn >= start && cursorColumn < end) { return { start, end }; } } return undefined; }