Files
yaak-mountain-loop/apps/yaak-client/lib/largeValue.ts
T

247 lines
8.9 KiB
TypeScript

import { save } from "@tauri-apps/plugin-dialog";
import { Icon } from "@yaakapp-internal/ui";
import mime from "mime";
import { createElement } from "react";
import type { DropdownItem } from "../components/core/Dropdown";
import type { SniffedValue } from "../components/core/Editor/sniffValue";
import { isEncodedRun } from "../components/core/Editor/sniffValue";
import { copyToClipboard } from "./copy";
import { fireAndForget } from "./fireAndForget";
import { invokeCmd } from "./tauri";
import { showToast } from "./toast";
/**
* How the value is written, which is the thing worth knowing about it — that it is base64
* explains why it reads as gibberish far better than its length does.
*
* A value we couldn't identify may still be plainly encoded, so it is checked here too: an
* unrecognised format and an unrecognised encoding are different things to be told.
*
* `head` need only be the first {@link SNIFF_HEAD_CHARS} characters of the value.
*/
export function encodingLabel(head: string, sniffed: SniffedValue | null, chars: number): string {
const size = `${chars.toLocaleString()} chars`;
if (sniffed?.encoding === "percent") {
return `Percent-encoded · ${size}`;
}
const encoded = sniffed?.encoding === "base64" || isEncodedRun(head, sniffed?.offset ?? 0);
return encoded ? `Base64 · ${size}` : size;
}
interface ActionOptions {
/** The whole value. A function, so opening a menu never copies megabytes to build it. */
value: () => string;
sniffed: SniffedValue | null;
/** What to copy. The tag copies only what it hides; the viewer copies what it shows. */
copyText: () => string;
/** Left out inside the viewer itself, where there is nothing further to open */
onView?: () => void;
}
/**
* The things you can do with a collapsed value, as menu items.
*
* Shared so the tag in the editor and the dialog it opens offer the same list, rather than
* drifting apart.
*/
export function largeValueActions({
value,
sniffed,
copyText,
onView,
}: ActionOptions): DropdownItem[] {
const items: DropdownItem[] = [];
if (onView != null) {
items.push({
label: sniffed == null ? "View" : `View ${sniffed.label}`,
leftSlot: createElement(Icon, { icon: "eye" }),
onSelect: onView,
});
}
// Two ways to copy a picture, because either can be the one you wanted: the image to paste
// somewhere that takes one, or the text to paste back into a request
const image = isCopyableImage(sniffed);
if (image) {
items.push({
label: "Copy Image",
leftSlot: createElement(Icon, { icon: "copy" }),
onSelect: () => copyImage(value(), sniffed, copyText()),
});
}
items.push({
label: sniffed?.encoding === "base64" ? "Copy Base64" : "Copy",
// Second of a pair reads as one thing with two options, so it keeps the indent without
// repeating the icon above it
leftSlot: createElement(Icon, { icon: image ? "empty" : "copy" }),
onSelect: () => copyToClipboard(copyText()),
});
items.push({
label: "Save to File",
leftSlot: createElement(Icon, { icon: "download" }),
onSelect: () =>
fireAndForget(saveValue(value(), sniffed, sniffed?.label.toLowerCase() ?? "value")),
});
return items;
}
/** The payload, with the `data:` header taken off. */
function payloadOf(text: string, sniffed: SniffedValue): string {
return text.slice(sniffed.offset);
}
/**
* A payload every strict base64 decoder will accept.
*
* The url-safe alphabet stands for the same bytes, and a ragged final group is dropped rather
* than padded — it can only ever be worth a single byte, and both `atob` and the Rust decoder
* behind the save command reject one outright.
*/
function normalizeBase64(payload: string): string {
const clean = payload.replace(/\s+/g, "").replaceAll("-", "+").replaceAll("_", "/");
return clean.slice(0, clean.length - (clean.length % 4));
}
/**
* The bytes a collapsed value stands for.
*
* `atob` rather than `Uint8Array.fromBase64`, which is too new to rely on across the webviews we
* ship on. Only ever called for something the user asked to see, never during layout.
*/
export function decodeValue(text: string, sniffed: SniffedValue): Uint8Array<ArrayBuffer> {
const payload = payloadOf(text, sniffed);
if (sniffed.encoding === "percent") {
return new TextEncoder().encode(decodeURIComponent(payload));
}
const binary = atob(normalizeBase64(payload));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/**
* Whether the clipboard can take this as a picture rather than as text.
*
* SVG is left out on purpose: it is markup, so it pastes usefully as text, and rasterising it
* would throw away the thing that makes it worth having.
*/
function isCopyableImage(sniffed: SniffedValue | null): sniffed is SniffedValue {
return (
sniffed != null && sniffed.mime.startsWith("image/") && !sniffed.mime.startsWith("image/svg")
);
}
/**
* The value as a PNG.
*
* Not a preference: PNG is the only image format the Clipboard API requires a browser to accept
* on write — `text/plain`, `text/html` and `image/png` are the spec's mandatory data types — and
* engines reject `image/jpeg` outright. So anything else is rasterised through a canvas, which
* costs a decode and an encode but means a JPEG or a WEBP pastes just as readily as a PNG.
*
* `ClipboardItem.supports()` exists to ask whether a format could be written as it stands, and
* skipping the conversion would be worth real time on a large photo. It was tried: WebKit says
* no to `image/jpeg`, so the check only ever chose PNG and was removed again.
*
* The re-encode is lossless, so nothing is degraded, but a photograph lands on the clipboard far
* larger than its JPEG. That matters less than it looks: the system pasteboard holds images
* uncompressed regardless of what we hand it.
*/
async function toPngBlob(text: string, sniffed: SniffedValue): Promise<Blob> {
const blob = new Blob([decodeValue(text, sniffed)], { type: sniffed.mime });
if (sniffed.mime === "image/png") {
return blob;
}
const bitmap = await createImageBitmap(blob);
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext("2d");
if (ctx == null) {
throw new Error("Could not get a canvas to convert the image");
}
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
return await new Promise((resolve, reject) => {
canvas.toBlob(
(png) => (png == null ? reject(new Error("Could not encode the image")) : resolve(png)),
"image/png",
);
});
}
/**
* Puts the picture on the clipboard, so it can be pasted anywhere that takes an image.
*
* Not awaited, and deliberately so. The webview only allows a clipboard write that starts inside
* the click that asked for it, and decoding a few megabytes takes longer than that lasts — so
* the item is handed the still-pending promise rather than a finished blob.
*/
export function copyImage(text: string, sniffed: SniffedValue, fallback: string) {
if (typeof ClipboardItem === "undefined") {
copyToClipboard(fallback);
return;
}
const png = toPngBlob(text, sniffed);
navigator.clipboard
.write([new ClipboardItem({ "image/png": png })])
.then(() =>
showToast({
id: "copied",
color: "success",
icon: "copy",
// Says PNG because that is what lands on the clipboard, whatever the value held
message: "Copied as PNG",
}),
)
.catch((err: unknown) => {
// Anything from a webview that won't take an image to a file we couldn't decode. The
// encoded text is always there to fall back on.
console.error("Failed to copy image, copying text instead", err);
copyToClipboard(fallback);
});
}
/** Base64 of a byte array, in chunks so a few megabytes don't blow the argument limit. */
function toBase64(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(binary);
}
/**
* Writes a collapsed value to a file the user picks.
*
* A value that is already base64 goes straight to the backend as-is — decoding it here only to
* encode it again would walk megabytes twice for nothing.
*/
export async function saveValue(text: string, sniffed: SniffedValue | null, name: string) {
const ext = sniffed == null ? "txt" : (mime.getExtension(sniffed.mime) ?? "bin");
const filepath = await save({ defaultPath: `${name}.${ext}`, title: "Save Value" });
if (filepath == null) {
return; // Cancelled
}
const data =
sniffed == null
? toBase64(new TextEncoder().encode(text))
: sniffed.encoding === "base64"
? normalizeBase64(payloadOf(text, sniffed))
: toBase64(decodeValue(text, sniffed));
await invokeCmd("cmd_save_base64_to_binary", { filepath, data });
showToast({ message: `Saved to ${filepath}` });
}