d0d5b547ff
- Added atomicTokenPattern support to treat image and paste markers as indivisible tokens, enabling atomic deletion on backspace and forward-delete instead of character-by-character removal. - Extended image marker format to include pixel dimensions [Image #N, WxH] and updated paste marker format to [Paste #N, +X lines] or [Paste #N, Y chars] with consistent comma-separated metadata. - Implemented accent styling for paste markers in editor rendering while preserving hyperlinks for image markers, improving visual distinction between marker types. - Refactored image reference handling to unified placeholder system supporting both image and paste markers with kind-aware rendering callbacks and regex patterns.
118 lines
3.9 KiB
TypeScript
118 lines
3.9 KiB
TypeScript
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
import { type BlobPutResult, blobExtensionForImageMimeType } from "../session/blob-store";
|
|
import { fileHyperlink } from "../tui/hyperlink";
|
|
|
|
/** Matches `[Image #N]`/`[Image #N, WxH]` and `[Paste #N, +X lines]`/`[Paste #N, Y chars]` tokens.
|
|
* Group 1 is the kind (`Image`/`Paste`), group 2 the 1-based index. The optional metadata
|
|
* tail (`, …`) is captured loosely (no `]`/newline) so future label tweaks keep matching. */
|
|
export const PLACEHOLDER_REGEX = /\[(Image|Paste) #([1-9]\d*)(?:,[^\]\n]*)?\]/g;
|
|
|
|
type ImageBlobWriter = (data: Buffer, options?: { extension?: string }) => Promise<BlobPutResult>;
|
|
type ImageBlobWriterSync = (data: Buffer, options?: { extension?: string }) => BlobPutResult;
|
|
|
|
export type PlaceholderKind = "image" | "paste";
|
|
|
|
export interface PlaceholderRenderers {
|
|
renderText: (text: string) => string;
|
|
renderReference: (label: string, kind: PlaceholderKind, index: number) => string;
|
|
}
|
|
|
|
export function renderPlaceholders(text: string, renderers: PlaceholderRenderers): string {
|
|
PLACEHOLDER_REGEX.lastIndex = 0;
|
|
let result = "";
|
|
let last = 0;
|
|
let matched = false;
|
|
|
|
for (;;) {
|
|
const match = PLACEHOLDER_REGEX.exec(text);
|
|
if (match === null) break;
|
|
matched = true;
|
|
if (match.index > last) {
|
|
result += renderers.renderText(text.slice(last, match.index));
|
|
}
|
|
const kind: PlaceholderKind = match[1] === "Paste" ? "paste" : "image";
|
|
result += renderers.renderReference(match[0], kind, Number(match[2]));
|
|
last = match.index + match[0].length;
|
|
}
|
|
|
|
if (!matched) {
|
|
return renderers.renderText(text);
|
|
}
|
|
if (last < text.length) {
|
|
result += renderers.renderText(text.slice(last));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function imageReferenceHyperlink(
|
|
label: string,
|
|
index: number,
|
|
imageLinks: readonly (string | undefined)[] | undefined,
|
|
renderLabel: (text: string) => string,
|
|
): string {
|
|
const rendered = renderLabel(label);
|
|
const target = imageLinks?.[index - 1];
|
|
return target ? fileHyperlink(target, rendered) : rendered;
|
|
}
|
|
|
|
async function materializeImageReferenceLinkAsync(
|
|
image: ImageContent,
|
|
index: number,
|
|
putBlob: ImageBlobWriter,
|
|
): Promise<string | undefined> {
|
|
try {
|
|
const result = await putBlob(Buffer.from(image.data, "base64"), {
|
|
extension: blobExtensionForImageMimeType(image.mimeType),
|
|
});
|
|
return result.displayPath;
|
|
} catch (error) {
|
|
logger.warn("Failed to write image reference blob", {
|
|
index,
|
|
mimeType: image.mimeType,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function materializeImageReferenceLink(
|
|
image: ImageContent,
|
|
index: number,
|
|
putBlob: ImageBlobWriterSync,
|
|
): string | undefined {
|
|
try {
|
|
const result = putBlob(Buffer.from(image.data, "base64"), {
|
|
extension: blobExtensionForImageMimeType(image.mimeType),
|
|
});
|
|
return result.displayPath;
|
|
} catch (error) {
|
|
logger.warn("Failed to write image reference blob", {
|
|
index,
|
|
mimeType: image.mimeType,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export async function materializeImageReferenceLinks(
|
|
images: readonly ImageContent[] | undefined,
|
|
putBlob: ImageBlobWriter,
|
|
): Promise<(string | undefined)[] | undefined> {
|
|
if (!images || images.length === 0) return undefined;
|
|
const links = await Promise.all(
|
|
images.map((image, index) => materializeImageReferenceLinkAsync(image, index + 1, putBlob)),
|
|
);
|
|
return links.some(link => link !== undefined) ? links : undefined;
|
|
}
|
|
|
|
export function materializeImageReferenceLinksSync(
|
|
images: readonly ImageContent[] | undefined,
|
|
putBlob: ImageBlobWriterSync,
|
|
): (string | undefined)[] | undefined {
|
|
if (!images || images.length === 0) return undefined;
|
|
const links = images.map((image, index) => materializeImageReferenceLink(image, index + 1, putBlob));
|
|
return links.some(link => link !== undefined) ? links : undefined;
|
|
}
|