feat: snip a region into chat, and make links clickable (#14)

This commit was merged in pull request #14.
This commit is contained in:
2026-10-05 08:38:50 +00:00
parent f5d1ced4d6
commit 576c5946f7
44 changed files with 3890 additions and 120 deletions
+137
View File
@@ -0,0 +1,137 @@
import { readFile } from "node:fs/promises";
import { extname } from "node:path";
import { pathToFileURL } from "node:url";
import type { Attachment, PendingAttachment } from "./types";
/**
* Reading attachments off disk, and describing them to the renderer.
*
* Kept separate from `capture.ts` because the two answer different questions:
* capture is about getting an image *onto* the disk, this is about getting it
* back off again and telling Vicinae how to draw it.
*
* Imports no Vicinae module, so it unit tests with no runtime.
*/
/** Extensions Ollama accepts. A snip is always PNG; the rest are here for a future picker. */
const MIME_TYPES: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
};
/**
* The box a preview is fitted into.
*
* The height is not a preference: `MdImage.qml` clamps rendered height to 200px
* whatever is asked for (`Math.min(_rawH, 200, maxImageHeight)`), so a taller
* request is silently ignored. The width is here to keep a very wide capture
* from being scaled up past the detail pane it sits in.
*/
const PREVIEW_MAX_WIDTH = 400;
const PREVIEW_MAX_HEIGHT = 200;
export function mimeForPath(path: string): string {
return MIME_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
}
/** The eight bytes every PNG starts with. */
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
/**
* Pixel dimensions from a PNG's `IHDR` chunk, or `undefined` for anything else.
*
* Only PNG is handled, because that is the only thing a snip produces and
* guessing at other formats would be a lie. The header is 24 bytes, so this
* costs nothing on top of the read that is already happening.
*/
export function pngSize(bytes: Buffer): { width: number; height: number } | undefined {
if (bytes.length < 24) return undefined;
if (!PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)) return undefined;
if (bytes.toString("ascii", 12, 16) !== "IHDR") return undefined;
const width = bytes.readUInt32BE(16);
const height = bytes.readUInt32BE(20);
return width > 0 && height > 0 ? { width, height } : undefined;
}
/**
* The exact box to ask the renderer for, or `undefined` when the size is unknown.
*
* This is the whole point of reading the header. With no hint, `MdImage.qml`
* sizes its container to the pane width and 200px tall and then aspect-fits the
* image inside it, so a wide screenshot sits in a letterbox with dead space
* above and below it. Asking for a box that already matches the image's aspect
* ratio leaves none.
*
* The scale is capped at 1 so a small capture is not blown up into a blurry one.
*/
export function previewBox(
width: number | undefined,
height: number | undefined,
): { width: number; height: number } | undefined {
if (!width || !height || width <= 0 || height <= 0) return undefined;
const scale = Math.min(1, PREVIEW_MAX_WIDTH / width, PREVIEW_MAX_HEIGHT / height);
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
};
}
/**
* A markdown image source for a local file.
*
* A `file://` URL rather than a bare path, because Vicinae's markdown renderer
* only resolves a scheme it recognises: `imageProviderUrl` in
* `markdown-model.cpp` routes `file://`, and a scheme-less string, through
* `ImageURL::local`, and anything it does not recognise it returns as empty.
* `pathToFileURL` does the escaping, so a capture under a directory with a space
* in its name still resolves.
*
* The query is read from the URL before it is resolved to a path, so the size
* hints travel here without ever reaching the file name. They are spelled the
* way Raycast spelled them, which is what the parser looks for.
*/
export function imageSource(attachment: Pick<Attachment, "path" | "width" | "height">): string {
const url = pathToFileURL(attachment.path);
const box = previewBox(attachment.width, attachment.height);
if (box) {
url.searchParams.set("raycast-width", String(box.width));
url.searchParams.set("raycast-height", String(box.height));
}
return url.href;
}
/**
* Reads an image into the shape a request needs.
*
* Ollama wants base64 with no `data:` URL prefix, which is why the payload is
* built here rather than handed straight to the renderer. The dimensions are
* kept for the same reason the path is: the transcript needs them long after
* this call is over, and the preview layout depends on them.
*/
export async function readAttachment(path: string): Promise<PendingAttachment> {
const bytes = await readFile(path);
const size = pngSize(bytes);
return {
path,
mime: mimeForPath(path),
base64: bytes.toString("base64"),
width: size?.width,
height: size?.height,
};
}
/** The persistable half of a pending attachment: everything except the base64 payload. */
export function toStoredAttachment(attachment: PendingAttachment): Attachment {
const { path, mime, width, height } = attachment;
return { path, mime, width, height };
}